94 lines
5.9 KiB
TypeScript
94 lines
5.9 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, 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); }
|