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