1pub mod actions;
5pub mod wire;
6
7use crate::llm::action_helper::call_llm;
8use crate::llm::actions::protocol_trait::ActionResult;
9use crate::logging::emit::Log;
10use crate::protocol::{Event, SpawnContext};
11use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter, DEFAULT_MAX_CONNECTIONS};
12use crate::server::connection::ConnectionId;
13use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
14use anyhow::{Context, Result};
15use serde_json::{json, Value};
16use std::collections::{HashSet, VecDeque};
17use std::net::SocketAddr;
18use std::sync::atomic::{AtomicU32, Ordering};
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::io::AsyncWriteExt;
22use tokio::net::{TcpListener, TcpStream};
23use wire::{error, Job, Message};
24
25pub const IDLE_TIMEOUT: Duration = Duration::from_secs(900);
26pub const MAX_IDLE_TIMEOUT_SECS: u64 = 86400;
27pub const MAX_JOBS: usize = 16;
29pub const MAX_SEEN_SHARES: usize = 4096;
31pub const MAX_WORKERS: usize = 32;
33pub const POOL_NBITS: u32 = 0x207f_ffff;
35
36struct Pool {
38 next_extranonce1: AtomicU32,
39 next_job: AtomicU32,
40 difficulty: f64,
41}
42
43struct Session {
44 extranonce1: [u8; wire::EXTRANONCE1_SIZE],
45 subscribed: bool,
46 user_agent: Option<String>,
47 workers: HashSet<String>,
48 difficulty: f64,
49 jobs: VecDeque<Job>,
50 seen: HashSet<(String, String, String, String)>,
51 height: u32,
52}
53
54fn params(ctx: &SpawnContext) -> Result<(f64, Duration)> {
55 let p = ctx.startup_params.as_ref();
56 let difficulty = p
57 .map(|p| p.get_optional_f64("difficulty"))
58 .transpose()?
59 .flatten()
60 .unwrap_or(actions::DEFAULT_DIFFICULTY);
61 anyhow::ensure!(
62 difficulty > 0.0 && difficulty <= actions::MAX_DIFFICULTY,
63 "difficulty must be above 0 and at most {}",
64 actions::MAX_DIFFICULTY
65 );
66 let idle = p
67 .map(|p| p.get_optional_u64("idle_timeout_secs"))
68 .transpose()?
69 .flatten()
70 .unwrap_or(IDLE_TIMEOUT.as_secs());
71 anyhow::ensure!(
72 (1..=MAX_IDLE_TIMEOUT_SECS).contains(&idle),
73 "idle_timeout_secs must be between 1 and {MAX_IDLE_TIMEOUT_SECS}"
74 );
75 Ok((difficulty, Duration::from_secs(idle)))
76}
77
78pub async fn spawn(ctx: SpawnContext) -> Result<SocketAddr> {
79 let (difficulty, idle) = params(&ctx)?;
80 let listener = TcpListener::bind(ctx.legacy_listen_addr()).await?;
81 let addr = listener.local_addr()?;
82 Log::new(Some(&ctx.status_tx)).info(format!(
83 "Stratum pool listening on {addr}, difficulty {difficulty}"
84 ));
85 let pool = Arc::new(Pool {
86 next_extranonce1: AtomicU32::new(rand::random()),
87 next_job: AtomicU32::new(1),
88 difficulty,
89 });
90 let state = ctx.state.clone();
91 let server_id = ctx.server_id;
92 let accept = tokio::spawn(async move {
93 let limiter = ConnectionLimiter::new(DEFAULT_MAX_CONNECTIONS);
94 loop {
95 let (socket, peer, permit) =
96 match accept_bounded(&listener, &limiter, b"", "Stratum", Some(&ctx.status_tx))
97 .await
98 {
99 Ok(v) => v,
100 Err(_) => break,
101 };
102 let id = ConnectionId::new(ctx.state.get_next_unified_id().await);
103 let now = crate::utils::clock::Instant::now();
104 ctx.state
105 .add_connection_to_server(
106 server_id,
107 ConnectionState {
108 id,
109 remote_addr: peer,
110 local_addr: addr,
111 bytes_sent: 0,
112 bytes_received: 0,
113 packets_sent: 0,
114 packets_received: 0,
115 last_activity: now,
116 status: ConnectionStatus::Active,
117 status_changed_at: now,
118 protocol_info: ProtocolConnectionInfo::empty(),
119 },
120 )
121 .await;
122 let child = ctx.clone();
123 let pool = pool.clone();
124 ctx.state
125 .spawn_server_task(server_id, async move {
126 let _permit = permit;
127 if let Err(e) = session(&child, &pool, id, socket, peer, idle).await {
128 Log::new(Some(&child.status_tx))
129 .warn(format!("Stratum connection {id} ended: {e}"));
130 }
131 child
132 .state
133 .update_connection_status(server_id, id, ConnectionStatus::Closed)
134 .await;
135 let _ = child.status_tx.send("__UPDATE_UI__".into());
136 })
137 .await;
138 }
139 });
140 state.register_server_task(server_id, accept).await;
141 Ok(addr)
142}
143
144fn outcome(ctx: &SpawnContext, id: ConnectionId, operation: &str, decision: &str) {
145 let summary = format!("Stratum connection {id} operation={operation} decision={decision}");
146 let log = Log::new(Some(&ctx.status_tx));
147 if decision.starts_with("fail_closed_") || decision == "model_silent" {
148 log.error(summary);
149 } else {
150 log.info(summary);
151 }
152}
153
154fn reply(id: &Value, result: Value, err: Value) -> Value {
155 json!({"id": id, "result": result, "error": err})
156}
157
158fn notify(method: &str, params: Value) -> Value {
159 json!({"id": null, "method": method, "params": params})
160}
161
162fn now_secs() -> u32 {
163 crate::utils::clock::SystemTime::now()
164 .duration_since(crate::utils::clock::UNIX_EPOCH)
165 .map(|d| d.as_secs() as u32)
166 .unwrap_or(0)
167}
168
169impl Session {
170 fn new_job(&mut self, pool: &Pool, message: Option<&str>, prev: [u8; 32], clean: bool) -> Job {
171 self.height = self.height.wrapping_add(1).max(1);
172 let job = wire::pool_job(
173 format!("{:x}", pool.next_job.fetch_add(1, Ordering::Relaxed)),
174 &prev,
175 self.height,
176 message.unwrap_or(actions::DEFAULT_COINBASE_MESSAGE),
177 POOL_NBITS,
178 now_secs(),
179 clean,
180 );
181 if clean {
182 self.jobs.clear();
183 self.seen.clear();
184 }
185 if self.jobs.len() >= MAX_JOBS {
186 self.jobs.pop_front();
187 }
188 self.jobs.push_back(job.clone());
189 job
190 }
191}
192
193struct Answer {
195 verdict: Option<Result<(), String>>,
196 extras: Vec<Value>,
197 invalid: bool,
198}
199
200fn split(results: Vec<ActionResult>) -> Answer {
201 let mut verdicts = Vec::new();
202 let mut extras = Vec::new();
203 let mut stack = results;
204 let mut ordered = Vec::new();
205 while let Some(r) = stack.pop() {
206 match r {
207 ActionResult::Custom { data, .. } => ordered.push(data),
208 ActionResult::Multiple(items) => stack.extend(items),
209 _ => {}
210 }
211 }
212 ordered.reverse();
213 for a in ordered {
214 match a["type"].as_str().unwrap_or_default() {
215 actions::ACCEPT => verdicts.push(Ok(())),
216 actions::REJECT => {
217 verdicts.push(Err(a["message"].as_str().unwrap_or("rejected").to_string()))
218 }
219 _ => extras.push(a),
220 }
221 }
222 Answer {
223 invalid: verdicts.len() > 1,
224 verdict: verdicts.into_iter().next(),
225 extras,
226 }
227}
228
229fn apply_extras(
231 session: &mut Session,
232 pool: &Pool,
233 extras: &[Value],
234 send_work: bool,
235 out: &mut Vec<Value>,
236) {
237 let mut sent_job = false;
238 for a in extras {
239 match a["type"].as_str().unwrap_or_default() {
240 actions::SET_DIFFICULTY => {
241 if let Some(d) = a["difficulty"].as_f64() {
242 session.difficulty = d;
243 out.push(notify("mining.set_difficulty", json!([d])));
244 }
245 }
246 actions::NEW_JOB => {
247 let prev = wire::parse_prev_display(a["prev_hash"].as_str()).unwrap_or([0u8; 32]);
248 let job = session.new_job(
249 pool,
250 a["message"].as_str(),
251 prev,
252 a["clean_jobs"].as_bool().unwrap_or(true),
253 );
254 out.push(notify("mining.notify", job.notify_params()));
255 sent_job = true;
256 }
257 actions::SHOW_MESSAGE => {
258 out.push(notify("client.show_message", json!([a["message"]])));
259 }
260 _ => {}
261 }
262 }
263 if send_work && !sent_job {
264 let job = match session.jobs.back() {
265 Some(j) => j.clone(),
266 None => session.new_job(pool, None, [0u8; 32], true),
267 };
268 if !out.iter().any(|m| m["method"] == "mining.set_difficulty") {
269 out.insert(
270 0,
271 notify("mining.set_difficulty", json!([session.difficulty])),
272 );
273 }
274 out.push(notify("mining.notify", job.notify_params()));
275 }
276}
277
278async fn ask(
279 ctx: &SpawnContext,
280 id: ConnectionId,
281 event: Event,
282 operation: &str,
283) -> Result<Answer, anyhow::Error> {
284 match call_llm(
285 &ctx.llm_client,
286 &ctx.state,
287 ctx.server_id,
288 Some(id),
289 &event,
290 &actions::StratumProtocol,
291 )
292 .await
293 {
294 Ok(r) if r.failures.is_empty() => {
295 let answer = split(r.protocol_results);
296 let decision = match (&answer.verdict, answer.invalid) {
297 (_, true) => "fail_closed_invalid_reply",
298 (Some(Ok(())), _) => "model_answer",
299 (Some(Err(_)), _) => "model_reject",
300 (None, _) => "model_silent",
301 };
302 outcome(ctx, id, operation, decision);
303 Ok(answer)
304 }
305 Ok(_) => {
306 outcome(ctx, id, operation, "fail_closed_invalid_reply");
307 Err(anyhow::anyhow!("invalid reply"))
308 }
309 Err(e) => {
310 outcome(ctx, id, operation, "fail_closed_llm_error");
311 Err(e)
312 }
313 }
314}
315
316fn failure_text(e: &anyhow::Error) -> &'static str {
317 crate::utils::wire_failure::wire_failure_text(e)
318}
319
320#[allow(clippy::too_many_arguments)]
322async fn answer(
323 ctx: &SpawnContext,
324 pool: &Pool,
325 session: &mut Session,
326 id: ConnectionId,
327 peer: SocketAddr,
328 rid: &Value,
329 method: &str,
330 params: &Value,
331) -> Vec<Value> {
332 let log = Log::new(Some(&ctx.status_tx));
333 let p = params.as_array().cloned().unwrap_or_default();
334 match method {
335 "mining.subscribe" => {
336 session.subscribed = true;
337 session.user_agent = p
338 .first()
339 .and_then(Value::as_str)
340 .map(|s| s.chars().take(128).collect());
341 let sub = hex::encode(session.extranonce1);
342 vec![reply(
343 rid,
344 json!([
345 [["mining.set_difficulty", sub], ["mining.notify", sub]],
346 hex::encode(session.extranonce1),
347 wire::EXTRANONCE2_SIZE
348 ]),
349 Value::Null,
350 )]
351 }
352 "mining.configure" => vec![reply(rid, json!({}), Value::Null)],
353 "mining.extranonce.subscribe" => vec![reply(rid, json!(false), Value::Null)],
354 "mining.suggest_difficulty" => {
355 log.debug(format!(
356 "Stratum connection {id} suggested difficulty {:?} (ignored)",
357 p.first()
358 ));
359 vec![reply(rid, json!(false), Value::Null)]
360 }
361 "mining.authorize" => {
362 if !session.subscribed {
363 return vec![reply(rid, json!(false), error(25, "Not subscribed"))];
364 }
365 let Some(worker) = p
366 .first()
367 .and_then(Value::as_str)
368 .filter(|w| !w.is_empty() && w.len() <= 128)
369 else {
370 return vec![reply(rid, json!(false), error(20, "worker name required"))];
371 };
372 if session.workers.len() >= MAX_WORKERS && !session.workers.contains(worker) {
373 return vec![reply(
374 rid,
375 json!(false),
376 error(20, "too many workers on one connection"),
377 )];
378 }
379 let password = p.get(1).and_then(Value::as_str).unwrap_or_default();
380 let event = Event::new(
381 &actions::AUTHORIZE_EVENT,
382 json!({"worker": worker, "password_given": !(password.is_empty() || password == "x"),
383 "user_agent": session.user_agent, "remote_addr": peer.to_string()}),
384 );
385 match ask(ctx, id, event, "authorize").await {
386 Ok(a) if !a.invalid && matches!(a.verdict, Some(Ok(()))) => {
387 session.workers.insert(worker.to_string());
388 let mut out = vec![reply(rid, json!(true), Value::Null)];
389 apply_extras(session, pool, &a.extras, true, &mut out);
390 out
391 }
392 Ok(a) if !a.invalid => {
393 let why = match a.verdict {
394 Some(Err(why)) => why,
395 _ => crate::utils::WireFailure::Unavailable.text().to_string(),
396 };
397 let mut out = vec![reply(rid, json!(false), error(24, &why))];
398 apply_extras(session, pool, &a.extras, false, &mut out);
399 out
400 }
401 Ok(_) => vec![reply(
402 rid,
403 json!(false),
404 error(20, crate::utils::WireFailure::Unavailable.text()),
405 )],
406 Err(e) => vec![reply(rid, json!(false), error(20, failure_text(&e)))],
407 }
408 }
409 "mining.submit" => submit(ctx, pool, session, id, peer, rid, &p).await,
410 _ => vec![reply(rid, Value::Null, error(20, "Unknown method"))],
411 }
412}
413
414async fn submit(
415 ctx: &SpawnContext,
416 pool: &Pool,
417 session: &mut Session,
418 id: ConnectionId,
419 peer: SocketAddr,
420 rid: &Value,
421 p: &[Value],
422) -> Vec<Value> {
423 let refuse = |code: u32, why: &str| {
424 Log::new(Some(&ctx.status_tx))
425 .info(format!("Stratum connection {id} share refused: {why}"));
426 vec![reply(rid, json!(false), error(code, why))]
427 };
428 if !session.subscribed {
429 return refuse(25, "Not subscribed");
430 }
431 if p.len() != 5 {
432 return refuse(
433 20,
434 "submit takes worker, job_id, extranonce2, ntime and nonce (no version rolling)",
435 );
436 }
437 let s = |i: usize| p[i].as_str().unwrap_or_default();
438 let (worker, job_id, en2_hex, ntime_hex, nonce_hex) = (s(0), s(1), s(2), s(3), s(4));
439 if !session.workers.contains(worker) {
440 return refuse(24, "Unauthorized worker");
441 }
442 let Some(job) = session.jobs.iter().find(|j| j.job_id == job_id).cloned() else {
443 return refuse(21, "Job not found");
444 };
445 let Ok(en2) = hex::decode(en2_hex) else {
446 return refuse(20, "extranonce2 is not hex");
447 };
448 if en2.len() != wire::EXTRANONCE2_SIZE {
449 return refuse(20, "Invalid extranonce2 size");
450 }
451 let (Ok(ntime), Ok(nonce)) = (
452 wire::parse_u32_hex(ntime_hex, "ntime"),
453 wire::parse_u32_hex(nonce_hex, "nonce"),
454 ) else {
455 return refuse(20, "ntime and nonce must be 8 hex digits");
456 };
457 if ntime < job.ntime || ntime > job.ntime.saturating_add(wire::NTIME_ROLL) {
458 return refuse(20, "Ntime out of range");
459 }
460 let key = (
461 job_id.to_string(),
462 en2_hex.to_lowercase(),
463 ntime_hex.to_lowercase(),
464 nonce_hex.to_lowercase(),
465 );
466 if session.seen.contains(&key) {
467 return refuse(22, "Duplicate share");
468 }
469 let hash = job.share_hash(&session.extranonce1, &en2, ntime, nonce);
470 let sdiff = wire::difficulty(&hash);
471 let hash_hex = wire::display_hex(&hash);
472 if sdiff < session.difficulty {
473 return refuse(
474 23,
475 &format!(
476 "Low difficulty share ({sdiff:.6} < {}): {hash_hex}",
477 session.difficulty
478 ),
479 );
480 }
481 if session.seen.len() >= MAX_SEEN_SHARES {
482 session.seen.clear();
483 }
484 session.seen.insert(key);
485 let event = Event::new(
486 &actions::SHARE_EVENT,
487 json!({"worker": worker, "job_id": job_id, "hash": hash_hex, "share_difficulty": sdiff,
488 "difficulty": session.difficulty, "remote_addr": peer.to_string()}),
489 );
490 match ask(ctx, id, event, "share").await {
491 Ok(a) if !a.invalid && matches!(a.verdict, Some(Ok(()))) => {
492 let mut out = vec![reply(rid, json!(true), Value::Null)];
493 apply_extras(session, pool, &a.extras, false, &mut out);
494 out
495 }
496 Ok(a) if !a.invalid => {
497 let why = match a.verdict {
498 Some(Err(why)) => why,
499 _ => crate::utils::WireFailure::Unavailable.text().to_string(),
500 };
501 let mut out = vec![reply(rid, json!(false), error(20, &why))];
502 apply_extras(session, pool, &a.extras, false, &mut out);
503 out
504 }
505 Ok(_) => vec![reply(
506 rid,
507 json!(false),
508 error(20, crate::utils::WireFailure::Unavailable.text()),
509 )],
510 Err(e) => vec![reply(rid, json!(false), error(20, failure_text(&e)))],
511 }
512}
513
514async fn session(
515 ctx: &SpawnContext,
516 pool: &Pool,
517 id: ConnectionId,
518 socket: TcpStream,
519 peer: SocketAddr,
520 idle: Duration,
521) -> Result<()> {
522 let (reader, mut writer) = tokio::io::split(socket);
523 let mut lines = wire::Lines::new(reader);
524 let mut session = Session {
525 extranonce1: pool
526 .next_extranonce1
527 .fetch_add(1, Ordering::Relaxed)
528 .to_be_bytes(),
529 subscribed: false,
530 user_agent: None,
531 workers: HashSet::new(),
532 difficulty: pool.difficulty,
533 jobs: VecDeque::new(),
534 seen: HashSet::new(),
535 height: 0,
536 };
537 while let Some(value) = lines.next(idle).await? {
538 ctx.state
539 .update_connection_stats(ctx.server_id, id, None, None, Some(1), None)
540 .await;
541 let out = match wire::parse(value)? {
542 Message::Request {
543 id: rid,
544 method,
545 params,
546 } => answer(ctx, pool, &mut session, id, peer, &rid, &method, ¶ms).await,
547 Message::Notification { method, .. } => {
548 Log::new(Some(&ctx.status_tx)).debug(format!(
549 "Stratum connection {id} notification {method} ignored"
550 ));
551 continue;
552 }
553 Message::Response { .. } => continue,
554 };
555 let bytes: Vec<u8> = out.iter().flat_map(wire::line).collect();
556 tokio::time::timeout(wire::IO_TIMEOUT, writer.write_all(&bytes))
557 .await
558 .context("Stratum write deadline")??;
559 ctx.state
560 .update_connection_stats(
561 ctx.server_id,
562 id,
563 None,
564 Some(bytes.len() as u64),
565 None,
566 Some(out.len() as u64),
567 )
568 .await;
569 }
570 Ok(())
571}