//! Stratum protocol for pool mining. //! //! Implements the Stratum v2 protocol for communication between miners and pools. //! This allows for efficient work distribution and share submission. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::mpsc; use synor_types::Hash256; use crate::kheavyhash::KHeavyHash; use crate::template::BlockTemplate; use crate::{MiningError, Target}; /// Stratum job for mining. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StratumJob { /// Job identifier. pub job_id: String, /// Block header hash (before nonce). pub header_hash: String, /// Target for shares. pub share_target: String, /// Target for blocks. pub block_target: String, /// Timestamp. pub timestamp: u64, /// Extra nonce 1 (pool-assigned). pub extra_nonce1: String, /// Extra nonce 2 size. pub extra_nonce2_size: usize, /// Clean jobs flag. pub clean_jobs: bool, } impl StratumJob { /// Creates a job from a block template. pub fn from_template(template: &BlockTemplate, job_id: String, extra_nonce1: &str) -> Self { StratumJob { job_id, header_hash: hex::encode(&template.header_data), share_target: hex::encode(template.target), block_target: hex::encode(template.target), timestamp: template.timestamp, extra_nonce1: extra_nonce1.to_string(), extra_nonce2_size: 8, clean_jobs: true, } } } /// Share submission from a miner. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ShareSubmission { /// Worker name. pub worker: String, /// Job ID. pub job_id: String, /// Extra nonce 2. pub extra_nonce2: String, /// Nonce. pub nonce: String, /// Timestamp. pub timestamp: u64, } /// Result of share validation. #[derive(Clone, Debug)] pub enum ShareResult { /// Valid share that meets share target. ValidShare, /// Valid block that meets block target. ValidBlock { pow_hash: Hash256, nonce: u64 }, /// Invalid share. Invalid(String), /// Stale share (old job). Stale, /// Duplicate share. Duplicate, } /// Stratum message types. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "method", content = "params")] pub enum StratumRequest { /// Subscribe to mining notifications. #[serde(rename = "mining.subscribe")] Subscribe { agent: String, protocol: String }, /// Authorize a worker. #[serde(rename = "mining.authorize")] Authorize { worker: String, password: String }, /// Submit a share. #[serde(rename = "mining.submit")] Submit(ShareSubmission), /// Suggest difficulty. #[serde(rename = "mining.suggest_difficulty")] SuggestDifficulty { difficulty: f64 }, /// Configure connection. #[serde(rename = "mining.configure")] Configure { extensions: Vec }, } /// Stratum response. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StratumResponse { /// Request ID. pub id: u64, /// Result data. pub result: Option, /// Error if any. pub error: Option, } /// Stratum error. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StratumError { /// Error code. pub code: i32, /// Error message. pub message: String, } impl StratumError { pub fn unauthorized() -> Self { StratumError { code: 24, message: "Unauthorized worker".into(), } } pub fn invalid_share() -> Self { StratumError { code: 23, message: "Invalid share".into(), } } pub fn stale_share() -> Self { StratumError { code: 21, message: "Stale share".into(), } } pub fn duplicate_share() -> Self { StratumError { code: 22, message: "Duplicate share".into(), } } pub fn low_difficulty() -> Self { StratumError { code: 23, message: "Low difficulty share".into(), } } } /// Stratum notification (server to client). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StratumNotification { /// Method name. pub method: String, /// Parameters. pub params: serde_json::Value, } /// Connected worker information. #[derive(Clone, Debug)] pub struct WorkerInfo { /// Worker name. pub name: String, /// Connected since. pub connected_at: u64, /// Shares submitted. pub shares_submitted: u64, /// Valid shares. pub shares_valid: u64, /// Invalid shares. pub shares_invalid: u64, /// Current difficulty. pub difficulty: f64, /// Extra nonce 1. pub extra_nonce1: String, /// Last share time. pub last_share_time: u64, /// Hashrate estimate. pub hashrate: f64, } /// Stratum server for pool mining. pub struct StratumServer { /// Server address. address: String, /// Current job. current_job: RwLock>, /// Connected workers. workers: RwLock>, /// Extra nonce counter. extra_nonce_counter: AtomicU64, /// Job counter. job_counter: AtomicU64, /// Block found channel. block_tx: mpsc::Sender<(Hash256, u64)>, /// Share target (for vardiff). share_target: RwLock, /// Submitted shares (for dedup). submitted_shares: RwLock>, /// PoW hasher for verification. hasher: KHeavyHash, } impl StratumServer { /// Creates a new stratum server. pub fn new(address: String, block_tx: mpsc::Sender<(Hash256, u64)>) -> Self { StratumServer { address, current_job: RwLock::new(None), workers: RwLock::new(HashMap::new()), extra_nonce_counter: AtomicU64::new(1), job_counter: AtomicU64::new(1), block_tx, share_target: RwLock::new(Target::max()), submitted_shares: RwLock::new(HashMap::new()), hasher: KHeavyHash::new(), } } /// Updates the current job from a template. pub fn update_job(&self, template: &BlockTemplate) { let job_id = format!("{:x}", self.job_counter.fetch_add(1, Ordering::Relaxed)); let job = StratumJob::from_template(template, job_id, "00000000"); *self.current_job.write() = Some(job); } /// Gets the current job. pub fn current_job(&self) -> Option { self.current_job.read().clone() } /// Validates a share submission. pub fn validate_share(&self, submission: &ShareSubmission) -> ShareResult { // Check if job exists let job = match self.current_job.read().clone() { Some(j) => j, None => return ShareResult::Invalid("No active job".into()), }; // Check job ID if submission.job_id != job.job_id { return ShareResult::Stale; } // Check for duplicate let share_key = format!( "{}:{}:{}", submission.job_id, submission.extra_nonce2, submission.nonce ); { let mut shares = self.submitted_shares.write(); if shares.contains_key(&share_key) { return ShareResult::Duplicate; } shares.insert(share_key, submission.timestamp); } // Parse nonce let nonce = match u64::from_str_radix(&submission.nonce, 16) { Ok(n) => n, Err(_) => return ShareResult::Invalid("Invalid nonce".into()), }; // Parse extra_nonce2 let extra_nonce2 = match hex::decode(&submission.extra_nonce2) { Ok(bytes) => bytes, Err(_) => return ShareResult::Invalid("Invalid extra_nonce2".into()), }; // Parse header hash from job let header_hash = match hex::decode(&job.header_hash) { Ok(bytes) => bytes, Err(_) => return ShareResult::Invalid("Invalid header hash in job".into()), }; // Parse extra_nonce1 from job let extra_nonce1 = match hex::decode(&job.extra_nonce1) { Ok(bytes) => bytes, Err(_) => return ShareResult::Invalid("Invalid extra_nonce1 in job".into()), }; // Reconstruct header: header_hash + extra_nonce1 + extra_nonce2 let mut header_data = Vec::with_capacity(header_hash.len() + extra_nonce1.len() + extra_nonce2.len()); header_data.extend_from_slice(&header_hash); header_data.extend_from_slice(&extra_nonce1); header_data.extend_from_slice(&extra_nonce2); // Compute PoW hash let pow_hash = self.hasher.hash(&header_data, nonce); // Parse share target let share_target_bytes: [u8; 32] = match hex::decode(&job.share_target) { Ok(bytes) if bytes.len() == 32 => { let mut arr = [0u8; 32]; arr.copy_from_slice(&bytes); arr } _ => return ShareResult::Invalid("Invalid share target".into()), }; let share_target = Target::from_bytes(share_target_bytes); // Parse block target let block_target_bytes: [u8; 32] = match hex::decode(&job.block_target) { Ok(bytes) if bytes.len() == 32 => { let mut arr = [0u8; 32]; arr.copy_from_slice(&bytes); arr } _ => return ShareResult::Invalid("Invalid block target".into()), }; let block_target = Target::from_bytes(block_target_bytes); // Check if meets block target (higher priority) if block_target.is_met_by(&pow_hash.hash) { return ShareResult::ValidBlock { pow_hash: pow_hash.hash, nonce, }; } // Check if meets share target if share_target.is_met_by(&pow_hash.hash) { return ShareResult::ValidShare; } // Hash doesn't meet either target ShareResult::Invalid("Share does not meet target difficulty".into()) } /// Allocates an extra nonce for a new worker. pub fn allocate_extra_nonce(&self) -> String { let nonce = self.extra_nonce_counter.fetch_add(1, Ordering::Relaxed); format!("{:016x}", nonce) } /// Registers a worker. pub fn register_worker(&self, name: String, extra_nonce1: String) { let info = WorkerInfo { name: name.clone(), connected_at: current_timestamp(), shares_submitted: 0, shares_valid: 0, shares_invalid: 0, difficulty: 1.0, extra_nonce1, last_share_time: 0, hashrate: 0.0, }; self.workers.write().insert(name, info); } /// Updates worker stats after share submission. pub fn update_worker_stats(&self, worker: &str, valid: bool) { if let Some(info) = self.workers.write().get_mut(worker) { info.shares_submitted += 1; if valid { info.shares_valid += 1; } else { info.shares_invalid += 1; } info.last_share_time = current_timestamp(); } } /// Gets worker information. pub fn get_worker(&self, name: &str) -> Option { self.workers.read().get(name).cloned() } /// Gets all workers. pub fn workers(&self) -> Vec { self.workers.read().values().cloned().collect() } /// Runs the stratum server. pub async fn run(self: Arc) -> Result<(), MiningError> { let listener = TcpListener::bind(&self.address) .await .map_err(|e| MiningError::Stratum(e.to_string()))?; tracing::info!("Stratum server listening on {}", self.address); loop { match listener.accept().await { Ok((socket, addr)) => { tracing::info!("New stratum connection from {}", addr); let server = Arc::clone(&self); tokio::spawn(async move { if let Err(e) = server.handle_connection(socket).await { tracing::warn!("Stratum connection error: {}", e); } }); } Err(e) => { tracing::error!("Accept error: {}", e); } } } } async fn handle_connection(self: Arc, socket: TcpStream) -> Result<(), MiningError> { let (reader, mut writer) = socket.into_split(); let mut reader = BufReader::new(reader); let mut line = String::new(); let mut _worker_name: Option = None; let mut authorized = false; loop { line.clear(); match reader.read_line(&mut line).await { Ok(0) => break, // EOF Ok(_) => { // Parse JSON-RPC request let request: serde_json::Value = match serde_json::from_str(&line) { Ok(v) => v, Err(e) => { tracing::warn!("Invalid JSON: {}", e); continue; } }; let id = request.get("id").and_then(|v| v.as_u64()).unwrap_or(0); let method = request.get("method").and_then(|v| v.as_str()).unwrap_or(""); let response = match method { "mining.subscribe" => { let extra_nonce = self.allocate_extra_nonce(); let result = serde_json::json!([[["mining.notify", "1"]], extra_nonce, 8]); StratumResponse { id, result: Some(result), error: None, } } "mining.authorize" => { let params = request.get("params").cloned().unwrap_or_default(); let worker = params.get(0).and_then(|v| v.as_str()).unwrap_or("unknown"); _worker_name = Some(worker.to_string()); authorized = true; self.register_worker(worker.to_string(), self.allocate_extra_nonce()); StratumResponse { id, result: Some(serde_json::json!(true)), error: None, } } "mining.submit" => { if !authorized { StratumResponse { id, result: None, error: Some(StratumError::unauthorized()), } } else { // Parse submission let params = request.get("params").cloned().unwrap_or_default(); let submission = ShareSubmission { worker: params .get(0) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), job_id: params .get(1) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), extra_nonce2: params .get(2) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), nonce: params .get(3) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), timestamp: current_timestamp(), }; let result = self.validate_share(&submission); let (accepted, error) = match result { ShareResult::ValidShare | ShareResult::ValidBlock { .. } => { self.update_worker_stats(&submission.worker, true); (true, None) } ShareResult::Stale => { self.update_worker_stats(&submission.worker, false); (false, Some(StratumError::stale_share())) } ShareResult::Duplicate => { self.update_worker_stats(&submission.worker, false); (false, Some(StratumError::duplicate_share())) } ShareResult::Invalid(_) => { self.update_worker_stats(&submission.worker, false); (false, Some(StratumError::invalid_share())) } }; StratumResponse { id, result: Some(serde_json::json!(accepted)), error, } } } _ => { tracing::debug!("Unknown method: {}", method); continue; } }; let response_str = serde_json::to_string(&response) .map_err(|e| MiningError::Stratum(e.to_string()))?; writer .write_all(format!("{}\n", response_str).as_bytes()) .await .map_err(|e| MiningError::Stratum(e.to_string()))?; } Err(e) => { return Err(MiningError::Stratum(e.to_string())); } } } Ok(()) } } /// Stratum client for connecting to pools. pub struct StratumClient { /// Pool address. pool_address: String, /// Worker name. worker_name: String, /// Password. password: String, /// Connection state. connected: std::sync::atomic::AtomicBool, /// Current job. current_job: RwLock>, /// Extra nonce 1 from pool. extra_nonce1: RwLock, /// Extra nonce 2 size. extra_nonce2_size: std::sync::atomic::AtomicUsize, } impl StratumClient { /// Creates a new stratum client. pub fn new(pool_address: String, worker_name: String, password: String) -> Self { StratumClient { pool_address, worker_name, password, connected: std::sync::atomic::AtomicBool::new(false), current_job: RwLock::new(None), extra_nonce1: RwLock::new(String::new()), extra_nonce2_size: std::sync::atomic::AtomicUsize::new(8), } } /// Checks if connected. pub fn is_connected(&self) -> bool { self.connected.load(Ordering::Relaxed) } /// Gets the current job. pub fn current_job(&self) -> Option { self.current_job.read().clone() } /// Gets extra nonce 1. pub fn extra_nonce1(&self) -> String { self.extra_nonce1.read().clone() } /// Connects to the pool. pub async fn connect(&self) -> Result<(), MiningError> { let stream = TcpStream::connect(&self.pool_address) .await .map_err(|e| MiningError::Stratum(e.to_string()))?; self.connected.store(true, Ordering::Relaxed); // Send subscribe let subscribe = serde_json::json!({ "id": 1, "method": "mining.subscribe", "params": ["synor-miner/0.1.0", null] }); let (_reader, mut writer) = stream.into_split(); writer .write_all(format!("{}\n", subscribe).as_bytes()) .await .map_err(|e| MiningError::Stratum(e.to_string()))?; // Send authorize let authorize = serde_json::json!({ "id": 2, "method": "mining.authorize", "params": [&self.worker_name, &self.password] }); writer .write_all(format!("{}\n", authorize).as_bytes()) .await .map_err(|e| MiningError::Stratum(e.to_string()))?; Ok(()) } /// Submits a share. pub async fn submit_share( &self, _job_id: &str, _extra_nonce2: u64, _nonce: u64, ) -> Result { // This would send via the open connection // Simplified for now Ok(true) } } /// Gets current Unix timestamp in milliseconds. fn current_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as u64 } #[cfg(test)] mod tests { use super::*; #[test] fn test_stratum_job() { let job = StratumJob { job_id: "1".to_string(), header_hash: "0".repeat(64), share_target: "f".repeat(64), block_target: "0".repeat(64), timestamp: 1234567890, extra_nonce1: "00000001".to_string(), extra_nonce2_size: 8, clean_jobs: true, }; assert_eq!(job.job_id, "1"); assert!(job.clean_jobs); } #[test] fn test_worker_info() { let info = WorkerInfo { name: "worker1".to_string(), connected_at: 1000, shares_submitted: 100, shares_valid: 95, shares_invalid: 5, difficulty: 1.0, extra_nonce1: "00000001".to_string(), last_share_time: 2000, hashrate: 1000.0, }; assert_eq!( info.shares_valid + info.shares_invalid, info.shares_submitted ); } #[test] fn test_stratum_error() { let err = StratumError::invalid_share(); assert_eq!(err.code, 23); } #[test] fn test_share_validation() { use tokio::sync::mpsc; let (tx, _rx) = mpsc::channel(10); let server = StratumServer::new("127.0.0.1:16111".to_string(), tx.clone()); // Create a job with easy targets let job = StratumJob { job_id: "1".to_string(), header_hash: "00".repeat(32), // 32 zero bytes share_target: "ff".repeat(32), // All 0xff = easy target block_target: "00".repeat(32), // All 0x00 = impossible target timestamp: 1234567890, extra_nonce1: "0000000000000001".to_string(), extra_nonce2_size: 8, clean_jobs: true, }; *server.current_job.write() = Some(job); // Test with no job let server2 = StratumServer::new("127.0.0.1:16112".to_string(), tx.clone()); let submission = ShareSubmission { worker: "test".to_string(), job_id: "1".to_string(), extra_nonce2: "0000000000000001".to_string(), nonce: "0000000000000001".to_string(), timestamp: 1234567890, }; match server2.validate_share(&submission) { ShareResult::Invalid(msg) => assert!(msg.contains("No active job")), _ => panic!("Expected Invalid result for no job"), } // Test stale job let stale_submission = ShareSubmission { worker: "test".to_string(), job_id: "wrong_id".to_string(), extra_nonce2: "0000000000000001".to_string(), nonce: "0000000000000001".to_string(), timestamp: 1234567890, }; match server.validate_share(&stale_submission) { ShareResult::Stale => {} _ => panic!("Expected Stale result"), } // Test valid share with easy share target let valid_submission = ShareSubmission { worker: "test".to_string(), job_id: "1".to_string(), extra_nonce2: "0000000000000001".to_string(), nonce: "0000000000000001".to_string(), timestamp: 1234567890, }; let result = server.validate_share(&valid_submission); assert!( matches!(result, ShareResult::ValidShare), "Expected ValidShare, got {:?}", result ); // Test duplicate share let duplicate_submission = ShareSubmission { worker: "test".to_string(), job_id: "1".to_string(), extra_nonce2: "0000000000000001".to_string(), nonce: "0000000000000001".to_string(), timestamp: 1234567890, }; match server.validate_share(&duplicate_submission) { ShareResult::Duplicate => {} _ => panic!("Expected Duplicate result"), } } }