From f5365caed6b0e03559158c1556d1cf7f0b5e794a Mon Sep 17 00:00:00 2001 From: Hansie Odendaal <39146854+hansieodendaal@users.noreply.github.com> Date: Mon, 3 Feb 2025 08:10:42 +0200 Subject: [PATCH] feat: add monerod fallback strategy (#6764) Description --- - Added a monerod fallback strategy whereby static monerod responses can be loaded if monerod goes offline. Options are always use monerod, use static monerod responses when monerod goes offline, or always use static monerod responses. With this implementation, it is possible to merge mine offline from monerod. - Reduced the general connection monerod timeout from 5s to 2s; this improves overall monerod responsiveness in event of a monerod connection error. - Fixed a bug whereby the same monerod entry would be retried over and over in the event of a monerod connection error. Closes #6756 Motivation and Context --- This could improve merge mining with Tari Universe. How Has This Been Tested? --- System-level testing Added unit tests What process can a PR reviewer use to test or verify this change? --- Code review System-level testing Breaking Changes --- - [x] None - [ ] Requires data directory on base node to be deleted - [ ] Requires hard fork - [ ] Other - Please specify --------- Co-authored-by: SW van Heerden --- Cargo.lock | 12 + .../minotari_merge_mining_proxy/Cargo.toml | 4 +- .../src/block_template_protocol.rs | 2 +- .../minotari_merge_mining_proxy/src/config.rs | 59 +- .../src/{proxy.rs => proxy/inner.rs} | 839 ++++++------------ .../src/proxy/mod.rs | 312 +++++++ .../src/proxy/monerod_method.rs | 103 +++ .../src/proxy/service.rs | 135 +++ .../src/proxy/static_responses.rs | 694 +++++++++++++++ .../src/proxy/utils.rs | 149 ++++ .../src/run_merge_miner.rs | 89 +- .../minotari_merge_mining_proxy/src/test.rs | 4 +- .../src/grpc/base_node_grpc_server.rs | 7 +- .../src/proof_of_work/monero_rx/helpers.rs | 2 +- .../config/presets/f_merge_mining_proxy.toml | 12 +- 15 files changed, 1815 insertions(+), 608 deletions(-) rename applications/minotari_merge_mining_proxy/src/{proxy.rs => proxy/inner.rs} (56%) create mode 100644 applications/minotari_merge_mining_proxy/src/proxy/mod.rs create mode 100644 applications/minotari_merge_mining_proxy/src/proxy/monerod_method.rs create mode 100644 applications/minotari_merge_mining_proxy/src/proxy/service.rs create mode 100644 applications/minotari_merge_mining_proxy/src/proxy/static_responses.rs create mode 100644 applications/minotari_merge_mining_proxy/src/proxy/utils.rs diff --git a/Cargo.lock b/Cargo.lock index da40b76979..735a43318c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4088,6 +4088,7 @@ dependencies = [ "minotari_node_grpc_client", "minotari_wallet_grpc_client", "monero", + "regex", "reqwest", "scraper", "serde", @@ -4101,6 +4102,7 @@ dependencies = [ "tari_utilities", "thiserror 1.0.69", "tokio", + "toml 0.8.19", "tonic 0.12.3", "tracing", "url", @@ -4376,6 +4378,7 @@ dependencies = [ "hex-literal 0.4.1", "sealed", "serde", + "serde-big-array", "thiserror 1.0.69", "tiny-keccak", ] @@ -6239,6 +6242,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde-value" version = "0.7.0" diff --git a/applications/minotari_merge_mining_proxy/Cargo.toml b/applications/minotari_merge_mining_proxy/Cargo.toml index 9315b01337..f23b94a186 100644 --- a/applications/minotari_merge_mining_proxy/Cargo.toml +++ b/applications/minotari_merge_mining_proxy/Cargo.toml @@ -40,7 +40,7 @@ hyper = { version = "0.14.12", features = ["default"] } jsonrpc = "0.12.0" log = { version = "0.4.8", features = ["std"] } markup5ever = "0.12.1" -monero = { version = "0.21.0" } +monero = { version = "0.21.0" , features = ["serde"] } reqwest = { version = "0.11.4", features = ["json"] } serde = { version = "1.0.136", features = ["derive"] } serde_json = "1.0.57" @@ -50,6 +50,8 @@ tonic = "0.12.3" tracing = "0.1" url = "2.1.1" scraper = "0.19.0" +toml = "0.8.19" +regex = "1.11.1" [build-dependencies] tari_features = { path = "../../common/tari_features", version = "1.11.1-pre.1" } diff --git a/applications/minotari_merge_mining_proxy/src/block_template_protocol.rs b/applications/minotari_merge_mining_proxy/src/block_template_protocol.rs index e44f6a6078..a46e2a2fd7 100644 --- a/applications/minotari_merge_mining_proxy/src/block_template_protocol.rs +++ b/applications/minotari_merge_mining_proxy/src/block_template_protocol.rs @@ -83,7 +83,7 @@ impl<'a> BlockTemplateProtocol<'a> { #[allow(clippy::too_many_lines)] impl BlockTemplateProtocol<'_> { /// Create [FinalBlockTemplateData] with [MoneroMiningData]. - pub async fn get_next_block_template( + pub async fn get_next_tari_block_template( mut self, monero_mining_data: MoneroMiningData, block_templates: &BlockTemplateRepository, diff --git a/applications/minotari_merge_mining_proxy/src/config.rs b/applications/minotari_merge_mining_proxy/src/config.rs index 3e9268e752..e217f1c47a 100644 --- a/applications/minotari_merge_mining_proxy/src/config.rs +++ b/applications/minotari_merge_mining_proxy/src/config.rs @@ -20,12 +20,15 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; use minotari_wallet_grpc_client::GrpcAuthentication; use serde::{Deserialize, Serialize}; use tari_common::{ - configuration::{Network, StringList}, + configuration::{serializers, Network, StringList}, SubConfigPath, }; use tari_common_types::tari_address::TariAddress; @@ -94,8 +97,22 @@ pub struct MergeMiningProxyConfig { pub wallet_payment_address: String, /// Range proof type - revealed_value or bullet_proof_plus: (default = revealed_value) pub range_proof_type: RangeProofType, - /// Use p2pool to submit and get block templates + /// Use p2pool to submit and get block templates (default = false) pub p2pool_enabled: bool, + /// Monero fallback strategy(default = MonerodFallback::MonerodOnly) + pub monerod_fallback: MonerodFallback, + /// The timeout duration for connecting to monerod (default = 2s) + #[serde(with = "serializers::seconds")] + pub monerod_connection_timeout: Duration, +} + +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MonerodFallback { + MonerodOnly, + #[default] + StaticWhenMonerodFails, + StaticOnly, } impl Default for MergeMiningProxyConfig { @@ -157,6 +174,8 @@ impl Default for MergeMiningProxyConfig { wallet_payment_address: TariAddress::default().to_base58(), range_proof_type: RangeProofType::RevealedValue, p2pool_enabled: false, + monerod_fallback: Default::default(), + monerod_connection_timeout: Duration::from_secs(2), } } } @@ -179,10 +198,11 @@ impl SubConfigPath for MergeMiningProxyConfig { mod test { use std::str::FromStr; + use serde::{Deserialize, Serialize}; use tari_common::DefaultConfigLoader; use tari_comms::multiaddr::Multiaddr; - use crate::config::MergeMiningProxyConfig; + use crate::config::{MergeMiningProxyConfig, MonerodFallback}; fn get_config(override_from: &str) -> config::Config { let s = r#" @@ -241,4 +261,35 @@ mod test { assert!(!config.monerod_use_auth); assert!(config.submit_to_origin); } + + #[derive(Clone, Serialize, Deserialize, Debug)] + #[allow(clippy::struct_excessive_bools)] + struct TestConfig { + name: String, + inner_config_1: TestInnerConfig, + inner_config_2: TestInnerConfig, + inner_config_3: TestInnerConfig, + } + + #[derive(Clone, Serialize, Deserialize, Debug)] + #[allow(clippy::struct_excessive_bools)] + struct TestInnerConfig { + monerod: MonerodFallback, + } + + #[test] + fn it_deserializes_enums() { + let config_str = r#" + name = "blockchain champion" + inner_config_1.monerod = "monerod_only" + inner_config_2.monerod = "static_when_monerod_fails" + inner_config_3.monerod = "static_only" + "#; + let config = toml::from_str::(config_str).unwrap(); + + // Enums in the config + assert_eq!(config.inner_config_1.monerod, MonerodFallback::MonerodOnly); + assert_eq!(config.inner_config_2.monerod, MonerodFallback::StaticWhenMonerodFails); + assert_eq!(config.inner_config_3.monerod, MonerodFallback::StaticOnly); + } } diff --git a/applications/minotari_merge_mining_proxy/src/proxy.rs b/applications/minotari_merge_mining_proxy/src/proxy/inner.rs similarity index 56% rename from applications/minotari_merge_mining_proxy/src/proxy.rs rename to applications/minotari_merge_mining_proxy/src/proxy/inner.rs index 00b3b68252..1f4c98ab02 100644 --- a/applications/minotari_merge_mining_proxy/src/proxy.rs +++ b/applications/minotari_merge_mining_proxy/src/proxy/inner.rs @@ -23,27 +23,24 @@ use std::{ cmp, convert::TryInto, - future::Future, - pin::Pin, + str::FromStr, sync::{ atomic::{AtomicBool, Ordering}, Arc, RwLock, }, - task::{Context, Poll}, - time::{Duration, Instant}, + time::Instant, }; use borsh::BorshSerialize; use bytes::Bytes; -use hyper::{header::HeaderValue, service::Service, Body, Method, Request, Response, StatusCode, Uri}; -use json::json; -use jsonrpc::error::StandardError; -use minotari_app_grpc::tari_rpc::SubmitBlockRequest; +use hyper::{header::HeaderValue, Body, Request, Response, StatusCode, Uri}; +use log::error; +use minotari_app_grpc::{tari_rpc, tari_rpc::SubmitBlockRequest}; use minotari_app_utilities::parse_miner_input::{BaseNodeGrpcClient, ShaP2PoolGrpcClient}; -use minotari_node_grpc_client::grpc; -use reqwest::{ResponseBuilderExt, Url}; +use monero::Hash; use serde_json as json; +use serde_json::json; use tari_common_types::tari_address::TariAddress; use tari_core::{ consensus::ConsensusManager, @@ -51,132 +48,60 @@ use tari_core::{ }; use tari_utilities::hex::Hex; use tokio::time::timeout; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, info, trace, warn}; +use url::Url; use crate::{ block_template_data::BlockTemplateRepository, block_template_protocol::{BlockTemplateProtocol, MoneroMiningData}, common::{json_rpc, monero_rpc::CoreRpcErrorCode, proxy, proxy::convert_json_to_hyper_json_response}, - config::MergeMiningProxyConfig, + config::{MergeMiningProxyConfig, MonerodFallback}, error::MmProxyError, + proxy::{ + monerod_method::{parse_monerod_rpc_method, MonerodMethod}, + static_responses::{ + convert_static_monerod_response_to_hyper_response, + self_select_submit_block_monerod_response, + static_json_rpc_url, + }, + utils::{convert_reqwest_response_to_hyper_json_response, request_bytes_to_value}, + }, }; -const LOG_TARGET: &str = "minotari_mm_proxy::proxy"; -/// The JSON object key name used for merge mining proxy response extensions -pub(crate) const MMPROXY_AUX_KEY_NAME: &str = "_aux"; +const LOG_TARGET: &str = "minotari_mm_proxy::proxy::inner"; /// The identifier used to identify the tari aux chain data const TARI_CHAIN_ID: &str = "xtr"; -/// The timeout duration for connecting to monerod -pub(crate) const MONEROD_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); -pub(crate) const NUMBER_OF_MONEROD_SERVERS: usize = 15; +const BUSY_QUALIFYING: &str = "BusyQualifyingMonerodUrl"; #[derive(Debug, Clone)] -pub struct MergeMiningProxyService { - inner: InnerService, -} - -impl MergeMiningProxyService { - pub fn new( - config: MergeMiningProxyConfig, - http_client: reqwest::Client, - base_node_client: BaseNodeGrpcClient, - p2pool_client: Option, - block_templates: BlockTemplateRepository, - randomx_factory: RandomXFactory, - wallet_payment_address: TariAddress, - ) -> Result { - trace!(target: LOG_TARGET, "Config: {:?}", config); - let consensus_manager = ConsensusManager::builder(config.network).build()?; - Ok(Self { - inner: InnerService { - config: Arc::new(config), - block_templates, - http_client, - base_node_client, - p2pool_client, - initial_sync_achieved: Arc::new(AtomicBool::new(false)), - current_monerod_server: Arc::new(RwLock::new(None)), - last_assigned_monerod_url: Arc::new(RwLock::new(None)), - randomx_factory, - consensus_manager, - wallet_payment_address, - }, - }) - } -} - -#[allow(clippy::type_complexity)] -impl Service> for MergeMiningProxyService { - type Error = hyper::Error; - type Future = Pin> + Send>>; - type Response = Response; - - fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, mut request: Request) -> Self::Future { - let inner = self.inner.clone(); - let future = async move { - let bytes = match proxy::read_body_until_end(request.body_mut()).await { - Ok(b) => b, - Err(err) => { - warn!(target: LOG_TARGET, "Method: Unknown, Failed to read request: {:?}", err); - let resp = proxy::json_response( - StatusCode::BAD_REQUEST, - &json_rpc::standard_error_response( - None, - StandardError::InvalidRequest, - Some(json!({"details": err.to_string()})), - ), - ) - .expect("unexpected failure"); - return Ok(resp); - }, - }; - let request = request.map(|_| bytes.freeze()); - let method_name = parse_method_name(&request); - match inner.handle(&method_name, request).await { - Ok(resp) => Ok(resp), - Err(err) => { - error!(target: LOG_TARGET, "Method \"{}\" failed handling request: {:?}", method_name, err); - Ok(proxy::json_response( - StatusCode::INTERNAL_SERVER_ERROR, - &json_rpc::standard_error_response( - None, - StandardError::InternalError, - Some(json!({"details": err.to_string()})), - ), - ) - .expect("unexpected failure")) - }, - } - }; - - Box::pin(future) - } +pub struct InnerService { + pub(crate) config: Arc, + pub(crate) block_templates: BlockTemplateRepository, + pub(crate) http_client: reqwest::Client, + pub(crate) base_node_client: BaseNodeGrpcClient, + pub(crate) p2pool_client: Option, + pub(crate) initial_sync_achieved: Arc, + pub(crate) current_monerod_server: Arc>>, + pub(crate) last_assigned_monerod_url: Arc>>, + pub(crate) monerod_cache_values: Arc>>, + pub(crate) randomx_factory: RandomXFactory, + pub(crate) consensus_manager: ConsensusManager, + pub(crate) wallet_payment_address: TariAddress, } -#[derive(Debug, Clone)] -struct InnerService { - config: Arc, - block_templates: BlockTemplateRepository, - http_client: reqwest::Client, - base_node_client: BaseNodeGrpcClient, - p2pool_client: Option, - initial_sync_achieved: Arc, - current_monerod_server: Arc>>, - last_assigned_monerod_url: Arc>>, - randomx_factory: RandomXFactory, - consensus_manager: ConsensusManager, - wallet_payment_address: TariAddress, +#[derive(Debug, Clone, Default)] +pub(crate) struct MonerodCacheValues { + pub(crate) height: u64, + pub(crate) prev_hash: Hash, + pub(crate) timestamp: Option, + pub(crate) seed_height: Option, + pub(crate) seed_hash: Option, } -const BUSY_QUALIFYING: &str = "BusyQualifyingMonerodUrl"; - impl InnerService { #[allow(clippy::cast_possible_wrap)] async fn handle_get_height(&self, monerod_resp: Response) -> Result, MmProxyError> { + trace!(target: LOG_TARGET, "handle_get_height monerod_resp body: {}", monerod_resp.body()); let (parts, mut json) = monerod_resp.into_parts(); if json["height"].is_null() { warn!(target: LOG_TARGET, r#"Monerod response was invalid: "height" is null"#); @@ -191,7 +116,7 @@ impl InnerService { let result = base_node_client - .get_tip_info(grpc::Empty {}) + .get_tip_info(tari_rpc::Empty {}) .await .map_err(|err| MmProxyError::GrpcRequestError { status: err, @@ -231,7 +156,7 @@ impl InnerService { let request = request.body(); let (parts, mut json_resp) = monerod_resp.into_parts(); - info!(target: LOG_TARGET, "Block submited: submit request #{}", request); + info!(target: LOG_TARGET, "Block submit request #{}", request); let params = match request["params"].as_array() { Some(v) => v, None => { @@ -247,25 +172,21 @@ impl InnerService { }, }; - for param in params.iter().filter_map(|p| p.as_str()) { + for (i, param) in params.iter().filter_map(|p| p.as_str()).enumerate() { + trace!(target: LOG_TARGET, "handle_submit_block, param {} of {}", i, params.len()); let monero_block = monero_rx::deserialize_monero_block_from_hex(param)?; - debug!(target: LOG_TARGET, "Monero block: {}", monero_block); + trace!(target: LOG_TARGET, "Monero block: {}", monero_block); let hash = monero_rx::extract_aux_merkle_root_from_block(&monero_block)?.ok_or_else(|| { MmProxyError::MissingDataError("Could not find Minotari header in coinbase".to_string()) })?; - - debug!( - target: LOG_TARGET, - "Minotari Hash found in Monero block: {}", - hex::encode(hash) - ); + debug!(target: LOG_TARGET, "Minotari Hash found in Monero block: {}", hex::encode(hash)); let mut block_data = match self.block_templates.get_final_template(&hash).await { Some(d) => d, None => { info!( target: LOG_TARGET, - "Block `{}` submitted but no matching block template was found, possible duplicate submission", + "Could not submit block `{}`, no matching block template found, possible duplicate submission", hex::encode(hash) ); continue; @@ -349,7 +270,7 @@ impl InnerService { json!({ "status": "OK", "untrusted": !self.initial_sync_achieved.load(Ordering::SeqCst) }), ); let resp = resp.into_inner(); - json_resp = append_aux_chain_data( + json_resp = crate::proxy::utils::append_aux_chain_data( json_resp, json!({"id": TARI_CHAIN_ID, "block_hash": resp.block_hash.to_hex()}), ); @@ -447,11 +368,11 @@ impl InnerService { // Add merge mining tag on blocktemplate request if !self.initial_sync_achieved.load(Ordering::SeqCst) { - let grpc::TipInfoResponse { + let tari_rpc::TipInfoResponse { initial_sync_achieved, metadata, .. - } = grpc_client.get_tip_info(grpc::Empty {}).await?.into_inner(); + } = grpc_client.get_tip_info(tari_rpc::Empty {}).await?.into_inner(); if initial_sync_achieved { self.initial_sync_achieved.store(true, Ordering::SeqCst); @@ -498,7 +419,7 @@ impl InnerService { }; let final_block_template_data = new_block_protocol - .get_next_block_template(monero_mining_data, &self.block_templates) + .get_next_tari_block_template(monero_mining_data, &self.block_templates) .await?; monerod_resp["result"]["blocktemplate_blob"] = final_block_template_data.blocktemplate_blob.clone().into(); @@ -516,11 +437,11 @@ impl InnerService { let aux_chain_mr = hex::encode(final_block_template_data.aux_chain_mr.clone()); let block_reward = final_block_template_data.template.tari_miner_data.reward; let total_fees = final_block_template_data.template.tari_miner_data.total_fees; - let monerod_resp = add_aux_data( + let monerod_resp = crate::proxy::utils::add_aux_data( monerod_resp, json!({ "base_difficulty": final_block_template_data.template.monero_difficulty }), ); - let monerod_resp = append_aux_chain_data( + let monerod_resp = crate::proxy::utils::append_aux_chain_data( monerod_resp, json!({ "id": TARI_CHAIN_ID, @@ -580,10 +501,12 @@ impl InnerService { ); let mut client = self.base_node_client.clone(); - let resp = client.get_header_by_hash(grpc::GetHeaderByHashRequest { hash }).await; + let resp = client + .get_header_by_hash(tari_rpc::GetHeaderByHashRequest { hash }) + .await; match resp { Ok(resp) => { - let json_block_header = try_into_json_block_header(resp.into_inner())?; + let json_block_header = crate::proxy::utils::try_into_json_block_header(resp.into_inner())?; debug!( target: LOG_TARGET, @@ -592,7 +515,7 @@ impl InnerService { let json_resp = json_rpc::success_response(request["id"].as_i64(), json!({ "block_header": json_block_header })); - let json_resp = append_aux_chain_data(json_resp, json!({ "id": TARI_CHAIN_ID })); + let json_resp = crate::proxy::utils::append_aux_chain_data(json_resp, json!({ "id": TARI_CHAIN_ID })); Ok(proxy::into_response(parts, &json_resp)) }, @@ -620,21 +543,21 @@ impl InnerService { } let mut client = self.base_node_client.clone(); - let tip_info = client.get_tip_info(grpc::Empty {}).await?; + let tip_info = client.get_tip_info(tari_rpc::Empty {}).await?; let tip_info = tip_info.into_inner(); let chain_metadata = tip_info.metadata.ok_or_else(|| { MmProxyError::UnexpectedTariBaseNodeResponse("get_tip_info returned no chain metadata".into()) })?; let tip_header = client - .get_header_by_hash(grpc::GetHeaderByHashRequest { + .get_header_by_hash(tari_rpc::GetHeaderByHashRequest { hash: chain_metadata.best_block_hash, }) .await?; let tip_header = tip_header.into_inner(); - let json_block_header = try_into_json_block_header(tip_header)?; - let resp = append_aux_chain_data( + let json_block_header = crate::proxy::utils::try_into_json_block_header(tip_header)?; + let resp = crate::proxy::utils::append_aux_chain_data( monero_resp, json!({ "id": TARI_CHAIN_ID, @@ -644,9 +567,18 @@ impl InnerService { Ok(proxy::into_response(parts, &resp)) } - fn clear_current_monerod_server_lock(&self) { + fn clear_current_monerod_server_lock(&self, last_assigned_server: Option<&str>) { + // Current let mut lock = self.current_monerod_server.write().expect("Write lock should not fail"); *lock = None; + // Last assigned + if let Some(server) = last_assigned_server { + let mut lock = self + .last_assigned_monerod_url + .write() + .expect("Write lock should not fail"); + *lock = Some(server.to_string()); + } trace!( target: LOG_TARGET, "Monerod status - Current: 'None', Last assigned: {}", self.last_assigned_monerod_url.read().expect("Read lock should not fail").clone().unwrap_or_default() @@ -664,8 +596,10 @@ impl InnerService { } fn update_monerod_server_locks(&self, server: &str) { + // Current let mut lock = self.current_monerod_server.write().expect("Write lock should not fail"); *lock = Some(server.to_string()); + // Last assigned let mut lock = self .last_assigned_monerod_url .write() @@ -674,7 +608,10 @@ impl InnerService { trace!(target: LOG_TARGET, "Monerod status - Current: {}, Last assigned: {}", server, server); } - async fn get_fully_qualified_monerod_url(&self, request_uri: &Uri) -> Result { + async fn get_monerod_url(&self, request_uri: &Uri) -> Result, MmProxyError> { + if self.config.monerod_fallback == MonerodFallback::StaticOnly { + return Ok(None); + } // Return the previously qualified monerod URL if it exists let mut parse_error = None; { @@ -688,13 +625,13 @@ impl InnerService { return Err(MmProxyError::ServersUnavailable(BUSY_QUALIFYING.to_string())); } match format!("{}{}", server, request_uri.path()).parse::() { - Ok(url) => return Ok(url), + Ok(url) => return Ok(Some(url)), Err(e) => parse_error = Some(e), } } } if let Some(e) = parse_error { - self.clear_current_monerod_server_lock(); + self.clear_current_monerod_server_lock(None); return Err(e.into()); } @@ -717,7 +654,7 @@ impl InnerService { .position(|x| x == &last_used_url) .unwrap_or(0); let (left, right) = self.config.monerod_url.split_at_checked(pos).ok_or_else(|| { - self.clear_current_monerod_server_lock(); + self.clear_current_monerod_server_lock(None); MmProxyError::ConversionError("Invalid utf 8 url".to_string()) })?; let left = left.to_vec(); @@ -728,9 +665,9 @@ impl InnerService { for server in iter { let start = Instant::now(); let url = match format!("{}{}", server, request_uri.path()).parse::() { - Ok(uri) => uri, + Ok(val) => val, Err(e) => { - self.clear_current_monerod_server_lock(); + self.clear_current_monerod_server_lock(Some(server)); return Err(e.into()); }, }; @@ -739,7 +676,7 @@ impl InnerService { target: LOG_TARGET, "Trying to connect to Monerod server at: {} (entry {} of {})", url.as_str(), pos + 1, self.config.monerod_url.len() ); - match timeout(MONEROD_CONNECTION_TIMEOUT, reqwest::get(url.clone())).await { + match timeout(self.config.monerod_connection_timeout, reqwest::get(url.clone())).await { Ok(response) => { self.update_monerod_server_locks(server); let data_len = match response { @@ -751,7 +688,7 @@ impl InnerService { "Monerod server available (response in {:.2?}, {} bytes): {}", start.elapsed(), data_len, url.as_str() ); - return Ok(url); + return Ok(Some(url)); }, Err(_) => { warn!( @@ -759,12 +696,16 @@ impl InnerService { "Monerod server unavailable (timeout in {:.2?}): {}", start.elapsed(), url.as_str() ); + self.clear_current_monerod_server_lock(Some(server)); + if self.config.monerod_fallback == MonerodFallback::StaticWhenMonerodFails { + return Ok(None); + } }, } } // Clear the "busy qualifying" state - self.clear_current_monerod_server_lock(); + self.clear_current_monerod_server_lock(None); Err(MmProxyError::ServersUnavailable(format!("{}", self.config.monerod_url))) } @@ -772,94 +713,79 @@ impl InnerService { async fn proxy_request_to_monerod( &self, request: Request, + monerod_method: MonerodMethod, ) -> Result<(Request, Response), MmProxyError> { - let monerod_uri = self.get_fully_qualified_monerod_url(request.uri()).await?; - - let mut headers = request.headers().clone(); - // Some public monerod setups (e.g. those that are reverse proxied by nginx) require the Host header. - // The mmproxy is the direct client of monerod and so is responsible for setting this header. - if let Some(host) = monerod_uri.host_str() { - let host: HeaderValue = match monerod_uri.port_or_known_default() { - Some(port) => format!("{}:{}", host, port).parse()?, - None => host.parse()?, - }; - headers.insert("host", host); - debug!( - target: LOG_TARGET, - "Host header updated to match monerod_uri. Request headers: {:?}", headers - ); - } - let mut builder = self - .http_client - .request(request.method().clone(), monerod_uri.clone()) - .headers(headers); - - if self.config.monerod_use_auth { - // Use HTTP basic auth. This is the only reason we are using `reqwest` over the standard hyper client. - builder = builder.basic_auth(&self.config.monerod_username, Some(&self.config.monerod_password)); - } - - debug!( - target: LOG_TARGET, - "[monerod] request: {} {}", - request.method(), - monerod_uri, - ); + trace!(target: LOG_TARGET, "proxy_request_to_monerod: '{}'", monerod_method); - let mut submit_block = false; + // This is a cheap clone of the request body let body: Bytes = request.body().clone(); let json = json::from_slice::(&body[..]).unwrap_or_default(); - if let Some(method) = json["method"].as_str() { - trace!(target: LOG_TARGET, "json[\"method\"]: {}", method); - match method { - "submitblock" | "submit_block" => { - submit_block = true; - }, - _ => {}, + let request_id = json["id"].as_i64(); + let self_select_response = monerod_method == MonerodMethod::SubmitBlock && !self.config.submit_to_origin; + + let json_response = if let Some(monerod_url) = self.get_monerod_url(request.uri()).await? { + let mut headers = request.headers().clone(); + // Some public monerod setups (e.g. those that are reverse proxied by nginx) require the Host header. + // The mmproxy is the direct client of monerod and so is responsible for setting this header. + if let Some(host) = monerod_url.host_str() { + let host: HeaderValue = match monerod_url.port_or_known_default() { + Some(port) => format!("{}:{}", host, port).parse()?, + None => host.parse()?, + }; + headers.insert("host", host); + debug!( + target: LOG_TARGET, + "Host header updated to match monerod_uri. Request headers: {:?}", headers + ); + } + let mut builder = self + .http_client + .request(request.method().clone(), monerod_url.clone()) + .headers(headers.clone()); + + if self.config.monerod_use_auth { + // Use HTTP basic auth. This is the only reason we are using `reqwest` over the standard hyper client. + builder = builder.basic_auth(&self.config.monerod_username, Some(&self.config.monerod_password)); } - trace!( - target: LOG_TARGET, - "submitblock({}), proxy_submit_to_origin({})", - submit_block, - self.config.submit_to_origin - ); - } - // If the request is a block submission and we are not submitting blocks - // to the origin (self-select mode, see next comment for a full explanation) - let json_response = if submit_block && !self.config.submit_to_origin { debug!( target: LOG_TARGET, - "[monerod] skip: Proxy configured for self-select mode. Pool will submit to MoneroD, submitting to \ - Minotari.", + "[monerod] request: {} {}", + request.method(), + monerod_url, ); - // This is required for self-select configuration. - // We are not submitting the block to Monero here (the pool does this), - // we are only interested in intercepting the request for the purposes of - // submitting the block to Tari which will only happen if the accept response - // (which normally would occur for normal mining) is provided here. - // There is no point in trying to submit the block to Monero here since the - // share submitted by XMRig is only guaranteed to meet the difficulty of - // min(Tari,Monero) since that is what was returned with the original template. - // So it would otherwise be a duplicate submission of what the pool will do - // itself (whether the miner submits directly to monerod or the pool does, - // the pool is the only one being paid out here due to the nature - // of self-select). Furthermore, discussions with devs from Monero and XMRig are - // very much against spamming the nodes unnecessarily. - // NB!: This is by design, do not change this without understanding - // it's implications. - let accept_response = json_rpc::default_block_accept_response(json["id"].as_i64()); - - convert_json_to_hyper_json_response(accept_response, StatusCode::OK, monerod_uri.clone()).await? + if self_select_response { + let accept_response = self_select_submit_block_monerod_response(request_id); + convert_json_to_hyper_json_response(accept_response, StatusCode::OK, monerod_url.clone()).await? + } else { + let resp = match builder + .body(body.clone()) + .send() + .await + .map_err(MmProxyError::MonerodRequestFailed) + { + Ok(val) => val, + Err(e) => { + debug!(target: LOG_TARGET, "[monerod] request '{}' response error '{}'", monerod_method, e); + return Err(e); + }, + }; + + let hyper_json_response = convert_reqwest_response_to_hyper_json_response(resp).await?; + self.update_monerod_cache_values(monerod_method, hyper_json_response.body())?; + hyper_json_response + } + } else if self_select_response { + let accept_response = self_select_submit_block_monerod_response(request_id); + convert_json_to_hyper_json_response(accept_response, StatusCode::OK, static_json_rpc_url()).await? } else { - let resp = builder - // This is a cheap clone of the request body - .body(body) - .send() - .await - .map_err(MmProxyError::MonerodRequestFailed)?; - convert_reqwest_response_to_hyper_json_response(resp).await? + let cache_values = self + .monerod_cache_values + .read() + .expect("Read lock should not fail") + .clone(); + convert_static_monerod_response_to_hyper_response(monerod_method, request_id, cache_values)? }; let rpc_status = if json_response.body()["error"].is_null() { @@ -875,47 +801,140 @@ impl InnerService { json_response.status(), rpc_status ); + trace!(target: LOG_TARGET, "[monerod] '{}' response '{:?}'", monerod_method, json_response); Ok((request, json_response)) } + fn update_monerod_cache_values( + &self, + monerod_method: MonerodMethod, + json: &json::Value, + ) -> Result<(), MmProxyError> { + let (timestamp, seed_height, seed_hash) = { + if let Some(cache) = self + .monerod_cache_values + .read() + .expect("Read lock should not fail") + .clone() + { + (cache.timestamp, cache.seed_height, cache.seed_hash) + } else { + (None, None, None) + } + }; + let mut lock = self.monerod_cache_values.write().expect("Write lock should not fail"); + match monerod_method { + MonerodMethod::GetHeight => { + *lock = Some(MonerodCacheValues { + height: json["height"] + .as_u64() + .ok_or(MmProxyError::InvalidMonerodResponse("height".to_string()))?, + prev_hash: Hash::from_str( + json["hash"] + .as_str() + .ok_or(MmProxyError::InvalidMonerodResponse("hash".to_string()))?, + ) + .map_err(|e| MmProxyError::InvalidMonerodResponse(e.to_string()))?, + timestamp, + seed_height, + seed_hash, + }); + }, + MonerodMethod::GetBlockTemplate => { + *lock = Some(MonerodCacheValues { + height: json["result"]["height"] + .as_u64() + .ok_or(MmProxyError::InvalidMonerodResponse("height".to_string()))?, + prev_hash: Hash::from_str( + json["result"]["prev_hash"] + .as_str() + .ok_or(MmProxyError::InvalidMonerodResponse("prev_hash".to_string()))?, + ) + .map_err(|e| MmProxyError::InvalidMonerodResponse(e.to_string()))?, + timestamp, + seed_height: Some( + json["result"]["seed_height"] + .as_u64() + .ok_or(MmProxyError::InvalidMonerodResponse("seed_height".to_string()))?, + ), + seed_hash: Some( + Hash::from_str( + json["result"]["seed_hash"] + .as_str() + .ok_or(MmProxyError::InvalidMonerodResponse("seed_hash".to_string()))?, + ) + .map_err(|e| MmProxyError::InvalidMonerodResponse(e.to_string()))?, + ), + }); + }, + MonerodMethod::GetLastBlockHeader => { + *lock = Some(MonerodCacheValues { + height: json["result"]["block_header"]["height"] + .as_u64() + .ok_or(MmProxyError::InvalidMonerodResponse("height".to_string()))?, + prev_hash: Hash::from_str( + json["result"]["block_header"]["prev_hash"] + .as_str() + .ok_or(MmProxyError::InvalidMonerodResponse("prev_hash".to_string()))?, + ) + .map_err(|e| MmProxyError::InvalidMonerodResponse(e.to_string()))?, + timestamp: Some( + json["result"]["block_header"]["timestamp"] + .as_u64() + .ok_or(MmProxyError::InvalidMonerodResponse("timestamp".to_string()))?, + ), + seed_height: Some( + json["result"]["block_header"]["seed_height"] + .as_u64() + .ok_or(MmProxyError::InvalidMonerodResponse("seed_height".to_string()))?, + ), + seed_hash: Some( + Hash::from_str( + json["result"]["block_header"]["seed_hash"] + .as_str() + .ok_or(MmProxyError::InvalidMonerodResponse("seed_hash".to_string()))?, + ) + .map_err(|e| MmProxyError::InvalidMonerodResponse(e.to_string()))?, + ), + }); + }, + _ => {}, + } + + Ok(()) + } + async fn get_proxy_response( &self, request: Request, monerod_resp: Response, + monerod_method: MonerodMethod, ) -> Result, MmProxyError> { - match request.method().clone() { - Method::GET => { - // All get requests go to /request_name, methods do not have a body, optionally could have query params - // if applicable. - match request.uri().path() { - "/get_height" | "/getheight" => self.handle_get_height(monerod_resp).await, - _ => Ok(proxy::into_body_from_response(monerod_resp)), - } + trace!(target: LOG_TARGET, "get_proxy_response: '{}'", monerod_method); + match monerod_method { + MonerodMethod::GetHeight => self.handle_get_height(monerod_resp).await, + MonerodMethod::GetBlockTemplate => self.handle_get_block_template(monerod_resp).await, + MonerodMethod::SubmitBlock => { + self.handle_submit_block(request_bytes_to_value(request)?, monerod_resp) + .await }, - Method::POST => { - // All post requests go to /json_rpc, body of request contains a field `method` to indicate which call - // takes place. - let json = json::from_slice::(request.body())?; - let request = request.map(move |_| json); - match request.body()["method"].as_str().unwrap_or_default() { - "submitblock" | "submit_block" => self.handle_submit_block(request, monerod_resp).await, - "getblocktemplate" | "get_block_template" => self.handle_get_block_template(monerod_resp).await, - "getblockheaderbyhash" | "get_block_header_by_hash" => { - self.handle_get_block_header_by_hash(request, monerod_resp).await - }, - "getlastblockheader" | "get_last_block_header" => { - self.handle_get_last_block_header(monerod_resp).await - }, - - _ => Ok(proxy::into_body_from_response(monerod_resp)), - } + MonerodMethod::GetBlockHeaderByHash => { + self.handle_get_block_header_by_hash(request_bytes_to_value(request)?, monerod_resp) + .await + }, + MonerodMethod::GetLastBlockHeader => self.handle_get_last_block_header(monerod_resp).await, + _ => { + // Simply return the response "as is" + Ok(proxy::into_body_from_response(monerod_resp)) }, - // Simply return the response "as is" - _ => Ok(proxy::into_body_from_response(monerod_resp)), } } - async fn handle(self, method_name: &str, request: Request) -> Result, MmProxyError> { + pub(crate) async fn handle( + self, + method_name: &str, + request: Request, + ) -> Result, MmProxyError> { let start = Instant::now(); debug!( @@ -926,18 +945,15 @@ impl InnerService { request.headers(), String::from_utf8_lossy(&request.body().clone()[..]), ); + let monerod_method = parse_monerod_rpc_method(request.method(), request.uri(), request.body()); - match self.proxy_request_to_monerod(request).await { + match self.proxy_request_to_monerod(request, monerod_method).await { Ok((request, monerod_resp)) => { // Any failed (!= 200 OK) responses from Monero are immediately returned to the requester let monerod_status = monerod_resp.status(); if !monerod_status.is_success() { // we dont break on monerod returning an error code. - warn!( - target: LOG_TARGET, - "Monerod returned an error: {}", - monerod_resp.status() - ); + warn!(target: LOG_TARGET, "Monerod returned an error: {}", monerod_resp.status()); debug!( "Method: {}, MoneroD Status: {}, Proxy Status: N/A, Response Time: {}ms", method_name, @@ -947,7 +963,7 @@ impl InnerService { return Ok(monerod_resp.map(|json| json.to_string().into())); } - match self.get_proxy_response(request, monerod_resp).await { + match self.get_proxy_response(request, monerod_resp, monerod_method).await { Ok(response) => { debug!( "Method: {}, MoneroD Status: {}, Proxy Status: {}, Response Time: {}ms", @@ -959,308 +975,19 @@ impl InnerService { Ok(response) }, Err(e) => { + error!(target: LOG_TARGET, "get_proxy_response: {}", e); // Monero Server encountered a problem processing the request, reset the current monerod server - self.clear_current_monerod_server_lock(); + self.clear_current_monerod_server_lock(None); Err(e) }, } }, Err(e) => { + error!(target: LOG_TARGET, "proxy_request_to_monerod: {}", e); // Monero Server encountered a problem processing the request, reset the current monerod server - self.clear_current_monerod_server_lock(); + self.clear_current_monerod_server_lock(None); Err(e) }, } } } - -async fn convert_reqwest_response_to_hyper_json_response( - resp: reqwest::Response, -) -> Result, MmProxyError> { - let mut builder = Response::builder(); - - let headers = builder - .headers_mut() - .expect("headers_mut errors only when the builder has an error (e.g invalid header value)"); - headers.extend(resp.headers().iter().map(|(name, value)| (name.clone(), value.clone()))); - - builder = builder - .version(resp.version()) - .status(resp.status()) - .url(resp.url().clone()); - - let body = resp.json().await.map_err(MmProxyError::MonerodRequestFailed)?; - let resp = builder.body(body)?; - Ok(resp) -} - -/// Add mmproxy extensions object to JSON RPC success response -pub fn add_aux_data(mut response: json::Value, mut ext: json::Value) -> json::Value { - if response["result"].is_null() { - return response; - } - match response["result"][MMPROXY_AUX_KEY_NAME].as_object_mut() { - Some(obj_mut) => { - let ext_mut = ext - .as_object_mut() - .expect("invalid parameter: expected `ext: json::Value` to be an object but it was not"); - obj_mut.append(ext_mut); - }, - None => { - response["result"][MMPROXY_AUX_KEY_NAME] = ext; - }, - } - response -} - -/// Append chain data to the result object. If the result object is null, a JSON object is created. -/// -/// ## Panics -/// -/// If response["result"] is not a JSON object type or null. -pub fn append_aux_chain_data(mut response: json::Value, chain_data: json::Value) -> json::Value { - let result = &mut response["result"]; - if result.is_null() { - *result = json!({}); - } - let chains = match result[MMPROXY_AUX_KEY_NAME]["chains"].as_array_mut() { - Some(arr_mut) => arr_mut, - None => { - result[MMPROXY_AUX_KEY_NAME]["chains"] = json!([]); - result[MMPROXY_AUX_KEY_NAME]["chains"].as_array_mut().unwrap() - }, - }; - - chains.push(chain_data); - response -} - -fn try_into_json_block_header(header: grpc::BlockHeaderResponse) -> Result { - let grpc::BlockHeaderResponse { - header, - reward, - confirmations, - difficulty, - num_transactions, - } = header; - let header = header.ok_or_else(|| { - MmProxyError::UnexpectedTariBaseNodeResponse( - "Base node GRPC returned an empty header field when calling get_header_by_hash".into(), - ) - })?; - - Ok(json!({ - "block_size": 0, - "depth": confirmations, - "difficulty": difficulty, - "hash": header.hash.to_hex(), - "height": header.height, - "major_version": header.version, - "minor_version": 0, - "nonce": header.nonce, - "num_txes": num_transactions, - // Cannot be an orphan - "orphan_status": false, - "prev_hash": header.prev_hash.to_hex(), - "reward": reward, - "timestamp": header.timestamp - })) -} - -fn parse_method_name(request: &Request) -> String { - match *request.method() { - Method::GET => { - let mut chars = request.uri().path().chars(); - chars.next(); - chars.as_str().to_string() - }, - Method::POST => { - let json = json::from_slice::(request.body()).unwrap_or_default(); - str::replace(json["method"].as_str().unwrap_or_default(), "\"", "") - }, - _ => "unsupported".to_string(), - } -} - -#[cfg(test)] -mod test { - use std::{fmt::Display, time::Instant}; - - use anyhow::{anyhow, Error}; - use chrono::{Local, Timelike}; - use reqwest::Client; - use serde_json::{json, Value}; - - #[allow(clippy::enum_variant_names)] - #[derive(Clone, Copy)] - enum Method { - GetHeight, - GetBlockTemplate, - GetVersion, - } - - impl Display for Method { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let str = match self { - Method::GetHeight => "get_height".to_string(), - Method::GetBlockTemplate => "get_block_template".to_string(), - Method::GetVersion => "get_version".to_string(), - }; - write!(f, "{}", str) - } - } - - async fn get_json_rpc(method: Method, json_rpc_port: u16) -> Result { - match method { - Method::GetHeight => get_response(json_rpc_port, method).await, - Method::GetBlockTemplate | Method::GetVersion => json_rpc_request(method, json_rpc_port).await, - } - } - - async fn get_response(json_rpc_port: u16, method: Method) -> Result { - let full_address = format!("http://127.0.0.1:{}", json_rpc_port); - match reqwest::get(format!("{}/{}", full_address, method)) - .await - .unwrap() - .json::() - .await - { - Ok(val) => Ok(val.to_string()), - Err(e) => Err(e.into()), - } - } - - async fn json_rpc_request(method: Method, json_rpc_port: u16) -> Result { - let rpc_method = format!("{}", method); - let request_body = match method { - Method::GetBlockTemplate => json!({ - "jsonrpc": "2.0", - "id": "0", - "method": rpc_method, - "params": { - "wallet_address": "489r43gR8bDMJNBf4Q6sL9CNERvZQrTqjRCSESqgWQEWWq2UGAfj2voaw3zBtD7U8CQ391Nc1PDHUHiN85yhbZnCDasqzyX", - } - }), - Method::GetVersion => json!({ - "jsonrpc": "2.0", - "id": "0", - "method": rpc_method, - "params": {} - }), - _ => return Err(anyhow!("'{}' not supported", method)), - }; - let rpc_url = format!("http://127.0.0.1:{}/json_rpc", json_rpc_port); - - // Create an HTTP client - let client = Client::new(); - - // Send the POST request - let response = client.post(rpc_url).json(&request_body).send().await?; - - // Parse the response body - if response.status().is_success() { - let response_text = response.text().await?; - let response_json: serde_json::Value = serde_json::from_str(&response_text)?; - if response_json.get("error").is_some() { - return Err(anyhow!("'{}' failed ({})", method, response_text)); - } - if response_json.get("result").is_none() { - return Err(anyhow!("'{}' failed ({})", method, response_text)); - } - Ok(response_text) - } else { - Err(anyhow!("{} failed({})", method, response.status())) - } - } - - fn time_now() -> String { - let now = Local::now(); - format!( - "{:02}:{:02}:{:02}.{:03}", - now.hour(), - now.minute(), - now.second(), - now.timestamp_subsec_millis() - ) - } - - async fn inner_json_rpc_loop(method: Method, json_rpc_port: u16, responses: &mut Vec, count: usize) { - let start = Instant::now(); - let response = get_json_rpc(method, json_rpc_port).await; - match response { - Ok(val) => { - responses.push(format!( - " {}: method: {}; time now: {}; duration: {:.2?}, response length: {}", - count, - method, - time_now(), - start.elapsed(), - val.len(), - )); - }, - Err(err) => { - responses.push(format!( - " {}: method: {}; time now: {}; duration: {:.2?}, response length: {}, Error: {}", - count, - method, - time_now(), - start.elapsed(), - err.to_string().len(), - err - )); - }, - } - } - - // To execute this test a merge mining proxy must be running (just verify the port, default used), ideally when - // RandomX mining with XMRig is taking place. - #[tokio::test] - #[ignore] - async fn test_get_monerod_info() { - let json_rpc_port = 18081; - let tick = tokio::time::Duration::from_secs(2); - let mut interval = tokio::time::interval(tick); - let mut responses = Vec::with_capacity(50); - for method in [Method::GetHeight, Method::GetVersion, Method::GetBlockTemplate] { - let mut count = 0; - responses.push(format!("method: {}, tick: {:.2?}", method, tick)); - loop { - interval.tick().await; - count += 1; - inner_json_rpc_loop(method, json_rpc_port, &mut responses, count).await; - if count >= 5 { - break; - } - } - } - for response in responses { - println!("{}", response); - } - } - - // To execute this test a merge mining proxy must be running (just verify the port, default used), ideally when - // RandomX mining with XMRig is taking place. - #[tokio::test] - #[ignore] - async fn stress_test_get_monerod_info() { - let json_rpc_port = 18081; - let tick = tokio::time::Duration::from_millis(1000); - let mut interval = tokio::time::interval(tick); - let mut responses = Vec::with_capacity(3010); - for method in [Method::GetHeight, Method::GetVersion, Method::GetBlockTemplate] { - let mut count = 0; - responses.push(format!("method: {}, tick: {:.2?}", method, tick)); - loop { - interval.tick().await; - count += 1; - inner_json_rpc_loop(method, json_rpc_port, &mut responses, count).await; - if count >= 500 { - break; - } - } - } - for response in responses { - println!("{}", response); - } - } -} diff --git a/applications/minotari_merge_mining_proxy/src/proxy/mod.rs b/applications/minotari_merge_mining_proxy/src/proxy/mod.rs new file mode 100644 index 0000000000..956102daa4 --- /dev/null +++ b/applications/minotari_merge_mining_proxy/src/proxy/mod.rs @@ -0,0 +1,312 @@ +// Copyright 2020, The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +pub(crate) mod inner; +mod monerod_method; +pub(crate) mod service; +pub(crate) mod static_responses; +pub(crate) mod utils; + +#[cfg(test)] +mod test { + use std::{str::FromStr, time::Instant}; + + use anyhow::{anyhow, Error}; + use chrono::{Local, Timelike}; + use monero::{blockdata::transaction::SubField::MergeMining, Hash, VarInt}; + use reqwest::Client; + use serde_json::json; + use tari_core::proof_of_work::monero_rx; + + use crate::proxy::{ + monerod_method::MonerodMethod, + static_responses::{get_empty_monero_block, BLOCK_HASH_AT_3336491, BLOCK_HEIGHT_3336491, TIMESTAMP_AT_3336491}, + utils::convert_reqwest_response_to_hyper_json_response, + }; + + /// This is the public Monero primary donation address for their general fund + /// (See https://www.getmonero.org/get-started/contributing/) + const DEFAULT_MONERO_ADDRESS: &str = + "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A"; + + async fn get_json_rpc( + method: MonerodMethod, + json_rpc_port: u16, + hyper_json_response: bool, + hash: Option, + block_blob: Option, + ) -> Result { + match method { + MonerodMethod::GetHeight => get_response(json_rpc_port, method).await, + MonerodMethod::GetBlockTemplate | + MonerodMethod::GetVersion | + MonerodMethod::SubmitBlock | + MonerodMethod::GetBlockHeaderByHash | + MonerodMethod::GetLastBlockHeader | + MonerodMethod::GetBlock => { + json_rpc_request_body(method, json_rpc_port, hyper_json_response, hash, block_blob).await + }, + _ => Err(anyhow!("'{}' not supported", method)), + } + } + + async fn get_response(json_rpc_port: u16, method: MonerodMethod) -> Result { + let full_address = format!("http://127.0.0.1:{}", json_rpc_port); + match reqwest::get(format!("{}/{}", full_address, method)) + .await + .unwrap() + .json::() + .await + { + Ok(val) => { + println!("{}: {}", method, val); + Ok(val.to_string()) + }, + Err(e) => Err(e.into()), + } + } + + // The static XMRig request bodies in this test helper function are for testing purposes only. Their content can be + // verified for similarity with the actual request bodies that are generated by XMRig as the first log entry in + // `applications/minotari_merge_mining_proxy/src/proxy/inner.rs` method `pub(crate) async fn handle(`. + async fn json_rpc_request_body( + method: MonerodMethod, + json_rpc_port: u16, + hyper_json_response: bool, + hash: Option, + block_blob: Option, + ) -> Result { + let rpc_method = format!("{}", method); + let request_body = match method { + MonerodMethod::GetBlockTemplate => json!({ + "jsonrpc": "2.0", + "id": "0", + "method": rpc_method, + "params": { + "wallet_address": DEFAULT_MONERO_ADDRESS, + } + }), + MonerodMethod::GetVersion | MonerodMethod::GetLastBlockHeader => json!({ + "jsonrpc": "2.0", + "id": "0", + "method": rpc_method, + "params": {} + }), + MonerodMethod::SubmitBlock => { + let block_blob = if let Some(blob) = block_blob { + blob + } else { + const ARBITRARY_MERGE_MINING_HASH: &str = + "8e6dab82d22909b40bda27ec0e96aa0c6c0012023d353f3be941eb6ef1793cad"; + let merge_mining_tag = Some(MergeMining( + VarInt(0), + Hash::from_str(ARBITRARY_MERGE_MINING_HASH).expect("will not fail"), + )); + let block = get_empty_monero_block( + Hash::from_str(BLOCK_HASH_AT_3336491)?, + TIMESTAMP_AT_3336491, + BLOCK_HEIGHT_3336491, + merge_mining_tag, + ); + monero_rx::serialize_monero_block_to_hex(&block)? + }; + assert!(monero_rx::deserialize_monero_block_from_hex(&block_blob).is_ok()); + + json!({ + "jsonrpc": "2.0", + "id": "0", + "method": rpc_method, + // These request params were generated by XMRig when submitting a block that was solved during merge + // mining on esmeralda. + "params": [block_blob] + }) + }, + MonerodMethod::GetBlockHeaderByHash | MonerodMethod::GetBlock => json!({ + "jsonrpc": "2.0", + "id": "0", + "method": rpc_method, + "params": { + "hash": hash.unwrap_or(BLOCK_HASH_AT_3336491.to_string()), + } + }), + _ => return Err(anyhow!("'{}' not supported", method)), + }; + let rpc_url = format!("http://127.0.0.1:{}/json_rpc", json_rpc_port); + + // Create an HTTP client + let client = Client::new(); + + // Send the POST request + let response = client.post(rpc_url).json(&request_body).send().await?; + + // Parse the response body + if response.status().is_success() { + if hyper_json_response { + let hyper_json_response = convert_reqwest_response_to_hyper_json_response(response).await?; + + println!(); + println!("{} - response: {:?}", method, hyper_json_response); + println!("{} - status: {:?}", method, hyper_json_response.status()); + println!("{} - version: {:?}", method, hyper_json_response.version()); + println!("{} - headers: {:?}", method, hyper_json_response.headers()); + println!("{} - extensions: {:?}", method, hyper_json_response.extensions()); + println!("{} - body: {:?}", method, hyper_json_response.body()); + + let response_json = hyper_json_response.body(); + if response_json.get("error").is_some() { + return Err(anyhow!("'{}' failed ({})", method, response_json)); + } + if response_json.get("result").is_none() { + return Err(anyhow!("'{}' failed ({})", method, response_json)); + } + + Ok(response_json.to_string()) + } else { + let response_text = response.text().await?; + let response_json: serde_json::Value = serde_json::from_str(&response_text)?; + if response_json.get("error").is_some() { + return Err(anyhow!("'{}' failed ({})", method, response_text)); + } + if response_json.get("result").is_none() { + return Err(anyhow!("'{}' failed ({})", method, response_text)); + } + Ok(response_text) + } + } else { + Err(anyhow!("{} failed({})", method, response.status())) + } + } + + fn time_now() -> String { + let now = Local::now(); + format!( + "{:02}:{:02}:{:02}.{:03}", + now.hour(), + now.minute(), + now.second(), + now.timestamp_subsec_millis() + ) + } + + pub(crate) async fn inner_json_rpc( + method: MonerodMethod, + json_rpc_port: u16, + responses: &mut Vec, + count: usize, + hyper_json_response: bool, + hash: Option, + block_blob: Option, + ) { + let start = Instant::now(); + let response = get_json_rpc(method, json_rpc_port, hyper_json_response, hash, block_blob).await; + match response { + Ok(val) => { + responses.push(format!( + " {}: method: {}; time now: {}; duration: {:.2?}, response length: {}, response body: {}", + count, + method, + time_now(), + start.elapsed(), + val.len(), + val + )); + }, + Err(err) => { + responses.push(format!( + " {}: method: {}; time now: {}; duration: {:.2?}, response length: {}, Error: {}", + count, + method, + time_now(), + start.elapsed(), + err.to_string().len(), + err + )); + }, + } + } + + // To execute this test a merge mining proxy must be running (just verify the port, default used), ideally when + // RandomX mining with XMRig is taking place. + #[tokio::test] + #[ignore] + async fn test_get_monerod_info() { + let json_rpc_port = 18081; + let tick = tokio::time::Duration::from_secs(2); + let mut interval = tokio::time::interval(tick); + let mut responses = Vec::with_capacity(50); + for method in [ + MonerodMethod::GetHeight, + MonerodMethod::GetVersion, + MonerodMethod::GetBlockTemplate, + MonerodMethod::SubmitBlock, + MonerodMethod::GetBlockHeaderByHash, + MonerodMethod::GetLastBlockHeader, + ] { + let mut count = 0; + responses.push(format!("method: {}, tick: {:.2?}", method, tick)); + loop { + interval.tick().await; + count += 1; + inner_json_rpc(method, json_rpc_port, &mut responses, count, false, None, None).await; + if count >= 2 { + break; + } + } + } + for response in responses { + println!("{}", response); + } + } + + // To execute this test a merge mining proxy must be running (just verify the port, default used), ideally when + // RandomX mining with XMRig is taking place. + #[tokio::test] + #[ignore] + async fn stress_test_get_monerod_info() { + let json_rpc_port = 18081; + let tick = tokio::time::Duration::from_millis(1000); + let mut interval = tokio::time::interval(tick); + let mut responses = Vec::with_capacity(3010); + for method in [ + MonerodMethod::GetHeight, + MonerodMethod::GetVersion, + MonerodMethod::GetBlockTemplate, + MonerodMethod::SubmitBlock, + MonerodMethod::GetBlockHeaderByHash, + MonerodMethod::GetLastBlockHeader, + ] { + let mut count = 0; + responses.push(format!("method: {}, tick: {:.2?}", method, tick)); + loop { + interval.tick().await; + count += 1; + inner_json_rpc(method, json_rpc_port, &mut responses, count, false, None, None).await; + if count >= 500 { + break; + } + } + } + for response in responses { + println!("{}", response); + } + } +} diff --git a/applications/minotari_merge_mining_proxy/src/proxy/monerod_method.rs b/applications/minotari_merge_mining_proxy/src/proxy/monerod_method.rs new file mode 100644 index 0000000000..776604de7a --- /dev/null +++ b/applications/minotari_merge_mining_proxy/src/proxy/monerod_method.rs @@ -0,0 +1,103 @@ +// Copyright 2020, The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use std::{fmt::Display, str::FromStr}; + +use bytes::Bytes; +use hyper::{Method, Uri}; +use log::warn; + +use crate::error::MmProxyError; + +const LOG_TARGET: &str = "minotari_mm_proxy::proxy::monerod_method"; + +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum MonerodMethod { + GetHeight, + GetVersion, + GetBlockTemplate, + SubmitBlock, + GetBlockHeaderByHash, + GetLastBlockHeader, + #[allow(dead_code)] + GetBlock, + RpcMethodNotDefined, +} + +impl FromStr for MonerodMethod { + type Err = MmProxyError; + + fn from_str(s: &str) -> Result { + match s { + "/get_height" | "/getheight" => Ok(MonerodMethod::GetHeight), + "get_height" | "getheight" => Ok(MonerodMethod::GetHeight), + "get_version" | "getversion" => Ok(MonerodMethod::GetVersion), + "get_block_template" | "getblocktemplate" => Ok(MonerodMethod::GetBlockTemplate), + "submit_block" | "submitblock" => Ok(MonerodMethod::SubmitBlock), + "get_block_header_by_hash" | "getblockheaderbyhash" => Ok(MonerodMethod::GetBlockHeaderByHash), + "get_last_block_header" | "getlastblockheader" => Ok(MonerodMethod::GetLastBlockHeader), + "get_block" | "getblovk" => Ok(MonerodMethod::GetLastBlockHeader), + _ => { + let msg = format!("Unknown monerod rpc method: '{}'", s); + warn!(target: LOG_TARGET, "{}", msg); + Err(MmProxyError::ConversionError(msg)) + }, + } + } +} + +impl Display for MonerodMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + MonerodMethod::GetHeight => "get_height".to_string(), + MonerodMethod::GetVersion => "get_version".to_string(), + MonerodMethod::GetBlockTemplate => "get_block_template".to_string(), + MonerodMethod::SubmitBlock => "submit_block".to_string(), + MonerodMethod::GetBlockHeaderByHash => "get_block_header_by_hash".to_string(), + MonerodMethod::GetLastBlockHeader => "get_last_block_header".to_string(), + MonerodMethod::GetBlock => "get_block".to_string(), + MonerodMethod::RpcMethodNotDefined => "rpc_method_not_defined".to_string(), + }; + write!(f, "{}", str) + } +} + +/// Parse the monerod RPC method from the request components +pub fn parse_monerod_rpc_method(request_method: &Method, request_uri: &Uri, request_body: &Bytes) -> MonerodMethod { + match *request_method { + // All get requests go to /request_name, methods do not have a body, optionally could have query params + // if applicable. + Method::GET => MonerodMethod::from_str(request_uri.path()).unwrap_or(MonerodMethod::RpcMethodNotDefined), + // All post requests go to /json_rpc, body of request contains a field `method` to indicate which call + // takes place. + Method::POST => { + let json = serde_json::from_slice::(&request_body[..]).unwrap_or_default(); + if let Some(method) = json["method"].as_str() { + MonerodMethod::from_str(method).unwrap_or(MonerodMethod::RpcMethodNotDefined) + } else { + MonerodMethod::RpcMethodNotDefined + } + }, + _ => MonerodMethod::RpcMethodNotDefined, + } +} diff --git a/applications/minotari_merge_mining_proxy/src/proxy/service.rs b/applications/minotari_merge_mining_proxy/src/proxy/service.rs new file mode 100644 index 0000000000..39a2ebd666 --- /dev/null +++ b/applications/minotari_merge_mining_proxy/src/proxy/service.rs @@ -0,0 +1,135 @@ +// Copyright 2020, The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use std::{ + future::Future, + pin::Pin, + sync::{atomic::AtomicBool, Arc, RwLock}, + task::{Context, Poll}, +}; + +use hyper::{Body, Request, Response, StatusCode}; +use jsonrpc::error::StandardError; +use minotari_app_utilities::parse_miner_input::{BaseNodeGrpcClient, ShaP2PoolGrpcClient}; +use serde_json::json; +use tari_common_types::tari_address::TariAddress; +use tari_comms::protocol::rpc::__macro_reexports::Service; +use tari_core::{consensus::ConsensusManager, proof_of_work::randomx_factory::RandomXFactory}; +use tracing::{error, trace, warn}; + +use crate::{ + block_template_data::BlockTemplateRepository, + common::{json_rpc, proxy}, + config::MergeMiningProxyConfig, + error::MmProxyError, + proxy::{inner::InnerService, utils::parse_method_name}, +}; + +const LOG_TARGET: &str = "minotari_mm_proxy::proxy::service"; + +#[derive(Debug, Clone)] +pub struct MergeMiningProxyService { + inner: InnerService, +} + +impl MergeMiningProxyService { + pub fn new( + config: MergeMiningProxyConfig, + http_client: reqwest::Client, + base_node_client: BaseNodeGrpcClient, + p2pool_client: Option, + block_templates: BlockTemplateRepository, + randomx_factory: RandomXFactory, + wallet_payment_address: TariAddress, + ) -> Result { + trace!(target: LOG_TARGET, "Config: {:?}", config); + let consensus_manager = ConsensusManager::builder(config.network).build()?; + Ok(Self { + inner: InnerService { + config: Arc::new(config), + block_templates, + http_client, + base_node_client, + p2pool_client, + initial_sync_achieved: Arc::new(AtomicBool::new(false)), + current_monerod_server: Arc::new(RwLock::new(None)), + last_assigned_monerod_url: Arc::new(RwLock::new(None)), + monerod_cache_values: Arc::new(RwLock::new(None)), + randomx_factory, + consensus_manager, + wallet_payment_address, + }, + }) + } +} + +#[allow(clippy::type_complexity)] +impl Service> for MergeMiningProxyService { + type Error = hyper::Error; + type Future = Pin> + Send>>; + type Response = Response; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, mut request: Request) -> Self::Future { + let inner = self.inner.clone(); + let future = async move { + let bytes = match proxy::read_body_until_end(request.body_mut()).await { + Ok(b) => b, + Err(err) => { + warn!(target: LOG_TARGET, "Method: Unknown, Failed to read request: {:?}", err); + let resp = proxy::json_response( + StatusCode::BAD_REQUEST, + &json_rpc::standard_error_response( + None, + StandardError::InvalidRequest, + Some(json!({"details": err.to_string()})), + ), + ) + .expect("unexpected failure"); + return Ok(resp); + }, + }; + let request = request.map(|_| bytes.freeze()); + let method_name = parse_method_name(&request); + match inner.handle(&method_name, request).await { + Ok(resp) => Ok(resp), + Err(err) => { + error!(target: LOG_TARGET, "Method \"{}\" failed handling request: {:?}", method_name, err); + Ok(proxy::json_response( + StatusCode::INTERNAL_SERVER_ERROR, + &json_rpc::standard_error_response( + None, + StandardError::InternalError, + Some(json!({"details": err.to_string()})), + ), + ) + .expect("unexpected failure")) + }, + } + }; + + Box::pin(future) + } +} diff --git a/applications/minotari_merge_mining_proxy/src/proxy/static_responses.rs b/applications/minotari_merge_mining_proxy/src/proxy/static_responses.rs new file mode 100644 index 0000000000..288c3f56c4 --- /dev/null +++ b/applications/minotari_merge_mining_proxy/src/proxy/static_responses.rs @@ -0,0 +1,694 @@ +// Copyright 2020, The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use std::str::FromStr; + +use log::trace; +use monero::{ + blockdata::{ + transaction, + transaction::{ + ExtraField, + RawExtraField, + SubField::{Nonce, TxPublicKey}, + }, + }, + util::ringct::Key, + Block, + BlockHeader, + Hash, + PublicKey, + Transaction, + TransactionPrefix, + VarInt, +}; +use tari_core::proof_of_work::monero_rx; +use tracing::debug; +use url::Url; + +use crate::{ + common::json_rpc, + error::MmProxyError, + proxy::{inner::MonerodCacheValues, monerod_method::MonerodMethod}, +}; + +const LOG_TARGET: &str = "minotari_mm_proxy::proxy::static_responses"; + +struct StaticResponse { + headers: hyper::HeaderMap, + version: hyper::Version, + status: hyper::StatusCode, + body: serde_json::Value, +} + +// Default metadata for Monero block at height 3336491 +pub(crate) const BLOCK_HEIGHT_3336491: u64 = 3336491; +pub(crate) const BLOCK_HASH_AT_3336491: &str = "a1b9f62c45d67d5e2acb21efcaf8804d3674005190152d11b6f04d80acf8013c"; +pub(crate) const TIMESTAMP_AT_3336491: u64 = 1738223139; +const SEED_HEIGHT_AT_3336491: u64 = 3336192; +const SEED_HASH_AT_3336491: &str = "91ef83186cefaa646dc4c6e950e68e4debab52b4f4a9b7f465891e91fe5f6ce4"; +const DIFFICULTY_AT_3336491: u64 = 490520097899; +const REWARD_AT_3336491: u64 = 600741780000; +const WIDE_DIFFICULTY_AT_3336491: &str = "0x6df86346d3"; +const MAX_HF_VERSION: u64 = 16; + +// These static monerod responses can be captured using the merge mining proxy connected to XMRig and monerod as the +// last log entry in `applications/minotari_merge_mining_proxy/src/proxy/inner.rs` method +// `async fn proxy_request_to_monerod(`. Also see `get_new_monerod_static_responses()` test function in this file. +#[allow(clippy::too_many_lines)] +fn get_static_monerod_response( + method: MonerodMethod, + req_id: Option, + monerod_cache_values: Option, +) -> Result { + let req_id = req_id.unwrap_or(-1); + let (height, hash, timestamp, seed_height, seed_hash) = if let Some(cache_values) = monerod_cache_values { + ( + cache_values.height, + cache_values.prev_hash, + cache_values.timestamp.unwrap_or(TIMESTAMP_AT_3336491), + cache_values.seed_height.unwrap_or(SEED_HEIGHT_AT_3336491), + if let Some(hash) = cache_values.seed_hash { + &hex::encode(hash) + } else { + SEED_HASH_AT_3336491 + }, + ) + } else { + ( + BLOCK_HEIGHT_3336491, + Hash::from_str(BLOCK_HASH_AT_3336491).expect("will not fail"), + TIMESTAMP_AT_3336491, + SEED_HEIGHT_AT_3336491, + SEED_HASH_AT_3336491, + ) + }; + + let response = match method { + // If hash and height are not provided, this will return the Monero hash for block 3336491 + MonerodMethod::GetHeight => StaticResponse { + headers: { + let mut headers = hyper::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers + }, + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::OK, + body: serde_json::json!({ + // The monero hash for blockchain at height 3331664 + "hash": hex::encode(hash), + "height": height, + "status": "OK", + "untrusted": false + }), + }, + // This was the 'get_version' response for monero blockchain height 3336491. A custom 'height' can be provided, + // but hard fork and version information will go out of date at the next hard fork. + MonerodMethod::GetVersion => StaticResponse { + headers: { + let mut headers = hyper::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers + }, + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::OK, + body: serde_json::json!({ + "id": req_id, + "jsonrpc": "2.0", + "result": { + "current_height": height, + "hard_forks": [ + {"height": 1, "hf_version": 1}, + {"height": 1009827, "hf_version": 2}, + {"height": 1141317, "hf_version": 3}, + {"height": 1220516, "hf_version": 4}, + {"height": 1288616, "hf_version": 5}, + {"height": 1400000, "hf_version": 6}, + {"height": 1546000, "hf_version": 7}, + {"height": 1685555, "hf_version": 8}, + {"height": 1686275, "hf_version": 9}, + {"height": 1788000, "hf_version": 10}, + {"height": 1788720, "hf_version": 11}, + {"height": 1978433, "hf_version": 12}, + {"height": 2210000, "hf_version": 13}, + {"height": 2210720, "hf_version": 14}, + {"height": 2688888, "hf_version": 15}, + {"height": 2689608, "hf_version": 16} + ], + "release": true, + "status": "OK", + "untrusted": false, + "version": 196622 + } + }), + }, + // This will return an empty 'get_block_template' response for monero blockchain height 'height + 1'. Dynamic + // values are used as far as possible, for example 'hash' and 'timestamp' must be supplied. + MonerodMethod::GetBlockTemplate => { + let monero_block = get_empty_monero_block(hash, timestamp, height + 1, None); + let blockhashing_blob = monero_rx::create_blockhashing_blob_from_block(&monero_block)?; + let blocktemplate_blob = monero_rx::serialize_monero_block_to_hex(&monero_block)?; + + StaticResponse { + headers: { + let mut headers = hyper::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers + }, + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::OK, + body: serde_json::json!({ + "id": req_id, + "jsonrpc": "2.0", + // The 'get_block_template' response for monero blockchain height 3334284 + "result": { + "blockhashing_blob": blockhashing_blob, + "blocktemplate_blob": blocktemplate_blob, + "difficulty": DIFFICULTY_AT_3336491, + "difficulty_top64": 0, + "expected_reward": REWARD_AT_3336491, + "height": height, + "next_seed_hash": "", + "prev_hash": hex::encode(monero_block.header.prev_id), + "reserved_offset": 0, + "seed_hash": seed_hash, + "seed_height": seed_height, + "status": "OK", + "untrusted": false, + "wide_difficulty": WIDE_DIFFICULTY_AT_3336491 + } + }), + } + }, + // This return an error response by design, as we can never construct a static block that will be accepted, and + // most if not all cases a block solved while merge mining will not be accepted by the Monero network. + MonerodMethod::SubmitBlock => StaticResponse { + headers: { + let mut headers = hyper::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers + }, + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::OK, + body: serde_json::json!({ + "error": { + "code": -7, + "message": "Block not accepted" + }, + "id": req_id, + "jsonrpc": "2.0" + }), + }, + // This return an error response by design, as it is impossible to return the correct header or block + // corresponding to 1 of millions of correct answers while offline. + MonerodMethod::GetBlockHeaderByHash | MonerodMethod::GetBlock => StaticResponse { + headers: { + let mut headers = hyper::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers + }, + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::OK, + body: serde_json::json!({ + "error": { + "code": -5, + "message": &format!("Internal error: can't get block by hash '{}'.", method) + }, + "id": req_id, + "jsonrpc": "2.0" + }), + }, + // This return a fixed header by design, as it is impossible to return the correct header corresponding to + // the last block while offline. + MonerodMethod::GetLastBlockHeader => StaticResponse { + headers: { + let mut headers = hyper::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers + }, + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::OK, + body: serde_json::json!({ + "id": req_id, + "jsonrpc": "2.0", + "result": { + "block_header": { + "block_size": 3865, + "block_weight": 3865, + "cumulative_difficulty": 418129042015270429u64, + "cumulative_difficulty_top64": 0, + "depth": 0, + "difficulty": DIFFICULTY_AT_3336491, + "difficulty_top64": 0, + "hash": BLOCK_HASH_AT_3336491, + "height": height, + "long_term_weight": 176470, + "major_version": MAX_HF_VERSION, + "miner_tx_hash": "8112cdbbd21a99a347386d03e0798d095a356ddde84ebb574011cb8cc33c200f", + "minor_version": MAX_HF_VERSION, + "nonce": 67153, + "num_txes": 38, + "orphan_status": false, + "pow_hash": "", + "prev_hash": &hex::encode(hash), + "reward": REWARD_AT_3336491, + "timestamp": timestamp, + "wide_cumulative_difficulty": "0x5cd7e25fb98361d", + "wide_difficulty": WIDE_DIFFICULTY_AT_3336491 + }, + "credits": 0, + "status": "OK", + "top_hash": "", + "untrusted": false + } + }), + }, + MonerodMethod::RpcMethodNotDefined => StaticResponse { + headers: hyper::HeaderMap::new(), + version: hyper::Version::HTTP_11, + status: hyper::StatusCode::BAD_REQUEST, + body: serde_json::json!({"error": "Unknown method"}), + }, + }; + + Ok(response) +} + +// Monero block with only the miner transaction and no other transactions - miner data correspond to block 3336491 +pub(crate) fn get_empty_monero_block( + prev_id: Hash, + timestamp: u64, + height: u64, + merge_mining_tag: Option, +) -> Block { + // Miner transaction data for block 3336491 + const TX_KEY: &str = "67399a3d8caf949713cf3aae1f4027b29a8df626a167ed84aef2c011e3a9ff5f"; + const PUBLIC_KEY: &str = "9785629f62f7688cd7fc7025d1c6837a818fc5d09c0a2adb4f77545cfe57fb6b"; + const NONCE: &str = "115fe80c4c8a36c100000000000000000000"; + + let key = Key::from(Hash::from_str(TX_KEY).unwrap().to_bytes()).key; + + let mut sub_fields = vec![ + TxPublicKey(PublicKey::from_str(PUBLIC_KEY).unwrap()), + Nonce(hex::decode(NONCE).unwrap()), + ]; + if let Some(tag) = merge_mining_tag { + sub_fields.insert(0, tag); + } + let extra = RawExtraField::from(ExtraField(sub_fields)); + + Block { + header: BlockHeader { + major_version: VarInt(MAX_HF_VERSION), + minor_version: VarInt(MAX_HF_VERSION), + timestamp: VarInt(timestamp), + prev_id, + nonce: 0, + }, + // This is an arbitrary miner transaction + miner_tx: Transaction { + prefix: TransactionPrefix { + version: VarInt(2), + unlock_time: VarInt(height + 60), + inputs: vec![transaction::TxIn::Gen { height: VarInt(height) }], + outputs: vec![transaction::TxOut { + amount: VarInt(600741780000), + target: transaction::TxOutTarget::ToTaggedKey { key, view_tag: 223 }, + }], + extra, + }, + signatures: vec![], + rct_signatures: monero::util::ringct::RctSig { + sig: Some(monero::util::ringct::RctSigBase { + rct_type: monero::util::ringct::RctType::Null, + txn_fee: monero::util::amount::Amount::ZERO, + pseudo_outs: vec![], + ecdh_info: vec![], + out_pk: vec![], + }), + p: None, + }, + }, + tx_hashes: vec![ + Hash::from_str("6893e92efa26b95975f96c493de78600e2aac40b833552421ebe579d67b7b6ec").expect("will not fail"), + Hash::from_str("ddbeb9bc923255a3117c1483c14449bf459fea824ab13917516d3863d89e5d6a").expect("will not fail"), + ], + } +} + +pub(crate) fn convert_static_monerod_response_to_hyper_response( + method: MonerodMethod, + req_id: Option, + monerod_cache_values: Option, +) -> Result, MmProxyError> { + if let Some(cache_values) = monerod_cache_values.clone() { + trace!( + target: LOG_TARGET, + "[monerod] use static response for {}, req_id: {:?}, height: {:?}, prev_hash: {:?}, timestamp: {:?}, \ + seed_height: {:?}, seed_hash: {:?}", + method, req_id, + cache_values.height, + cache_values.prev_hash, + cache_values.timestamp, + cache_values.seed_height, + cache_values.seed_hash.map(hex::encode), + ); + } else { + trace!(target: LOG_TARGET, "[monerod] use static response for {}, req_id: {:?}", method, req_id); + } + let static_response = get_static_monerod_response(method, req_id, monerod_cache_values)?; + + let mut builder = hyper::Response::builder(); + + let headers = builder + .headers_mut() + .expect("headers_mut errors only when the builder has an error (e.g invalid header value)"); + headers.extend( + static_response + .headers + .iter() + .map(|(name, value)| (name.clone(), value.clone())), + ); + + builder = builder.version(static_response.version).status(static_response.status); + + let resp = builder.body(static_response.body)?; + Ok(resp) +} + +/// This is required for self-select configuration if the request is a block submission and we are not submitting blocks +/// to the origin (self-select mode) +pub(crate) fn self_select_submit_block_monerod_response(request_id: Option) -> serde_json::Value { + debug!( + target: LOG_TARGET, + "[monerod] skip: Proxy configured for self-select mode. Pool will submit to MoneroD, submitting to \ + Minotari.", + ); + + // We are not submitting the block to Monero here (the pool does this), + // we are only interested in intercepting the request for the purposes of + // submitting the block to Tari which will only happen if the accept response + // (which normally would occur for normal mining) is provided here. + // There is no point in trying to submit the block to Monero here since the + // share submitted by XMRig is only guaranteed to meet the difficulty of + // min(Tari,Monero) since that is what was returned with the original template. + // So it would otherwise be a duplicate submission of what the pool will do + // itself (whether the miner submits directly to monerod or the pool does, + // the pool is the only one being paid out here due to the nature + // of self-select). Furthermore, discussions with devs from Monero and XMRig are + // very much against spamming the nodes unnecessarily. + // NB!: This is by design, do not change this without understanding + // it's implications. + json_rpc::default_block_accept_response(request_id) +} + +pub(crate) fn static_json_rpc_url() -> Url { + Url::parse("http://82.64.166.200:18081/json_rpc").expect("Invalid URL") +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use hyper::HeaderMap; + use monero::{blockdata::transaction::SubField, Hash, VarInt}; + use regex::Regex; + use serde_json::json; + use tari_core::proof_of_work::monero_rx; + use url::Url; + + use crate::proxy::{ + inner::MonerodCacheValues, + monerod_method::MonerodMethod, + static_responses::{ + convert_static_monerod_response_to_hyper_response, + get_empty_monero_block, + get_static_monerod_response, + self_select_submit_block_monerod_response, + static_json_rpc_url, + BLOCK_HASH_AT_3336491, + MAX_HF_VERSION, + }, + test, + }; + + fn extract_error_json(response: &str) -> Option { + let re = Regex::new(r"Error: '.*?' failed \((\{.*\})\)").unwrap(); + if let Some(captures) = re.captures(response) { + if let Some(json_str) = captures.get(1) { + let json_value: serde_json::Value = serde_json::from_str(json_str.as_str()).unwrap(); + return Some(json_value); + } + } + None + } + + // To execute this test a merge mining proxy must be running (just verify the port, default used), together with a + // base node. This function can also be used to capture monerod responses. + // Note: `config.monerod_fallback` must be `MonerodOnly` or `StaticWhenMonerodFails` + #[tokio::test] + #[ignore] + async fn get_monerod_dynamic_responses() { + let json_rpc_port = 18081; + let mut responses = Vec::with_capacity(50); + + println!(); + for method in [ + MonerodMethod::GetHeight, + MonerodMethod::GetVersion, + MonerodMethod::GetBlockTemplate, + MonerodMethod::SubmitBlock, + MonerodMethod::GetBlockHeaderByHash, + MonerodMethod::GetLastBlockHeader, + MonerodMethod::GetBlock, + ] { + let block_hash = if method == MonerodMethod::GetBlockHeaderByHash || method == MonerodMethod::GetBlock { + Some(BLOCK_HASH_AT_3336491.to_string()) + } else { + None + }; + test::inner_json_rpc(method, json_rpc_port, &mut responses, 1, true, block_hash.clone(), None).await; + // Investigate the responses + let json: serde_json::Value = if responses[responses.len() - 1].contains(" response body: ") { + let response = responses[responses.len() - 1] + .split(" response body: ") + .collect::>()[1]; + serde_json::from_str(response).unwrap_or(serde_json::Value::Null) + } else { + extract_error_json(&responses[responses.len() - 1]).unwrap_or(serde_json::Value::Null) + }; + match method { + MonerodMethod::GetVersion => { + // Assert no error + assert_eq!(json["error"], json!(null)); + // Verify the hard fork version information is still up to date + let max_hf_version = get_max_hf_version(json); + assert_eq!(max_hf_version, MAX_HF_VERSION); + }, + MonerodMethod::GetHeight | + MonerodMethod::GetBlockTemplate | + MonerodMethod::GetBlockHeaderByHash | + MonerodMethod::GetLastBlockHeader | + MonerodMethod::GetBlock => { + // Assert no error + assert_eq!(json["error"], json!(null)); + }, + MonerodMethod::SubmitBlock => { + // Assert error + assert_ne!(json["error"], json!(null)); + }, + MonerodMethod::RpcMethodNotDefined => {}, + } + } + + // Manipulate the first numeric character of the hash that is less than 9 to get a different hash (we want + // "get_block_header_by_hash" to fail) + let mut hash = BLOCK_HASH_AT_3336491.to_string(); + if let Some(pos) = hash + .chars() + .position(|c| c.is_ascii_digit() && c.to_digit(10).unwrap() < 9) + { + let mut chars: Vec = hash.chars().collect(); + chars[pos] = std::char::from_digit(chars[pos].to_digit(10).unwrap() + 1, 10).unwrap(); + hash = chars.into_iter().collect(); + } + let method = MonerodMethod::GetBlockHeaderByHash; + test::inner_json_rpc(method, json_rpc_port, &mut responses, 1, true, Some(hash), None).await; + if let Some(json) = extract_error_json(&responses[responses.len() - 1]) { + // Assert error + assert_ne!(json["error"], json!(null)); + } else { + panic!("Expected error response"); + } + + println!(); + for response in responses { + println!("{}", response); + } + } + + fn headers_to_json(headers: &HeaderMap) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for (key, value) in headers { + map.insert( + key.to_string(), + serde_json::Value::String(value.to_str().unwrap().to_string()), + ); + } + serde_json::Value::Object(map) + } + + #[test] + fn test_monerod_static_responses() { + for method in [ + MonerodMethod::GetHeight, + MonerodMethod::GetVersion, + MonerodMethod::GetBlockTemplate, + MonerodMethod::SubmitBlock, + MonerodMethod::GetBlockHeaderByHash, + MonerodMethod::GetLastBlockHeader, + MonerodMethod::GetBlock, + MonerodMethod::RpcMethodNotDefined, + ] { + let static_hyper_response = convert_static_monerod_response_to_hyper_response( + method, + Some(123), + Some(MonerodCacheValues { + height: 3331664, + prev_hash: Hash::from_str("98f83f921a006ccb8ab14ec7e7245e4a4350471027b4d490c41e8d84e4b8a196") + .unwrap(), + timestamp: Some(12345678), + seed_height: None, + seed_hash: None, + }), + ) + .unwrap(); + let static_response = get_static_monerod_response( + method, + Some(123), + Some(MonerodCacheValues { + height: 3331664, + prev_hash: Hash::from_str("98f83f921a006ccb8ab14ec7e7245e4a4350471027b4d490c41e8d84e4b8a196") + .unwrap(), + timestamp: Some(12345678), + seed_height: None, + seed_hash: None, + }), + ) + .unwrap(); + + // Version + assert_eq!(static_hyper_response.version(), static_response.version); + + // Status + assert_eq!(static_hyper_response.status(), static_response.status); + + // Headers + assert_eq!( + headers_to_json(static_hyper_response.headers()), + headers_to_json(&static_response.headers) + ); + + // Body + assert_eq!(static_hyper_response.body(), &static_response.body); + + if method == MonerodMethod::GetBlockTemplate { + let (_parts, monerod_resp) = static_hyper_response.into_parts(); + let blocktemplate_blob = monerod_resp["result"]["blocktemplate_blob"] + .to_string() + .replace('\"', ""); + assert!(monero_rx::deserialize_monero_block_from_hex(&blocktemplate_blob).is_ok()); + } + } + + let monerod_response = self_select_submit_block_monerod_response(Some(123)); + assert_eq!( + monerod_response, + json!({ + "id": 123, + "jsonrpc": "2.0", + "result": "{}", + "status": "OK", + "untrusted": false, + }) + ); + + assert_eq!( + static_json_rpc_url(), + Url::parse("http://82.64.166.200:18081/json_rpc").unwrap() + ); + } + + #[test] + fn test_get_empty_block() { + let prev_id = Hash::from_str("840915066009f63da3bf1160ce0ac3b2a57865d0b9329dcbf9ae1627200987d7").unwrap(); + let timestamp = 1234567890; + let height = 123456; + let block = get_empty_monero_block(prev_id, timestamp, height, None); + let extra = block.miner_tx.prefix.extra.try_parse(); + for field in &extra.0 { + if let SubField::MergeMining(..) = field { + panic!("Merge mining tag should not be present"); + } + } + + assert_eq!(block.header.prev_id, prev_id); + assert_eq!(block.header.timestamp, VarInt(timestamp)); + assert_eq!(block.header.major_version, VarInt(MAX_HF_VERSION)); + assert_eq!(block.header.minor_version, VarInt(MAX_HF_VERSION)); + + let blocktemplate_blob = monero_rx::serialize_monero_block_to_hex(&block).unwrap(); + assert!(monero_rx::deserialize_monero_block_from_hex(&blocktemplate_blob).is_ok()); + + const ARBITRARY_MERGE_MINING_HASH: &str = "8e6dab82d22909b40bda27ec0e96aa0c6c0012023d353f3be941eb6ef1793cad"; + let merge_mining_tag = Some(SubField::MergeMining( + VarInt(0), + Hash::from_str(ARBITRARY_MERGE_MINING_HASH).expect("will not fail"), + )); + let block = get_empty_monero_block(prev_id, timestamp, height, merge_mining_tag); + let extra = block.miner_tx.prefix.extra.try_parse(); + let mut found_merge_mining_tag = false; + for field in &extra.0 { + if let SubField::MergeMining(..) = field { + found_merge_mining_tag = true; + } + } + assert!(found_merge_mining_tag); + } + + fn get_max_hf_version(json: serde_json::Value) -> u64 { + json["result"]["hard_forks"] + .as_array() + .unwrap() + .iter() + .map(|hf| hf["hf_version"].as_u64().unwrap()) + .max() + .unwrap() + } + + #[test] + fn test_hf_version() { + let get_static = get_static_monerod_response(MonerodMethod::GetVersion, Some(123), None).unwrap(); + let max_hf_version = get_max_hf_version(get_static.body); + assert_eq!(max_hf_version, MAX_HF_VERSION); + } +} diff --git a/applications/minotari_merge_mining_proxy/src/proxy/utils.rs b/applications/minotari_merge_mining_proxy/src/proxy/utils.rs new file mode 100644 index 0000000000..c32158316a --- /dev/null +++ b/applications/minotari_merge_mining_proxy/src/proxy/utils.rs @@ -0,0 +1,149 @@ +// Copyright 2020, The Tari Project +// +// Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +// following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following +// disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the +// following disclaimer in the documentation and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote +// products derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +// USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +use bytes::Bytes; +use hyper::{Method, Request, Response}; +use minotari_app_grpc::tari_rpc; +use reqwest::ResponseBuilderExt; +use serde_json as json; +use serde_json::json; +use tari_utilities::hex::Hex; + +use crate::error::MmProxyError; + +/// The JSON object key name used for merge mining proxy response extensions +pub(crate) const MMPROXY_AUX_KEY_NAME: &str = "_aux"; + +pub async fn convert_reqwest_response_to_hyper_json_response( + resp: reqwest::Response, +) -> Result, MmProxyError> { + let mut builder = Response::builder(); + + let headers = builder + .headers_mut() + .expect("headers_mut errors only when the builder has an error (e.g invalid header value)"); + headers.extend(resp.headers().iter().map(|(name, value)| (name.clone(), value.clone()))); + + builder = builder + .version(resp.version()) + .status(resp.status()) + .url(resp.url().clone()); + + let body = resp.json().await.map_err(MmProxyError::MonerodRequestFailed)?; + let resp = builder.body(body)?; + Ok(resp) +} + +/// Add mmproxy extensions object to JSON RPC success response +pub fn add_aux_data(mut response: json::Value, mut ext: json::Value) -> json::Value { + if response["result"].is_null() { + return response; + } + match response["result"][MMPROXY_AUX_KEY_NAME].as_object_mut() { + Some(obj_mut) => { + let ext_mut = ext + .as_object_mut() + .expect("invalid parameter: expected `ext: json::Value` to be an object but it was not"); + obj_mut.append(ext_mut); + }, + None => { + response["result"][MMPROXY_AUX_KEY_NAME] = ext; + }, + } + response +} + +/// Append chain data to the result object. If the result object is null, a JSON object is created. +/// +/// ## Panics +/// +/// If response["result"] is not a JSON object type or null. +pub fn append_aux_chain_data(mut response: json::Value, chain_data: json::Value) -> json::Value { + let result = &mut response["result"]; + if result.is_null() { + *result = json!({}); + } + let chains = match result[MMPROXY_AUX_KEY_NAME]["chains"].as_array_mut() { + Some(arr_mut) => arr_mut, + None => { + result[MMPROXY_AUX_KEY_NAME]["chains"] = json!([]); + result[MMPROXY_AUX_KEY_NAME]["chains"].as_array_mut().unwrap() + }, + }; + + chains.push(chain_data); + response +} + +pub fn try_into_json_block_header(header: tari_rpc::BlockHeaderResponse) -> Result { + let tari_rpc::BlockHeaderResponse { + header, + reward, + confirmations, + difficulty, + num_transactions, + } = header; + let header = header.ok_or_else(|| { + MmProxyError::UnexpectedTariBaseNodeResponse( + "Base node GRPC returned an empty header field when calling get_header_by_hash".into(), + ) + })?; + + Ok(json!({ + "block_size": 0, + "depth": confirmations, + "difficulty": difficulty, + "hash": header.hash.to_hex(), + "height": header.height, + "major_version": header.version, + "minor_version": 0, + "nonce": header.nonce, + "num_txes": num_transactions, + // Cannot be an orphan + "orphan_status": false, + "prev_hash": header.prev_hash.to_hex(), + "reward": reward, + "timestamp": header.timestamp + })) +} + +/// Parse the method name from the request +pub fn parse_method_name(request: &Request) -> String { + match *request.method() { + Method::GET => { + let mut chars = request.uri().path().chars(); + chars.next(); + chars.as_str().to_string() + }, + Method::POST => { + let json = json::from_slice::(request.body()).unwrap_or_default(); + str::replace(json["method"].as_str().unwrap_or_default(), "\"", "") + }, + _ => "unsupported".to_string(), + } +} + +/// Convert a request with a Bytes body to a request with a json Value body +pub fn request_bytes_to_value(request: Request) -> Result, MmProxyError> { + let json = json::from_slice::(request.body())?; + Ok(request.map(move |_| json)) +} diff --git a/applications/minotari_merge_mining_proxy/src/run_merge_miner.rs b/applications/minotari_merge_mining_proxy/src/run_merge_miner.rs index d2b60d6ebc..1ad8d1d1bf 100644 --- a/applications/minotari_merge_mining_proxy/src/run_merge_miner.rs +++ b/applications/minotari_merge_mining_proxy/src/run_merge_miner.rs @@ -43,10 +43,10 @@ use tonic::transport::{Certificate, ClientTlsConfig, Endpoint}; use crate::{ block_template_data::BlockTemplateRepository, - config::MergeMiningProxyConfig, + config::{MergeMiningProxyConfig, MonerodFallback}, error::MmProxyError, monero_fail::{get_monerod_info, order_and_select_monerod_info, MonerodEntry}, - proxy::{MergeMiningProxyService, MONEROD_CONNECTION_TIMEOUT, NUMBER_OF_MONEROD_SERVERS}, + proxy::service::MergeMiningProxyService, Cli, }; @@ -59,47 +59,52 @@ pub async fn start_merge_miner(cli: Cli) -> Result<(), anyhow::Error> { let mut config = MergeMiningProxyConfig::load_from(&cfg)?; config.set_base_path(cli.common.get_base_path()); - // Get reputable monerod URLs - let mut assigned_dynamic_fail = false; - if config.use_dynamic_fail_data { - if let Ok(entries) = get_monerod_info( - NUMBER_OF_MONEROD_SERVERS, - MONEROD_CONNECTION_TIMEOUT, - &config.monero_fail_url, - ) - .await - { - if !entries.is_empty() { - let entries_len = entries.len(); - config.monerod_url = StringList::from(entries.into_iter().map(|entry| entry.url).collect::>()); - assigned_dynamic_fail = true; - debug!( - target: LOG_TARGET, - "Using {} vetted monerod servers from the Monero website at '{}'", - entries_len, config.monero_fail_url - ); + if config.monerod_fallback != MonerodFallback::StaticOnly { + // Get reputable monerod URLs + let mut assigned_dynamic_fail = false; + if config.use_dynamic_fail_data { + if let Ok(entries) = get_monerod_info( + NUMBER_OF_MONEROD_SERVERS, + config.monerod_connection_timeout, + &config.monero_fail_url, + ) + .await + { + if !entries.is_empty() { + let entries_len = entries.len(); + config.monerod_url = + StringList::from(entries.into_iter().map(|entry| entry.url).collect::>()); + assigned_dynamic_fail = true; + debug!( + target: LOG_TARGET, + "Using {} vetted monerod servers from the Monero website at '{}'", + entries_len, config.monero_fail_url + ); + } } } - } - if !assigned_dynamic_fail { - let mut entries = Vec::new(); - for url in config.monerod_url.clone().into_vec() { - entries.push(MonerodEntry { - url, - ..Default::default() - }); - } - if let Ok(entries) = - order_and_select_monerod_info(NUMBER_OF_MONEROD_SERVERS, MONEROD_CONNECTION_TIMEOUT, &entries).await - { - if !entries.is_empty() { - let entries_len = entries.len(); - config.monerod_url = StringList::from(entries.into_iter().map(|entry| entry.url).collect::>()); - debug!( - target: LOG_TARGET, - "Using {} vetted monerod servers from the config list'", - entries_len - ); + if !assigned_dynamic_fail { + let mut entries = Vec::new(); + for url in config.monerod_url.clone().into_vec() { + entries.push(MonerodEntry { + url, + ..Default::default() + }); + } + if let Ok(entries) = + order_and_select_monerod_info(NUMBER_OF_MONEROD_SERVERS, config.monerod_connection_timeout, &entries) + .await + { + if !entries.is_empty() { + let entries_len = entries.len(); + config.monerod_url = + StringList::from(entries.into_iter().map(|entry| entry.url).collect::>()); + debug!( + target: LOG_TARGET, + "Using {} vetted monerod servers from the config list'", + entries_len + ); + } } } } @@ -265,3 +270,5 @@ async fn connect_sha_p2pool(config: &MergeMiningProxyConfig) -> Result Result<(), Status> { if !self.is_method_enabled(method) { - warn!(target: LOG_TARGET, "`{}` method called but it is not allowed. Allow it in the config file or start the node with a different set of CLI options", method); + warn!( + target: LOG_TARGET, + "`{}` method called but it is not allowed. Allow it in the config file or start the node with a \ + different set of CLI options", + method + ); return Err(Status::permission_denied(format!( "`{}` method not made available", method diff --git a/base_layer/core/src/proof_of_work/monero_rx/helpers.rs b/base_layer/core/src/proof_of_work/monero_rx/helpers.rs index 7f57275e38..fb13e96aef 100644 --- a/base_layer/core/src/proof_of_work/monero_rx/helpers.rs +++ b/base_layer/core/src/proof_of_work/monero_rx/helpers.rs @@ -221,7 +221,7 @@ pub fn deserialize_monero_block_from_hex(data: T) -> Result { let bytes = hex::decode(data).map_err(|_| HexError::HexConversionError {})?; let obj = consensus::deserialize::(&bytes) - .map_err(|_| MergeMineError::ValidationError("blocktemplate blob invalid".to_string()))?; + .map_err(|e| MergeMineError::ValidationError(format!("blocktemplate blob invalid: {}", e)))?; Ok(obj) } diff --git a/common/config/presets/f_merge_mining_proxy.toml b/common/config/presets/f_merge_mining_proxy.toml index 61c0b40a7b..c841ba0dd9 100644 --- a/common/config/presets/f_merge_mining_proxy.toml +++ b/common/config/presets/f_merge_mining_proxy.toml @@ -110,5 +110,15 @@ # The Tari wallet address (valid address in hex) where the mining funds will be sent to - must be assigned # e.g. "78e724f466d202abdee0f23c261289074e4a2fc9eb61e83e0179eead76ce2d3f17" #wallet_payment_address = "YOUR_WALLET_TARI_ADDRESS" -# Range proof type - revealed_value or bullet_proof_plus: (default = "revealed_value") +# Range proof type - "revealed_value" or "bullet_proof_plus": (default = "revealed_value") #range_proof_type = "revealed_value" + +# Use p2pool to submit and get block templates (default = false) +#p2pool_enabled = false, + +# Monero fallback strategy - ["monerod_only", "static_when_monerod_fails" or "static_only"] +# (default = "static_when_monerod_fails") +#monerod_fallback = "static_when_monerod_fails" + +# The timeout duration for connecting to monerod (default = 2s) +#monerod_connection_timeout = 2