darkfid/
rpc_xmr.rs

1/* This file is part of DarkFi (https://dark.fi)
2 *
3 * Copyright (C) 2020-2025 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, str::FromStr};
20
21use darkfi::{
22    blockchain::{
23        header_store::PowData,
24        monero::{
25            fixed_array::FixedByteArray, merkle_proof::MerkleProof, monero_block_deserialize,
26            MoneroPowData,
27        },
28        HeaderHash,
29    },
30    rpc::jsonrpc::{ErrorCode, ErrorCode::InvalidParams, JsonError, JsonResponse, JsonResult},
31    util::encoding::base64,
32    validator::consensus::Proposal,
33};
34use darkfi_sdk::{
35    crypto::{pasta_prelude::PrimeField, FuncId, PublicKey},
36    pasta::pallas,
37};
38use darkfi_serial::{deserialize_async, serialize_async};
39use hex::FromHex;
40use tinyjson::JsonValue;
41use tracing::{error, info};
42
43use crate::{
44    proto::ProposalMessage,
45    rpc_miner::{generate_next_block, MinerRewardsRecipientConfig},
46    server_error, DarkfiNode, RpcError,
47};
48
49// https://github.com/SChernykh/p2pool/blob/master/docs/MERGE_MINING.MD
50
51impl DarkfiNode {
52    // RPCAPI:
53    // Gets a unique ID that identifies this merge mined chain and
54    // separates it from other chains.
55    //
56    // * `chain_id`: A unique 32-byte hex-encoded value that identifies
57    //   this merge mined chain.
58    //
59    // darkfid will send the hash of the genesis block header.
60    //
61    // --> {"jsonrpc":"2.0", "method": "merge_mining_get_chain_id", "id": 1}
62    // <-- {"jsonrpc":"2.0", "result": {"chain_id": "0f28c...7863"}, "id": 1}
63    pub async fn xmr_merge_mining_get_chain_id(&self, id: u16, params: JsonValue) -> JsonResult {
64        // Check request doesn't contain params
65        if !params.get::<Vec<JsonValue>>().unwrap().is_empty() {
66            return JsonError::new(InvalidParams, None, id).into()
67        }
68
69        // Grab genesis block to use as chain identifier
70        let (_, genesis_hash) = match self.validator.blockchain.genesis() {
71            Ok(v) => v,
72            Err(e) => {
73                error!(
74                    target: "darkfid::rpc::xmr_merge_mining_get_chain_id",
75                    "[RPC-XMR] Error fetching genesis block hash: {e}"
76                );
77                return JsonError::new(ErrorCode::InternalError, None, id).into()
78            }
79        };
80
81        // TODO: XXX: This should also have more specialized identifiers.
82        // e.g. chain_id = H(genesis || aux_nonce || checkpoint_height)
83
84        let response =
85            HashMap::from([("chain_id".to_string(), JsonValue::from(genesis_hash.to_string()))]);
86        JsonResponse::new(JsonValue::from(response), id).into()
87    }
88
89    // RPCAPI:
90    // Gets a blob of data, the blocks hash and difficutly used for
91    // merge mining.
92    //
93    // **Request:**
94    // * `address` : A base-64 encoded wallet address mining configuration on the merge mined chain
95    // * `aux_hash`: Merge mining job that is currently being polled
96    // * `height`  : Monero height
97    // * `prev_id` : Hash of the previous Monero block
98    //
99    // **Response:**
100    // * `aux_blob`: The hex-encoded wallet address mining configuration blob
101    // * `aux_diff`: Mining difficulty (decimal number)
102    // * `aux_hash`: A 32-byte hex-encoded hash of merge mined block
103    //
104    // --> {"jsonrpc":"2.0", "method": "merge_mining_get_aux_block", "params": {"address": "MERGE_MINED_CHAIN_ADDRESS", "aux_hash": "f6952d6eef555ddd87aca66e56b91530222d6e318414816f3ba7cf5bf694bf0f", "height": 3000000, "prev_id":"ad505b0be8a49b89273e307106fa42133cbd804456724c5e7635bd953215d92a"}, "id": 1}
105    // <-- {"jsonrpc":"2.0", "result": {"aux_blob": "4c6f72656d20697073756d", "aux_diff": 123456, "aux_hash":"f6952d6eef555ddd87aca66e56b91530222d6e318414816f3ba7cf5bf694bf0f"}, "id": 1}
106    pub async fn xmr_merge_mining_get_aux_block(&self, id: u16, params: JsonValue) -> JsonResult {
107        // Check if node is synced before responding to p2pool
108        if !*self.validator.synced.read().await {
109            return server_error(RpcError::NotSynced, id, None)
110        }
111
112        // Parse request params
113        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
114            return JsonError::new(InvalidParams, None, id).into()
115        };
116
117        // Parse address mining configuration
118        let Some(address) = params.get("address") else {
119            return JsonError::new(InvalidParams, Some("missing address".to_string()), id).into()
120        };
121        let Some(address) = address.get::<String>() else {
122            return JsonError::new(InvalidParams, Some("invalid address format".to_string()), id)
123                .into()
124        };
125        let Some(address_bytes) = base64::decode(address) else {
126            return JsonError::new(InvalidParams, Some("invalid address format".to_string()), id)
127                .into()
128        };
129        let Ok((recipient, spend_hook, user_data)) =
130            deserialize_async::<(PublicKey, Option<String>, Option<String>)>(&address_bytes).await
131        else {
132            return JsonError::new(InvalidParams, Some("invalid address format".to_string()), id)
133                .into()
134        };
135        let spend_hook = match spend_hook {
136            Some(s) => match FuncId::from_str(&s) {
137                Ok(s) => Some(s),
138                Err(_) => {
139                    return JsonError::new(
140                        InvalidParams,
141                        Some("invalid address format".to_string()),
142                        id,
143                    )
144                    .into()
145                }
146            },
147            None => None,
148        };
149        let user_data: Option<pallas::Base> = match user_data {
150            Some(u) => {
151                let Ok(bytes) = bs58::decode(&u).into_vec() else {
152                    return JsonError::new(
153                        InvalidParams,
154                        Some("invalid address format".to_string()),
155                        id,
156                    )
157                    .into()
158                };
159                let bytes: [u8; 32] = match bytes.try_into() {
160                    Ok(b) => b,
161                    Err(_) => {
162                        return JsonError::new(
163                            InvalidParams,
164                            Some("invalid address format".to_string()),
165                            id,
166                        )
167                        .into()
168                    }
169                };
170                match pallas::Base::from_repr(bytes).into() {
171                    Some(v) => Some(v),
172                    None => {
173                        return JsonError::new(
174                            InvalidParams,
175                            Some("invalid address format".to_string()),
176                            id,
177                        )
178                        .into()
179                    }
180                }
181            }
182            None => None,
183        };
184
185        // Parse aux_hash
186        let Some(aux_hash) = params.get("aux_hash") else {
187            return JsonError::new(InvalidParams, Some("missing aux_hash".to_string()), id).into()
188        };
189        let Some(aux_hash) = aux_hash.get::<String>() else {
190            return JsonError::new(InvalidParams, Some("invalid aux_hash format".to_string()), id)
191                .into()
192        };
193        let Ok(aux_hash) = HeaderHash::from_str(aux_hash) else {
194            return JsonError::new(InvalidParams, Some("invalid aux_hash format".to_string()), id)
195                .into()
196        };
197
198        // Parse height
199        let Some(height) = params.get("height") else {
200            return JsonError::new(InvalidParams, Some("missing height".to_string()), id).into()
201        };
202        let Some(height) = height.get::<f64>() else {
203            return JsonError::new(InvalidParams, Some("invalid height format".to_string()), id)
204                .into()
205        };
206        let height = *height as u64;
207
208        // Parse prev_id
209        let Some(prev_id) = params.get("prev_id") else {
210            return JsonError::new(InvalidParams, Some("missing prev_id".to_string()), id).into()
211        };
212        let Some(prev_id) = prev_id.get::<String>() else {
213            return JsonError::new(InvalidParams, Some("invalid prev_id format".to_string()), id)
214                .into()
215        };
216        let Ok(prev_id) = hex::decode(prev_id) else {
217            return JsonError::new(InvalidParams, Some("invalid prev_id format".to_string()), id)
218                .into()
219        };
220        let prev_id = monero::Hash::from_slice(&prev_id);
221
222        // Now that method params format is correct, we can check if we
223        // already have a mining job for this wallet. If we already
224        // have it, we check if the fork it extends is still the best
225        // one. If both checks pass, we can just return an empty
226        // response if the request `aux_hash` matches the job one,
227        // otherwise return the job block template hash. In case the
228        // best fork has changed, we drop this job and generate a
229        // new one. If we don't know this wallet, we create a new job.
230        // We'll also obtain a lock here to avoid getting polled
231        // multiple times and potentially missing a job. The lock is
232        // released when this function exits.
233        let mut mm_blocktemplates = self.mm_blocktemplates.lock().await;
234        let mut extended_fork = match self.validator.best_current_fork().await {
235            Ok(f) => f,
236            Err(e) => {
237                error!(
238                    target: "darkfid::rpc_xmr::xmr_merge_mining_get_aux_block",
239                    "[RPC-XMR] Finding best fork index failed: {e}",
240                );
241                return JsonError::new(ErrorCode::InternalError, None, id).into()
242            }
243        };
244        if let Some((block, difficulty, _)) = mm_blocktemplates.get(&address_bytes) {
245            let last_proposal = match extended_fork.last_proposal() {
246                Ok(p) => p,
247                Err(e) => {
248                    error!(
249                        target: "darkfid::rpc_xmr::xmr_merge_mining_get_aux_block",
250                        "[RPC-XMR] Retrieving best fork last proposal failed: {e}",
251                    );
252                    return JsonError::new(ErrorCode::InternalError, None, id).into()
253                }
254            };
255            if last_proposal.hash == block.header.previous {
256                let blockhash = block.header.template_hash();
257                return if blockhash != aux_hash {
258                    JsonResponse::new(
259                        JsonValue::from(HashMap::from([
260                            ("aux_blob".to_string(), JsonValue::from(hex::encode(address_bytes))),
261                            ("aux_diff".to_string(), JsonValue::from(*difficulty)),
262                            ("aux_hash".to_string(), JsonValue::from(blockhash.as_string())),
263                        ])),
264                        id,
265                    )
266                    .into()
267                } else {
268                    JsonResponse::new(JsonValue::from(HashMap::new()), id).into()
269                }
270            }
271            mm_blocktemplates.remove(&address_bytes);
272        }
273
274        // At this point, we should query the Validator for a new blocktemplate.
275        // We first need to construct `MinerRewardsRecipientConfig` from the
276        // address configuration provided to us through the RPC.
277        let recipient_str = format!("{recipient}");
278        let spend_hook_str = match spend_hook {
279            Some(spend_hook) => format!("{spend_hook}"),
280            None => String::from("-"),
281        };
282        let user_data_str = match user_data {
283            Some(user_data) => bs58::encode(user_data.to_repr()).into_string(),
284            None => String::from("-"),
285        };
286        let recipient_config = MinerRewardsRecipientConfig { recipient, spend_hook, user_data };
287
288        // Now let's try to construct the blocktemplate.
289        // Find the difficulty. Note we cast it to f64 here.
290        let difficulty: f64 = match extended_fork.module.next_difficulty() {
291            Ok(v) => {
292                // We will attempt to cast it to f64. This should always work.
293                v.to_string().parse().unwrap()
294            }
295            Err(e) => {
296                error!(
297                    target: "darkfid::rpc_xmr::xmr_merge_mining_get_aux_block",
298                    "[RPC-XMR] Finding next mining difficulty failed: {e}",
299                );
300                return JsonError::new(ErrorCode::InternalError, None, id).into()
301            }
302        };
303
304        let (_, blocktemplate, block_signing_secret) = match generate_next_block(
305            &mut extended_fork,
306            &recipient_config,
307            &self.powrewardv1_zk.zkbin,
308            &self.powrewardv1_zk.provingkey,
309            self.validator.consensus.module.read().await.target,
310            self.validator.verify_fees,
311        )
312        .await
313        {
314            Ok(v) => v,
315            Err(e) => {
316                error!(
317                    target: "darkfid::rpc_xmr::xmr_merge_mining_get_aux_block",
318                    "[RPC-XMR] Failed to generate next blocktemplate: {e}",
319                );
320                return JsonError::new(ErrorCode::InternalError, None, id).into()
321            }
322        };
323
324        // Now we have the blocktemplate. We'll mark it down in memory,
325        // and then ship it to RPC.
326        let blockhash = blocktemplate.header.template_hash();
327        mm_blocktemplates
328            .insert(address_bytes.clone(), (blocktemplate, difficulty, block_signing_secret));
329        info!(
330            target: "darkfid::rpc_xmr::xmr_merge_mining_get_aux_block",
331            "[RPC-XMR] Created new blocktemplate: address={recipient_str}, spend_hook={spend_hook_str}, user_data={user_data_str}, aux_hash={blockhash}, height={height}, prev_id={prev_id}"
332        );
333
334        let response = JsonValue::from(HashMap::from([
335            ("aux_blob".to_string(), JsonValue::from(hex::encode(address_bytes))),
336            ("aux_diff".to_string(), JsonValue::from(difficulty)),
337            ("aux_hash".to_string(), JsonValue::from(blockhash.as_string())),
338        ]));
339
340        JsonResponse::new(response, id).into()
341    }
342
343    // RPCAPI:
344    // Submits a PoW solution for the merge mined chain's block. Note that
345    // when merge mining with Monero, the PoW solution is always a Monero
346    // block template with merge mining data included into it.
347    //
348    // **Request:**
349    // * `aux_blob`: Blob of data returned by `merge_mining_get_aux_block`
350    // * `aux_hash`: A 32-byte hex-encoded hash of merge mined block
351    // * `blob`: Monero block template that has enough PoW to satisfy the difficulty
352    //   returned by `merge_mining_get_aux_block`. It must also have a merge mining
353    //   tag in `tx_extra` of the coinbase transaction.
354    // * `merkle_proof`: A proof that `aux_hash` was included when calculating the
355    //   Merkle root hash from the merge mining tag
356    // * `path`: A path bitmap (32-bit unsigned integer) that complements `merkle_proof`
357    // * `seed_hash`: A 32-byte hex-encoded key that is used to initialize the
358    //   RandomX dataset
359    //
360    // **Response:**
361    // * `status`: Block submit status
362    //
363    // --> {"jsonrpc":"2.0", "method": "merge_mining_submit_solution", "params": {"aux_blob": "4c6f72656d20697073756d", "aux_hash": "f6952d6eef555ddd87aca66e56b91530222d6e318414816f3ba7cf5bf694bf0f", "blob": "...", "merkle_proof": ["hash1", "hash2", "hash3"], "path": 3, "seed_hash": "22c3d47c595ae888b5d7fc304235f92f8854644d4fad38c5680a5d4a81009fcd"}, "id": 1}
364    // <-- {"jsonrpc":"2.0", "result": {"status": "accepted"}, "id": 1}
365    pub async fn xmr_merge_mining_submit_solution(&self, id: u16, params: JsonValue) -> JsonResult {
366        // Check if node is synced before responding to p2pool
367        if !*self.validator.synced.read().await {
368            return server_error(RpcError::NotSynced, id, None)
369        }
370
371        // Parse request params
372        let Some(params) = params.get::<HashMap<String, JsonValue>>() else {
373            return JsonError::new(InvalidParams, None, id).into()
374        };
375
376        // Parse address mining configuration from aux_blob
377        let Some(aux_blob) = params.get("aux_blob") else {
378            return JsonError::new(InvalidParams, Some("missing aux_blob".to_string()), id).into()
379        };
380        let Some(aux_blob) = aux_blob.get::<String>() else {
381            return JsonError::new(InvalidParams, Some("invalid aux_blob format".to_string()), id)
382                .into()
383        };
384        let Ok(address_bytes) = hex::decode(aux_blob) else {
385            return JsonError::new(InvalidParams, Some("invalid aux_blob format".to_string()), id)
386                .into()
387        };
388        let Ok((_, spend_hook, user_data)) =
389            deserialize_async::<(PublicKey, Option<String>, Option<String>)>(&address_bytes).await
390        else {
391            return JsonError::new(InvalidParams, Some("invalid aux_blob format".to_string()), id)
392                .into()
393        };
394        if let Some(spend_hook) = spend_hook {
395            if FuncId::from_str(&spend_hook).is_err() {
396                return JsonError::new(
397                    InvalidParams,
398                    Some("invalid aux_blob format".to_string()),
399                    id,
400                )
401                .into()
402            }
403        };
404        if let Some(user_data) = user_data {
405            let Ok(bytes) = bs58::decode(&user_data).into_vec() else {
406                return JsonError::new(InvalidParams, Some("invalid address format".to_string()), id)
407                    .into()
408            };
409            let bytes: [u8; 32] = match bytes.try_into() {
410                Ok(b) => b,
411                Err(_) => {
412                    return JsonError::new(
413                        InvalidParams,
414                        Some("invalid aux_blob format".to_string()),
415                        id,
416                    )
417                    .into()
418                }
419            };
420            let _: pallas::Base = match pallas::Base::from_repr(bytes).into() {
421                Some(v) => v,
422                None => {
423                    return JsonError::new(
424                        InvalidParams,
425                        Some("invalid aux_blob format".to_string()),
426                        id,
427                    )
428                    .into()
429                }
430            };
431        };
432
433        // Parse aux_hash
434        let Some(aux_hash) = params.get("aux_hash") else {
435            return JsonError::new(InvalidParams, Some("missing aux_hash".to_string()), id).into()
436        };
437        let Some(aux_hash) = aux_hash.get::<String>() else {
438            return JsonError::new(InvalidParams, Some("invalid aux_hash format".to_string()), id)
439                .into()
440        };
441        let Ok(aux_hash) = HeaderHash::from_str(aux_hash) else {
442            return JsonError::new(InvalidParams, Some("invalid aux_hash format".to_string()), id)
443                .into()
444        };
445
446        // If we don't know about this job, we can just abort here.
447        let mut mm_blocktemplates = self.mm_blocktemplates.lock().await;
448        if !mm_blocktemplates.contains_key(&address_bytes) {
449            return JsonError::new(InvalidParams, Some("unknown address".to_string()), id).into()
450        }
451
452        // Parse blob
453        let Some(blob) = params.get("blob") else {
454            return JsonError::new(InvalidParams, Some("missing blob".to_string()), id).into()
455        };
456        let Some(blob) = blob.get::<String>() else {
457            return JsonError::new(InvalidParams, Some("invalid blob format".to_string()), id).into()
458        };
459        let Ok(block) = monero_block_deserialize(blob) else {
460            return JsonError::new(InvalidParams, Some("invalid blob format".to_string()), id).into()
461        };
462
463        // Parse merkle_proof
464        let Some(merkle_proof_j) = params.get("merkle_proof") else {
465            return JsonError::new(InvalidParams, Some("missing merkle_proof".to_string()), id)
466                .into()
467        };
468        let Some(merkle_proof_j) = merkle_proof_j.get::<Vec<JsonValue>>() else {
469            return JsonError::new(
470                InvalidParams,
471                Some("invalid merkle_proof format".to_string()),
472                id,
473            )
474            .into()
475        };
476        let mut merkle_proof: Vec<monero::Hash> = Vec::with_capacity(merkle_proof_j.len());
477        for hash in merkle_proof_j.iter() {
478            match hash.get::<String>() {
479                Some(v) => {
480                    let Ok(val) = monero::Hash::from_hex(v) else {
481                        return JsonError::new(
482                            InvalidParams,
483                            Some("invalid merkle_proof format".to_string()),
484                            id,
485                        )
486                        .into()
487                    };
488
489                    merkle_proof.push(val);
490                }
491                None => {
492                    return JsonError::new(
493                        InvalidParams,
494                        Some("invalid merkle_proof format".to_string()),
495                        id,
496                    )
497                    .into()
498                }
499            }
500        }
501
502        // Parse path
503        let Some(path) = params.get("path") else {
504            return JsonError::new(InvalidParams, Some("missing path".to_string()), id).into()
505        };
506        let Some(path) = path.get::<f64>() else {
507            return JsonError::new(InvalidParams, Some("invalid path format".to_string()), id).into()
508        };
509        let path = *path as u32;
510
511        // Parse seed_hash
512        let Some(seed_hash) = params.get("seed_hash") else {
513            return JsonError::new(InvalidParams, Some("missing seed_hash".to_string()), id).into()
514        };
515        let Some(seed_hash) = seed_hash.get::<String>() else {
516            return JsonError::new(InvalidParams, Some("invalid seed_hash format".to_string()), id)
517                .into()
518        };
519        let Ok(seed_hash) = monero::Hash::from_hex(seed_hash) else {
520            return JsonError::new(InvalidParams, Some("invalid seed_hash format".to_string()), id)
521                .into()
522        };
523        let Ok(seed_hash) = FixedByteArray::from_bytes(seed_hash.as_bytes()) else {
524            return JsonError::new(InvalidParams, Some("invalid seed_hash format".to_string()), id)
525                .into()
526        };
527
528        info!(
529            target: "darkfid::rpc_xmr::xmr_merge_mining_submit_solution",
530            "[RPC-XMR] Got solution submission: aux_hash={aux_hash}",
531        );
532
533        // Construct the MoneroPowData
534        let Some(merkle_proof) = MerkleProof::try_construct(merkle_proof, path) else {
535            return JsonError::new(
536                InvalidParams,
537                Some("could not construct aux chain merkle proof".to_string()),
538                id,
539            )
540            .into()
541        };
542        let monero_pow_data = match MoneroPowData::new(block, seed_hash, merkle_proof) {
543            Ok(v) => v,
544            Err(e) => {
545                error!(
546                    target: "darkfid::rpc_xmr::xmr_merge_mining_submit_solution",
547                    "[RPC-XMR] Failed constructing MoneroPowData: {e}",
548                );
549                return JsonError::new(
550                    InvalidParams,
551                    Some("failed constructing moneropowdata".to_string()),
552                    id,
553                )
554                .into()
555            }
556        };
557
558        // Append MoneroPowData to the DarkFi block and sign it
559        let (block, _, secret) = &mm_blocktemplates.get(&address_bytes).unwrap();
560        let mut block = block.clone();
561        block.header.pow_data = PowData::Monero(monero_pow_data);
562        block.sign(secret);
563
564        // At this point we should be able to remove the submitted job.
565        // We still won't release the lock in hope of proposing the block
566        // first.
567        mm_blocktemplates.remove(&address_bytes);
568
569        // Propose the new block
570        info!(
571            target: "darkfid::rpc_xmr::xmr_merge_mining_submit_solution",
572            "[RPC-XMR] Proposing new block to network",
573        );
574        let proposal = Proposal::new(block);
575        if let Err(e) = self.validator.append_proposal(&proposal).await {
576            error!(
577                target: "darkfid::rpc_xmr::xmr_merge_submit_solution",
578                "[RPC-XMR] Error proposing new block: {e}",
579            );
580            return JsonResponse::new(
581                JsonValue::from(HashMap::from([(
582                    "status".to_string(),
583                    JsonValue::from("rejected".to_string()),
584                )])),
585                id,
586            )
587            .into()
588        }
589
590        let proposals_sub = self.subscribers.get("proposals").unwrap();
591        let enc_prop = JsonValue::String(base64::encode(&serialize_async(&proposal).await));
592        proposals_sub.notify(vec![enc_prop].into()).await;
593
594        info!(
595            target: "darkfid::rpc_xmr::xmr_merge_mining_submit_solution",
596            "[RPC-XMR] Broadcasting new block to network",
597        );
598        let message = ProposalMessage(proposal);
599        self.p2p_handler.p2p.broadcast(&message).await;
600
601        JsonResponse::new(
602            JsonValue::from(HashMap::from([(
603                "status".to_string(),
604                JsonValue::from("accepted".to_string()),
605            )])),
606            id,
607        )
608        .into()
609    }
610}