● 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:
Matt Hess
2026-02-01 17:50:07 +00:00
parent 3690068cad
commit 244bfb78a2
11 changed files with 3290 additions and 132 deletions
+1738
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
+129
View File
@@ -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(())
}
}
+333
View File
@@ -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));
}
}
}
}
+330
View File
@@ -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);
}
}
+454
View File
@@ -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
}
}
+21
View File
@@ -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
View File
@@ -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;
}
}
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}