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        let validator = self.validator.read().await;
126        if !validator.synced {
127            return JsonResponse::new(JsonValue::from(HashMap::new()), id).into()
128        }
129
130        // Parse request params
131        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
132            return JsonError::new(InvalidParams, None, id).into()
133        };
134
135        // Parse login
136        let Some(wallet) = params.get("login") else {
137            return server_error(RpcError::MinerMissingLogin, id, None)
138        };
139        let Some(wallet) = wallet.get::<String>() else {
140            return server_error(RpcError::MinerInvalidLogin, id, None)
141        };
142        let config =
143            match MinerRewardsRecipientConfig::from_str(&self.registry.network, wallet).await {
144                Ok(c) => c,
145                Err(e) => return server_error(e, id, None),
146            };
147
148        // Parse password
149        let Some(pass) = params.get("pass") else {
150            return server_error(RpcError::MinerMissingPassword, id, None)
151        };
152        let Some(_pass) = pass.get::<String>() else {
153            return server_error(RpcError::MinerInvalidPassword, id, None)
154        };
155
156        // Parse agent
157        let Some(agent) = params.get("agent") else {
158            return server_error(RpcError::MinerMissingAgent, id, None)
159        };
160        let Some(agent) = agent.get::<String>() else {
161            return server_error(RpcError::MinerInvalidAgent, id, None)
162        };
163
164        // Parge algo
165        let Some(algo) = params.get("algo") else {
166            return server_error(RpcError::MinerMissingAlgo, id, None)
167        };
168        let Some(algo) = algo.get::<Vec<JsonValue>>() else {
169            return server_error(RpcError::MinerInvalidAlgo, id, None)
170        };
171
172        // Iterate through `algo` to see if "rx/0" is supported.
173        // rx/0 is RandomX.
174        let mut found_rx0 = false;
175        for i in algo {
176            let Some(algo) = i.get::<String>() else {
177                return server_error(RpcError::MinerInvalidAlgo, id, None)
178            };
179            if algo == "rx/0" {
180                found_rx0 = true;
181                break
182            }
183        }
184        if !found_rx0 {
185            return server_error(RpcError::MinerRandomXNotSupported, id, None)
186        }
187
188        // Register the new miner
189        info!(
190            target: "darkfid::rpc::rpc_stratum::stratum_login",
191            "[RPC-STRATUM] Got login from {wallet} ({agent})",
192        );
193        let (client_id, job_id, job, publisher) = match self
194            .registry
195            .state
196            .write()
197            .await
198            .register_miner(&validator, wallet, &config)
199            .await
200        {
201            Ok(p) => p,
202            Err(e) => {
203                error!(
204                    target: "darkfid::rpc::rpc_stratum::stratum_login",
205                    "[RPC-STRATUM] Failed to register miner: {e}",
206                );
207                return JsonResponse::new(JsonValue::from(HashMap::new()), id).into()
208            }
209        };
210
211        // Now we have the new job, we ship it to RPC
212        info!(
213            target: "darkfid::rpc::rpc_stratum::stratum_login",
214            "[RPC-STRATUM] Created new mining job for client {client_id}: {job_id}"
215        );
216        let response = JsonValue::from(HashMap::from([
217            ("id".to_string(), JsonValue::from(client_id)),
218            ("job".to_string(), job),
219            ("status".to_string(), JsonValue::from(String::from("OK"))),
220        ]));
221        (publisher, JsonResponse::new(response, id)).into()
222    }
223
224    // RPCAPI:
225    // Miner submits a job solution.
226    //
227    // **Request:**
228    // * `id`     : Registry client ID
229    // * `job_id` : Registry mining job ID
230    // * `nonce`  : The hex encoded solution header nonce.
231    // * `result` : RandomX calculated hash
232    //
233    // **Response:**
234    // * `status`: Block submit status
235    //
236    // --> {
237    //       "jsonrpc": "2.0",
238    //       "method": "submit",
239    //       "params": {
240    //         "id": "unique_connection-id",
241    //         "job_id": "unique_job-id",
242    //         "nonce": "d0030040",
243    //         "result": "e1364b8782719d7683e2ccd3d8f724bc59dfa780a9e960e7c0e0046acdb40100"
244    //       },
245    //       "id": 1
246    //     }
247    // <-- {"jsonrpc": "2.0", "result": {"status": "OK"}, "id": 1}
248    pub async fn stratum_submit(&self, id: u16, params: JsonValue) -> JsonResult {
249        // Check if node is synced before responding
250        let mut validator = self.validator.write().await;
251        if !validator.synced {
252            return miner_status_response(id, "rejected")
253        }
254
255        // Parse request params
256        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
257            return JsonError::new(InvalidParams, None, id).into()
258        };
259
260        // Parse client id
261        let Some(client_id) = params.get("id") else {
262            return server_error(RpcError::MinerMissingClientId, id, None)
263        };
264        let Some(client_id) = client_id.get::<String>() else {
265            return server_error(RpcError::MinerInvalidClientId, id, None)
266        };
267
268        // If we don't know about this client, we can just abort here
269        let mut registry = self.registry.state.write().await;
270        let Some(client) = registry.jobs.get(client_id) else {
271            return miner_status_response(id, "rejected")
272        };
273
274        // Parse job id
275        let Some(job_id) = params.get("job_id") else {
276            return server_error(RpcError::MinerMissingJobId, id, None)
277        };
278        let Some(job_id) = job_id.get::<String>() else {
279            return server_error(RpcError::MinerInvalidJobId, id, None)
280        };
281
282        // If this job doesn't match the client one, we can just abort
283        // here.
284        if &client.job != job_id {
285            return miner_status_response(id, "rejected")
286        }
287        let wallet = client.wallet.clone();
288
289        // If this client job wallet template doesn't exist, we can
290        // just abort here.
291        let Some(block_template) = registry.block_templates.get(&wallet) else {
292            return miner_status_response(id, "rejected")
293        };
294
295        // If this template has been already submitted, reject this
296        // submission.
297        if block_template.submitted {
298            return miner_status_response(id, "rejected")
299        }
300
301        // Parse nonce
302        let Some(nonce) = params.get("nonce") else {
303            return server_error(RpcError::MinerMissingNonce, id, None)
304        };
305        let Some(nonce) = nonce.get::<String>() else {
306            return server_error(RpcError::MinerInvalidNonce, id, None)
307        };
308        let Ok(nonce_bytes) = hex::decode(nonce) else {
309            return server_error(RpcError::MinerInvalidNonce, id, None)
310        };
311        if nonce_bytes.len() != 4 {
312            return server_error(RpcError::MinerInvalidNonce, id, None)
313        }
314        let nonce = u32::from_le_bytes(nonce_bytes.try_into().unwrap());
315
316        // Parse result
317        let Some(result) = params.get("result") else {
318            return server_error(RpcError::MinerMissingResult, id, None)
319        };
320        let Some(_result) = result.get::<String>() else {
321            return server_error(RpcError::MinerInvalidResult, id, None)
322        };
323
324        info!(
325            target: "darkfid::rpc::rpc_stratum::stratum_submit",
326            "[RPC-STRATUM] Got solution submission from client {client_id} for job: {job_id}",
327        );
328
329        // Update the block nonce and sign it
330        let mut block = block_template.block.clone();
331        block.header.nonce = nonce;
332        block.sign(&block_template.secret);
333
334        // Keep the template in memory so we can safely refernce the
335        // registry.
336        let mut block_template = block_template.clone();
337
338        // Submit the new block through the registry
339        if let Err(e) =
340            registry.submit(&mut validator, &self.subscribers, &self.p2p_handler, block).await
341        {
342            error!(
343                target: "darkfid::rpc::rpc_stratum::stratum_submit",
344                "[RPC-STRATUM] Error submitting new block: {e}",
345            );
346
347            // Try to refresh the jobs before returning error
348            if let Err(e) = registry.refresh(&validator).await {
349                error!(
350                    target: "darkfid::rpc::rpc_stratum::stratum_submit",
351                    "[RPC-STRATUM] Error refreshing registry jobs: {e}",
352                );
353            }
354
355            return miner_status_response(id, "rejected")
356        }
357
358        // Mark block as submitted
359        block_template.submitted = true;
360        registry.block_templates.insert(wallet, block_template);
361
362        miner_status_response(id, "OK")
363    }
364
365    // RPCAPI:
366    // Miner sends `keepalived` to prevent connection timeout.
367    //
368    // **Request:**
369    // * `id` : Registry client ID
370    //
371    // **Response:**
372    // * `status`: Response status
373    //
374    // --> {"jsonrpc": "2.0", "method": "keepalived", "params": {"id": "foo"}, "id": 1}
375    // <-- {"jsonrpc": "2.0", "result": {"status": "KEEPALIVED"}, "id": 1}
376    pub async fn stratum_keepalived(&self, id: u16, params: JsonValue) -> JsonResult {
377        // Parse request params
378        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
379            return JsonError::new(InvalidParams, None, id).into()
380        };
381
382        // Parse client id
383        let Some(client_id) = params.get("id") else {
384            return server_error(RpcError::MinerMissingClientId, id, None)
385        };
386        let Some(client_id) = client_id.get::<String>() else {
387            return server_error(RpcError::MinerInvalidClientId, id, None)
388        };
389
390        // If we don't know about this client job, we can just abort here
391        if !self.registry.state.read().await.jobs.contains_key(client_id) {
392            return server_error(RpcError::MinerUnknownClient, id, None)
393        };
394
395        // Respond with keepalived message
396        miner_status_response(id, "KEEPALIVED")
397    }
398}