Skip to main content

netget/server/stratum/
actions.rs

1//! What the model decides as a Stratum V1 pool: who may mine, which valid shares it credits,
2//! and the work and difficulty it hands out. Rust owns the sessions, extranonces, jobs and
3//! every share's arithmetic, so a share reaches the model only once it is proven to meet the
4//! difficulty.
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::{EventType, SpawnContext};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16pub const ACCEPT: &str = "stratum_accept";
17pub const REJECT: &str = "stratum_reject";
18pub const SET_DIFFICULTY: &str = "stratum_set_difficulty";
19pub const NEW_JOB: &str = "stratum_new_job";
20pub const SHOW_MESSAGE: &str = "stratum_show_message";
21/// The share difficulty a connection starts at.
22pub const DEFAULT_DIFFICULTY: f64 = 1.0;
23/// A difficulty must lie in (0, MAX_DIFFICULTY].
24pub const MAX_DIFFICULTY: f64 = 1e15;
25/// What a job's coinbase says when the model names nothing.
26pub const DEFAULT_COINBASE_MESSAGE: &str = "NetGet";
27
28#[derive(Default)]
29pub struct StratumProtocol;
30impl StratumProtocol {
31    pub fn new() -> Self {
32        Self
33    }
34}
35
36pub fn p(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
37    Parameter {
38        name: name.into(),
39        type_hint: kind.into(),
40        description: description.into(),
41        required,
42    }
43}
44
45pub fn action(
46    name: &str,
47    description: &str,
48    parameters: Vec<Parameter>,
49    example: Value,
50) -> ActionDefinition {
51    ActionDefinition {
52        name: name.into(),
53        description: description.into(),
54        parameters,
55        example,
56        log_template: Some(LogTemplate::new().with_info(format!("-> Stratum {name}"))),
57    }
58}
59
60fn accept() -> ActionDefinition {
61    action(
62        ACCEPT,
63        "Authorize the worker, or credit the share, being answered.",
64        vec![],
65        json!({"type": ACCEPT}),
66    )
67}
68
69fn reject() -> ActionDefinition {
70    action(
71        REJECT,
72        "Refuse the worker or the share being answered; the miner is told why.",
73        vec![p(
74            "message",
75            "string",
76            "The reason the miner is shown, e.g. unknown worker",
77            true,
78        )],
79        json!({"type": REJECT, "message": "unknown worker"}),
80    )
81}
82
83pub fn set_difficulty() -> ActionDefinition {
84    action(
85        SET_DIFFICULTY,
86        "Set this miner's share difficulty (mining.set_difficulty); it applies to every share from now on.",
87        vec![p("difficulty", "number", "Share difficulty, above 0; 1 is the classic minimum, fractions are allowed", true)],
88        json!({"type": SET_DIFFICULTY, "difficulty": 0.5}),
89    )
90}
91
92fn new_job() -> ActionDefinition {
93    action(
94        NEW_JOB,
95        "Hand this miner new work (mining.notify). Rust builds the coinbase and header fields.",
96        vec![
97            p("message", "string", "Text the coinbase carries, at most 64 bytes (default NetGet)", false),
98            p("prev_hash", "string", "Previous block hash as RPC shows it, 64 hex digits (default all zeros)", false),
99            p("clean_jobs", "boolean", "Whether earlier jobs are void (default true): their shares are then refused as stale", false),
100        ],
101        json!({"type": NEW_JOB, "message": "block 2", "clean_jobs": true}),
102    )
103}
104
105fn show_message() -> ActionDefinition {
106    action(
107        SHOW_MESSAGE,
108        "Show the miner a message (client.show_message).",
109        vec![p(
110            "message",
111            "string",
112            "Text for the miner's operator",
113            true,
114        )],
115        json!({"type": SHOW_MESSAGE, "message": "maintenance at noon"}),
116    )
117}
118
119pub fn all_actions() -> Vec<ActionDefinition> {
120    vec![
121        accept(),
122        reject(),
123        set_difficulty(),
124        new_job(),
125        show_message(),
126    ]
127}
128
129pub static AUTHORIZE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
130    EventType::new(
131        "stratum_authorize",
132        "A miner asked to mine as a worker (mining.authorize). Accept or reject it; work and the difficulty follow an accept.",
133        json!({"type": ACCEPT}),
134    )
135    .with_parameters(vec![
136        p("worker", "string", "The worker name, usually account.rig", true),
137        p("password_given", "boolean", "Whether a password other than empty or x was sent (it is not shown)", true),
138        p("user_agent", "string", "What the miner said it is in mining.subscribe", false),
139        p("remote_addr", "string", "The miner's address and port", true),
140    ])
141    .with_actions(all_actions())
142});
143
144pub static SHARE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
145    EventType::new(
146        "stratum_share",
147        "A miner submitted a share that Rust verified meets its difficulty. Accept (credit) or reject it.",
148        json!({"type": ACCEPT}),
149    )
150    .with_parameters(vec![
151        p("worker", "string", "The worker that submitted it", true),
152        p("job_id", "string", "The job it was mined on", true),
153        p("hash", "string", "The block header hash, as RPC shows hashes", true),
154        p("share_difficulty", "number", "The difficulty the hash actually meets", true),
155        p("difficulty", "number", "The miner's assigned difficulty, which the share meets", true),
156        p("remote_addr", "string", "The miner's address and port", true),
157    ])
158    .with_actions(all_actions())
159});
160
161pub fn check(v: &Value) -> Result<()> {
162    match v["type"].as_str().unwrap_or_default() {
163        ACCEPT => {}
164        REJECT | SHOW_MESSAGE => {
165            let m = v["message"].as_str().context("message is required")?;
166            ensure!(
167                !m.is_empty() && m.len() <= 256,
168                "message must be 1-256 bytes"
169            );
170        }
171        SET_DIFFICULTY => {
172            let d = v["difficulty"]
173                .as_f64()
174                .context("difficulty must be a number")?;
175            ensure!(
176                d > 0.0 && d <= MAX_DIFFICULTY,
177                "difficulty must be above 0 and at most {MAX_DIFFICULTY}"
178            );
179        }
180        NEW_JOB => {
181            if let Some(m) = v.get("message").filter(|m| !m.is_null()) {
182                let m = m.as_str().context("message must be text")?;
183                ensure!(
184                    m.len() <= super::wire::MAX_COINBASE_MESSAGE,
185                    "message must be at most 64 bytes"
186                );
187            }
188            super::wire::parse_prev_display(v["prev_hash"].as_str())?;
189        }
190        other => bail!("Unknown Stratum action {other:?}"),
191    }
192    Ok(())
193}
194
195impl Protocol for StratumProtocol {
196    fn protocol_name(&self) -> &'static str {
197        "Stratum"
198    }
199    fn stack_name(&self) -> &'static str {
200        "ETH>IP>TCP>Stratum"
201    }
202    fn keywords(&self) -> Vec<&'static str> {
203        vec![
204            "stratum",
205            "mining pool",
206            "bitcoin mining",
207            "miner",
208            "stratum+tcp",
209        ]
210    }
211    fn description(&self) -> &'static str {
212        "Stratum V1 mining pool: the model authorizes workers, credits shares Rust has verified, and sets difficulty and work"
213    }
214    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
215        Vec::new()
216    }
217    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
218        all_actions()
219    }
220    fn get_event_types(&self) -> Vec<EventType> {
221        vec![AUTHORIZE_EVENT.clone(), SHARE_EVENT.clone()]
222    }
223    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
224        vec![
225            ParameterDefinition {
226                name: "difficulty".into(),
227                type_hint: "number".into(),
228                description: "Share difficulty every miner starts at (fractions allowed, above 0)"
229                    .into(),
230                required: false,
231                example: json!(0.01),
232                default: Some(json!(DEFAULT_DIFFICULTY)),
233            },
234            ParameterDefinition {
235                name: "idle_timeout_secs".into(),
236                type_hint: "number".into(),
237                description:
238                    "Seconds a miner may stay silent before it is disconnected (1..=86400)".into(),
239                required: false,
240                example: json!(1800),
241                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
242            },
243        ]
244    }
245    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
246        use crate::protocol::metadata::*;
247        ProtocolMetadataV2::builder()
248            .state(DevelopmentState::Experimental)
249            .privilege_requirement(PrivilegeRequirement::None)
250            .well_known_port(3333)
251            .implementation("Newline-delimited JSON-RPC over Tokio TCP: mining.subscribe (a 4-byte extranonce1 per connection, 4-byte extranonce2), mining.authorize, mining.submit, mining.configure (no extensions), mining.extranonce.subscribe and mining.suggest_difficulty; jobs are built in Rust (BIP 34 height and a message in the coinbase, no other transactions) and every share is rebuilt into its header, double-SHA-256 hashed and measured before anyone is asked")
252            .llm_control("Which workers may mine, whether each verified share is credited, and the difficulty, work and messages each miner gets")
253            .e2e_testing("tests/server/stratum: cpuminer 2.5.1 (pooler, sha256d) mines against NetGet's jobs and has its shares accepted; raw JSON-RPC for refused shares, bounds and a failed handler")
254            .notes("The pool is not connected to any node: its work is its own, so a share proves hashing and nothing is ever submitted as a block. Shares below the difficulty, duplicates, stale or unknown jobs, bad extranonce2 sizes and ntimes outside the job's window are refused by Rust (codes 20-25) without asking the model. Lines are capped at 16 KiB; a miner may be silent idle_timeout_secs.")
255            .request_only("Every message answers a miner's request, except the work and difficulty that follow an answer")
256            .answers_on_failure()
257            .max_inbound_bytes(super::wire::MAX_LINE)
258            .build()
259    }
260    fn example_prompt(&self) -> &'static str {
261        "Stratum mining pool on port 3333 that accepts any worker and credits every share"
262    }
263    fn get_startup_examples(&self) -> StartupExamples {
264        let llm = json!({"type":"open_server","base_stack":"stratum","port":3333,
265            "instruction":"Authorize workers whose name starts with netget, credit every share, and raise the difficulty to 2 after the first share"});
266        let mut static_example = llm.clone();
267        static_example["event_handlers"] =
268            json!([{"event_pattern":"*","handler":{"type":"static","actions":[{"type":ACCEPT}]}}]);
269        let mut scripted = llm.clone();
270        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
271            "code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\nif t=='stratum_authorize': a=[{'type':'stratum_accept'}] if e['worker'].startswith('netget') else [{'type':'stratum_reject','message':'unknown worker'}]\nelse: a=[{'type':'stratum_accept'}]\nprint(json.dumps({'actions':a}))"}}]);
272        StartupExamples::new(llm, scripted, static_example)
273    }
274    fn group_name(&self) -> &'static str {
275        "Application"
276    }
277}
278
279impl Server for StratumProtocol {
280    fn spawn(
281        &self,
282        ctx: SpawnContext,
283    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
284    {
285        Box::pin(super::spawn(ctx))
286    }
287    fn execute_action(&self, action: Value) -> Result<ActionResult> {
288        check(&action)?;
289        Ok(ActionResult::Custom {
290            name: action["type"].as_str().unwrap_or_default().to_string(),
291            data: action,
292        })
293    }
294}