randomx
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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);});
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user