● Add native Rust RandomX miner with IPC mode and JS mining scripts
Native miner achieves ~360 H/s/thread (vs ~10 H/s in WASM) using hardware AES-NI/AVX2 via direct RandomX C FFI. Includes multi-threaded mining with shared 2GB dataset, JSON-lines IPC protocol for parent process control, and JS-based mining scripts for testnet.
This commit is contained in:
Generated
+1738
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "salvium-miner"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Native RandomX CPU miner for Salvium cryptocurrency"
|
||||
|
||||
[[bin]]
|
||||
name = "salvium-miner"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
randomx-rs = "1.4"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
hex = "0.4"
|
||||
num_cpus = "1"
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Salvium daemon JSON-RPC client
|
||||
//!
|
||||
//! Implements the subset of RPC methods needed for solo mining:
|
||||
//! get_block_template, submit_block, get_info
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct DaemonClient {
|
||||
url: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BlockTemplate {
|
||||
pub difficulty: u64,
|
||||
pub wide_difficulty: Option<String>,
|
||||
pub height: u64,
|
||||
pub seed_hash: String,
|
||||
pub next_seed_hash: Option<String>,
|
||||
pub blocktemplate_blob: String,
|
||||
pub blockhashing_blob: String,
|
||||
pub expected_reward: u64,
|
||||
pub prev_hash: String,
|
||||
pub reserved_offset: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DaemonInfo {
|
||||
pub height: u64,
|
||||
pub difficulty: u64,
|
||||
pub wide_difficulty: Option<String>,
|
||||
pub testnet: bool,
|
||||
pub mainnet: bool,
|
||||
pub synchronized: bool,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonRpcRequest {
|
||||
jsonrpc: &'static str,
|
||||
id: &'static str,
|
||||
method: String,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcResponse {
|
||||
result: Option<Value>,
|
||||
error: Option<Value>,
|
||||
}
|
||||
|
||||
impl DaemonClient {
|
||||
pub fn new(url: &str) -> Self {
|
||||
Self {
|
||||
url: url.trim_end_matches('/').to_string(),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: "2.0",
|
||||
id: "0",
|
||||
method: method.to_string(),
|
||||
params,
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&format!("{}/json_rpc", self.url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.map_err(|e| format!("HTTP error: {}", e))?;
|
||||
|
||||
let body: JsonRpcResponse = resp.json().map_err(|e| format!("JSON parse error: {}", e))?;
|
||||
|
||||
if let Some(err) = body.error {
|
||||
return Err(format!("RPC error: {}", err));
|
||||
}
|
||||
|
||||
body.result.ok_or_else(|| "No result in response".to_string())
|
||||
}
|
||||
|
||||
pub fn get_info(&self) -> Result<DaemonInfo, String> {
|
||||
let result = self.call("get_info", serde_json::json!({}))?;
|
||||
serde_json::from_value(result).map_err(|e| format!("Parse error: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_block_template(&self, address: &str, reserve_size: u32) -> Result<BlockTemplate, String> {
|
||||
let result = self.call(
|
||||
"get_block_template",
|
||||
serde_json::json!({
|
||||
"wallet_address": address,
|
||||
"reserve_size": reserve_size
|
||||
}),
|
||||
)?;
|
||||
serde_json::from_value(result).map_err(|e| format!("Parse error: {}", e))
|
||||
}
|
||||
|
||||
pub fn submit_block(&self, block_blob_hex: &str) -> Result<(), String> {
|
||||
// submit_block takes an array of hex strings (not a JSON-RPC params object)
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: "2.0",
|
||||
id: "0",
|
||||
method: "submit_block".to_string(),
|
||||
params: serde_json::json!([block_blob_hex]),
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&format!("{}/json_rpc", self.url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.map_err(|e| format!("HTTP error: {}", e))?;
|
||||
|
||||
let body: JsonRpcResponse = resp.json().map_err(|e| format!("JSON parse error: {}", e))?;
|
||||
|
||||
if let Some(err) = body.error {
|
||||
return Err(format!("Block rejected: {}", err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! IPC mode: JSON-lines protocol over stdin/stdout
|
||||
//!
|
||||
//! The parent process (Node.js/browser bridge) sends commands on stdin
|
||||
//! and receives events on stdout. All messages are single-line JSON.
|
||||
//!
|
||||
//! ## Protocol
|
||||
//!
|
||||
//! ### Parent → Miner (stdin)
|
||||
//!
|
||||
//! **init** — Initialize RandomX with a seed hash. Must be sent first.
|
||||
//! ```json
|
||||
//! {"method":"init","seed_hash":"abc123..."}
|
||||
//! ```
|
||||
//!
|
||||
//! **job** — Start mining a new job.
|
||||
//! ```json
|
||||
//! {"method":"job","job_id":"1","hashing_blob":"...","template_blob":"...","difficulty":99401,"height":46}
|
||||
//! ```
|
||||
//!
|
||||
//! **stop** — Stop mining (keeps engine alive for next job).
|
||||
//! ```json
|
||||
//! {"method":"stop"}
|
||||
//! ```
|
||||
//!
|
||||
//! **shutdown** — Exit the process.
|
||||
//! ```json
|
||||
//! {"method":"shutdown"}
|
||||
//! ```
|
||||
//!
|
||||
//! ### Miner → Parent (stdout)
|
||||
//!
|
||||
//! **ready** — Engine initialized, workers spawned.
|
||||
//! ```json
|
||||
//! {"event":"ready","threads":7,"mode":"full"}
|
||||
//! ```
|
||||
//!
|
||||
//! **hashrate** — Periodic stats (every 5s).
|
||||
//! ```json
|
||||
//! {"event":"hashrate","hashrate":2720.5,"hashes":54570,"elapsed":20.1}
|
||||
//! ```
|
||||
//!
|
||||
//! **block** — Block found!
|
||||
//! ```json
|
||||
//! {"event":"block","job_id":"1","nonce":12345,"hash":"...","blob_hex":"..."}
|
||||
//! ```
|
||||
//!
|
||||
//! **error** — Something went wrong.
|
||||
//! ```json
|
||||
//! {"event":"error","message":"..."}
|
||||
//! ```
|
||||
//!
|
||||
//! **stopped** — Mining stopped (in response to stop command).
|
||||
//! ```json
|
||||
//! {"event":"stopped"}
|
||||
//! ```
|
||||
|
||||
use crate::miner::{MiningEngine, MiningJob};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InMessage {
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
seed_hash: String,
|
||||
#[serde(default)]
|
||||
job_id: String,
|
||||
#[serde(default)]
|
||||
hashing_blob: String,
|
||||
#[serde(default)]
|
||||
template_blob: String,
|
||||
#[serde(default)]
|
||||
difficulty: u64,
|
||||
#[serde(default)]
|
||||
wide_difficulty: Option<String>,
|
||||
#[serde(default)]
|
||||
height: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OutMessage {
|
||||
event: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
threads: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hashrate: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hashes: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
elapsed: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
job_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
nonce: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
blob_hex: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl OutMessage {
|
||||
fn new(event: &str) -> Self {
|
||||
Self {
|
||||
event: event.to_string(),
|
||||
threads: None,
|
||||
mode: None,
|
||||
hashrate: None,
|
||||
hashes: None,
|
||||
elapsed: None,
|
||||
job_id: None,
|
||||
nonce: None,
|
||||
hash: None,
|
||||
blob_hex: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(msg: &OutMessage) {
|
||||
let mut stdout = io::stdout().lock();
|
||||
let _ = serde_json::to_writer(&mut stdout, msg);
|
||||
let _ = stdout.write_all(b"\n");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
|
||||
fn send_error(msg: &str) {
|
||||
let mut out = OutMessage::new("error");
|
||||
out.message = Some(msg.to_string());
|
||||
send(&out);
|
||||
}
|
||||
|
||||
/// Stdin line or EOF signal
|
||||
enum StdinEvent {
|
||||
Line(String),
|
||||
Eof,
|
||||
}
|
||||
|
||||
pub fn run_ipc(threads: usize, light: bool) {
|
||||
let mode_str = if light { "light" } else { "full" };
|
||||
eprintln!("[IPC] Waiting for commands on stdin (threads={}, mode={})", threads, mode_str);
|
||||
|
||||
// Read stdin on a dedicated thread so the main loop stays non-blocking
|
||||
let (stdin_tx, stdin_rx) = mpsc::channel::<StdinEvent>();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = io::stdin();
|
||||
let reader = stdin.lock();
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(l) => {
|
||||
if stdin_tx.send(StdinEvent::Line(l)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = stdin_tx.send(StdinEvent::Eof);
|
||||
});
|
||||
|
||||
let mut engine: Option<MiningEngine> = None;
|
||||
let mut current_job_id = String::new();
|
||||
let mut start_time = Instant::now();
|
||||
let mut last_stats = Instant::now();
|
||||
|
||||
loop {
|
||||
// Check for found blocks
|
||||
if let Some(ref eng) = engine {
|
||||
while let Some(block) = eng.try_recv_block() {
|
||||
let mut out = OutMessage::new("block");
|
||||
out.job_id = Some(current_job_id.clone());
|
||||
out.nonce = Some(block.nonce);
|
||||
out.hash = Some(hex::encode(&block.hash));
|
||||
out.blob_hex = Some(block.blob_hex);
|
||||
send(&out);
|
||||
}
|
||||
|
||||
// Send hashrate stats every 5 seconds
|
||||
if last_stats.elapsed() > Duration::from_secs(5) {
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let total = eng.hash_count.load(Ordering::Relaxed);
|
||||
let hr = if elapsed > 0.0 { total as f64 / elapsed } else { 0.0 };
|
||||
|
||||
let mut out = OutMessage::new("hashrate");
|
||||
out.hashrate = Some(hr);
|
||||
out.hashes = Some(total);
|
||||
out.elapsed = Some(elapsed);
|
||||
send(&out);
|
||||
last_stats = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Poll stdin (non-blocking, 100ms timeout)
|
||||
let event = stdin_rx.recv_timeout(Duration::from_millis(100));
|
||||
let line = match event {
|
||||
Ok(StdinEvent::Line(l)) => l,
|
||||
Ok(StdinEvent::Eof) => {
|
||||
eprintln!("[IPC] stdin closed, shutting down");
|
||||
if let Some(ref eng) = engine {
|
||||
eng.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
eprintln!("[IPC] stdin thread disconnected");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg: InMessage = match serde_json::from_str(line) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
send_error(&format!("Invalid JSON: {}", e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match msg.method.as_str() {
|
||||
"init" => {
|
||||
if let Some(ref eng) = engine {
|
||||
eng.stop();
|
||||
}
|
||||
|
||||
let seed_bytes = match hex::decode(&msg.seed_hash) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
send_error(&format!("Invalid seed_hash hex: {}", e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[IPC] Initializing RandomX (seed={}...)", &msg.seed_hash[..16.min(msg.seed_hash.len())]);
|
||||
|
||||
let result = if light {
|
||||
MiningEngine::new_light(threads, &seed_bytes)
|
||||
} else {
|
||||
MiningEngine::new_full(threads, &seed_bytes)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(eng) => {
|
||||
engine = Some(eng);
|
||||
start_time = Instant::now();
|
||||
last_stats = Instant::now();
|
||||
|
||||
let mut out = OutMessage::new("ready");
|
||||
out.threads = Some(threads);
|
||||
out.mode = Some(mode_str.to_string());
|
||||
send(&out);
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(&format!("Init failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"job" => {
|
||||
let eng = match engine {
|
||||
Some(ref e) => e,
|
||||
None => {
|
||||
send_error("Engine not initialized. Send 'init' first.");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let hashing_blob = match hex::decode(&msg.hashing_blob) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
send_error(&format!("Invalid hashing_blob hex: {}", e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let template_blob = match hex::decode(&msg.template_blob) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
send_error(&format!("Invalid template_blob hex: {}", e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let difficulty = crate::miner::parse_difficulty(
|
||||
msg.difficulty,
|
||||
msg.wide_difficulty.as_deref(),
|
||||
);
|
||||
|
||||
current_job_id = msg.job_id.clone();
|
||||
|
||||
eng.hash_count.store(0, Ordering::Relaxed);
|
||||
start_time = Instant::now();
|
||||
last_stats = Instant::now();
|
||||
|
||||
eng.send_job(MiningJob {
|
||||
job_id: 0,
|
||||
hashing_blob,
|
||||
template_blob,
|
||||
difficulty,
|
||||
height: msg.height,
|
||||
});
|
||||
|
||||
eprintln!("[IPC] Job {} started (height={}, diff={})", current_job_id, msg.height, difficulty);
|
||||
}
|
||||
|
||||
"stop" => {
|
||||
eprintln!("[IPC] Mining stopped");
|
||||
send(&OutMessage::new("stopped"));
|
||||
}
|
||||
|
||||
"shutdown" => {
|
||||
eprintln!("[IPC] Shutdown requested");
|
||||
if let Some(ref eng) = engine {
|
||||
eng.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
other => {
|
||||
send_error(&format!("Unknown method: {}", other));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use clap::Parser;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod daemon;
|
||||
mod ipc;
|
||||
mod miner;
|
||||
mod stratum;
|
||||
|
||||
use daemon::DaemonClient;
|
||||
use miner::{MiningEngine, MiningJob};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "salvium-miner")]
|
||||
#[command(about = "Native RandomX CPU miner for Salvium")]
|
||||
struct Args {
|
||||
/// Daemon RPC URL
|
||||
#[arg(short, long, default_value = "http://127.0.0.1:29081")]
|
||||
daemon: String,
|
||||
|
||||
/// Wallet address for mining rewards (not required in IPC mode)
|
||||
#[arg(short, long, default_value = "")]
|
||||
wallet: String,
|
||||
|
||||
/// Number of mining threads
|
||||
#[arg(short, long, default_value_t = default_threads())]
|
||||
threads: usize,
|
||||
|
||||
/// Use light mode (256MB per thread instead of 2GB shared dataset)
|
||||
#[arg(long)]
|
||||
light: bool,
|
||||
|
||||
/// Run benchmark for 20 seconds and exit
|
||||
#[arg(long)]
|
||||
benchmark: bool,
|
||||
|
||||
/// IPC mode: read jobs from stdin, write results to stdout (JSON lines)
|
||||
#[arg(long)]
|
||||
ipc: bool,
|
||||
}
|
||||
|
||||
fn default_threads() -> usize {
|
||||
std::cmp::max(1, num_cpus::get().saturating_sub(1))
|
||||
}
|
||||
|
||||
fn format_hashrate(hr: f64) -> String {
|
||||
if hr >= 1_000_000.0 {
|
||||
format!("{:.2} MH/s", hr / 1_000_000.0)
|
||||
} else if hr >= 1_000.0 {
|
||||
format!("{:.2} KH/s", hr / 1_000.0)
|
||||
} else {
|
||||
format!("{:.2} H/s", hr)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
if args.ipc {
|
||||
ipc::run_ipc(args.threads, args.light);
|
||||
return;
|
||||
}
|
||||
|
||||
if args.wallet.is_empty() {
|
||||
eprintln!("Error: --wallet is required (unless using --ipc mode)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!("Salvium Native RandomX Miner");
|
||||
eprintln!("============================");
|
||||
eprintln!("Daemon: {}", args.daemon);
|
||||
eprintln!("Wallet: {}...", &args.wallet[..20.min(args.wallet.len())]);
|
||||
eprintln!("Threads: {}", args.threads);
|
||||
eprintln!("Mode: {}", if args.light { "light (256MB/thread)" } else { "full (2GB shared dataset)" });
|
||||
eprintln!();
|
||||
|
||||
// Connect to daemon
|
||||
let client = DaemonClient::new(&args.daemon);
|
||||
|
||||
let info = match client.get_info() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("Cannot connect to daemon: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("Daemon height: {}, difficulty: {}", info.height, info.difficulty);
|
||||
|
||||
// Get initial block template
|
||||
let template = match client.get_block_template(&args.wallet, 8) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get block template: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let difficulty = miner::parse_difficulty(
|
||||
template.difficulty,
|
||||
template.wide_difficulty.as_deref(),
|
||||
);
|
||||
|
||||
eprintln!("Template: height={}, difficulty={}", template.height, difficulty);
|
||||
|
||||
let seed_bytes = hex::decode(&template.seed_hash).unwrap_or_else(|_| vec![0u8; 32]);
|
||||
let hashing_blob = hex::decode(&template.blockhashing_blob).expect("Invalid hashing blob");
|
||||
let template_blob = hex::decode(&template.blocktemplate_blob).expect("Invalid template blob");
|
||||
|
||||
// Initialize mining engine
|
||||
let engine = if args.light {
|
||||
MiningEngine::new_light(args.threads, &seed_bytes)
|
||||
} else {
|
||||
MiningEngine::new_full(args.threads, &seed_bytes)
|
||||
};
|
||||
|
||||
let engine = match engine {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to initialize mining engine: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up SIGINT handler
|
||||
let running = engine.running.clone();
|
||||
ctrlc_handler(running.clone());
|
||||
|
||||
eprintln!();
|
||||
eprintln!("Mining started. Press Ctrl+C to stop.");
|
||||
eprintln!();
|
||||
|
||||
// Send initial job
|
||||
let mut job_id = 0u64;
|
||||
let mut current_height = template.height;
|
||||
let mut current_difficulty = difficulty;
|
||||
let current_seed = template.seed_hash.clone();
|
||||
|
||||
engine.send_job(MiningJob {
|
||||
job_id,
|
||||
hashing_blob: hashing_blob.clone(),
|
||||
template_blob: template_blob.clone(),
|
||||
difficulty,
|
||||
height: template.height,
|
||||
});
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut last_template_fetch = Instant::now();
|
||||
let mut last_stats = Instant::now();
|
||||
let mut blocks_found = 0u64;
|
||||
|
||||
let mut current_prev_hash = template.prev_hash.clone();
|
||||
|
||||
// Main loop
|
||||
while engine.running.load(Ordering::Relaxed) {
|
||||
// Check for found blocks — submit only the first, discard rest
|
||||
let mut block_found = false;
|
||||
if let Some(block) = engine.try_recv_block() {
|
||||
// Skip stale blocks from old jobs
|
||||
if block.job_id != job_id {
|
||||
continue;
|
||||
}
|
||||
block_found = true;
|
||||
|
||||
// Drain any stale blocks
|
||||
let mut drained = 0;
|
||||
while engine.try_recv_block().is_some() { drained += 1; }
|
||||
|
||||
eprintln!();
|
||||
eprintln!("*** BLOCK FOUND at height {}! nonce={} ***", current_height, block.nonce);
|
||||
|
||||
match client.submit_block(&block.blob_hex) {
|
||||
Ok(()) => {
|
||||
blocks_found += 1;
|
||||
eprintln!("Block accepted! Total: {}", blocks_found);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Block rejected: {}", e);
|
||||
}
|
||||
}
|
||||
if drained > 0 {
|
||||
eprintln!("(discarded {} stale blocks)", drained);
|
||||
}
|
||||
|
||||
// Drain stale blocks, then immediately fetch new template.
|
||||
while engine.try_recv_block().is_some() {}
|
||||
|
||||
if let Ok(tmpl) = client.get_block_template(&args.wallet, 8) {
|
||||
let new_diff = miner::parse_difficulty(
|
||||
tmpl.difficulty,
|
||||
tmpl.wide_difficulty.as_deref(),
|
||||
);
|
||||
|
||||
if tmpl.seed_hash != current_seed {
|
||||
eprintln!("Seed hash changed — need to restart with new dataset");
|
||||
engine.stop();
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain again right before sending new job
|
||||
while engine.try_recv_block().is_some() {}
|
||||
|
||||
current_height = tmpl.height;
|
||||
current_difficulty = new_diff;
|
||||
current_prev_hash = tmpl.prev_hash.clone();
|
||||
job_id += 1;
|
||||
|
||||
let hb = hex::decode(&tmpl.blockhashing_blob).unwrap_or_default();
|
||||
let tb = hex::decode(&tmpl.blocktemplate_blob).unwrap_or_default();
|
||||
|
||||
engine.send_job(MiningJob {
|
||||
job_id,
|
||||
hashing_blob: hb,
|
||||
template_blob: tb,
|
||||
difficulty: new_diff,
|
||||
height: tmpl.height,
|
||||
});
|
||||
|
||||
eprintln!("Template: height={} diff={} prev={:.16}...",
|
||||
tmpl.height, new_diff, tmpl.prev_hash);
|
||||
}
|
||||
last_template_fetch = Instant::now();
|
||||
}
|
||||
|
||||
// Refresh template every 5 seconds (routine poll)
|
||||
if !block_found && last_template_fetch.elapsed() > Duration::from_secs(5) {
|
||||
if let Ok(tmpl) = client.get_block_template(&args.wallet, 8) {
|
||||
let new_diff = miner::parse_difficulty(
|
||||
tmpl.difficulty,
|
||||
tmpl.wide_difficulty.as_deref(),
|
||||
);
|
||||
|
||||
if tmpl.seed_hash != current_seed {
|
||||
eprintln!("\nSeed hash changed — need to restart with new dataset");
|
||||
engine.stop();
|
||||
break;
|
||||
}
|
||||
|
||||
// Send new job if anything changed
|
||||
if tmpl.prev_hash != current_prev_hash || tmpl.height != current_height || new_diff != current_difficulty {
|
||||
current_height = tmpl.height;
|
||||
current_difficulty = new_diff;
|
||||
current_prev_hash = tmpl.prev_hash.clone();
|
||||
job_id += 1;
|
||||
|
||||
let hb = hex::decode(&tmpl.blockhashing_blob).unwrap_or_default();
|
||||
let tb = hex::decode(&tmpl.blocktemplate_blob).unwrap_or_default();
|
||||
|
||||
engine.send_job(MiningJob {
|
||||
job_id,
|
||||
hashing_blob: hb,
|
||||
template_blob: tb,
|
||||
difficulty: new_diff,
|
||||
height: tmpl.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
last_template_fetch = Instant::now();
|
||||
}
|
||||
|
||||
// Print stats every 10 seconds
|
||||
if last_stats.elapsed() > Duration::from_secs(10) {
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let total = engine.hash_count.load(Ordering::Relaxed);
|
||||
let hr = total as f64 / elapsed;
|
||||
let est_block = current_difficulty as f64 / hr;
|
||||
|
||||
eprint!(
|
||||
"\r[H={}] {} | Hashes: {} | Blocks: {} | Diff: {} | Est: {:.0}s/block ",
|
||||
current_height,
|
||||
format_hashrate(hr),
|
||||
total,
|
||||
blocks_found,
|
||||
current_difficulty,
|
||||
est_block
|
||||
);
|
||||
last_stats = Instant::now();
|
||||
|
||||
// Benchmark mode: exit after 20 seconds
|
||||
if args.benchmark && elapsed > 20.0 {
|
||||
eprintln!();
|
||||
eprintln!();
|
||||
eprintln!("=== Benchmark Results ===");
|
||||
eprintln!("Threads: {}", args.threads);
|
||||
eprintln!("Mode: {}", if args.light { "light" } else { "full" });
|
||||
eprintln!("Duration: {:.1}s", elapsed);
|
||||
eprintln!("Hashes: {}", total);
|
||||
eprintln!("Hashrate: {} ({:.1} H/s per thread)",
|
||||
format_hashrate(hr), hr / args.threads as f64);
|
||||
engine.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// Final stats
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let total = engine.hash_count.load(Ordering::Relaxed);
|
||||
eprintln!();
|
||||
eprintln!("Shutting down...");
|
||||
eprintln!("Total hashes: {}", total);
|
||||
eprintln!("Blocks found: {}", blocks_found);
|
||||
eprintln!("Avg hashrate: {}", format_hashrate(total as f64 / elapsed));
|
||||
|
||||
engine.stop();
|
||||
}
|
||||
|
||||
fn ctrlc_handler(running: std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
let _ = std::thread::spawn(move || {});
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::signal(libc::SIGINT, handle_sigint as *const () as libc::sighandler_t);
|
||||
RUNNING_FLAG.store(running.as_ref() as *const _ as usize, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
static RUNNING_FLAG: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(unix)]
|
||||
extern "C" fn handle_sigint(_: libc::c_int) {
|
||||
let ptr = RUNNING_FLAG.load(Ordering::SeqCst);
|
||||
if ptr != 0 {
|
||||
let flag = unsafe { &*(ptr as *const std::sync::atomic::AtomicBool) };
|
||||
flag.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Multi-threaded RandomX mining engine
|
||||
//!
|
||||
//! Full mode: uses direct C FFI to share a single 2GB dataset across worker VMs.
|
||||
//! Light mode: uses randomx-rs Rust API with per-thread 256MB caches.
|
||||
|
||||
use randomx_rs::{RandomXCache, RandomXFlag, RandomXVM};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
// Direct C FFI for dataset sharing (the Rust wrapper doesn't support this)
|
||||
extern "C" {
|
||||
fn randomx_alloc_dataset(flags: u32) -> *mut std::ffi::c_void;
|
||||
fn randomx_init_dataset(
|
||||
dataset: *mut std::ffi::c_void,
|
||||
cache: *mut std::ffi::c_void,
|
||||
start_item: u64,
|
||||
item_count: u64,
|
||||
);
|
||||
fn randomx_dataset_item_count() -> u64;
|
||||
fn randomx_create_vm(
|
||||
flags: u32,
|
||||
cache: *mut std::ffi::c_void,
|
||||
dataset: *mut std::ffi::c_void,
|
||||
) -> *mut std::ffi::c_void;
|
||||
fn randomx_destroy_vm(vm: *mut std::ffi::c_void);
|
||||
fn randomx_calculate_hash(
|
||||
vm: *mut std::ffi::c_void,
|
||||
input: *const u8,
|
||||
input_size: u64,
|
||||
output: *mut u8,
|
||||
);
|
||||
fn randomx_alloc_cache(flags: u32) -> *mut std::ffi::c_void;
|
||||
fn randomx_init_cache(
|
||||
cache: *mut std::ffi::c_void,
|
||||
key: *const u8,
|
||||
key_size: u64,
|
||||
);
|
||||
fn randomx_release_cache(cache: *mut std::ffi::c_void);
|
||||
fn randomx_release_dataset(dataset: *mut std::ffi::c_void);
|
||||
fn randomx_get_flags() -> u32;
|
||||
}
|
||||
|
||||
/// A found block ready for submission
|
||||
pub struct FoundBlock {
|
||||
pub nonce: u32,
|
||||
pub hash: Vec<u8>,
|
||||
pub blob_hex: String,
|
||||
pub job_id: u64,
|
||||
}
|
||||
|
||||
/// Job data sent to worker threads
|
||||
#[derive(Clone)]
|
||||
pub struct MiningJob {
|
||||
pub job_id: u64,
|
||||
pub hashing_blob: Vec<u8>,
|
||||
pub template_blob: Vec<u8>,
|
||||
pub difficulty: u128,
|
||||
pub height: u64,
|
||||
}
|
||||
|
||||
/// Wrapper to send raw pointers across threads.
|
||||
/// Safety: RandomX dataset is read-only after init; VMs are per-thread.
|
||||
struct RawPtr(*mut std::ffi::c_void);
|
||||
unsafe impl Send for RawPtr {}
|
||||
unsafe impl Sync for RawPtr {}
|
||||
|
||||
/// Mining engine managing worker threads
|
||||
pub struct MiningEngine {
|
||||
pub hash_count: Arc<AtomicU64>,
|
||||
pub running: Arc<AtomicBool>,
|
||||
result_rx: mpsc::Receiver<FoundBlock>,
|
||||
job_senders: Vec<mpsc::Sender<MiningJob>>,
|
||||
_handles: Vec<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MiningEngine {
|
||||
/// Initialize the mining engine with full mode (shared 2GB dataset, direct C FFI)
|
||||
pub fn new_full(
|
||||
num_threads: usize,
|
||||
seed_hash: &[u8],
|
||||
) -> Result<Self, String> {
|
||||
let flags = unsafe { randomx_get_flags() }
|
||||
| 0x4 // FLAG_FULL_MEM
|
||||
| 0x8; // FLAG_JIT
|
||||
|
||||
eprintln!("RandomX flags: 0x{:x}", flags);
|
||||
|
||||
// Allocate and init cache via C FFI
|
||||
let cache_ptr = unsafe { randomx_alloc_cache(flags) };
|
||||
if cache_ptr.is_null() {
|
||||
return Err("Failed to allocate RandomX cache".to_string());
|
||||
}
|
||||
unsafe {
|
||||
randomx_init_cache(cache_ptr, seed_hash.as_ptr(), seed_hash.len() as u64);
|
||||
}
|
||||
eprintln!("Cache initialized (256MB)");
|
||||
|
||||
// Allocate and init dataset
|
||||
let dataset_ptr = unsafe { randomx_alloc_dataset(flags) };
|
||||
if dataset_ptr.is_null() {
|
||||
unsafe { randomx_release_cache(cache_ptr); }
|
||||
return Err("Failed to allocate RandomX dataset (need ~2GB free RAM)".to_string());
|
||||
}
|
||||
|
||||
let item_count = unsafe { randomx_dataset_item_count() };
|
||||
eprintln!("Generating dataset ({} items, ~2GB)...", item_count);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Initialize dataset using multiple threads for speed
|
||||
let items_per_thread = item_count / num_threads as u64;
|
||||
let ds_shared = Arc::new(RawPtr(dataset_ptr));
|
||||
let ca_shared = Arc::new(RawPtr(cache_ptr));
|
||||
let mut init_handles = Vec::new();
|
||||
for i in 0..num_threads {
|
||||
let ds = Arc::clone(&ds_shared);
|
||||
let ca = Arc::clone(&ca_shared);
|
||||
let start_item = i as u64 * items_per_thread;
|
||||
let count = if i == num_threads - 1 {
|
||||
item_count - start_item
|
||||
} else {
|
||||
items_per_thread
|
||||
};
|
||||
init_handles.push(thread::spawn(move || unsafe {
|
||||
randomx_init_dataset(ds.0, ca.0, start_item, count);
|
||||
}));
|
||||
}
|
||||
for h in init_handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
|
||||
eprintln!("Dataset ready in {:.1}s", start.elapsed().as_secs_f64());
|
||||
|
||||
// Now create per-thread VMs sharing the dataset
|
||||
let hash_count = Arc::new(AtomicU64::new(0));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let mut job_senders = Vec::new();
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for worker_id in 0..num_threads {
|
||||
let (job_tx, job_rx) = mpsc::channel::<MiningJob>();
|
||||
job_senders.push(job_tx);
|
||||
|
||||
let hash_count = Arc::clone(&hash_count);
|
||||
let running = Arc::clone(&running);
|
||||
let result_tx = result_tx.clone();
|
||||
let ds = Arc::clone(&ds_shared);
|
||||
let nonce_start = (worker_id as u64 * (u32::MAX as u64 / num_threads as u64)) as u32;
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let vm_ptr = unsafe {
|
||||
randomx_create_vm(flags, std::ptr::null_mut(), ds.0)
|
||||
};
|
||||
if vm_ptr.is_null() {
|
||||
eprintln!("Worker {} failed to create VM", worker_id);
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("Worker {} ready", worker_id);
|
||||
|
||||
worker_loop(
|
||||
vm_ptr, &job_rx, &running, &hash_count, &result_tx, nonce_start,
|
||||
);
|
||||
|
||||
unsafe { randomx_destroy_vm(vm_ptr); }
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Release cache (dataset is self-contained after init)
|
||||
unsafe { randomx_release_cache(cache_ptr); }
|
||||
|
||||
// NOTE: dataset_ptr is intentionally leaked — it must outlive all worker VMs.
|
||||
// In a long-running miner this is fine; the OS reclaims on exit.
|
||||
|
||||
Ok(Self {
|
||||
hash_count,
|
||||
running,
|
||||
result_rx,
|
||||
job_senders,
|
||||
_handles: handles,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize light mode (each thread has own 256MB cache via Rust API)
|
||||
pub fn new_light(
|
||||
num_threads: usize,
|
||||
seed_hash: &[u8],
|
||||
) -> Result<Self, String> {
|
||||
let flags = RandomXFlag::get_recommended_flags();
|
||||
|
||||
let hash_count = Arc::new(AtomicU64::new(0));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let mut job_senders = Vec::new();
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let seed = seed_hash.to_vec();
|
||||
|
||||
for worker_id in 0..num_threads {
|
||||
let (job_tx, job_rx) = mpsc::channel::<MiningJob>();
|
||||
job_senders.push(job_tx);
|
||||
|
||||
let hash_count = Arc::clone(&hash_count);
|
||||
let running = Arc::clone(&running);
|
||||
let result_tx = result_tx.clone();
|
||||
let seed = seed.clone();
|
||||
let nonce_start = (worker_id as u64 * (u32::MAX as u64 / num_threads as u64)) as u32;
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let cache = match RandomXCache::new(flags, &seed) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Worker {} cache init failed: {:?}", worker_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut vm = match RandomXVM::new(flags, Some(cache), None) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Worker {} VM init failed: {:?}", worker_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("Worker {} ready (light mode)", worker_id);
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
let job = match job_rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(j) => j,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let nonce_offset = find_nonce_offset(&job.hashing_blob);
|
||||
mine_job_rust(&mut vm, &job, &running, &hash_count, &result_tx, nonce_start, nonce_offset);
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
hash_count,
|
||||
running,
|
||||
result_rx,
|
||||
job_senders,
|
||||
_handles: handles,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_job(&self, job: MiningJob) {
|
||||
for tx in &self.job_senders {
|
||||
let _ = tx.send(job.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_recv_block(&self) -> Option<FoundBlock> {
|
||||
self.result_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker loop using raw C FFI VM pointer (full mode)
|
||||
fn worker_loop(
|
||||
vm_ptr: *mut std::ffi::c_void,
|
||||
job_rx: &mpsc::Receiver<MiningJob>,
|
||||
running: &AtomicBool,
|
||||
hash_count: &AtomicU64,
|
||||
result_tx: &mpsc::Sender<FoundBlock>,
|
||||
nonce_start: u32,
|
||||
) {
|
||||
let mut hash_out = [0u8; 32];
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
// Wait for a job
|
||||
let mut job = match job_rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(j) => j,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let mut nonce_offset = find_nonce_offset(&job.hashing_blob);
|
||||
let mut nonce = nonce_start;
|
||||
let mut blob = job.hashing_blob.clone();
|
||||
let mut blob_len = blob.len();
|
||||
|
||||
loop {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for new job (non-blocking)
|
||||
if let Ok(new_job) = job_rx.try_recv() {
|
||||
job = new_job;
|
||||
nonce_offset = find_nonce_offset(&job.hashing_blob);
|
||||
nonce = nonce_start;
|
||||
blob = job.hashing_blob.clone();
|
||||
blob_len = blob.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set nonce
|
||||
set_nonce(&mut blob, nonce_offset, nonce);
|
||||
|
||||
// Hash
|
||||
unsafe {
|
||||
randomx_calculate_hash(
|
||||
vm_ptr,
|
||||
blob.as_ptr(),
|
||||
blob_len as u64,
|
||||
hash_out.as_mut_ptr(),
|
||||
);
|
||||
}
|
||||
|
||||
hash_count.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if check_hash(&hash_out, job.difficulty) {
|
||||
let mut template = job.template_blob.clone();
|
||||
let tmpl_offset = find_nonce_offset(&template);
|
||||
set_nonce(&mut template, tmpl_offset, nonce);
|
||||
|
||||
let _ = result_tx.send(FoundBlock {
|
||||
nonce,
|
||||
hash: hash_out.to_vec(),
|
||||
blob_hex: hex::encode(&template),
|
||||
job_id: job.job_id,
|
||||
});
|
||||
}
|
||||
|
||||
nonce = nonce.wrapping_add(1);
|
||||
if nonce == nonce_start {
|
||||
break; // exhausted nonce space
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mine a single job using the Rust RandomXVM wrapper (light mode)
|
||||
fn mine_job_rust(
|
||||
vm: &mut RandomXVM,
|
||||
job: &MiningJob,
|
||||
running: &AtomicBool,
|
||||
hash_count: &AtomicU64,
|
||||
result_tx: &mpsc::Sender<FoundBlock>,
|
||||
nonce_start: u32,
|
||||
nonce_offset: usize,
|
||||
) {
|
||||
let mut nonce = nonce_start;
|
||||
|
||||
loop {
|
||||
if !running.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut blob = job.hashing_blob.clone();
|
||||
set_nonce(&mut blob, nonce_offset, nonce);
|
||||
|
||||
let hash = match vm.calculate_hash(&blob) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
nonce = nonce.wrapping_add(1);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
hash_count.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if check_hash(&hash, job.difficulty) {
|
||||
let mut template = job.template_blob.clone();
|
||||
let tmpl_offset = find_nonce_offset(&template);
|
||||
set_nonce(&mut template, tmpl_offset, nonce);
|
||||
|
||||
let _ = result_tx.send(FoundBlock {
|
||||
nonce,
|
||||
hash: hash.clone(),
|
||||
blob_hex: hex::encode(&template),
|
||||
job_id: job.job_id,
|
||||
});
|
||||
}
|
||||
|
||||
nonce = nonce.wrapping_add(1);
|
||||
if nonce == nonce_start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_nonce(blob: &mut [u8], offset: usize, nonce: u32) {
|
||||
blob[offset] = (nonce & 0xff) as u8;
|
||||
blob[offset + 1] = ((nonce >> 8) & 0xff) as u8;
|
||||
blob[offset + 2] = ((nonce >> 16) & 0xff) as u8;
|
||||
blob[offset + 3] = ((nonce >> 24) & 0xff) as u8;
|
||||
}
|
||||
|
||||
/// Find nonce offset in block hashing blob.
|
||||
/// Layout: major_version(varint) + minor_version(varint) + timestamp(varint) + prev_id(32 bytes) + nonce(4 bytes)
|
||||
pub fn find_nonce_offset(blob: &[u8]) -> usize {
|
||||
let mut offset = 0;
|
||||
// Skip 3 varints (major_version, minor_version, timestamp)
|
||||
for _ in 0..3 {
|
||||
while blob[offset] & 0x80 != 0 {
|
||||
offset += 1;
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
// Skip prev_id (32 bytes)
|
||||
offset += 32;
|
||||
offset
|
||||
}
|
||||
|
||||
/// Check if hash meets difficulty target.
|
||||
/// CryptoNote convention: interpret hash as little-endian 256-bit integer,
|
||||
/// block is valid if hash * difficulty < 2^256.
|
||||
fn check_hash(hash: &[u8], difficulty: u128) -> bool {
|
||||
if difficulty == 0 {
|
||||
return false;
|
||||
}
|
||||
// Read hash as little-endian u128 pair [low, high]
|
||||
let mut lo = 0u128;
|
||||
let mut hi = 0u128;
|
||||
for i in 0..16 {
|
||||
lo |= (hash[i] as u128) << (i * 8);
|
||||
}
|
||||
for i in 0..16 {
|
||||
hi |= (hash[16 + i] as u128) << (i * 8);
|
||||
}
|
||||
|
||||
// Check: hash * difficulty < 2^256
|
||||
// Multiply as 256-bit: result = [lo*diff, hi*diff + carry]
|
||||
let (_, lo_overflow) = lo.overflowing_mul(difficulty);
|
||||
let hi_prod = match hi.checked_mul(difficulty) {
|
||||
Some(h) => h,
|
||||
None => return false,
|
||||
};
|
||||
let carry = if lo_overflow { difficulty } else { 0 };
|
||||
hi_prod.checked_add(carry).is_some()
|
||||
}
|
||||
|
||||
/// Parse difficulty from wide_difficulty hex string or u64
|
||||
pub fn parse_difficulty(difficulty: u64, wide_difficulty: Option<&str>) -> u128 {
|
||||
if let Some(wide) = wide_difficulty {
|
||||
let hex_str = wide.strip_prefix("0x").unwrap_or(wide);
|
||||
u128::from_str_radix(hex_str, 16).unwrap_or(difficulty as u128)
|
||||
} else {
|
||||
difficulty as u128
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Stratum protocol client (stub for future implementation)
|
||||
//!
|
||||
//! TODO: Implement stratum+tcp/ssl for pool mining
|
||||
//! - Login (mining.subscribe, mining.authorize)
|
||||
//! - Job reception (mining.notify)
|
||||
//! - Share submission (mining.submit)
|
||||
//! - Keepalive
|
||||
//! - TLS support
|
||||
|
||||
/// Placeholder for future stratum pool client
|
||||
pub struct StratumClient {
|
||||
_pool_url: String,
|
||||
}
|
||||
|
||||
impl StratumClient {
|
||||
pub fn new(pool_url: &str) -> Self {
|
||||
Self {
|
||||
_pool_url: pool_url.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Local RandomX miner against remote Salvium daemon
|
||||
*
|
||||
* Uses our WASM RandomX (full mode, 2GB dataset) + daemon RPC.
|
||||
* Single-threaded but with full dataset for maximum per-thread hashrate.
|
||||
*
|
||||
* Usage:
|
||||
* bun mine-testnet.js [mode]
|
||||
* mode: "full" (default, 2GB dataset, ~30-50 H/s) or "light" (256MB, ~10 H/s)
|
||||
*/
|
||||
|
||||
import { DaemonRPC } from './src/rpc/daemon.js';
|
||||
import { RandomXContext } from './src/randomx/index.js';
|
||||
import { RandomXFullMode } from './src/randomx/full-mode.js';
|
||||
import {
|
||||
parseBlockTemplate,
|
||||
findNonceOffset,
|
||||
setNonce,
|
||||
checkHash,
|
||||
formatBlockForSubmission,
|
||||
formatHashrate,
|
||||
} from './src/mining.js';
|
||||
import { hexToBytes, bytesToHex } from './src/address.js';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
const DAEMON_URL = process.env.DAEMON_URL || 'http://web.whiskymine.io:29081';
|
||||
const WALLET_FILE = process.env.WALLET_FILE || `${process.env.HOME}/testnet-wallet/wallet.json`;
|
||||
const MODE = process.argv[2] || 'full';
|
||||
const TEMPLATE_REFRESH_MS = 5000;
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
const wallet = JSON.parse(await Bun.file(WALLET_FILE).text());
|
||||
const daemon = new DaemonRPC({ url: DAEMON_URL });
|
||||
|
||||
console.log('Salvium Local RandomX Miner');
|
||||
console.log('===========================');
|
||||
console.log(`Daemon: ${DAEMON_URL}`);
|
||||
console.log(`Wallet: ${wallet.address.slice(0, 20)}...`);
|
||||
console.log(`Mode: ${MODE} (${MODE === 'full' ? '2GB dataset' : '256MB cache'})`);
|
||||
console.log('');
|
||||
|
||||
// Verify daemon
|
||||
const info = await daemon.getInfo();
|
||||
if (!info.success) {
|
||||
console.error('Cannot connect to daemon');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Daemon height: ${info.result.height}, difficulty: ${info.result.difficulty}`);
|
||||
console.log('');
|
||||
|
||||
// Get block template
|
||||
let template = await daemon.getBlockTemplate(wallet.address, 8);
|
||||
if (!template.success) {
|
||||
console.error('Failed to get block template:', template.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let parsed = parseBlockTemplate(template.result);
|
||||
console.log(`Template: height=${parsed.height}, difficulty=${parsed.difficulty}`);
|
||||
|
||||
// Initialize RandomX
|
||||
const seedBytes = parsed.seedHash ? hexToBytes(parsed.seedHash) : new Uint8Array(32);
|
||||
let rx;
|
||||
|
||||
if (MODE === 'full') {
|
||||
console.log('Initializing RandomX full mode (256MB cache + 2GB dataset)...');
|
||||
console.log('This will take 30-60 seconds...');
|
||||
rx = new RandomXFullMode();
|
||||
await rx.init(seedBytes, {
|
||||
onProgress: (pct, phase) => {
|
||||
process.stdout.write(`\r ${phase}: ${pct}% `);
|
||||
}
|
||||
});
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('Initializing RandomX light mode (256MB cache)...');
|
||||
rx = new RandomXContext();
|
||||
await rx.init(seedBytes);
|
||||
}
|
||||
|
||||
console.log('RandomX ready.\n');
|
||||
|
||||
// ============================================================================
|
||||
// Mining loop
|
||||
// ============================================================================
|
||||
|
||||
let totalHashes = 0;
|
||||
let blocksFound = 0;
|
||||
const startTime = Date.now();
|
||||
let lastTemplateFetch = Date.now();
|
||||
let lastStatsPrint = Date.now();
|
||||
let currentSeedHash = parsed.seedHash;
|
||||
let running = true;
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
running = false;
|
||||
console.log('\nShutting down...');
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
console.log(`Total hashes: ${totalHashes.toLocaleString()}`);
|
||||
console.log(`Blocks found: ${blocksFound}`);
|
||||
console.log(`Avg hashrate: ${formatHashrate(totalHashes / elapsed)}`);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log('Mining started. Press Ctrl+C to stop.\n');
|
||||
|
||||
while (running) {
|
||||
const hashingBlob = parsed.blockhashingBytes;
|
||||
const nonceOffset = findNonceOffset(hashingBlob);
|
||||
const difficulty = parsed.difficulty;
|
||||
const templateBlob = parsed.blocktemplateBytes;
|
||||
|
||||
// Random starting nonce
|
||||
const startNonce = Math.floor(Math.random() * 0xFFFFFFFF);
|
||||
|
||||
for (let i = 0; i < 256 && running; i++) {
|
||||
const nonce = (startNonce + i) & 0xFFFFFFFF;
|
||||
const blob = setNonce(hashingBlob, nonce, nonceOffset);
|
||||
const hash = rx.hash(blob);
|
||||
totalHashes++;
|
||||
|
||||
if (checkHash(hash, difficulty)) {
|
||||
const blockHex = formatBlockForSubmission(templateBlob, nonce,
|
||||
findNonceOffset(templateBlob));
|
||||
|
||||
console.log(`\n*** BLOCK FOUND at height ${parsed.height}! nonce=${nonce} ***`);
|
||||
|
||||
const submitResult = await daemon.submitBlock([blockHex]);
|
||||
if (submitResult.success) {
|
||||
blocksFound++;
|
||||
console.log(`Block accepted! Total: ${blocksFound}`);
|
||||
} else {
|
||||
console.log(`Rejected: ${JSON.stringify(submitResult.error || submitResult.result)}`);
|
||||
}
|
||||
|
||||
// New template immediately
|
||||
lastTemplateFetch = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh template
|
||||
const now = Date.now();
|
||||
if (now - lastTemplateFetch > TEMPLATE_REFRESH_MS) {
|
||||
try {
|
||||
const newTemplate = await daemon.getBlockTemplate(wallet.address, 8);
|
||||
if (newTemplate.success) {
|
||||
const newParsed = parseBlockTemplate(newTemplate.result);
|
||||
if (newParsed.seedHash !== currentSeedHash) {
|
||||
console.log(`\nSeed changed, re-initializing RandomX...`);
|
||||
if (MODE === 'full') {
|
||||
await rx.init(hexToBytes(newParsed.seedHash), {});
|
||||
} else {
|
||||
await rx.init(hexToBytes(newParsed.seedHash));
|
||||
}
|
||||
currentSeedHash = newParsed.seedHash;
|
||||
}
|
||||
parsed = newParsed;
|
||||
}
|
||||
lastTemplateFetch = now;
|
||||
} catch (e) {
|
||||
// Keep mining
|
||||
}
|
||||
}
|
||||
|
||||
// Stats every 10s
|
||||
if (now - lastStatsPrint > 10000) {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const hr = totalHashes / elapsed;
|
||||
const estBlockTime = Number(parsed.difficulty) / hr;
|
||||
process.stdout.write(
|
||||
`\r[H=${parsed.height}] ${formatHashrate(hr)} | ` +
|
||||
`Hashes: ${totalHashes.toLocaleString()} | ` +
|
||||
`Blocks: ${blocksFound} | ` +
|
||||
`Diff: ${parsed.difficulty} | ` +
|
||||
`Est: ${isFinite(estBlockTime) ? estBlockTime.toFixed(0) + 's' : '...'} `
|
||||
);
|
||||
lastStatsPrint = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Mining worker thread - runs its own RandomX context and mines independently
|
||||
*/
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import { RandomXContext } from './src/randomx/index.js';
|
||||
import { findNonceOffset, setNonce, checkHash } from './src/mining.js';
|
||||
import { hexToBytes, bytesToHex } from './src/address.js';
|
||||
|
||||
const { workerId, seedHash } = workerData;
|
||||
|
||||
// Initialize RandomX
|
||||
const rx = new RandomXContext();
|
||||
await rx.init(hexToBytes(seedHash));
|
||||
parentPort.postMessage({ type: 'ready', workerId });
|
||||
|
||||
let hashCount = 0;
|
||||
let currentJob = null;
|
||||
|
||||
// Listen for jobs from main thread
|
||||
parentPort.on('message', (msg) => {
|
||||
if (msg.type === 'job') {
|
||||
currentJob = msg;
|
||||
mine(msg);
|
||||
} else if (msg.type === 'stop') {
|
||||
process.exit(0);
|
||||
} else if (msg.type === 'newSeed') {
|
||||
rx.init(hexToBytes(msg.seedHash)).then(() => {
|
||||
parentPort.postMessage({ type: 'seedReady', workerId });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function mine(job) {
|
||||
const hashingBlob = hexToBytes(job.hashingBlobHex);
|
||||
const nonceOffset = findNonceOffset(hashingBlob);
|
||||
const difficulty = BigInt(job.difficulty);
|
||||
const jobId = job.jobId;
|
||||
|
||||
// Each worker starts at a different offset to avoid overlap
|
||||
let nonce = (workerId * 0x20000000 + Math.floor(Math.random() * 0x1FFFFFFF)) >>> 0;
|
||||
|
||||
while (currentJob && currentJob.jobId === jobId) {
|
||||
const blob = setNonce(hashingBlob, nonce, nonceOffset);
|
||||
const hash = rx.hash(blob);
|
||||
hashCount++;
|
||||
|
||||
if (checkHash(hash, difficulty)) {
|
||||
parentPort.postMessage({
|
||||
type: 'block',
|
||||
workerId,
|
||||
nonce,
|
||||
hash: bytesToHex(hash),
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
nonce = (nonce + 1) >>> 0;
|
||||
|
||||
// Report hashrate every 64 hashes
|
||||
if (hashCount % 64 === 0) {
|
||||
parentPort.postMessage({ type: 'hashes', workerId, count: 64 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -34,7 +34,9 @@
|
||||
"scripts": {
|
||||
"test": "node test/run.js",
|
||||
"build:wasm": "asc assembly/index.ts --outFile build/randomx.wasm --optimize --enable simd --initialMemory 16 --maximumMemory 8192",
|
||||
"build:wasm-crypto": "cd crates/salvium-crypto && PATH=\"$HOME/.cargo/bin:$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH\" RUSTUP_HOME=\"$HOME/.rustup\" CARGO_HOME=\"$HOME/.cargo\" RUSTFLAGS=\"-Ctarget-feature=+simd128\" wasm-pack build --target web --out-dir ../../src/crypto/wasm"
|
||||
"build:wasm-crypto": "cd crates/salvium-crypto && PATH=\"$HOME/.cargo/bin:$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH\" RUSTUP_HOME=\"$HOME/.rustup\" CARGO_HOME=\"$HOME/.cargo\" RUSTFLAGS=\"-Ctarget-feature=+simd128\" wasm-pack build --target web --out-dir ../../src/crypto/wasm",
|
||||
"build:miner": "cd crates/salvium-miner && PATH=\"$HOME/.cargo/bin:$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH\" RUSTUP_HOME=\"$HOME/.rustup\" CARGO_HOME=\"$HOME/.cargo\" cargo build --release",
|
||||
"mine": "crates/salvium-miner/target/release/salvium-miner"
|
||||
},
|
||||
"files": [
|
||||
"src/",
|
||||
|
||||
+8
-131
@@ -362,12 +362,6 @@ export class WalletSync {
|
||||
// Process blocks in batches using batch RPC for better performance
|
||||
const BLOCK_BATCH_SIZE = 100; // Fetch 100 blocks per RPC call
|
||||
|
||||
// Debug: log first syncBatch call
|
||||
if (this._syncBatchCount === undefined) this._syncBatchCount = 0;
|
||||
this._syncBatchCount++;
|
||||
if (this._syncBatchCount === 1) {
|
||||
console.log(`[DEBUG] First _syncBatch call, headers count=${headers.length}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < headers.length; i += BLOCK_BATCH_SIZE) {
|
||||
if (this._stopRequested) break;
|
||||
@@ -378,10 +372,6 @@ export class WalletSync {
|
||||
// Batch fetch all blocks at once
|
||||
const blocksResponse = await this.daemon.getBlocksByHeight(heights);
|
||||
|
||||
// Debug: log batch response status
|
||||
if (this._syncBatchCount === 1 && i === 0) {
|
||||
console.log(`[DEBUG] Batch fetch response: success=${blocksResponse.success}, has blocks=${!!blocksResponse.result?.blocks}, blocks count=${blocksResponse.result?.blocks?.length || 0}`);
|
||||
}
|
||||
|
||||
if (!blocksResponse.success || !blocksResponse.result?.blocks) {
|
||||
// Fallback to individual fetches if batch fails
|
||||
@@ -457,20 +447,10 @@ export class WalletSync {
|
||||
// blockData from get_blocks_by_height.bin contains block blob
|
||||
// We need to parse it or use the included transactions
|
||||
|
||||
// Debug: log batch processing status
|
||||
if (this._batchDebugCount === undefined) this._batchDebugCount = 0;
|
||||
this._batchDebugCount++;
|
||||
if (this._batchDebugCount <= 3) {
|
||||
console.log(`[DEBUG] _processBlockFromBatch at height ${header.height}`);
|
||||
console.log(`[DEBUG] blockData keys: ${blockData ? Object.keys(blockData).join(', ') : 'null'}`);
|
||||
}
|
||||
|
||||
// If blockData has the structure we need, use it directly
|
||||
// Otherwise fall back to individual fetch
|
||||
if (!blockData || !blockData.block) {
|
||||
if (this._batchDebugCount <= 3) {
|
||||
console.log(`[DEBUG] Falling back to individual fetch for height ${header.height}`);
|
||||
}
|
||||
return this._processBlock(header);
|
||||
}
|
||||
|
||||
@@ -543,9 +523,6 @@ export class WalletSync {
|
||||
* @param {Object} header - Block header
|
||||
*/
|
||||
async _processBlock(header) {
|
||||
// Debug: count blocks processed via individual fetch
|
||||
if (this._individualBlockCount === undefined) this._individualBlockCount = 0;
|
||||
this._individualBlockCount++;
|
||||
|
||||
// Get full block data - includes miner_tx and protocol_tx in JSON
|
||||
const blockResponse = await this.daemon.getBlock({ height: header.height });
|
||||
@@ -566,14 +543,6 @@ export class WalletSync {
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: log first few blocks' miner_tx structure
|
||||
if (this._individualBlockCount <= 3 && blockJson?.miner_tx) {
|
||||
console.log(`[DEBUG] Block ${header.height} miner_tx keys: ${Object.keys(blockJson.miner_tx).join(', ')}`);
|
||||
console.log(`[DEBUG] miner_tx.vout count: ${(blockJson.miner_tx.vout || []).length}`);
|
||||
if (blockJson.miner_tx.vout?.[0]?.target) {
|
||||
console.log(`[DEBUG] First vout target keys: ${Object.keys(blockJson.miner_tx.vout[0].target).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process miner_tx (coinbase - block reward)
|
||||
if (blockJson?.miner_tx && block.miner_tx_hash) {
|
||||
@@ -637,15 +606,6 @@ export class WalletSync {
|
||||
async _processEmbeddedTransaction(txJson, txHash, header, options = {}) {
|
||||
const { isMinerTx = false, isProtocolTx = false } = options;
|
||||
|
||||
// Debug: count embedded txs processed
|
||||
if (this._embeddedTxCount === undefined) this._embeddedTxCount = 0;
|
||||
this._embeddedTxCount++;
|
||||
if (this._embeddedTxCount <= 3) {
|
||||
console.log(`[DEBUG] Processing embedded tx at height ${header.height}, isMinerTx=${isMinerTx}, vout count=${(txJson.vout || []).length}`);
|
||||
if (txJson.extra) {
|
||||
console.log(`[DEBUG] extra field type: ${Array.isArray(txJson.extra) ? 'array' : typeof txJson.extra}, length=${txJson.extra.length || 0}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have this transaction
|
||||
const existing = await this.storage.getTransaction(txHash);
|
||||
@@ -659,11 +619,6 @@ export class WalletSync {
|
||||
const txPubKey = extractTxPubKey(tx);
|
||||
const paymentId = extractPaymentId(tx);
|
||||
|
||||
// Debug: log txPubKey extraction
|
||||
if (this._embeddedTxCount <= 3) {
|
||||
console.log(`[DEBUG] txPubKey extracted: ${txPubKey ? bytesToHex(txPubKey) : 'null'}`);
|
||||
console.log(`[DEBUG] converted vout count: ${(tx.prefix?.vout || []).length}`);
|
||||
}
|
||||
|
||||
// Determine transaction type
|
||||
const txType = isMinerTx ? 'miner' : (isProtocolTx ? 'protocol' : this._getTxType(tx));
|
||||
@@ -816,10 +771,17 @@ export class WalletSync {
|
||||
viewTag
|
||||
};
|
||||
} else if (target?.key) {
|
||||
// target.key can be a string (plain key) or object {key, asset_type, unlock_time}
|
||||
// The latter appears in genesis/pre-tagged_key blocks
|
||||
const keyVal = typeof target.key === 'object' ? target.key.key : target.key;
|
||||
const assetType = typeof target.key === 'object' ? (target.key.asset_type || 'SAL') : 'SAL';
|
||||
const unlockTime = typeof target.key === 'object' ? (target.key.unlock_time || 0) : 0;
|
||||
return {
|
||||
amount: this._safeBigInt(out.amount),
|
||||
type: 0x02, // TXOUT_TYPE.KEY
|
||||
key: hexToBytes(target.key)
|
||||
key: hexToBytes(keyVal),
|
||||
assetType,
|
||||
unlockTime
|
||||
};
|
||||
}
|
||||
return { amount: this._safeBigInt(out.amount), type: 0 };
|
||||
@@ -880,12 +842,6 @@ export class WalletSync {
|
||||
_convertExtraJson(extraArray) {
|
||||
// Extra in JSON is just an array of bytes
|
||||
if (!Array.isArray(extraArray) || extraArray.length === 0) {
|
||||
// Debug: log if extra is empty or wrong format
|
||||
if (this._extraDebugCount === undefined) this._extraDebugCount = 0;
|
||||
this._extraDebugCount++;
|
||||
if (this._extraDebugCount <= 3) {
|
||||
console.log(`[DEBUG] _convertExtraJson: extraArray is ${Array.isArray(extraArray) ? 'array of length ' + extraArray.length : typeof extraArray}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -893,12 +849,6 @@ export class WalletSync {
|
||||
const extraBytes = new Uint8Array(extraArray);
|
||||
const parsed = [];
|
||||
|
||||
// Debug: log first few bytes
|
||||
if (this._extraDebugCount === undefined) this._extraDebugCount = 0;
|
||||
this._extraDebugCount++;
|
||||
if (this._extraDebugCount <= 3) {
|
||||
console.log(`[DEBUG] _convertExtraJson: extraBytes length=${extraBytes.length}, first bytes: ${Array.from(extraBytes.slice(0, 5)).map(b => b.toString(16)).join(' ')}`);
|
||||
}
|
||||
let offset = 0;
|
||||
|
||||
while (offset < extraBytes.length) {
|
||||
@@ -971,12 +921,6 @@ export class WalletSync {
|
||||
const txHash = txData.tx_hash;
|
||||
const txBlob = txData.as_hex;
|
||||
|
||||
// Debug: count regular txs processed
|
||||
if (this._regularTxCount === undefined) this._regularTxCount = 0;
|
||||
this._regularTxCount++;
|
||||
if (this._regularTxCount <= 3) {
|
||||
console.log(`[DEBUG] _processTransaction at height ${header.height}, tx ${txHash.slice(0, 16)}...`);
|
||||
}
|
||||
|
||||
// Check if we already have this transaction
|
||||
const existing = await this.storage.getTransaction(txHash);
|
||||
@@ -990,10 +934,6 @@ export class WalletSync {
|
||||
const txPubKey = extractTxPubKey(tx);
|
||||
const paymentId = extractPaymentId(tx);
|
||||
|
||||
// Debug: log tx structure
|
||||
if (this._regularTxCount <= 3) {
|
||||
console.log(`[DEBUG] Regular tx: txPubKey=${txPubKey ? bytesToHex(txPubKey).slice(0, 16) + '...' : 'null'}, vout count=${(tx.prefix?.vout || []).length}`);
|
||||
}
|
||||
|
||||
// Determine transaction type (Salvium-specific)
|
||||
// For miner_tx and protocol_tx, use the type from the prefix
|
||||
@@ -1095,24 +1035,6 @@ export class WalletSync {
|
||||
const ownedOutputs = [];
|
||||
const outputs = tx.prefix?.vout || tx.outputs || [];
|
||||
|
||||
// Debug: log first 3 calls unconditionally
|
||||
if (this._scanOutputsCount === undefined) this._scanOutputsCount = 0;
|
||||
this._scanOutputsCount++;
|
||||
if (this._scanOutputsCount <= 3) {
|
||||
console.log(`\n=== _scanOutputs call #${this._scanOutputsCount} at height ${header.height} ===`);
|
||||
console.log(`txHash: ${txHash}`);
|
||||
console.log(`txPubKey: ${txPubKey ? bytesToHex(txPubKey) : 'NULL'}`);
|
||||
console.log(`outputs count: ${outputs.length}`);
|
||||
console.log(`txType: ${txType}`);
|
||||
if (outputs.length > 0) {
|
||||
const firstOutput = outputs[0];
|
||||
console.log(`first output type: ${firstOutput.type}`);
|
||||
console.log(`first output keys: ${Object.keys(firstOutput).join(', ')}`);
|
||||
const firstKey = this._extractOutputPubKey(firstOutput);
|
||||
console.log(`first output pubkey: ${firstKey ? bytesToHex(firstKey).slice(0, 32) + '...' : 'NULL'}`);
|
||||
}
|
||||
console.log(`===\n`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < outputs.length; i++) {
|
||||
const output = outputs[i];
|
||||
@@ -1220,13 +1142,6 @@ export class WalletSync {
|
||||
? hexToBytes(firstKi)
|
||||
: firstKi;
|
||||
inputContext = makeInputContext(firstKeyImage);
|
||||
// Debug: show first key image
|
||||
if (header.height >= 405588 && header.height <= 405590 && this._inputContextDebug === undefined) {
|
||||
this._inputContextDebug = true;
|
||||
console.log(`[INPUT CONTEXT DEBUG] height=${header.height} txHash=${txHash.slice(0,16)}...`);
|
||||
console.log(` firstKeyImage: ${bytesToHex(firstKeyImage)}`);
|
||||
console.log(` inputContext: ${bytesToHex(inputContext)}`);
|
||||
}
|
||||
} else {
|
||||
// Coinbase transaction: input_context = 'C' || block_height
|
||||
inputContext = makeInputContextCoinbase(header.height);
|
||||
@@ -1269,15 +1184,6 @@ export class WalletSync {
|
||||
encryptedAmount: tx.rct?.ecdhInfo?.[outputIndex]?.amount
|
||||
};
|
||||
|
||||
// Debug CARROT scanning inputs
|
||||
if (header.height >= 405585 && header.height <= 405595) {
|
||||
console.log(`[DEBUG CARROT SCAN] height=${header.height} outputIndex=${outputIndex}`);
|
||||
console.log(` key: ${outputForScan.key ? bytesToHex(outputForScan.key).slice(0,16) + '...' : 'NULL'}`);
|
||||
console.log(` viewTag: ${outputForScan.viewTag}`);
|
||||
console.log(` enoteEphemeralPubkey: ${outputForScan.enoteEphemeralPubkey ? (typeof outputForScan.enoteEphemeralPubkey === 'string' ? outputForScan.enoteEphemeralPubkey.slice(0,16) : bytesToHex(outputForScan.enoteEphemeralPubkey).slice(0,16)) + '...' : 'NULL'}`);
|
||||
console.log(` amountCommitment: ${amountCommitment ? bytesToHex(amountCommitment).slice(0,16) + '...' : 'NULL'}`);
|
||||
console.log(` carrotKeys: viewIncomingKey=${this.carrotKeys?.viewIncomingKey ? 'SET' : 'NULL'}, accountSpendPubkey=${this.carrotKeys?.accountSpendPubkey ? 'SET' : 'NULL'}`);
|
||||
}
|
||||
|
||||
// Scan with CARROT algorithm
|
||||
let result;
|
||||
@@ -1370,33 +1276,21 @@ export class WalletSync {
|
||||
* @private
|
||||
*/
|
||||
async _scanCNOutput(output, outputIndex, tx, txHash, txPubKey, header) {
|
||||
// Debug: count CN scan attempts
|
||||
if (this._cnScanCount === undefined) this._cnScanCount = 0;
|
||||
this._cnScanCount++;
|
||||
|
||||
const outputPubKey = this._extractOutputPubKey(output);
|
||||
if (!outputPubKey) {
|
||||
if (this._cnScanCount <= 3) {
|
||||
console.log(`[DEBUG] _scanCNOutput: no outputPubKey for output ${outputIndex}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute key derivation: D = 8 * viewSecretKey * txPubKey
|
||||
const derivation = generateKeyDerivation(txPubKey, this.keys.viewSecretKey);
|
||||
if (!derivation) {
|
||||
if (this._cnScanCount <= 3) {
|
||||
console.log(`[DEBUG] _scanCNOutput: derivation failed`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check view tag FIRST (if available) for fast rejection
|
||||
if (output.viewTag !== undefined) {
|
||||
const expectedViewTag = deriveViewTag(derivation, outputIndex);
|
||||
if (this._cnScanCount <= 3) {
|
||||
console.log(`[DEBUG] _scanCNOutput: viewTag check - output.viewTag=${output.viewTag}, expected=${expectedViewTag}`);
|
||||
}
|
||||
if (output.viewTag !== expectedViewTag) {
|
||||
return null; // Not our output - skip expensive operations
|
||||
}
|
||||
@@ -1407,9 +1301,6 @@ export class WalletSync {
|
||||
// This gives us the spend pubkey that was used to create this output
|
||||
const derivedSpendPubKey = deriveSubaddressPublicKey(outputPubKey, derivation, outputIndex);
|
||||
if (!derivedSpendPubKey) {
|
||||
if (this._cnScanCount <= 3) {
|
||||
console.log(`[DEBUG] _scanCNOutput: deriveSubaddressPublicKey failed`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1421,12 +1312,6 @@ export class WalletSync {
|
||||
subaddressIndex = this.subaddresses.get(derivedSpendPubKeyHex);
|
||||
}
|
||||
|
||||
// Debug: log first few lookups
|
||||
if (this._cnScanCount <= 3) {
|
||||
console.log(`[DEBUG] _scanCNOutput: derived spend key=${derivedSpendPubKeyHex.slice(0, 16)}...`);
|
||||
console.log(`[DEBUG] _scanCNOutput: subaddress map size=${this.subaddresses?.size || 0}`);
|
||||
console.log(`[DEBUG] _scanCNOutput: found in map=${!!subaddressIndex}`);
|
||||
}
|
||||
|
||||
if (!subaddressIndex) {
|
||||
return null; // Not our output
|
||||
@@ -1500,9 +1385,6 @@ export class WalletSync {
|
||||
const spentOutputs = [];
|
||||
const inputs = tx.prefix?.vin || tx.inputs || [];
|
||||
|
||||
// Debug: log first few checks
|
||||
if (this._spentCheckCount === undefined) this._spentCheckCount = 0;
|
||||
this._spentCheckCount++;
|
||||
|
||||
for (const input of inputs) {
|
||||
// Parsed transactions use input.keyImage directly
|
||||
@@ -1517,13 +1399,8 @@ export class WalletSync {
|
||||
// Check if this key image belongs to one of our outputs
|
||||
const output = await this.storage.getOutput(keyImage);
|
||||
|
||||
// Debug: log when we find a potential match or at specific heights
|
||||
if (output || (header.height >= 270510 && header.height <= 270530)) {
|
||||
console.log(`[DEBUG] _checkSpentOutputs height=${header.height} keyImage=${keyImage.slice(0,16)}... found=${!!output}`);
|
||||
}
|
||||
|
||||
if (output && !output.isSpent) {
|
||||
console.log(`[SPENT] Output spent at height ${header.height}: keyImage=${keyImage.slice(0,16)}... amount=${output.amount}`);
|
||||
await this.storage.markOutputSpent(keyImage, txHash, header.height);
|
||||
spentOutputs.push(output);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user