This commit is contained in:
2026-10-19 15:42:39 -04:00
parent 6d3f470922
commit 4b47386853
218 changed files with 25348 additions and 383 deletions
+142
View File
@@ -0,0 +1,142 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { z } from 'zod';
import { STARTER_ARTICLES } from '../../shared/catalog.ts';
import { NETWORK, CONSENSUS, articleTuple, headerMessage, blockClaimMessage, nftTransferMessage,
type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type MiningTemplate } from '../../shared/testnet.ts';
import type { PowEngine } from './randomx.ts';
export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
// Consensus manifest: an independently running node derives exactly the same genesis.
export const GENESIS_TIME = 1_792_411_200;
export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET,
STARTER_ARTICLES.map(articleTuple)]));
export const GENESIS_HASH = sha256(`${NETWORK}:genesis:${CHAIN_ID}`);
export const INITIAL_KEY = sha256(`${NETWORK}:initial-key:${CHAIN_ID}`);
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const key = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256);
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88);
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
export const transferSchema = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
cardHash: hex, from: hex, to: hex, sequence: integer, publicKey: key, signature }).strict();
const articleSchema = z.object({ pageId: integer, title: z.string().max(200), extract: z.string().max(1000),
image: z.string().max(1000).optional(), url: z.string().max(500), revisionId: integer,
source: z.enum(['live', 'offline']), category: z.string().max(50) }).strict();
export const blockSchema = z.object({ version: z.literal(2), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
height: integer, previousHash: hex, createdAt: integer, article: articleSchema, owner: hex,
publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex,
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).max(CONSENSUS.maxTransactions) }).strict();
export function address(publicKey: string): string { return sha256(Buffer.from(publicKey, 'base64')); }
export function verifySignature(publicKey: string, owner: string, message: string, sig: string): boolean {
try {
const bytes = Buffer.from(publicKey, 'base64');
if (bytes.toString('base64') !== publicKey || address(publicKey) !== owner) return false;
const k = createPublicKey({ key: bytes, type: 'spki', format: 'der' });
if (k.asymmetricKeyType !== 'ec' || k.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false;
if (!k.export({ format: 'der', type: 'spki' }).equals(bytes)) return false;
return verify('sha256', Buffer.from(message), { key: k, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64'));
} catch { return false; }
}
export function transferId(t: NftTransfer): string {
// Exclude malleable ECDSA signature bytes from transaction identity.
return sha256(nftTransferMessage(t));
}
export function merkleRoot(leaves: string[]): string {
if (!leaves.length) return sha256(`${NETWORK}:empty`);
let layer = leaves.map(leaf => sha256(Buffer.concat([Buffer.from([0]), Buffer.from(leaf,'hex')])));
while (layer.length > 1) {
const next: string[] = [];
for (let i = 0; i < layer.length; i += 2) next.push(sha256(Buffer.concat([Buffer.from([1]),
Buffer.from(layer[i], 'hex'), Buffer.from(layer[i + 1] ?? layer[i], 'hex')])));
layer = next;
}
// Commit the count as well: odd-leaf padding must not make [a,b,c] equal [a,b,c,c].
return sha256(JSON.stringify([NETWORK,'merkle',leaves.length,layer[0]]));
}
export function stateRoot(state: NftState): string {
return merkleRoot(Object.keys(state).sort().map(id => {
const n = state[id]; return sha256(JSON.stringify([id, n.owner, n.sequence, n.mintedHeight, n.pageId]));
}));
}
export function mintId(h: Pick<BlockHeader, 'previousHash' | 'height' | 'owner' | 'article'>): string {
return sha256(JSON.stringify([NETWORK, CHAIN_ID, 'mint', h.previousHash, h.height, h.owner, h.article.pageId]));
}
export function miningBlob(header: BlockHeader): Buffer {
const nonce = Buffer.alloc(8);
nonce.writeBigUInt64LE(BigInt(header.nonce));
return Buffer.concat([Buffer.from(sha256(headerMessage(header)), 'hex'), nonce]);
}
export function workFor(target: string): bigint { return (1n << 256n) / (BigInt(`0x${target}`) + 1n); }
export function selectArticle(previousHash: string) {
const index = Number(BigInt(`0x${sha256(`${CHAIN_ID}:article:${previousHash}`)}`) % BigInt(STARTER_ARTICLES.length));
return { ...STARTER_ARTICLES[index] };
}
export function expectedKey(ancestors: TestnetBlock[], height: number): string {
const keyHeight = Math.floor(Math.max(0, height - CONSENSUS.epochDelay) / CONSENSUS.epochBlocks) * CONSENSUS.epochBlocks;
return keyHeight === 0 ? INITIAL_KEY : ancestors[keyHeight - 1].hash;
}
export function expectedTarget(ancestors: TestnetBlock[]): string {
if (!ancestors.length) return MAX_TARGET;
const parent = ancestors.at(-1)!;
if (ancestors.length % CONSENSUS.retargetInterval !== 0) return parent.target;
const start = ancestors[ancestors.length - CONSENSUS.retargetInterval];
const expected = (CONSENSUS.retargetInterval - 1) * CONSENSUS.targetSeconds;
const elapsed = Math.max(Math.floor(expected / 4), Math.min(expected * 4, parent.createdAt - start.createdAt));
const value = BigInt(`0x${parent.target}`) * BigInt(elapsed) / BigInt(expected);
return (value < 1n ? 1n : value > BigInt(`0x${MAX_TARGET}`) ? BigInt(`0x${MAX_TARGET}`) : value).toString(16).padStart(64, '0');
}
export function medianTime(ancestors: TestnetBlock[]): number {
const values = ancestors.slice(-CONSENSUS.medianWindow).map(b => b.createdAt).sort((a,b) => a-b);
return values.length ? values[Math.floor(values.length / 2)] : GENESIS_TIME;
}
export function applyTransfer(state: NftState, tx: NftTransfer, height: number): void {
if (!transferSchema.safeParse(tx).success) throw new Error('Invalid transfer encoding/network');
const nft = state[tx.cardHash];
if (!nft || nft.owner !== tx.from || tx.from === tx.to || nft.sequence !== tx.sequence) throw new Error('Invalid NFT owner/sequence');
if (height - nft.mintedHeight < CONSENSUS.coinbaseMaturity) throw new Error('NFT coinbase is immature');
if (!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Invalid transfer signature');
state[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 };
}
export function transition(parent: NftState, header: BlockHeader, txs: NftTransfer[]): NftState {
const next = structuredClone(parent);
const ids = new Set<string>();
for (const tx of txs) {
const id = transferId(tx);
if (ids.has(id)) throw new Error('Duplicate transfer');
ids.add(id); applyTransfer(next, tx, header.height);
}
const id = mintId(header);
if (next[id]) throw new Error('Duplicate NFT issuance');
next[id] = { owner: header.owner, sequence: 0, mintedHeight: header.height, pageId: header.article.pageId };
return next;
}
export function makeTemplate(ancestors: TestnetBlock[], state: NftState, owner: string,
publicKey: string, transactions: NftTransfer[], now = Math.floor(Date.now()/1000)): MiningTemplate {
if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity');
const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH;
const header: BlockHeader = { version: 2, network: NETWORK, chainId: CHAIN_ID,
height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1),
article: selectArticle(previousHash), owner, publicKey, nonce: 0, target: expectedTarget(ancestors),
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), stateRoot: '' };
header.stateRoot = stateRoot(transition(state, header, transactions));
return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 };
}
export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], parentState: NftState,
pow: PowEngine, now = Math.floor(Date.now()/1000)): Promise<{block:TestnetBlock;state:NftState}> {
if (Buffer.byteLength(JSON.stringify(raw)) > CONSENSUS.maxBlockBytes) throw new Error('Block size limit exceeded');
const b = blockSchema.parse(raw);
if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height');
if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp');
if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key');
if (JSON.stringify(articleTuple(b.article)) !== JSON.stringify(articleTuple(selectArticle(b.previousHash)))) throw new Error('Article is not in the consensus catalog');
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root');
const next = transition(parentState, b, b.transactions);
if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment');
const blob = miningBlob(b);
if (b.hash !== sha256(blob)) throw new Error('Invalid block ID');
if (!verifySignature(b.publicKey, b.owner, blockClaimMessage(b.hash), b.signature)) throw new Error('Invalid miner signature');
if (BigInt(`0x${b.powHash}`) > BigInt(`0x${b.target}`)) throw new Error('Insufficient work');
if (await pow.hash(b.key, blob) !== b.powHash) throw new Error('Invalid RandomX proof');
return { block: b, state: next };
}
+106
View File
@@ -0,0 +1,106 @@
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, open, unlink } from 'node:fs/promises';
import { z } from 'zod';
import { NETWORK } from '../../shared/testnet.ts';
import { CHAIN_ID, blockSchema, transferSchema } from './consensus.ts';
import { RandomX } from './randomx.ts';
import { Ledger } from './ledger.ts';
import { PeerNetwork } from './peers.ts';
const root = fileURLToPath(new URL('../../', import.meta.url));
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data');
await mkdir(dataDir,{recursive:true});
// Never permit two processes to rewrite the same ledger. A stale lock requires operator review.
const lockPath = resolve(dataDir,'testnet-node.lock');
const lock = await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Data directory locked. Stop its other node; remove stale testnet-node.lock only after verifying no node is running.');});
await lock.writeFile(String(process.pid));
let engine: RandomX;
try { engine = new RandomX(); }
catch(e) { await lock.close();await unlink(lockPath);throw e; }
let ledger: Ledger;
try { ledger = await Ledger.open(dataDir,engine); }
catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;}
let peers: PeerNetwork;
try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean),
allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); }
catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;}
const app = express();
app.disable('x-powered-by');
app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"],
imgSrc:["'self'",'data:','https://upload.wikimedia.org','https://thumb.wikimedia.org'],styleSrc:["'self'","'unsafe-inline'"],
connectSrc:["'self'"],upgradeInsecureRequests:null}},crossOriginEmbedderPolicy:false,
strictTransportSecurity:process.env.NODE_ENV==='production'}));
app.get('/healthz',(_req,res)=>{res.setHeader('Cache-Control','no-store');res.json({status:'ok',network:NETWORK});});
app.use(express.json({limit:'68kb',strict:true}));
app.use(['/api','/p2p'],rateLimit({windowMs:60000,limit:300,standardHeaders:'draft-8',legacyHeaders:false}));
app.use(['/api','/p2p'],(req,res,next)=>{
res.setHeader('Cache-Control','no-store');
if(req.method==='POST') {
if(!req.is('application/json')){res.status(415).json({error:'JSON required'});return;}
const origin=req.get('origin');
const allowed=new Set(['http://127.0.0.1:5173','http://localhost:5173','http://127.0.0.1:3001','http://localhost:3001',process.env.PUBLIC_ORIGIN]);
if(origin&&!allowed.has(origin)){res.status(403).json({error:'Origin not allowed'});return;}
}
next();
});
app.get('/api/state',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
app.get('/p2p/status',(_req,res)=>res.json({network:NETWORK,chainId:CHAIN_ID,size:ledger.size,tip:ledger.snapshot().tip}));
app.get('/p2p/blocks',(req,res)=>{
const offset=Number(req.query.offset??0);
if(!Number.isSafeInteger(offset)||offset<0||offset>10000){res.status(400).json({error:'Invalid offset'});return;}
const blocks=ledger.inventory(offset,32);
res.json({blocks,next:offset+blocks.length<ledger.size?offset+blocks.length:null});
});
app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.snapshot().pendingTransfers}));
app.get('/api/nfts/:id', (req,res)=>{
const s=ledger.snapshot();const block=s.blocks.find(b=>b.tokenId===req.params.id);
if(!block){res.status(404).json({error:'NFT not found on the canonical chain'});return;}
let owner=block.owner,sequence=0;
for(const tx of s.transfers)if(tx.cardHash===block.tokenId){owner=tx.to;sequence++;}
res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner,sequence,blockHash:block.hash,
confirmations:s.blocks.length-block.height+1,article:block.article,stateRoot:s.blocks.at(-1)?.stateRoot});
});
app.post('/api/mining/template', (req,res)=>{
const body=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),publicKey:z.string().max(256)}).strict().parse(req.body);
res.json(ledger.template(body.owner,body.publicKey));
});
const workLimit=rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false});
app.post(['/api/blocks','/p2p/block'],workLimit,async(req,res)=>{
const block=blockSchema.parse(req.body);
const result=await ledger.submit(block);
res.status(result.accepted?201:200).json(result);
if(result.accepted)void peers.broadcastBlock(block).catch(()=>{});
});
app.post(['/api/transfer','/p2p/transaction'],workLimit,async(req,res)=>{
const tx=transferSchema.parse(req.body);
const id=await ledger.submitTransfer(tx);
res.status(202).json({id,status:'pending',message:'Ownership changes only when this transaction is mined.'});
void peers.broadcastTransfer(tx).catch(()=>{});
});
app.use(['/api','/p2p'],(_req,res)=>{res.status(404).json({error:'Unknown testnet endpoint'});});
app.use(express.static(resolve(root,'dist')));
app.get('/{*path}',(_req,res)=>res.sendFile(resolve(root,'dist/index.html')));
app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,_next:express.NextFunction)=>{
const input=err instanceof z.ZodError||err instanceof SyntaxError;
const message=input?'Invalid request encoding':err.message;
// No keys/passwords are accepted by any node endpoint.
res.status(err.status===413?413:400).json({error:message.slice(0,200)});
});
const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1';
const server=app.listen(port,host,()=>{
const a=server.address();console.log(`Wikideck RandomX testnet: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
peers.start();
});
let stopping=false;
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
if(stopping)return;stopping=true;peers.close();
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{peers.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
+213
View File
@@ -0,0 +1,213 @@
import { mkdir, open, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { z } from 'zod';
import { NETWORK, CONSENSUS, type TestnetBlock, type NftTransfer, type NftState,
type MiningTemplate, type TestnetSnapshot } from '../../shared/testnet.ts';
import { CHAIN_ID, GENESIS_HASH, applyTransfer, blockSchema, makeTemplate, selectArticle,
transferId, transferSchema, validateBlock, workFor } from './consensus.ts';
import type { PowEngine } from './randomx.ts';
const MAX_BLOCKS = 10_000;
const MAX_PENDING = 256;
const MAX_FILE = 128 * 1024 * 1024;
const diskSchema = z.object({ version: z.literal(1), network: z.literal(NETWORK),
chainId: z.literal(CHAIN_ID), blocks: z.array(blockSchema).max(MAX_BLOCKS),
pendingTransfers: z.array(transferSchema).max(MAX_PENDING) }).strict();
type Node = { block: TestnetBlock; state: NftState; work: bigint };
/** Experimental, bounded DAG. The supplied engine is always used, including on restart.
* One process must own a data directory; there is no cross-process writer locking.
*/
export class Ledger {
private readonly nodes = new Map<string, Node>();
private tip = GENESIS_HASH;
private pending: NftTransfer[] = [];
private queue: Promise<unknown> = Promise.resolve();
private queued = 0;
/** Application-owned connectivity metadata; never part of consensus or persistence. */
peers = 0;
private readonly file: string;
private readonly pow: PowEngine;
private constructor(file: string, pow: PowEngine) { this.file = file; this.pow = pow; }
static async open(dir: string, pow: PowEngine): Promise<Ledger> {
const directory = join(dir, 'testnet');
await mkdir(directory, { recursive: true, mode: 0o700 });
const ledger = new Ledger(join(directory, 'ledger.json'), pow);
let handle;
try { handle = await open(ledger.file, 'r'); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
await ledger.persist([], []);
return ledger;
}
let text: string;
try {
if ((await handle.stat()).size > MAX_FILE) throw new Error('Testnet ledger file exceeds 128 MiB');
// Bounded reads also protect against a file growing after stat().
const chunks: Buffer[] = [];
let size = 0;
for (;;) {
const buffer = Buffer.alloc(Math.min(64 * 1024, MAX_FILE + 1 - size));
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (!bytesRead) break;
size += bytesRead;
if (size > MAX_FILE) throw new Error('Testnet ledger file exceeds 128 MiB');
chunks.push(buffer.subarray(0, bytesRead));
}
text = Buffer.concat(chunks).toString('utf8');
} finally { await handle.close(); }
const disk = diskSchema.parse(JSON.parse(text)); // Mismatches/corruption fail closed, never reset.
for (const raw of disk.blocks) {
if (ledger.nodes.has(raw.hash)) throw new Error('Duplicate persisted block');
const node = await ledger.validate(raw);
ledger.nodes.set(node.block.hash, node);
if (node.work > ledger.tipWork()) ledger.tip = node.block.hash;
}
const state = structuredClone(ledger.tipState());
const seen = new Set(ledger.canonical().flatMap(b => b.transactions.map(transferId)));
for (const tx of disk.pendingTransfers) {
const id = transferId(tx);
if (seen.has(id)) throw new Error('Duplicate persisted transfer');
applyTransfer(state, tx, ledger.height() + 1);
seen.add(id);
}
ledger.pending = disk.pendingTransfers;
return ledger;
}
get size(): number { return this.nodes.size; }
get allBlocks(): TestnetBlock[] { return this.inventory(0, MAX_BLOCKS); }
block(hash: string): TestnetBlock | undefined {
const block = this.nodes.get(hash)?.block;
return block ? structuredClone(block) : undefined;
}
/** Append-only insertion order is topological, including all valid side branches.
* HTTP callers should request limit=32 and return next=offset+length or null.
*/
inventory(offset: number, limit: number): TestnetBlock[] {
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_BLOCKS) {
throw new Error('Invalid inventory range');
}
return structuredClone(Array.from(this.nodes.values()).slice(offset, offset + limit).map(n => n.block));
}
async submit(raw: unknown): Promise<{ accepted: boolean; reorg: boolean }> {
const input = structuredClone(raw);
return this.serialize(async () => {
const parsed = blockSchema.parse(input);
if (this.nodes.has(parsed.hash)) return { accepted: false, reorg: false };
if (this.size >= MAX_BLOCKS) throw new Error('Experimental testnet limit: 10000 stored blocks');
const node = await this.validate(parsed);
const wins = node.work > this.tipWork();
const oldChain = this.canonical();
const chain = wins ? [...this.branch(node.block.previousHash), node.block] : oldChain;
const hashes = new Set(chain.map(b => b.hash));
const disconnected = wins ? oldChain.filter(b => !hashes.has(b.hash)) : [];
const pending = wins ? this.filterPending(node.state, chain,
[...disconnected.flatMap(b => b.transactions), ...this.pending]) : this.pending;
// Nothing visible changes until the atomic replacement succeeds.
await this.persist([...Array.from(this.nodes.values(), n => n.block), node.block], pending);
this.nodes.set(node.block.hash, node);
if (wins) this.tip = node.block.hash;
this.pending = pending;
return { accepted: true, reorg: disconnected.length > 0 };
});
}
async submitTransfer(raw: unknown): Promise<string> {
const input = structuredClone(raw);
return this.serialize(async () => {
const tx = transferSchema.parse(input);
const id = transferId(tx);
if (this.pending.some(t => transferId(t) === id)) return id;
if (this.pending.length >= MAX_PENDING) throw new Error('Testnet mempool limit: 256 transfers');
const state = structuredClone(this.tipState());
for (const pending of this.pending) applyTransfer(state, pending, this.height() + 1);
applyTransfer(state, tx, this.height() + 1); // No speculative issuance.
const pending = [...this.pending, tx];
await this.persist(Array.from(this.nodes.values(), n => n.block), pending);
this.pending = pending;
return id;
});
}
template(owner: string, publicKey: string): MiningTemplate {
return makeTemplate(this.canonical(), this.tipState(), owner, publicKey,
structuredClone(this.pending.slice(0, CONSENSUS.maxTransactions)));
}
snapshot(): TestnetSnapshot {
const blocks = this.canonical();
return structuredClone({ network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
tip: this.tip, cumulativeWork: this.tipWork().toString(), blocks,
transfers: blocks.flatMap(b => b.transactions), pendingTransfers: this.pending,
nextArticle: selectArticle(this.tip), peers: this.peers, algorithm: 'RandomX',
maturity: CONSENSUS.coinbaseMaturity });
}
private height(): number { return this.nodes.get(this.tip)?.block.height ?? 0; }
private tipState(): NftState { return this.nodes.get(this.tip)?.state ?? {}; }
private tipWork(): bigint { return this.nodes.get(this.tip)?.work ?? 0n; }
private branch(hash: string): TestnetBlock[] {
const result: TestnetBlock[] = [];
while (hash !== GENESIS_HASH) {
const node = this.nodes.get(hash);
if (!node) throw new Error('Unknown parent');
result.push(node.block);
hash = node.block.previousHash;
}
return result.reverse();
}
private canonical(): TestnetBlock[] { return this.branch(this.tip); }
private async validate(raw: TestnetBlock): Promise<Node> {
const ancestors = this.branch(raw.previousHash);
const parent = this.nodes.get(raw.previousHash);
const { block, state } = await validateBlock(raw, ancestors, parent?.state ?? {}, this.pow);
return { block, state, work: (parent?.work ?? 0n) + workFor(block.target) };
}
private filterPending(parent: NftState, chain: TestnetBlock[], candidates: NftTransfer[]): NftTransfer[] {
const state = structuredClone(parent);
const seen = new Set(chain.flatMap(b => b.transactions.map(transferId)));
const pending: NftTransfer[] = [];
for (const tx of candidates) {
if (pending.length === MAX_PENDING) break;
const id = transferId(tx);
if (seen.has(id)) continue;
try { applyTransfer(state, tx, chain.length + 1); }
catch { continue; } // Disconnected issuance, immature coins and conflicts are not replayable.
seen.add(id); pending.push(tx);
}
return pending;
}
private serialize<T>(operation: () => Promise<T>): Promise<T> {
if (this.queued >= 256) return Promise.reject(new Error('Testnet mutation queue full'));
this.queued++;
const result = this.queue.then(operation);
this.queue = result.catch(() => undefined).finally(() => { this.queued--; });
return result;
}
private async persist(blocks: TestnetBlock[], pendingTransfers: NftTransfer[]): Promise<void> {
const data = JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, blocks, pendingTransfers });
if (Buffer.byteLength(data) > MAX_FILE) throw new Error('Testnet ledger file exceeds 128 MiB');
const temp = `${this.file}.${randomUUID()}.tmp`;
try {
const handle = await open(temp, 'wx', 0o600);
try { await handle.writeFile(data); await handle.sync(); }
finally { await handle.close(); }
// Windows scanners can briefly hold the destination open without delete sharing.
// Retry only transient sharing failures; never unlink the old durable ledger.
for (let attempt = 0; ; attempt++) {
try { await rename(temp, this.file); break; }
catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (process.platform !== 'win32' || attempt >= 5 ||
!['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '')) throw error;
await delay(10 * 2 ** attempt);
}
}
} finally { await unlink(temp).catch(() => undefined); }
}
}
+93
View File
@@ -0,0 +1,93 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { stdin, stdout } from 'node:process';
import { randomInt } from 'node:crypto';
import { decryptWallet } from '../../shared/wallet-backup.ts';
import { blockClaimMessage, NETWORK, type MiningTemplate, type TestnetBlock } from '../../shared/testnet.ts';
import { CHAIN_ID, sha256, miningBlob, mintId, blockSchema } from './consensus.ts';
import { RandomX } from './randomx.ts';
const args = process.argv.slice(2);
const arg = (key: string) => { const i = args.indexOf(key); return i < 0 ? undefined : args[i+1]; };
if (!args.includes('--consent') || !arg('--wallet')) {
console.error('Usage: npm run miner -- --wallet <encrypted-backup.json> --consent [--node http://127.0.0.1:3001] [--fast] [--blocks 1]');
console.error('Native RandomX mining uses CPU, energy and 256 MiB (light) or roughly 2.1 GiB (fast). Ctrl+C stops mining.');
process.exit(1);
}
const endpoint = new URL(arg('--node') ?? 'http://127.0.0.1:3001');
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/') throw new Error('Node must be an HTTP(S) origin');
if (endpoint.protocol !== 'https:' && !['localhost','127.0.0.1','[::1]'].includes(endpoint.hostname) && !args.includes('--allow-insecure-node')) throw new Error('Non-loopback HTTP requires --allow-insecure-node');
const limit = Number(arg('--blocks') ?? 1);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100000) throw new Error('Invalid block limit');
const file = await readFile(resolve(arg('--wallet')!));
if (file.length > 32768) throw new Error('Wallet backup too large');
if (!stdin.isTTY || !stdin.setRawMode) throw new Error('Run the native miner in an interactive terminal to unlock the encrypted wallet.');
console.log('Unlocking locally. The backup and password are never sent to a node.');
async function passwordPrompt(): Promise<string> {
stdout.write('Backup password: ');
stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8');
return new Promise((resolve,reject) => {
let password = '';
const finish = () => { stdin.off('data', handler); stdin.setRawMode(false); stdin.pause(); stdout.write('\n'); };
const handler = (data: string) => {
for (const c of data) {
if (c === '\u0003') { finish(); reject(new Error('Unlock cancelled')); return; }
if (c === '\r' || c === '\n') { finish(); resolve(password); return; }
if (c === '\u007f' || c === '\b') password = password.slice(0,-1);
else if (c >= ' ' && password.length < 1024) password += c;
}
};
stdin.on('data', handler);
});
}
let password = await passwordPrompt();
const wallet = await decryptWallet(JSON.parse(file.toString('utf8')), password);
password = '';
console.log(`RandomX ${args.includes('--fast') ? 'FAST (~2.1 GiB)' : 'LIGHT (256 MiB)'} miner · ${NETWORK}`);
console.log(`Reward address: ${wallet.address}\nNode: ${endpoint.origin}\nMining at most ${limit} blocks. Ctrl+C stops.`);
const engine = new RandomX({ fast: args.includes('--fast') });
let stopping = false;
const stop = () => { stopping = true; engine.close(); };
process.once('SIGINT',stop); process.once('SIGTERM',stop);
async function api(path: string, body?: unknown) {
const response = await fetch(new URL(path,endpoint), { method: body ? 'POST' : 'GET', redirect:'error',
signal: AbortSignal.timeout(15000), headers: body ? {'Content-Type':'application/json'} : {}, body: body ? JSON.stringify(body) : undefined });
const text = await response.text();
if (text.length > 131072) throw new Error('Node response too large');
const data = JSON.parse(text);
if (!response.ok) throw new Error(data.error ?? `Node rejected request (${response.status})`);
return data;
}
try {
let accepted = 0;
while (!stopping && accepted < limit) {
const template = await api('/api/mining/template', { owner:wallet.address, publicKey:wallet.publicKey }) as MiningTemplate;
if (template.header.chainId !== CHAIN_ID || template.header.network !== NETWORK || template.nonceOffset !== 32) throw new Error('Node returned incompatible network');
const header = template.header;
header.nonce = randomInt(0, 2 ** 32);
const started = Date.now(); let attempts = 0;
let solved = false;
while (!stopping && !solved) {
const result = await engine.mine(header.key, miningBlob(header), header.target, 32, 16);
attempts += 16;
if (result) {
header.nonce = result.nonce;
const hash = sha256(miningBlob(header));
const signature = Buffer.from(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},wallet.privateKey,new TextEncoder().encode(blockClaimMessage(hash)))).toString('base64');
const block: TestnetBlock = { ...header, hash, tokenId:mintId(header), powHash:result.hash, signature, transactions:template.transactions };
blockSchema.parse(block);
try { await api('/api/blocks',block); accepted++; console.log(`Accepted block #${header.height} · NFT ${block.tokenId} · ${accepted}/${limit}`); }
catch (e) { console.error((e as Error).message); }
solved = true;
} else {
header.nonce += 16;
stdout.write(`\r${(attempts / ((Date.now()-started)/1000)).toFixed(1)} H/s · searching block #${header.height} `);
const status = await api('/p2p/status');
if (status.tip !== header.previousHash || Date.now()/1000 - header.createdAt > 60) { console.log('\nRefreshing stale work'); break; }
// Explicit idle interval; native miner never starts with the web page.
await new Promise(resolve => setTimeout(resolve,100));
}
}
}
} catch(e) { if(!stopping) { console.error((e as Error).message); process.exitCode=1; } }
finally { engine.close(); process.off('SIGINT',stop);process.off('SIGTERM',stop); }
+195
View File
@@ -0,0 +1,195 @@
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import dns from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { z } from 'zod';
import { NETWORK, type TestnetBlock, type NftTransfer } from '../../shared/testnet.ts';
import { CHAIN_ID, blockSchema, transferId, transferSchema } from './consensus.ts';
import type { Ledger } from './ledger.ts';
const MAX_BODY = 2 * 1024 * 1024;
const excluded = new BlockList();
for (const [ip, prefix] of [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10],
['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
['192.0.2.0', 24], ['192.168.0.0', 16], ['192.88.99.0', 24], ['198.18.0.0', 15],
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 3] ] as const) excluded.addSubnet(ip, prefix, 'ipv4');
const globalV6 = new BlockList();
globalV6.addSubnet('2000::', 3, 'ipv6');
for (const [ip, prefix] of [['2001::', 23], ['2001:db8::', 32], ['2002::', 16],
['3fff::', 20]] as const) excluded.addSubnet(ip, prefix, 'ipv6');
function publicAddress(ip: string): boolean {
const family = isIP(ip);
if (family === 4) return !excluded.check(ip, 'ipv4');
// Only global unicast: also rejects mapped IPv4, local, unspecified and multicast IPv6.
return family === 6 && globalV6.check(ip, 'ipv6') && !excluded.check(ip, 'ipv6');
}
const remoteStatus = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
size: z.number().int().min(0).max(10_000), tip: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict();
const pageSchema = z.object({ blocks: z.array(blockSchema).max(32),
next: z.number().int().min(0).max(10_000).nullable() }).strict();
const poolSchema = z.object({ transactions: z.array(transferSchema).max(256) }).strict();
type Peer = { url: string; connected: boolean; error?: string; offset: number; busy: boolean };
/** Outbound-only fixed allowlist. HTTP routes are deliberately left to the caller.
* allowPrivate is an explicit operator permission for local/Docker networks, not peer input.
*/
export class PeerNetwork {
private readonly peers: Peer[];
private readonly ledger: Ledger;
private readonly allowPrivate: boolean;
private readonly intervalMs: number;
private timer: ReturnType<typeof setInterval> | undefined;
private closed = false;
private readonly active = new Set<AbortController>();
constructor(options: { ledger: Ledger; urls: string[]; allowPrivate: boolean; intervalMs?: number }) {
this.ledger = options.ledger;
this.allowPrivate = options.allowPrivate === true;
this.intervalMs = options.intervalMs ?? 5000;
if (!Number.isSafeInteger(this.intervalMs) || this.intervalMs < 1000 || this.intervalMs > 3_600_000) {
throw new Error('Peer interval must be 1000..3600000 ms');
}
if (options.urls.length > 16) throw new Error('At most 16 configured peers');
this.peers = [...new Set(options.urls.map(raw => {
if (raw.length > 2048) throw new Error('Peer URL too long');
// Reject forbidden syntax before URL normalization can erase paths or empty delimiters.
if (!/^https?:\/\/[^/?#\\@\s]+\/?$/i.test(raw)) throw new Error('Peer URL must contain only an origin');
const url = new URL(raw);
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash ||
(url.protocol !== 'https:' && !(this.allowPrivate && url.protocol === 'http:'))) {
throw new Error('Peer URL must be an HTTPS origin without credentials/path/query/fragment');
}
const host = url.hostname.replace(/^\[|\]$/g, '');
if (!this.allowPrivate && (host.toLowerCase().replace(/\.$/, '') === 'localhost' ||
host.toLowerCase().replace(/\.$/, '').endsWith('.localhost') ||
(isIP(host) && !publicAddress(host)))) throw new Error('Private peer requires allowPrivate');
return url.origin;
}))].map(url => ({ url, connected: false, offset: 0, busy: false }));
}
start(): void {
if (this.closed || this.timer) return;
const pass = () => { for (const peer of this.peers) void this.run(peer, () => this.sync(peer)); };
pass();
this.timer = setInterval(pass, this.intervalMs);
this.timer.unref();
}
close(): void {
this.closed = true;
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
for (const controller of this.active) controller.abort();
for (const peer of this.peers) peer.connected = false;
this.updateCount();
}
status(): { url: string; connected: boolean; error?: string }[] {
return this.peers.map(({ url, connected, error }) => ({ url, connected, ...(error ? { error } : {}) }));
}
async broadcastBlock(block: TestnetBlock): Promise<void> {
const known = this.ledger.block(blockSchema.parse(block).hash);
if (!known) throw new Error('Cannot broadcast an unaccepted block');
await this.broadcast('/p2p/block', known);
}
async broadcastTransfer(tx: NftTransfer): Promise<void> {
const id = transferId(transferSchema.parse(tx));
const snapshot = this.ledger.snapshot();
const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transferId(t) === id);
if (!known) throw new Error('Cannot broadcast an unaccepted transfer');
await this.broadcast('/p2p/transaction', known);
}
private async broadcast(path: string, body: unknown): Promise<void> {
// Drop gossip to busy/disconnected peers instead of building an unbounded retry queue.
await Promise.all(this.peers.filter(p => p.connected).map(p => this.run(p, async () => {
await this.request(p, path, body);
})));
}
private async run(peer: Peer, operation: () => Promise<void>): Promise<void> {
if (this.closed || peer.busy) return;
peer.busy = true;
try {
await operation();
if (!this.closed) { peer.connected = true; delete peer.error; }
} catch (error) {
peer.connected = false;
peer.error = (error instanceof Error ? error.message : 'Peer request failed').replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 200);
} finally { peer.busy = false; this.updateCount(); }
}
private updateCount(): void { this.ledger.peers = this.peers.filter(p => p.connected).length; }
private async sync(peer: Peer): Promise<void> {
const status = remoteStatus.parse(await this.request(peer, '/p2p/status'));
if (peer.offset > status.size) peer.offset = 0;
if (peer.offset < status.size) {
const page = pageSchema.parse(await this.request(peer, `/p2p/blocks?offset=${peer.offset}`));
const end = peer.offset + page.blocks.length;
if (!page.blocks.length || end > 10_000 ||
(page.next !== null && (page.next !== end || end >= 10_000)) ||
(page.next === null && end < status.size)) throw new Error('Invalid peer pagination');
for (const block of page.blocks) {
if (this.closed) throw new Error('Peer network closed');
if (!this.ledger.block(block.hash)) await this.ledger.submit(block);
}
peer.offset = end; // At most 32 blocks per peer/pass, never trust a remote cursor.
}
const pool = poolSchema.parse(await this.request(peer, '/p2p/mempool'));
for (const tx of pool.transactions) {
if (this.closed) throw new Error('Peer network closed');
// Valid remote transactions may be immature/conflicting on our current tip.
try { await this.ledger.submitTransfer(tx); } catch { /* Independently reject; do not relay. */ }
}
}
private async request(peer: Peer, path: string, body?: unknown): Promise<unknown> {
if (this.closed) throw new Error('Peer network closed');
const url = new URL(path, peer.url);
const controller = new AbortController();
this.active.add(controller);
const timeout = setTimeout(() => controller.abort(), 10_000);
const encoded = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
try {
if (encoded && encoded.length > MAX_BODY) throw new Error('Peer request exceeds 2 MiB');
return await new Promise<unknown>((resolve, reject) => {
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
method: encoded ? 'POST' : 'GET', signal: controller.signal, agent: false,
// Supported by net.connect; absent from HTTP's narrower RequestOptions type.
...{ autoSelectFamily: false }, maxHeaderSize: 16 * 1024,
headers: { accept: 'application/json', ...(encoded ? {
'content-type': 'application/json', 'content-length': encoded.length } : {}) },
// Validate every DNS answer and pin the connection to that exact lookup result.
// A separate preflight lookup followed by fetch would allow DNS rebinding.
lookup: (host, _options, callback) => {
void dns.lookup(host, { all: true, verbatim: true }).then(addresses => {
if (!addresses.length || (!this.allowPrivate && addresses.some(a => !publicAddress(a.address)))) {
callback(new Error('Peer DNS resolves to a non-public address'), '', 4);
} else callback(null, addresses[0].address, addresses[0].family);
}, () => callback(new Error('Peer DNS lookup failed'), '', 4));
},
}, response => {
// Node's HTTP client never follows redirects. Reject them, including same-origin.
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
response.destroy(); reject(new Error(`Peer HTTP ${response.statusCode}`)); return;
}
if (!/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '') ||
(response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) {
response.destroy(); reject(new Error('Peer must return uncompressed JSON')); return;
}
const chunks: Buffer[] = [];
let length = 0;
response.on('data', (chunk: Buffer) => {
length += chunk.length;
if (length > MAX_BODY) {
response.destroy(new Error('Peer response exceeds 2 MiB')); return;
}
chunks.push(chunk);
});
response.on('error', reject);
response.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
catch { reject(new Error('Invalid peer JSON')); }
});
});
request.on('error', reject);
request.end(encoded);
});
} finally { clearTimeout(timeout); this.active.delete(controller); }
}
}
+208
View File
@@ -0,0 +1,208 @@
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();
}
}
}