Skip to main content

netget/client/stratum/
actions.rs

1//! What the model does as a Stratum V1 miner: mine the pool's current job for a bounded number
2//! of hashes (Rust does the hashing and submits what meets the difficulty), submit a share by
3//! hand, suggest a difficulty, authorize more workers.
4use crate::llm::actions::{
5    client_trait::{Client, ClientActionResult},
6    protocol_trait::Protocol,
7    ActionDefinition, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{ConnectContext, EventType};
10use crate::server::stratum::actions::{action, p};
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16pub const MINE: &str = "stratum_mine";
17pub const SUBMIT: &str = "stratum_submit";
18pub const SUGGEST: &str = "stratum_suggest_difficulty";
19pub const AUTHORIZE: &str = "stratum_authorize";
20/// Hashes one stratum_mine tries when the model names no number.
21pub const DEFAULT_MINE_HASHES: u64 = 1_000_000;
22/// Hashes one stratum_mine may try at most.
23pub const MAX_MINE_HASHES: u64 = 50_000_000;
24/// The worker the client authorizes when none is named.
25pub const DEFAULT_USER: &str = "netget";
26/// The password sent with it when none is named (pools conventionally ignore it).
27pub const DEFAULT_PASSWORD: &str = "x";
28
29#[derive(Default)]
30pub struct StratumClientProtocol;
31impl StratumClientProtocol {
32    pub fn new() -> Self {
33        Self
34    }
35}
36
37pub fn actions() -> Vec<ActionDefinition> {
38    vec![
39        action(MINE, "Mine the current job: Rust hashes up to max_hashes nonces and submits the first share that meets the pool's difficulty (and, with submit_best, the best one found if none does).",
40            vec![
41                p("max_hashes", "number", "How many nonces to try (default 1000000, at most 50000000)", false),
42                p("submit_best", "boolean", "Submit the best share found even when it is below the difficulty, e.g. to see the pool's verdict (default false)", false),
43            ],
44            json!({"type": MINE, "max_hashes": 2000000})),
45        action(SUBMIT, "Submit a share by hand (mining.submit) for the current or a named job.",
46            vec![
47                p("nonce", "number", "The header nonce, 0..=4294967295", true),
48                p("extranonce2", "number", "The extranonce2 counter, 0..=4294967295", true),
49                p("job_id", "string", "The job (default the latest)", false),
50                p("ntime", "number", "Header timestamp in Unix seconds (default the job's)", false),
51            ],
52            json!({"type": SUBMIT, "nonce": 12345, "extranonce2": 1})),
53        action(SUGGEST, "Ask the pool for a share difficulty (mining.suggest_difficulty); the pool may ignore it.",
54            vec![p("difficulty", "number", "The difficulty wanted, above 0", true)],
55            json!({"type": SUGGEST, "difficulty": 1})),
56        action(AUTHORIZE, "Authorize another worker on this connection (mining.authorize).",
57            vec![
58                p("user", "string", "Worker name, usually account.rig", true),
59                p("password", "string", "Its password (default x)", false),
60            ],
61            json!({"type": AUTHORIZE, "user": "netget.rig2"})),
62        action("disconnect", "Close the connection to the pool.", vec![], json!({"type": "disconnect"})),
63    ]
64}
65
66fn event(id: &str, description: &str, params: Vec<crate::llm::actions::Parameter>) -> EventType {
67    EventType::new(
68        id,
69        description,
70        json!({"type": MINE, "max_hashes": 1000000}),
71    )
72    .with_parameters(params)
73    .with_actions(actions())
74}
75
76pub static AUTHORIZED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
77    event(
78        "stratum_authorized",
79        "The pool answered mining.authorize.",
80        vec![
81            p("worker", "string", "The worker that asked", true),
82            p("ok", "boolean", "Whether the pool authorized it", true),
83            p(
84                "error",
85                "array",
86                "The pool's [code, message] when it refused",
87                false,
88            ),
89        ],
90    )
91});
92
93pub static JOB_EVENT: LazyLock<EventType> = LazyLock::new(|| {
94    event(
95        "stratum_job",
96        "The pool sent new work (mining.notify).",
97        vec![
98            p(
99                "job_id",
100                "string",
101                "The job's id, which stratum_submit can name",
102                true,
103            ),
104            p(
105                "clean_jobs",
106                "boolean",
107                "Whether earlier jobs are void",
108                true,
109            ),
110            p(
111                "prev_hash",
112                "string",
113                "The previous block hash, as RPC shows hashes",
114                true,
115            ),
116            p(
117                "ntime",
118                "number",
119                "The job's header timestamp (Unix seconds)",
120                true,
121            ),
122            p("nbits", "string", "The network target, compact form", true),
123            p(
124                "merkle_branches",
125                "number",
126                "How many transaction branches the job carries",
127                true,
128            ),
129            p(
130                "difficulty",
131                "number",
132                "The share difficulty the pool currently asks for",
133                true,
134            ),
135        ],
136    )
137});
138
139pub static SHARE_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
140    event(
141        "stratum_share_result",
142        "The pool answered a submitted share.",
143        vec![
144            p("job_id", "string", "The job the share was for", true),
145            p("nonce", "number", "The share's nonce", true),
146            p("extranonce2", "number", "The share's extranonce2", true),
147            p(
148                "hash",
149                "string",
150                "The header hash Rust computed for the share, as RPC shows hashes",
151                true,
152            ),
153            p(
154                "share_difficulty",
155                "number",
156                "The difficulty that hash meets",
157                true,
158            ),
159            p("accepted", "boolean", "Whether the pool accepted it", true),
160            p(
161                "error",
162                "array",
163                "The pool's [code, message] when it refused",
164                false,
165            ),
166        ],
167    )
168});
169
170pub static MINED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
171    event(
172        "stratum_mining_done",
173        "A stratum_mine run ended without submitting anything.",
174        vec![
175            p("hashes", "number", "How many nonces the run tried", true),
176            p(
177                "best_difficulty",
178                "number",
179                "The best share difficulty found",
180                true,
181            ),
182            p(
183                "difficulty",
184                "number",
185                "The difficulty the pool asks for",
186                true,
187            ),
188            p(
189                "reason",
190                "string",
191                "Why nothing was submitted (no job yet, nothing met the difficulty)",
192                true,
193            ),
194        ],
195    )
196});
197
198pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
199    event("stratum_message", "The pool sent something else: client.show_message, client.reconnect, mining.set_extranonce, or a reply to suggest_difficulty.", vec![
200        p("method", "string", "The pool's method, or the action it answers", true),
201        p("params", "array", "What it carried", true),
202    ])
203});
204
205fn u32_field(v: &Value, key: &str, required: bool) -> Result<()> {
206    match v.get(key).filter(|x| !x.is_null()) {
207        Some(x) => {
208            let n = x
209                .as_u64()
210                .with_context(|| format!("{key} must be a whole number"))?;
211            ensure!(n <= u64::from(u32::MAX), "{key} must fit in 32 bits");
212            Ok(())
213        }
214        None if required => bail!("{key} is required"),
215        None => Ok(()),
216    }
217}
218
219pub fn check(v: &Value) -> Result<()> {
220    match v["type"].as_str().unwrap_or_default() {
221        MINE => {
222            if let Some(n) = v.get("max_hashes").filter(|x| !x.is_null()) {
223                let n = n.as_u64().context("max_hashes must be a whole number")?;
224                ensure!(
225                    (1..=MAX_MINE_HASHES).contains(&n),
226                    "max_hashes must be 1..={MAX_MINE_HASHES}"
227                );
228            }
229        }
230        SUBMIT => {
231            u32_field(v, "nonce", true)?;
232            u32_field(v, "extranonce2", true)?;
233            u32_field(v, "ntime", false)?;
234        }
235        SUGGEST => {
236            let d = v["difficulty"]
237                .as_f64()
238                .context("difficulty must be a number")?;
239            ensure!(d > 0.0, "difficulty must be above 0");
240        }
241        AUTHORIZE => {
242            let u = v["user"].as_str().context("user is required")?;
243            ensure!(!u.is_empty() && u.len() <= 128, "user must be 1-128 bytes");
244        }
245        other => bail!("Unknown Stratum client action {other:?}"),
246    }
247    Ok(())
248}
249
250impl Protocol for StratumClientProtocol {
251    fn protocol_name(&self) -> &'static str {
252        "Stratum"
253    }
254    fn stack_name(&self) -> &'static str {
255        "ETH>IP>TCP>Stratum"
256    }
257    fn keywords(&self) -> Vec<&'static str> {
258        vec![
259            "stratum",
260            "miner",
261            "mining client",
262            "stratum+tcp",
263            "bitcoin mining",
264        ]
265    }
266    fn description(&self) -> &'static str {
267        "Stratum V1 miner: subscribes and authorizes, hears the pool's work, and mines and submits shares as the model directs"
268    }
269    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
270        actions()
271    }
272    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
273        vec![]
274    }
275    fn get_event_types(&self) -> Vec<EventType> {
276        vec![
277            AUTHORIZED_EVENT.clone(),
278            JOB_EVENT.clone(),
279            SHARE_RESULT_EVENT.clone(),
280            MINED_EVENT.clone(),
281            MESSAGE_EVENT.clone(),
282        ]
283    }
284    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
285        vec![
286            ParameterDefinition {
287                name: "user".into(),
288                type_hint: "string".into(),
289                description: "Worker name authorized on connect, usually account.rig (a payout address on solo pools)".into(),
290                required: false,
291                example: json!("netget.rig1"),
292                default: Some(json!(DEFAULT_USER)),
293            },
294            ParameterDefinition {
295                name: "password".into(),
296                type_hint: "string".into(),
297                description: "Password sent with the worker; most pools ignore it or read options from it".into(),
298                required: false,
299                example: json!("d=1"),
300                default: Some(json!(DEFAULT_PASSWORD)),
301            },
302        ]
303    }
304    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
305        use crate::protocol::metadata::*;
306        ProtocolMetadataV2::builder()
307            .state(DevelopmentState::Experimental)
308            .privilege_requirement(PrivilegeRequirement::None)
309            .implementation("Newline-delimited JSON-RPC over Tokio TCP: mining.subscribe and mining.authorize on connect, mining.notify and mining.set_difficulty tracked, shares built from the job (coinbase, merkle root, header) and double-SHA-256 hashed in Rust")
310            .llm_control("When and how long to mine, what to submit, which workers to authorize and what difficulty to ask for")
311            .e2e_testing("tests/client/stratum: ckpool (solo mode) over Bitcoin Core 28.1 in regtest; ckpool's own hash of every submitted share is read from its log and must equal NetGet's")
312            .notes("Mining is CPU-only, single-threaded and bounded per action (50M hashes); it exists to prove work, not to earn. No version rolling, no extranonce subscription. A handler chain stops after 8 follow-ups.")
313            .build()
314    }
315    fn example_prompt(&self) -> &'static str {
316        "Connect to the pool at 127.0.0.1:3333 as netget.rig1 and mine each job for a million hashes"
317    }
318    fn get_startup_examples(&self) -> StartupExamples {
319        let llm = json!({"type":"open_client","protocol":"stratum","remote_addr":"127.0.0.1:3333",
320            "startup_params":{"user":"netget.rig1"},
321            "instruction":"Mine every new job for a million hashes and report accepted shares"});
322        let mut static_example = llm.clone();
323        static_example["event_handlers"] = json!([{"event_pattern":"stratum_job","handler":{"type":"static","actions":[{"type":MINE,"max_hashes":1000000}]}}]);
324        let mut scripted = llm.clone();
325        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
326            "code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']\na=[{'type':'stratum_mine','max_hashes':1000000}] if t=='stratum_job' else []\nprint(json.dumps({'actions':a}))"}}]);
327        StartupExamples::new(llm, scripted, static_example)
328    }
329    fn group_name(&self) -> &'static str {
330        "Application"
331    }
332}
333
334impl Client for StratumClientProtocol {
335    fn connect(
336        &self,
337        ctx: ConnectContext,
338    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
339    {
340        Box::pin(super::connect(ctx))
341    }
342    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
343        let name = v["type"].as_str().unwrap_or_default().to_string();
344        if name == "disconnect" {
345            return Ok(ClientActionResult::Disconnect);
346        }
347        check(&v)?;
348        Ok(ClientActionResult::Custom { name, data: v })
349    }
350}