Skip to main content

netget/server/stratum/
mod.rs

1//! Stratum V1 pool (server role). Rust owns each session — subscription, extranonce1, jobs,
2//! the share arithmetic and every refusal the arithmetic decides — and asks the model only
3//! who may mine and whether a share proven valid is credited.
4pub 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;
27/// Jobs a connection keeps; shares for older ones are stale.
28pub const MAX_JOBS: usize = 16;
29/// Shares remembered per connection to refuse duplicates.
30pub const MAX_SEEN_SHARES: usize = 4096;
31/// Workers one connection may authorize.
32pub const MAX_WORKERS: usize = 32;
33/// nbits of the pool's own jobs: the easiest target there is, as on regtest.
34pub const POOL_NBITS: u32 = 0x207f_ffff;
35
36/// What every connection of one pool shares.
37struct 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
193/// The model's answer, split into its verdict and what accompanies it.
194struct 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
229/// Lines for the work, difficulty and messages accompanying an answer.
230fn 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/// Lines answering one request.
321#[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, &params).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}