darkfid/rpc/
stratum.rs

1/* This file is part of DarkFi (https://dark.fi)
2 *
3 * Copyright (C) 2020-2026 Dyne.org foundation
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU Affero General Public License as
7 * published by the Free Software Foundation, either version 3 of the
8 * License, or (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
17 */
18
19use std::collections::{HashMap, HashSet};
20
21use async_trait::async_trait;
22use smol::lock::MutexGuard;
23use tinyjson::JsonValue;
24use tracing::{debug, error, info};
25
26use darkfi::{
27    rpc::{
28        jsonrpc::{
29            ErrorCode, ErrorCode::InvalidParams, JsonError, JsonRequest, JsonResponse, JsonResult,
30        },
31        server::RequestHandler,
32    },
33    system::StoppableTaskPtr,
34};
35
36use crate::{
37    error::{miner_status_response, server_error, RpcError},
38    registry::model::MinerRewardsRecipientConfig,
39    DarkfiNode,
40};
41
42// https://github.com/xmrig/xmrig-proxy/blob/master/doc/STRATUM.md
43// https://github.com/xmrig/xmrig-proxy/blob/master/doc/STRATUM_EXT.md
44
45/// JSON-RPC `RequestHandler` for Stratum
46pub struct StratumRpcHandler;
47
48#[async_trait]
49#[rustfmt::skip]
50impl RequestHandler<StratumRpcHandler> for DarkfiNode {
51	async fn handle_request(&self, req: JsonRequest) -> JsonResult {
52		debug!(target: "darkfid::rpc::stratum_rpc", "--> {}", req.stringify().unwrap());
53
54		match req.method.as_str() {
55			// ======================
56			// Stratum mining methods
57			// ======================
58			"login" => self.stratum_login(req.id, req.params).await,
59			"submit" => self.stratum_submit(req.id, req.params).await,
60			"keepalived" => self.stratum_keepalived(req.id, req.params).await,
61			_ => JsonError::new(ErrorCode::MethodNotFound, None, req.id).into(),
62		}
63	}
64
65    async fn connections_mut(&self) -> MutexGuard<'life0, HashSet<StoppableTaskPtr>> {
66        self.registry.stratum_rpc_connections.lock().await
67    }
68}
69
70impl DarkfiNode {
71    // RPCAPI:
72    // Register a new mining client to the registry and generate a new
73    // job.
74    //
75    // **Request:**
76    // * `login` : A wallet address or its base-64 encoded mining configuration
77    // * `pass`  : Unused client password field
78    // * `agent` : Client agent description
79    // * `algo`  : Client supported mining algorithms
80    //
81    // **Response:**
82    // * `id`     : Registry client ID
83    // * `job`    : The generated mining job
84    // * `status` : Response status
85    //
86    // The generated mining job map consists of the following fields:
87    // * `blob`      : The hex encoded block hashing blob of the job block
88    // * `job_id`    : Registry mining job ID
89    // * `height`    : The job block height
90    // * `target`    : Current mining target
91    // * `algo`      : The mining algorithm - RandomX
92    // * `seed_hash` : Current RandomX key
93    // * `next_seed_hash`: (optional) Next RandomX key if it is known
94    //
95    // --> {
96    //       "jsonrpc": "2.0",
97    //       "method": "login",
98    //       "params": {
99    //         "login": "WALLET_ADDRESS",
100    //         "pass": "x",
101    //         "agent": "XMRig",
102    //         "algo": ["rx/0"]
103    //       },
104    //       "id": 1
105    //     }
106    // <-- {
107    //       "jsonrpc": "2.0",
108    //       "result": {
109    //         "id": "unique_connection-id",
110    //         "job": {
111    //           "blob": "abcdef...001234",
112    //           "job_id": "unique_job-id",
113    //           "height": 1234,
114    //           "target": "abcd1234",
115    //           "algo": "rx/0",
116    //           "seed_hash": "deadbeef...0234",
117    //           "next_seed_hash": "c0fefe...1243"
118    //         },
119    //         "status": "OK"
120    //       },
121    //       "id": 1
122    //     }
123    pub async fn stratum_login(&self, id: u16, params: JsonValue) -> JsonResult {
124        // Check if node is synced before responding
125        if !*self.validator.synced.read().await {
126            return JsonResponse::new(JsonValue::from(HashMap::new()), id).into()
127        }
128
129        // Parse request params
130        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
131            return JsonError::new(InvalidParams, None, id).into()
132        };
133
134        // Parse login
135        let Some(wallet) = params.get("login") else {
136            return server_error(RpcError::MinerMissingLogin, id, None)
137        };
138        let Some(wallet) = wallet.get::<String>() else {
139            return server_error(RpcError::MinerInvalidLogin, id, None)
140        };
141        let config =
142            match MinerRewardsRecipientConfig::from_str(&self.registry.network, wallet).await {
143                Ok(c) => c,
144                Err(e) => return server_error(e, id, None),
145            };
146
147        // Parse password
148        let Some(pass) = params.get("pass") else {
149            return server_error(RpcError::MinerMissingPassword, id, None)
150        };
151        let Some(_pass) = pass.get::<String>() else {
152            return server_error(RpcError::MinerInvalidPassword, id, None)
153        };
154
155        // Parse agent
156        let Some(agent) = params.get("agent") else {
157            return server_error(RpcError::MinerMissingAgent, id, None)
158        };
159        let Some(agent) = agent.get::<String>() else {
160            return server_error(RpcError::MinerInvalidAgent, id, None)
161        };
162
163        // Parge algo
164        let Some(algo) = params.get("algo") else {
165            return server_error(RpcError::MinerMissingAlgo, id, None)
166        };
167        let Some(algo) = algo.get::<Vec<JsonValue>>() else {
168            return server_error(RpcError::MinerInvalidAlgo, id, None)
169        };
170
171        // Iterate through `algo` to see if "rx/0" is supported.
172        // rx/0 is RandomX.
173        let mut found_rx0 = false;
174        for i in algo {
175            let Some(algo) = i.get::<String>() else {
176                return server_error(RpcError::MinerInvalidAlgo, id, None)
177            };
178            if algo == "rx/0" {
179                found_rx0 = true;
180                break
181            }
182        }
183        if !found_rx0 {
184            return server_error(RpcError::MinerRandomXNotSupported, id, None)
185        }
186
187        // Register the new miner
188        info!(
189            target: "darkfid::rpc::rpc_stratum::stratum_login",
190            "[RPC-STRATUM] Got login from {wallet} ({agent})",
191        );
192        let (client_id, job_id, job, publisher) =
193            match self.registry.register_miner(&self.validator, wallet, &config).await {
194                Ok(p) => p,
195                Err(e) => {
196                    error!(
197                        target: "darkfid::rpc::rpc_stratum::stratum_login",
198                        "[RPC-STRATUM] Failed to register miner: {e}",
199                    );
200                    return JsonResponse::new(JsonValue::from(HashMap::new()), id).into()
201                }
202            };
203
204        // Now we have the new job, we ship it to RPC
205        info!(
206            target: "darkfid::rpc::rpc_stratum::stratum_login",
207            "[RPC-STRATUM] Created new mining job for client {client_id}: {job_id}"
208        );
209        let response = JsonValue::from(HashMap::from([
210            ("id".to_string(), JsonValue::from(client_id)),
211            ("job".to_string(), job),
212            ("status".to_string(), JsonValue::from(String::from("OK"))),
213        ]));
214        (publisher, JsonResponse::new(response, id)).into()
215    }
216
217    // RPCAPI:
218    // Miner submits a job solution.
219    //
220    // **Request:**
221    // * `id`     : Registry client ID
222    // * `job_id` : Registry mining job ID
223    // * `nonce`  : The hex encoded solution header nonce.
224    // * `result` : RandomX calculated hash
225    //
226    // **Response:**
227    // * `status`: Block submit status
228    //
229    // --> {
230    //       "jsonrpc": "2.0",
231    //       "method": "submit",
232    //       "params": {
233    //         "id": "unique_connection-id",
234    //         "job_id": "unique_job-id",
235    //         "nonce": "d0030040",
236    //         "result": "e1364b8782719d7683e2ccd3d8f724bc59dfa780a9e960e7c0e0046acdb40100"
237    //       },
238    //       "id": 1
239    //     }
240    // <-- {"jsonrpc": "2.0", "result": {"status": "OK"}, "id": 1}
241    pub async fn stratum_submit(&self, id: u16, params: JsonValue) -> JsonResult {
242        // Check if node is synced before responding
243        if !*self.validator.synced.read().await {
244            return miner_status_response(id, "rejected")
245        }
246
247        // Grab registry submissions lock
248        let submit_lock = self.registry.submit_lock.write().await;
249
250        // Parse request params
251        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
252            return JsonError::new(InvalidParams, None, id).into()
253        };
254
255        // Parse client id
256        let Some(client_id) = params.get("id") else {
257            return server_error(RpcError::MinerMissingClientId, id, None)
258        };
259        let Some(client_id) = client_id.get::<String>() else {
260            return server_error(RpcError::MinerInvalidClientId, id, None)
261        };
262
263        // If we don't know about this client, we can just abort here
264        let mut jobs = self.registry.jobs.write().await;
265        let Some(client) = jobs.get(client_id) else {
266            return miner_status_response(id, "rejected")
267        };
268
269        // Parse job id
270        let Some(job_id) = params.get("job_id") else {
271            return server_error(RpcError::MinerMissingJobId, id, None)
272        };
273        let Some(job_id) = job_id.get::<String>() else {
274            return server_error(RpcError::MinerInvalidJobId, id, None)
275        };
276
277        // If this job doesn't match the client one, we can just abort
278        // here.
279        if &client.job != job_id {
280            return miner_status_response(id, "rejected")
281        }
282
283        // If this client job wallet template doesn't exist, we can
284        // just abort here.
285        let mut block_templates = self.registry.block_templates.write().await;
286        let Some(block_template) = block_templates.get_mut(&client.wallet) else {
287            return miner_status_response(id, "rejected")
288        };
289
290        // If this template has been already submitted, reject this
291        // submission.
292        if block_template.submitted {
293            return miner_status_response(id, "rejected")
294        }
295
296        // Parse nonce
297        let Some(nonce) = params.get("nonce") else {
298            return server_error(RpcError::MinerMissingNonce, id, None)
299        };
300        let Some(nonce) = nonce.get::<String>() else {
301            return server_error(RpcError::MinerInvalidNonce, id, None)
302        };
303        let Ok(nonce_bytes) = hex::decode(nonce) else {
304            return server_error(RpcError::MinerInvalidNonce, id, None)
305        };
306        if nonce_bytes.len() != 4 {
307            return server_error(RpcError::MinerInvalidNonce, id, None)
308        }
309        let nonce = u32::from_le_bytes(nonce_bytes.try_into().unwrap());
310
311        // Parse result
312        let Some(result) = params.get("result") else {
313            return server_error(RpcError::MinerMissingResult, id, None)
314        };
315        let Some(_result) = result.get::<String>() else {
316            return server_error(RpcError::MinerInvalidResult, id, None)
317        };
318
319        info!(
320            target: "darkfid::rpc::rpc_stratum::stratum_submit",
321            "[RPC-STRATUM] Got solution submission from client {client_id} for job: {job_id}",
322        );
323
324        // Update the block nonce and sign it
325        let mut block = block_template.block.clone();
326        block.header.nonce = nonce;
327        block.sign(&block_template.secret);
328
329        // Submit the new block through the registry
330        if let Err(e) =
331            self.registry.submit(&self.validator, &self.subscribers, &self.p2p_handler, block).await
332        {
333            error!(
334                target: "darkfid::rpc::rpc_stratum::stratum_submit",
335                "[RPC-STRATUM] Error submitting new block: {e}",
336            );
337
338            // Try to refresh the jobs before returning error
339            let mut mm_jobs = self.registry.mm_jobs.write().await;
340            if let Err(e) = self
341                .registry
342                .refresh_jobs(&mut block_templates, &mut jobs, &mut mm_jobs, &self.validator)
343                .await
344            {
345                error!(
346                    target: "darkfid::rpc::rpc_stratum::stratum_submit",
347                    "[RPC-STRATUM] Error refreshing registry jobs: {e}",
348                );
349            }
350
351            // Release all locks
352            drop(block_templates);
353            drop(jobs);
354            drop(mm_jobs);
355            drop(submit_lock);
356
357            return miner_status_response(id, "rejected")
358        }
359
360        // Mark block as submitted
361        block_template.submitted = true;
362
363        // Release all locks
364        drop(block_templates);
365        drop(jobs);
366        drop(submit_lock);
367
368        miner_status_response(id, "OK")
369    }
370
371    // RPCAPI:
372    // Miner sends `keepalived` to prevent connection timeout.
373    //
374    // **Request:**
375    // * `id` : Registry client ID
376    //
377    // **Response:**
378    // * `status`: Response status
379    //
380    // --> {"jsonrpc": "2.0", "method": "keepalived", "params": {"id": "foo"}, "id": 1}
381    // <-- {"jsonrpc": "2.0", "result": {"status": "KEEPALIVED"}, "id": 1}
382    pub async fn stratum_keepalived(&self, id: u16, params: JsonValue) -> JsonResult {
383        // Parse request params
384        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
385            return JsonError::new(InvalidParams, None, id).into()
386        };
387
388        // Parse client id
389        let Some(client_id) = params.get("id") else {
390            return server_error(RpcError::MinerMissingClientId, id, None)
391        };
392        let Some(client_id) = client_id.get::<String>() else {
393            return server_error(RpcError::MinerInvalidClientId, id, None)
394        };
395
396        // If we don't know about this client job, we can just abort here
397        if !self.registry.jobs.read().await.contains_key(client_id) {
398            return server_error(RpcError::MinerUnknownClient, id, None)
399        };
400
401        // Respond with keepalived message
402        miner_status_response(id, "KEEPALIVED")
403    }
404}