Files
wikideck/server/testnet/miner.ts
T
2026-10-20 03:14:08 -04:00

97 lines
6.0 KiB
TypeScript

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, CONSENSUS, NETWORK, type MiningTemplate, type TestnetBlock } from '../../shared/testnet.ts';
import { CHAIN_ID, sha256, miningBlob, mintId, blockSchema } from './consensus.ts';
import { RandomX } from './randomx.ts';
import { assertLaunchAllowed } from '../launch.ts';
assertLaunchAllowed();
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(30000), 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 > CONSENSUS.targetSeconds * 2) { 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); }