Files
Matt Hess 733ecd2681 Migrate all JS tests to Rust: 9-crate workspace, 703 tests, 0 JS remaining
Add root Cargo workspace with 9 crates: salvium-crypto (extended),
  salvium-types, salvium-consensus, salvium-wallet, salvium-tx,
  salvium-rpc, salvium-miner (extended), salvium-cli, salvium-multisig.

  New modules: chain_state, block_weight, alt_chain, validation,
  offline signing, stake lifecycle, wallet sync/query/encryption/utxo,
  randomx utilities, and full multisig crate with CARROT support.

  Delete 188 JS test/helper/debug files; archive integration test
  scripts to test/legacy-js/ for live testnet use. Testnet integration
  tests (transfer, stake, burn, convert, sweep) remain as #[ignore]-
  gated Rust tests runnable with --ignored against a live daemon.
2026-02-17 23:09:35 +00:00

558 lines
19 KiB
JavaScript

#!/usr/bin/env bun
/**
* Solo Miner — mines blocks against a daemon using WASM or Rust RandomX.
*
* Supports:
* - WASM JIT: multi-threaded via worker pool (light mode)
* - Rust native: spawns salvium-miner binary (light or full mode)
*
* Usage:
* bun test/solo-miner.js --backend <wasm|rust> --blocks <N> --address <ADDR> [options]
*
* Options:
* --backend, -b wasm|rust (default: wasm)
* --mode, -m light|full (default: light)
* --blocks, -n Number of blocks to mine (default: 5)
* --address, -a Wallet address for mining rewards (required)
* --daemon, -d Daemon URL (default: http://node12.whiskymine.io:29081)
* --threads, -t Number of mining threads (default: 4, max: 4)
*/
import { Worker } from 'worker_threads';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { DaemonRPC } from '../src/rpc/daemon.js';
import { randomx_init_cache } from '../src/randomx/vendor/index.js';
import {
findNonceOffset, formatBlockForSubmission, formatHashrate, formatDuration
} from '../src/mining.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RUST_MINER_PATH = join(__dirname, '../crates/salvium-miner/target/release/salvium-miner');
// ─── Parse CLI args ──────────────────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
backend: 'wasm',
mode: 'light',
blocks: 5,
address: '',
daemon: 'http://node12.whiskymine.io:29081',
threads: 4,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--backend': case '-b': opts.backend = args[++i]; break;
case '--mode': case '-m': opts.mode = args[++i]; break;
case '--blocks': case '-n': opts.blocks = parseInt(args[++i], 10); break;
case '--address': case '-a': opts.address = args[++i]; break;
case '--daemon': case '-d': opts.daemon = args[++i]; break;
case '--threads': case '-t': opts.threads = Math.min(4, parseInt(args[++i], 10)); break;
case '--help': case '-h':
console.log('Usage: bun test/solo-miner.js --backend <js|wasm|rust> --blocks <N> --address <ADDR> [--mode light|full] [--threads T] [--daemon URL]');
process.exit(0);
}
}
if (!opts.address) {
console.error('Error: --address is required');
process.exit(1);
}
if (!['wasm', 'rust'].includes(opts.backend)) {
console.error('Error: --backend must be "wasm" or "rust"');
process.exit(1);
}
if (!['light', 'full'].includes(opts.mode)) {
console.error('Error: --mode must be "light" or "full"');
process.exit(1);
}
// WASM full mode not implemented
if (opts.backend === 'wasm' && opts.mode === 'full') {
console.log('Note: WASM full mode (2GB dataset) not yet implemented, using light mode.');
opts.mode = 'light';
}
return opts;
}
// ─── Hex helpers ─────────────────────────────────────────────────────────────
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
return bytes;
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Worker Pool Mining (WASM-JIT backend) ───────────────────────────────────
class WorkerMinerPool {
/**
* @param {number} numThreads
*/
constructor(numThreads) {
this.numThreads = numThreads;
this.workers = [];
this.ready = false;
this.workerPath = new URL('../src/randomx/randomx-worker.js', import.meta.url).pathname;
this._handlers = []; // Track active handlers for cleanup
this._jobCounter = 0;
}
async init(seedHash) {
const seedBytes = hexToBytes(seedHash);
// Terminate old workers if re-initializing
this.terminate();
// Initialize cache ONCE with shared memory (256MB total, not 256MB per worker)
const cache = randomx_init_cache(seedBytes, { shared: true });
const initPromises = [];
for (let i = 0; i < this.numThreads; i++) {
// Workers receive shared cache via workerData — no per-worker init needed
const worker = new Worker(this.workerPath, {
workerData: {
id: i,
memory: cache.memory,
thunk: cache.thunk,
vm: cache.vm,
}
});
this.workers.push(worker);
const p = new Promise((resolve, reject) => {
const handler = (msg) => {
if (msg.type === 'ready') {
worker.off('message', handler);
resolve();
} else if (msg.type === 'error') {
reject(new Error(msg.error));
}
};
worker.on('message', handler);
worker.on('error', reject);
});
initPromises.push(p);
}
await Promise.all(initPromises);
this.ready = true;
console.log(` ${this.numThreads} WASM-JIT workers initialized (256MB shared cache)`);
}
/**
* Mine a block: each worker searches a different nonce range.
* Workers use async chunked mining, so sending a new mine message
* cancels the previous one without needing to kill/respawn workers.
*
* Uses jobId to prevent stale messages from previous blocks being
* accepted by the current block's handler.
*
* Returns { nonce, hash, hashCount } on success.
*/
async mineBlock(hashingBlob, nonceOffset, difficulty) {
const RANGE_PER_WORKER = 0x10000000; // ~268M nonces per worker
const baseNonce = Math.floor(Math.random() * 0x40000000);
const jobId = ++this._jobCounter;
// Clean up any leftover handlers from previous blocks
for (const { worker, handler } of this._handlers) {
worker.off('message', handler);
}
this._handlers = [];
// Cancel any in-flight mining from previous blocks
for (const w of this.workers) {
w.postMessage({ type: 'cancel' });
}
return new Promise((resolve, reject) => {
let found = false;
let completed = 0;
let totalHashCount = 0;
const workerHashes = new Array(this.workers.length).fill(0);
const mineStart = Date.now();
for (let i = 0; i < this.workers.length; i++) {
const worker = this.workers[i];
const startNonce = (baseNonce + i * RANGE_PER_WORKER) >>> 0;
const endNonce = (startNonce + RANGE_PER_WORKER) >>> 0;
const handler = (msg) => {
// Ignore messages from previous jobs
if (msg.jobId !== undefined && msg.jobId !== jobId) return;
if (found) return;
if (msg.type === 'found') {
found = true;
// Cancel all workers and remove all handlers
for (const w of this.workers) {
w.postMessage({ type: 'cancel' });
}
for (const h of this._handlers) {
h.worker.off('message', h.handler);
}
this._handlers = [];
totalHashCount += msg.hashCount || 0;
resolve({ nonce: msg.nonce, hash: msg.hash, hashCount: totalHashCount });
} else if (msg.type === 'progress') {
workerHashes[i] = msg.hashCount || 0;
const runningTotal = workerHashes.reduce((a, b) => a + b, 0);
const elapsed = (Date.now() - mineStart) / 1000;
if (elapsed > 0.5) {
const hr = runningTotal / elapsed;
process.stderr.write(`\r ${runningTotal} hashes, ${formatHashrate(hr)}...`);
}
} else if (msg.type === 'notfound') {
completed++;
totalHashCount += msg.hashCount || 0;
if (completed === this.workers.length && !found) {
for (const h of this._handlers) {
h.worker.off('message', h.handler);
}
this._handlers = [];
reject(new Error('No nonce found in range'));
}
} else if (msg.type === 'error') {
if (!found) {
for (const h of this._handlers) {
h.worker.off('message', h.handler);
}
this._handlers = [];
reject(new Error(msg.error));
}
}
};
worker.on('message', handler);
this._handlers.push({ worker, handler });
worker.postMessage({
type: 'mine',
id: i,
jobId,
input: {
template: Array.from(hashingBlob),
nonceOffset,
difficulty: Number(difficulty),
startNonce,
endNonce,
}
});
}
});
}
terminate() {
for (const w of this.workers) w.terminate();
this.workers = [];
this.ready = false;
}
}
// ─── Rust Native Mining (child process) ─────────────────────────────────────
class RustMiner {
constructor(opts) {
this.daemon = opts.daemon;
this.address = opts.address;
this.threads = opts.threads;
this.light = opts.mode === 'light';
this.proc = null;
}
async init(_seedHash) {
const { access } = await import('fs/promises');
try {
await access(RUST_MINER_PATH);
} catch {
throw new Error(`Rust miner binary not found at ${RUST_MINER_PATH}. Run: cargo build --release -p salvium-miner`);
}
const modeStr = this.light ? 'light (256MB/thread)' : 'full (2GB shared dataset)';
console.log(` Rust miner ready: ${this.threads} threads, ${modeStr}`);
}
async mineNBlocks(targetBlocks) {
const args = [
'--daemon', this.daemon,
'--wallet', this.address,
'--threads', String(this.threads),
];
if (this.light) args.push('--light');
return new Promise((resolve, reject) => {
const results = [];
let totalHashes = 0;
const startTime = Date.now();
let stderrBuffer = '';
this.proc = spawn(RUST_MINER_PATH, args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.proc.on('error', (err) => {
reject(new Error(`Failed to spawn Rust miner: ${err.message}`));
});
this.proc.stderr.on('data', (chunk) => {
const text = chunk.toString();
stderrBuffer += text;
const lines = stderrBuffer.split('\n');
stderrBuffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
const foundMatch = trimmed.match(/BLOCK FOUND at height (\d+).*nonce=(\d+)/);
if (foundMatch) {
results.push({
height: parseInt(foundMatch[1], 10),
nonce: parseInt(foundMatch[2], 10),
elapsed: 0,
status: 'pending',
});
}
if (trimmed.includes('Block accepted!')) {
const last = results[results.length - 1];
if (last && last.status === 'pending') {
last.status = 'accepted';
last.elapsed = (Date.now() - startTime) / 1000;
console.log(` Block ${last.height} ACCEPTED (nonce=${last.nonce})`);
}
if (results.filter(r => r.status === 'accepted').length >= targetBlocks) {
this.proc.kill('SIGTERM');
}
}
if (trimmed.includes('Block rejected:')) {
const last = results[results.length - 1];
if (last && last.status === 'pending') {
last.status = 'rejected';
last.elapsed = (Date.now() - startTime) / 1000;
console.log(` Block ${last.height} REJECTED: ${trimmed}`);
}
}
const statsMatch = trimmed.match(/Hashes:\s*(\d+)/);
if (statsMatch) totalHashes = parseInt(statsMatch[1], 10);
const hrMatch = trimmed.match(/\[H=\d+\]\s+([\d.]+ [KMG]?H\/s)/);
if (hrMatch) {
process.stderr.write(`\r Rust: ${hrMatch[1]}, ${results.filter(r => r.status === 'accepted').length}/${targetBlocks} blocks...`);
}
}
});
this.proc.stdout.on('data', () => {});
this.proc.on('close', () => {
process.stderr.write('\r' + ' '.repeat(80) + '\r');
const totalElapsed = (Date.now() - startTime) / 1000;
for (const r of results) {
if (r.status === 'pending') r.status = 'error';
}
resolve({ results, totalHashes, totalElapsed });
});
});
}
terminate() {
if (this.proc && !this.proc.killed) {
this.proc.kill('SIGTERM');
}
}
}
// ─── Main mining loop (WASM backend) ──────────────────────────────────────────
async function mineBlocksWorkerPool(opts) {
const daemon = new DaemonRPC({ url: opts.daemon });
const info = await daemon.getInfo();
const height = info.result?.height || info.height;
const label = `WASM-JIT light (${opts.threads} threads)`;
console.log(`\nSolo Miner — ${label}`);
console.log(`Daemon: ${opts.daemon} (height ${height})`);
console.log(`Target: ${opts.blocks} blocks → ${opts.address.slice(0, 20)}...`);
console.log();
const tmpl0 = await daemon.getBlockTemplate(opts.address, 8);
let currentSeedHash = tmpl0.result.seed_hash;
const miner = new WorkerMinerPool(opts.threads);
console.log(`Initializing RandomX (seed: ${currentSeedHash.slice(0, 16)}...)...`);
await miner.init(currentSeedHash);
console.log();
const results = [];
let totalHashes = 0;
const overallStart = Date.now();
for (let blockNum = 0; blockNum < opts.blocks; blockNum++) {
const tmplResp = await daemon.getBlockTemplate(opts.address, 8);
const tmpl = tmplResp.result;
if (tmpl.seed_hash !== currentSeedHash) {
console.log(` Seed hash changed, re-initializing...`);
await miner.init(tmpl.seed_hash);
currentSeedHash = tmpl.seed_hash;
}
const difficulty = BigInt(tmpl.difficulty);
const tmplHeight = tmpl.height;
const hashingBlob = hexToBytes(tmpl.blockhashing_blob);
const templateBlob = hexToBytes(tmpl.blocktemplate_blob);
const nonceOffset = findNonceOffset(hashingBlob);
console.log(`[${blockNum + 1}/${opts.blocks}] Mining height ${tmplHeight} (diff ${difficulty})...`);
const blockStart = Date.now();
try {
const result = await miner.mineBlock(hashingBlob, nonceOffset, difficulty);
const blockElapsed = (Date.now() - blockStart) / 1000;
const hashCount = result.hashCount || Math.round(Number(difficulty) * 0.5);
const hr = hashCount / blockElapsed;
process.stderr.write('\r' + ' '.repeat(80) + '\r');
console.log(` FOUND! nonce=${result.nonce}, ${hashCount} hashes, time=${blockElapsed.toFixed(1)}s, ~${formatHashrate(hr)}`);
// Submit block
const templateNonceOffset = findNonceOffset(templateBlob);
const blockHex = formatBlockForSubmission(templateBlob, result.nonce, templateNonceOffset);
if (nonceOffset !== templateNonceOffset) {
console.log(` WARNING: nonce offset mismatch! hashing=${nonceOffset} template=${templateNonceOffset}`);
}
const submitResp = await daemon.submitBlock([blockHex]);
if (submitResp.result?.status === 'OK' || submitResp.error === undefined) {
console.log(` Block ${tmplHeight} ACCEPTED`);
results.push({ height: tmplHeight, nonce: result.nonce, elapsed: blockElapsed, status: 'accepted' });
} else {
console.log(` Block ${tmplHeight} REJECTED: ${JSON.stringify(submitResp)}`);
results.push({ height: tmplHeight, nonce: result.nonce, elapsed: blockElapsed, status: 'rejected' });
}
totalHashes += hashCount;
} catch (err) {
console.log(` Mining error: ${err.message}`);
results.push({ height: tmplHeight, nonce: 0, elapsed: 0, status: 'error' });
}
}
miner.terminate();
const totalElapsed = (Date.now() - overallStart) / 1000;
return { results, totalHashes, totalElapsed };
}
// ─── Main mining loop (Rust backend) ─────────────────────────────────────────
async function mineBlocksRust(opts) {
const daemon = new DaemonRPC({ url: opts.daemon });
const info = await daemon.getInfo();
const height = info.result?.height || info.height;
const modeLabel = opts.mode === 'full' ? 'full (2GB dataset)' : 'light (256MB/thread)';
const backendLabel = `Rust native ${modeLabel} (${opts.threads} threads)`;
console.log(`\nSolo Miner — ${backendLabel}`);
console.log(`Daemon: ${opts.daemon} (height ${height})`);
console.log(`Target: ${opts.blocks} blocks → ${opts.address.slice(0, 20)}...`);
console.log();
const miner = new RustMiner(opts);
await miner.init();
console.log();
console.log(`Mining ${opts.blocks} blocks with Rust native miner...`);
const { results, totalHashes, totalElapsed } = await miner.mineNBlocks(opts.blocks);
miner.terminate();
return { results, totalHashes, totalElapsed };
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
const opts = parseArgs();
let summary;
if (opts.backend === 'rust') {
summary = await mineBlocksRust(opts);
} else {
summary = await mineBlocksWorkerPool(opts);
}
const { results, totalHashes, totalElapsed } = summary;
const accepted = results.filter(r => r.status === 'accepted').length;
const avgBlockTime = totalElapsed / Math.max(1, results.length);
const backendLabel = opts.backend === 'rust'
? `Rust ${opts.mode} (${opts.threads} threads)`
: `WASM-JIT light (${opts.threads} threads)`;
console.log(`\n${'='.repeat(60)}`);
console.log(`Solo Miner Summary — ${backendLabel}`);
console.log(`${'='.repeat(60)}`);
console.log(`Blocks: ${accepted}/${opts.blocks} accepted`);
console.log(`Total time: ${formatDuration(totalElapsed)}`);
console.log(`Avg time/block: ${formatDuration(avgBlockTime)}`);
if (totalHashes > 0) {
console.log(`Total hashes: ${totalHashes}`);
console.log(`Avg hashrate: ${formatHashrate(totalHashes / totalElapsed)}`);
}
console.log();
for (const r of results) {
console.log(` Height ${r.height}: ${r.status} (${r.elapsed.toFixed(1)}s)`);
}
// JSON output for programmatic use
const jsonSummary = {
backend: opts.backend,
mode: opts.mode,
threads: opts.threads,
blocks: opts.blocks,
accepted,
rejected: results.filter(r => r.status === 'rejected').length,
errors: results.filter(r => r.status === 'error').length,
totalHashes,
totalElapsed,
avgBlockTime,
results,
};
if (process.env.SOLO_MINER_LOG) {
const { writeFile } = await import('fs/promises');
await writeFile(process.env.SOLO_MINER_LOG, JSON.stringify(jsonSummary, null, 2));
console.log(`\nLog written to ${process.env.SOLO_MINER_LOG}`);
}
if (accepted < opts.blocks) {
console.error(`\nWARNING: ${opts.blocks - accepted} blocks were not accepted!`);
process.exit(1);
}
}
main().catch(err => {
console.error(`Fatal: ${err.message}`);
process.exit(1);
});