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