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 --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 { 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); }