1use 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";
20pub const DEFAULT_MINE_HASHES: u64 = 1_000_000;
22pub const MAX_MINE_HASHES: u64 = 50_000_000;
24pub const DEFAULT_USER: &str = "netget";
26pub 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}