From af4fbc3104879ad3ec395fbdc2660bf74b4aee13 Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 21:19:04 -0300 Subject: [PATCH 01/19] JDS: plumb downstream id into validator API --- .../job_declaration_message_handler.rs | 18 +++++-- .../job_validation/bitcoin_core_ipc.rs | 14 +++-- .../lib/job_declarator/job_validation/mod.rs | 10 +++- .../jd-server/src/lib/job_declarator/mod.rs | 54 ++++++++++--------- .../job_declarator/token_management/mod.rs | 12 +++-- 5 files changed, 68 insertions(+), 40 deletions(-) diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs b/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs index 19a6bf1cf..2d35b1963 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs @@ -168,7 +168,7 @@ impl HandleJobDeclarationMessagesFromClientAsync for JobDeclarator { // validate job let response = match self .job_validator - .handle_declare_mining_job(msg.clone(), None) + .handle_declare_mining_job(client_id, msg.clone(), None) .await { // if job is valid, activate token and return DeclareMiningJobSuccess @@ -299,7 +299,11 @@ impl HandleJobDeclarationMessagesFromClientAsync for JobDeclarator { let response = match self .job_validator - .handle_declare_mining_job(pending_declare_mining_job.clone(), Some(msg.clone())) + .handle_declare_mining_job( + client_id, + pending_declare_mining_job.clone(), + Some(msg.clone()), + ) .await { // if job is valid, activate token and return DeclareMiningJobSuccess @@ -368,13 +372,19 @@ impl HandleJobDeclarationMessagesFromClientAsync for JobDeclarator { async fn handle_push_solution( &mut self, - _client_id: Option, + client_id: Option, msg: PushSolution<'_>, _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { info!("Received: {}", msg); - self.job_validator.handle_push_solution(msg).await; + // Shutdown: client_id is always Some; None indicates a bug. + let client_id = + client_id.ok_or_else(|| JDSError::shutdown(error::JDSErrorKind::ClientNotFound(0)))?; + + self.job_validator + .handle_push_solution(client_id, msg) + .await; Ok(()) } diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 307285a5a..17ea68e24 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -55,7 +55,7 @@ use stratum_apps::{ }, }, tp_type::BitcoinNetwork, - utils::types::{JdToken, RequestId}, + utils::types::{DownstreamId, JdToken, RequestId}, }; /// Snapshot of a previously declared mining job, stored after a `DeclareMiningJob` is @@ -432,6 +432,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { /// `DeclaredCustomJob` for later `SetCustomMiningJob` validation. async fn handle_declare_mining_job( &self, + _downstream_id: DownstreamId, declare_mining_job: DeclareMiningJob<'_>, provide_missing_transactions_success: Option>, ) -> DeclareMiningJobResult { @@ -636,7 +637,11 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } - async fn handle_push_solution(&self, push_solution: PushSolution<'_>) { + async fn handle_push_solution( + &self, + downstream_id: DownstreamId, + push_solution: PushSolution<'_>, + ) { // Convert to static lifetime for channel transfer let push_solution_static = push_solution.into_static(); @@ -646,9 +651,9 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { }; if let Err(e) = self.request_sender.send(request).await { - tracing::error!("Failed to send PushSolution request: {}", e); + tracing::error!(downstream_id, "Failed to send PushSolution request: {}", e); } else { - tracing::debug!("PushSolution request sent successfully"); + tracing::debug!(downstream_id, "PushSolution request sent successfully"); } } @@ -663,6 +668,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // DeclareMiningJob token. async fn handle_set_custom_mining_job( &self, + _downstream_id: DownstreamId, set_custom_mining_job: SetCustomMiningJob<'_>, allocated_token: JdToken, // Note: This is the corresponding DeclareMiningJob token ) -> SetCustomMiningJobResult { diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs index b95410d32..0fdb4d966 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs @@ -6,7 +6,7 @@ use stratum_apps::{ job_declaration_sv2::{DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution}, mining_sv2::SetCustomMiningJob, }, - utils::types::JdToken, + utils::types::{DownstreamId, JdToken}, }; pub mod bitcoin_core_ipc; @@ -30,17 +30,23 @@ pub trait JobValidationEngine: Send + Sync { /// Handles a declare mining job request. async fn handle_declare_mining_job( &self, + downstream_id: DownstreamId, declare_mining_job: DeclareMiningJob<'_>, provide_missing_transactions_success: Option>, ) -> DeclareMiningJobResult; /// Submits a mining solution to the backend. - async fn handle_push_solution(&self, push_solution: PushSolution<'_>); + async fn handle_push_solution( + &self, + downstream_id: DownstreamId, + push_solution: PushSolution<'_>, + ); /// Validates a `SetCustomMiningJob` (Mining Protocol) against the previously declared job /// identified by `allocated_token`. async fn handle_set_custom_mining_job( &self, + downstream_id: DownstreamId, set_custom_mining_job: SetCustomMiningJob<'_>, allocated_token: JdToken, ) -> SetCustomMiningJobResult; diff --git a/pool-apps/jd-server/src/lib/job_declarator/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/mod.rs index fdd8f4596..31c655de1 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/mod.rs @@ -438,38 +438,40 @@ impl JobDeclarator { }; // this allows JobValidationEngine to lookup the corresponding DeclareMiningJob - let allocated_token = match self.token_manager.allocated_from_active(active_token) { - Some(token) => { - debug!( - request_id, - channel_id, - active_token, - allocated_token = token, - "SetCustomMiningJob: active token mapped to allocated token" - ); - token - } - None => { - debug!( - request_id, - channel_id, - active_token, - "SetCustomMiningJob: active token not found in TokenManager" - ); - return Ok(SetCustomMiningJobResponse::error( - request_id, - channel_id, - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, - )); - } - }; + let (allocated_token, downstream_id) = + match self.token_manager.allocated_from_active(active_token) { + Some((token, downstream_id)) => { + debug!( + request_id, + channel_id, + active_token, + allocated_token = token, + downstream_id, + "SetCustomMiningJob: active token mapped to allocated token" + ); + (token, downstream_id) + } + None => { + debug!( + request_id, + channel_id, + active_token, + "SetCustomMiningJob: active token not found in TokenManager" + ); + return Ok(SetCustomMiningJobResponse::error( + request_id, + channel_id, + ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, + )); + } + }; // Clean up TokenManager self.token_manager.deactivate(active_token); match self .job_validator - .handle_set_custom_mining_job(set_custom_mining_job, allocated_token) + .handle_set_custom_mining_job(downstream_id, set_custom_mining_job, allocated_token) .await { SetCustomMiningJobResult::Success => { diff --git a/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs index 9933ebf31..11492b7dc 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs @@ -134,13 +134,17 @@ impl TokenManager { ); } - /// Returns the allocated token that corresponds to an active token. + /// Returns the allocated token and owning downstream that correspond to an active token. /// Returns `None` if the active token is not found. - pub fn allocated_from_active(&self, active_token: JdToken) -> Option { - let mapped = self.active_tokens.get(&active_token).map(|entry| entry.0); + pub fn allocated_from_active(&self, active_token: JdToken) -> Option<(JdToken, DownstreamId)> { + let mapped = self + .active_tokens + .get(&active_token) + .map(|entry| (entry.0, entry.2)); debug!( active_token, - mapped_allocated_token = mapped, + mapped_allocated_token = mapped.map(|(allocated, _)| allocated), + mapped_downstream_id = mapped.map(|(_, downstream_id)| downstream_id), found = mapped.is_some(), active_tokens_len = self.active_tokens.len(), allocated_tokens_len = self.allocated_tokens.len(), From 4518c49f2e18861798c51166c97f10402c9389b3 Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 21:20:36 -0300 Subject: [PATCH 02/19] JDS: scope bitcoin-core IPC state per downstream --- .../job_validation/bitcoin_core_ipc.rs | 189 +++++++++++------- 1 file changed, 119 insertions(+), 70 deletions(-) diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 17ea68e24..698d55195 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -7,8 +7,8 @@ use crate::{ ALLOCATED_TOKEN_TIMEOUT_SECS, JANITOR_INTERVAL_SECS, }, }; -use dashmap::DashMap; use std::{ + collections::HashMap, path::PathBuf, sync::{Arc, Mutex}, thread::JoinHandle, @@ -54,6 +54,7 @@ use stratum_apps::{ ERROR_CODE_SET_CUSTOM_MINING_JOB_STALE_CHAIN_TIP, }, }, + sync::SharedMap, tp_type::BitcoinNetwork, utils::types::{DownstreamId, JdToken, RequestId}, }; @@ -77,6 +78,12 @@ struct AllocatedTokenEntry { inserted_at: Instant, } +#[derive(Default)] +struct DownstreamState { + declared_custom_jobs: HashMap, + allocated_token_entries: HashMap, +} + #[cfg_attr(not(test), hotpath::measure_all)] impl DeclaredCustomJob { /// Returns the block version from the original `DeclareMiningJob`. @@ -214,8 +221,7 @@ impl DeclaredCustomJob { #[derive(Clone)] pub struct BitcoinCoreIPCEngine { request_sender: async_channel::Sender, - allocated_token_entries: Arc>, - declared_custom_jobs: Arc>, + downstream_state: SharedMap, cancellation_token: CancellationToken, jdp_thread_handle: Arc>>>, } @@ -350,14 +356,11 @@ impl BitcoinCoreIPCEngine { } } - let allocated_token_to_request_id = - Arc::new(DashMap::::new()); - let declared_custom_jobs = Arc::new(DashMap::::new()); + let downstream_state = SharedMap::::new(); // Spawn janitor task to clean up stale declared jobs that were never // consumed by SetCustomMiningJob. - let janitor_allocated_token_to_request_id = Arc::clone(&allocated_token_to_request_id); - let janitor_declared_custom_jobs = Arc::clone(&declared_custom_jobs); + let janitor_downstream_state = downstream_state.clone(); let janitor_cancellation = cancellation_token.clone(); tokio::spawn(async move { let janitor_interval = Duration::from_secs(JANITOR_INTERVAL_SECS); @@ -367,23 +370,33 @@ impl BitcoinCoreIPCEngine { _ = janitor_cancellation.cancelled() => break, _ = tokio::time::sleep(janitor_interval) => { let now = Instant::now(); - let expired_tokens: Vec = janitor_allocated_token_to_request_id - .iter() - .filter_map(|entry| { - let inserted_at = entry.value().inserted_at; - if now.saturating_duration_since(inserted_at) > token_timeout { - Some(*entry.key()) - } else { - None + janitor_downstream_state.for_each_mut(|downstream_id, state| { + let expired_tokens: Vec = state + .allocated_token_entries + .iter() + .filter_map(|(token, entry)| { + if now.saturating_duration_since(entry.inserted_at) + > token_timeout + { + Some(*token) + } else { + None + } + }) + .collect(); + + for token in expired_tokens { + if let Some(entry) = state.allocated_token_entries.remove(&token) { + state.declared_custom_jobs.remove(&entry.request_id); + tracing::debug!( + downstream_id, + token, + request_id = entry.request_id, + "Removed expired declared custom job state" + ); } - }) - .collect(); - - for token in expired_tokens { - if let Some((_, entry)) = janitor_allocated_token_to_request_id.remove(&token) { - janitor_declared_custom_jobs.remove(&entry.request_id); } - } + }); } } } @@ -391,8 +404,7 @@ impl BitcoinCoreIPCEngine { Ok(Self { request_sender, - allocated_token_entries: allocated_token_to_request_id, - declared_custom_jobs, + downstream_state, cancellation_token, jdp_thread_handle: Arc::new(Mutex::new(Some(jdp_thread_handle))), }) @@ -432,7 +444,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { /// `DeclaredCustomJob` for later `SetCustomMiningJob` validation. async fn handle_declare_mining_job( &self, - _downstream_id: DownstreamId, + downstream_id: DownstreamId, declare_mining_job: DeclareMiningJob<'_>, provide_missing_transactions_success: Option>, ) -> DeclareMiningJobResult { @@ -514,9 +526,14 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { let previous_pending_validation_context = provide_missing_transactions_success.as_ref().and_then(|_| { - self.declared_custom_jobs - .get(&declare_mining_job.request_id) - .map(|job| job.validation_context) + self.downstream_state + .with(&downstream_id, |state| { + state + .declared_custom_jobs + .get(&declare_mining_job.request_id) + .map(|job| job.validation_context) + }) + .flatten() }); // Create oneshot channel for response @@ -565,24 +582,31 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { txid_list: Some(txid_list), validated: true, }; - self.declared_custom_jobs - .insert(declare_mining_job.request_id, declared_custom_job); - self.allocated_token_entries.insert( - allocated_token, - AllocatedTokenEntry { - request_id: declare_mining_job.request_id, - inserted_at: Instant::now(), - }, - ); + self.downstream_state + .with_mut_or_default(downstream_id, |state| { + state + .declared_custom_jobs + .insert(declare_mining_job.request_id, declared_custom_job); + state.allocated_token_entries.insert( + allocated_token, + AllocatedTokenEntry { + request_id: declare_mining_job.request_id, + inserted_at: Instant::now(), + }, + ); + }); DeclareMiningJobResult::Success } JdResponse::Error { error_code, validation_context, } => { - self.declared_custom_jobs - .remove(&declare_mining_job.request_id); - self.allocated_token_entries.remove(&allocated_token); + self.downstream_state.with_mut(&downstream_id, |state| { + state + .declared_custom_jobs + .remove(&declare_mining_job.request_id); + state.allocated_token_entries.remove(&allocated_token); + }); let tip_drifted = previous_pending_validation_context .map(|previous_ctx| { @@ -609,9 +633,12 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // If this is a retry after ProvideMissingTransactionsSuccess and context drifted, // classify as stale-chain-tip instead of asking for yet another missing-txs round. if provide_missing_transactions_success.is_some() && tip_drifted { - self.declared_custom_jobs - .remove(&declare_mining_job.request_id); - self.allocated_token_entries.remove(&allocated_token); + self.downstream_state.with_mut(&downstream_id, |state| { + state + .declared_custom_jobs + .remove(&declare_mining_job.request_id); + state.allocated_token_entries.remove(&allocated_token); + }); DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP) } else { @@ -621,15 +648,19 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { txid_list: None, validated: false, // this is only set to true on JdResponse::Success }; - self.declared_custom_jobs - .insert(declare_mining_job.request_id, declared_custom_job); - self.allocated_token_entries.insert( - allocated_token, - AllocatedTokenEntry { - request_id: declare_mining_job.request_id, - inserted_at: Instant::now(), - }, - ); + self.downstream_state + .with_mut_or_default(downstream_id, |state| { + state + .declared_custom_jobs + .insert(declare_mining_job.request_id, declared_custom_job); + state.allocated_token_entries.insert( + allocated_token, + AllocatedTokenEntry { + request_id: declare_mining_job.request_id, + inserted_at: Instant::now(), + }, + ); + }); DeclareMiningJobResult::MissingTransactions(missing_wtxids) } @@ -668,17 +699,27 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // DeclareMiningJob token. async fn handle_set_custom_mining_job( &self, - _downstream_id: DownstreamId, + downstream_id: DownstreamId, set_custom_mining_job: SetCustomMiningJob<'_>, allocated_token: JdToken, // Note: This is the corresponding DeclareMiningJob token ) -> SetCustomMiningJobResult { // Look up request_id using the allocated token - let request_id = match self.allocated_token_entries.get(&allocated_token) { - Some(entry) => entry.request_id, + let request_id = match self + .downstream_state + .with(&downstream_id, |state| { + state + .allocated_token_entries + .get(&allocated_token) + .map(|entry| entry.request_id) + }) + .flatten() + { + Some(request_id) => request_id, None => { tracing::debug!( - "Provided token {} is not associated with any DeclareMiningJob request", - allocated_token + downstream_id, + allocated_token, + "Provided token is not associated with any DeclareMiningJob request" ); return SetCustomMiningJobResult::Error( ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, @@ -686,18 +727,26 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } }; - // Clean up immediately - the job is being consumed regardless of validation result - self.allocated_token_entries.remove(&allocated_token); - - let declared_custom_job = { - match self.declared_custom_jobs.remove(&request_id) { - Some((_request_id, declared_custom_job)) => declared_custom_job, - None => { - tracing::debug!("DeclaredCustomJob associated with allocated token {} and request id {} not found", allocated_token, request_id); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, - ); - } + let declared_custom_job = match self + .downstream_state + .with_mut(&downstream_id, |state| { + // Clean up immediately - the job is being consumed regardless of validation result. + state.allocated_token_entries.remove(&allocated_token); + state.declared_custom_jobs.remove(&request_id) + }) + .flatten() + { + Some(declared_custom_job) => declared_custom_job, + None => { + tracing::debug!( + downstream_id, + allocated_token, + request_id, + "DeclaredCustomJob associated with allocated token and request id not found" + ); + return SetCustomMiningJobResult::Error( + ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, + ); } }; From 771be54c8a81605e4cf6bedb537bf19787180ca1 Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 21:21:32 -0300 Subject: [PATCH 03/19] JDS eagerly cleans up downstream-owned state --- pool-apps/jd-server/README.md | 2 +- .../job_declarator/job_validation/bitcoin_core_ipc.rs | 4 ++++ .../src/lib/job_declarator/job_validation/mod.rs | 5 +++++ pool-apps/jd-server/src/lib/job_declarator/mod.rs | 1 + .../src/lib/job_declarator/token_management/mod.rs | 9 +++++---- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/pool-apps/jd-server/README.md b/pool-apps/jd-server/README.md index 7e7044b50..40904be06 100644 --- a/pool-apps/jd-server/README.md +++ b/pool-apps/jd-server/README.md @@ -40,4 +40,4 @@ Active tokens are single-use. During `SetCustomMiningJob` handling, the `JobDecl Malformed, unknown, expired, wrong-owner, or already consumed tokens are rejected with `invalid-mining-job-token`. -Allocated and active tokens are periodically removed by a janitor task after their configured timeouts. When a downstream disconnects, its allocated tokens are removed immediately because they only authorize future `DeclareMiningJob` messages from that JDS connection. Active tokens are retained until they are consumed or expire, because they authorize a later `SetCustomMiningJob` sent through the Pool's Mining Protocol path, not through the JDS connection. +Allocated and active tokens are periodically removed by a janitor task after their configured timeouts. When a downstream disconnects, both allocated and active tokens owned by that downstream are removed immediately. diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 698d55195..080b4a217 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -434,6 +434,10 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } + fn cleanup_downstream(&self, downstream_id: DownstreamId) { + self.downstream_state.remove(&downstream_id); + } + /// Validates a `DeclareMiningJob` by forwarding it to Bitcoin Core over IPC. /// /// Steps: diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs index 0fdb4d966..39065ff0d 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs @@ -51,6 +51,11 @@ pub trait JobValidationEngine: Send + Sync { allocated_token: JdToken, ) -> SetCustomMiningJobResult; + /// Removes validation state associated with a downstream connection. + /// + /// Called by [`crate::job_declarator::JobDeclarator`] when a downstream disconnects. + fn cleanup_downstream(&self, _downstream_id: DownstreamId) {} + /// Performs backend-specific shutdown work. /// /// Default implementation is a no-op so non-threaded engines do not need to diff --git a/pool-apps/jd-server/src/lib/job_declarator/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/mod.rs index 31c655de1..5f8b1f9ca 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/mod.rs @@ -361,6 +361,7 @@ impl JobDeclarator { .remove(&downstream_id) .is_some(); + self.job_validator.cleanup_downstream(downstream_id); self.token_manager.remove_downstream(downstream_id); debug!( diff --git a/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs index 11492b7dc..756a18089 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs @@ -160,15 +160,15 @@ impl TokenManager { } /// Removes allocated tokens belonging to a given downstream. - /// - /// Active tokens are intentionally retained here and can still be consumed by - /// `SetCustomMiningJob` or evicted later by the janitor timeout. + /// Also removes active tokens that were activated by the same downstream. pub fn remove_downstream(&self, downstream_id: DownstreamId) { let allocated_tokens_before = self.allocated_tokens.len(); let active_tokens_before = self.active_tokens.len(); self.allocated_tokens .retain(|_, (_, owner)| *owner != downstream_id); + self.active_tokens + .retain(|_, (_, _, owner)| *owner != downstream_id); let allocated_tokens_after = self.allocated_tokens.len(); let active_tokens_after = self.active_tokens.len(); @@ -178,11 +178,12 @@ impl TokenManager { downstream_id, removed_allocated_tokens = allocated_tokens_before.saturating_sub(allocated_tokens_after), + removed_active_tokens = active_tokens_before.saturating_sub(active_tokens_after), allocated_tokens_before, allocated_tokens_after, active_tokens_before, active_tokens_after, - "TokenManager: removed downstream-allocated tokens and retained active tokens" + "TokenManager: removed downstream tokens" ); } From 7b19a8fff468d491f81001571d5060d9bf1d7fe9 Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 21:24:44 -0300 Subject: [PATCH 04/19] add integration test to cover JDS colliding request-id isolation --- .../tests/jds_downstream_state_isolation.rs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 integration-tests/tests/jds_downstream_state_isolation.rs diff --git a/integration-tests/tests/jds_downstream_state_isolation.rs b/integration-tests/tests/jds_downstream_state_isolation.rs new file mode 100644 index 000000000..04266bb55 --- /dev/null +++ b/integration-tests/tests/jds_downstream_state_isolation.rs @@ -0,0 +1,167 @@ +use integration_tests_sv2::{ + interceptor::MessageDirection, + mock_roles::{MockDownstream, WithSetup}, + sniffer::Sniffer, + template_provider::DifficultyLevel, + *, +}; +use stratum_apps::stratum_core::{ + common_messages_sv2::Protocol, + job_declaration_sv2::*, + mining_sv2::*, + parsers_sv2::{AnyMessage, JobDeclaration, Mining}, +}; + +#[tokio::test] +// Regression coverage for https://github.com/stratum-mining/sv2-apps/issues/590 +// +// Rationale: +// - DeclareMiningJob.request_id is scoped to a single downstream connection. +// - The prior implementation in JDS stored declaration state in a global map keyed only by +// request_id, so two different downstreams reusing the same request_id could overwrite each +// other. +// - That cross-downstream state collision could surface as SetCustomMiningJobError in one flow, +// even though both downstreams followed valid DeclareMiningJob/SetCustomMiningJob sequences. +// +// This test intentionally drives two independent downstreams to collide on request_id and then +// asserts both flows complete without any SetCustomMiningJobError, proving JDS state isolation by +// downstream. +async fn jds_isolates_state_for_colliding_request_ids_across_downstreams() { + start_tracing(); + + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + let (pool, pool_addr, jds_addr, _) = + start_pool_with_jds(tp.bitcoin_core(), vec![], vec![], false).await; + + let (jdc1_jds_sniffer, jdc1_jds_sniffer_addr) = + start_sniffer("jdc1-jds", jds_addr, false, vec![], None); + let (jdc2_jds_sniffer, jdc2_jds_sniffer_addr) = + start_sniffer("jdc2-jds", jds_addr, false, vec![], None); + let (jdc1_pool_sniffer, jdc1_pool_sniffer_addr) = + start_sniffer("jdc1-pool", pool_addr, false, vec![], None); + let (jdc2_pool_sniffer, jdc2_pool_sniffer_addr) = + start_sniffer("jdc2-pool", pool_addr, false, vec![], None); + + let (jdc1, jdc1_addr, _) = start_jdc( + &[(jdc1_pool_sniffer_addr, jdc1_jds_sniffer_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + false, + None, + ); + let (jdc2, jdc2_addr, _) = start_jdc( + &[(jdc2_pool_sniffer_addr, jdc2_jds_sniffer_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + false, + None, + ); + + // Attach one mock mining downstream per JDC so both JDCs open mining channels and + // independently trigger the DeclareMiningJob/SetCustomMiningJob flow against the same JDS. + let send_to_jdc1 = MockDownstream::new( + jdc1_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + let send_to_jdc2 = MockDownstream::new( + jdc2_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + + // Trigger both JDCs to start job declaration for their mining channel. + send_to_jdc1 + .send(AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 1, + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 0, + }, + ))) + .await + .unwrap(); + send_to_jdc2 + .send(AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 1, + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 0, + }, + ))) + .await + .unwrap(); + + // Wait until both DeclareMiningJob messages are observed, then assert request_id collision. + jdc1_jds_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_DECLARE_MINING_JOB, + ) + .await; + jdc2_jds_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_DECLARE_MINING_JOB, + ) + .await; + + let jdc1_request_id = next_declare_mining_job_request_id(&jdc1_jds_sniffer); + let jdc2_request_id = next_declare_mining_job_request_id(&jdc2_jds_sniffer); + assert_eq!( + jdc1_request_id, jdc2_request_id, + "the regression test expects both downstreams to collide on request_id" + ); + + // Both downstream flows must complete without token-crossing failures. + jdc1_pool_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_SUCCESS, + ) + .await; + jdc2_pool_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_SUCCESS, + ) + .await; + + assert_no_set_custom_mining_job_error(&jdc1_pool_sniffer); + assert_no_set_custom_mining_job_error(&jdc2_pool_sniffer); + + jdc1.shutdown().await; + jdc2.shutdown().await; + pool.shutdown().await; +} + +// Returns the first DeclareMiningJob request_id observed from a given sniffer. +fn next_declare_mining_job_request_id(sniffer: &Sniffer<'_>) -> u32 { + loop { + if let Some((_, AnyMessage::JobDeclaration(JobDeclaration::DeclareMiningJob(msg)))) = + sniffer.next_message_from_downstream() + { + return msg.request_id; + } + } +} + +// Scans captured downstream responses and ensures no SetCustomMiningJobError is emitted. +fn assert_no_set_custom_mining_job_error(sniffer: &Sniffer<'_>) { + while let Some((_, message)) = sniffer.next_message_from_upstream() { + if let AnyMessage::Mining(Mining::SetCustomMiningJobError(msg)) = message { + panic!( + "unexpected SetCustomMiningJobError while validating colliding request_id flow: {}", + msg.error_code.as_utf8_or_hex() + ); + } + } +} From 38f291d80b9749676dc7efa26873c955819063f0 Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 17:43:37 -0300 Subject: [PATCH 05/19] prepare JDP responses for full-block submitBlock support Bitcoin Core submitBlock requires a fully assembled block, so JDS must retain non-coinbase transaction bodies from DeclareMiningJob validation. As a prerequisite for upcoming PushSolution handling with v32 IPC support, JdResponse::Success now carries txdata and no longer returns redundant txid_list. JDS derives txids directly from txdata when validating merkle-root/merkle-path consistency for SetCustomMiningJob, without storing an extra txid cache in DeclaredCustomJob. This addresses an earlier design blindspot and does not change v30/v31 validation behavior. --- .../src/common/job_declaration_protocol/io.rs | 11 +++++---- .../v30x/job_declaration_protocol/handlers.rs | 6 ++--- .../template_data.rs | 2 +- .../v31x/job_declaration_protocol/handlers.rs | 6 ++--- .../template_data.rs | 2 +- .../tests/bitcoin_core_ipc_jdp_io.rs | 6 ++--- .../job_validation/bitcoin_core_ipc.rs | 24 +++++++++---------- 7 files changed, 29 insertions(+), 28 deletions(-) diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs index 8155a7dc8..54d69b152 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs @@ -1,7 +1,7 @@ //! Request / response types exchanged between `jd-server` and the Bitcoin Core IPC thread. use stratum_core::{ - bitcoin::{BlockHash, CompactTarget, Transaction, Txid, Wtxid, block::Version}, + bitcoin::{BlockHash, CompactTarget, Transaction, Wtxid, block::Version}, job_declaration_sv2::PushSolution, }; use tokio::sync::oneshot; @@ -46,10 +46,11 @@ pub enum JdResponse { prev_hash: BlockHash, nbits: CompactTarget, min_ntime: u32, - /// Txids for all transactions (excluding coinbase), in the same order as the declared - /// wtxid_list. Enables the caller to build the txid merkle tree for validating - /// SetCustomMiningJob.merkle_path. - txid_list: Vec, + /// Full non-coinbase transaction list in declaration order. + /// + /// This is used by `jd-server` both to reconstruct solved blocks when handling + /// `PushSolution` and to derive txids for merkle-root/merkle-path validation. + txdata: Vec, }, Error { error_code: &'static str, diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs index 430d5257c..4d9c59d64 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs @@ -8,7 +8,7 @@ use crate::{ }; use stratum_core::{ bitcoin::{ - Block, Transaction, TxMerkleNode, Txid, Wtxid, + Block, Transaction, TxMerkleNode, Wtxid, block::{Header, Version}, consensus::serialize, hashes::Hash, @@ -111,7 +111,7 @@ impl BitcoinCoreSv2JDP { (initial_validation_context, initial_bip34_height, txdata) }; // mempool_mirror dropped here, we don't want to hold it across await points - let txid_list: Vec = txdata.iter().map(|tx| tx.compute_txid()).collect(); + let txdata_for_response = txdata.clone(); let valid_job = { let mut all_transactions = Vec::with_capacity(1 + txdata.len()); @@ -269,7 +269,7 @@ impl BitcoinCoreSv2JDP { prev_hash: initial_validation_context.prev_hash, nbits: initial_validation_context.nbits, min_ntime: initial_validation_context.min_ntime, - txid_list, + txdata: txdata_for_response, } } else { let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs index 2d46c0dd7..08ce374d2 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs @@ -244,7 +244,7 @@ impl TemplateData { thread_map: ThreadMapIpcClient, path_dir: &Path, ) -> Result<(), TemplateDataError> { - let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.to_owned_bytes(); + let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.as_ref().to_vec(); let solution_coinbase_tx: Transaction = deserialize(&solution_coinbase_tx_bytes).map_err(|e| { diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs index 4d4e0a821..75699a17b 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs @@ -8,7 +8,7 @@ use crate::{ }; use stratum_core::{ bitcoin::{ - Block, Transaction, TxMerkleNode, Txid, Wtxid, + Block, Transaction, TxMerkleNode, Wtxid, block::{Header, Version}, consensus::serialize, hashes::Hash, @@ -111,7 +111,7 @@ impl BitcoinCoreSv2JDP { (initial_validation_context, initial_bip34_height, txdata) }; // mempool_mirror dropped here, we don't want to hold it across await points - let txid_list: Vec = txdata.iter().map(|tx| tx.compute_txid()).collect(); + let txdata_for_response = txdata.clone(); let valid_job = { let mut all_transactions = Vec::with_capacity(1 + txdata.len()); @@ -284,7 +284,7 @@ impl BitcoinCoreSv2JDP { prev_hash: initial_validation_context.prev_hash, nbits: initial_validation_context.nbits, min_ntime: initial_validation_context.min_ntime, - txid_list, + txdata: txdata_for_response, } } else { let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs index 87a7bf74b..c19d7ad0d 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs @@ -246,7 +246,7 @@ impl TemplateData { thread_map: ThreadMapIpcClient, path_dir: &Path, ) -> Result<(), TemplateDataError> { - let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.to_owned_bytes(); + let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.as_ref().to_vec(); let solution_coinbase_tx: Transaction = deserialize(&solution_coinbase_tx_bytes).map_err(|e| { diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index dc5eb573d..0a84410df 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -144,10 +144,10 @@ async fn assert_jdp_success_scenario( .await; match response { - JdResponse::Success { txid_list, .. } => { + JdResponse::Success { txdata, .. } => { assert!( - txid_list.is_empty(), - "txid_list should be empty when no non-coinbase txs were declared" + txdata.is_empty(), + "txdata should be empty when no non-coinbase txs were declared" ); } response => panic!("expected Success, got: {response:?}"), diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 080b4a217..2cacc73f3 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -29,7 +29,7 @@ use stratum_apps::{ block::Version, consensus::{Decodable, Encodable}, hashes::Hash, - BlockHash, CompactTarget, Transaction, TxMerkleNode, Txid, Wtxid, + BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, }, job_declaration_sv2::{ DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution, @@ -68,7 +68,7 @@ use stratum_apps::{ struct DeclaredCustomJob { declare_mining_job: DeclareMiningJob<'static>, validation_context: ValidationContext, // committed at the time we receive DeclareMiningJob - txid_list: Option>, // populated only on JdResponse::Success + txdata: Option>, // populated only on JdResponse::Success validated: bool, } @@ -160,12 +160,12 @@ impl DeclaredCustomJob { /// Returns the sibling hashes at each level from leaf to root, needed to /// reconstruct the block header's merkle root from the coinbase position (index 0). /// - /// Requires `txid_list` to have been populated via `JdResponse::Success`. + /// Requires `txdata` to have been populated via `JdResponse::Success`. /// The coinbase txid is derived from the declared coinbase prefix/suffix. /// /// Used to compare with a `SetCustomMiningJob.merkle_path`. /// - /// Internally, errors may come from missing txid_list + /// Internally, errors may come from missing txdata /// so error_code = "declared-job-not-yet-validated" /// therefore () error type is sufficient. fn get_merkle_path(&self) -> Result, ()> { @@ -173,17 +173,17 @@ impl DeclaredCustomJob { return Err(()); } - let txid_list = self.txid_list.as_ref().ok_or(())?; + let txdata = self.txdata.as_ref().ok_or(())?; let coinbase_tx = self .get_coinbase_tx() .expect("coinbase tx already validated"); let coinbase_txid: TxMerkleNode = coinbase_tx.compute_txid().into(); - let mut hashes: Vec = Vec::with_capacity(1 + txid_list.len()); + let mut hashes: Vec = Vec::with_capacity(1 + txdata.len()); hashes.push(coinbase_txid); - for txid in txid_list { - hashes.push((*txid).into()); + for tx in txdata { + hashes.push(tx.compute_txid().into()); } if hashes.len() == 1 { @@ -476,7 +476,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { * validation */ min_ntime: 0, // irrelevant for coinbase tx validation }, - txid_list: None, // irrelevant for coinbase tx validation + txdata: None, // irrelevant for coinbase tx validation validated: false, // irrelevant for coinbase tx validation }; @@ -574,7 +574,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { prev_hash, nbits, min_ntime, - txid_list, + txdata, } => { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, @@ -583,7 +583,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { nbits, min_ntime, }, - txid_list: Some(txid_list), + txdata: Some(txdata), validated: true, }; self.downstream_state @@ -649,7 +649,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, validation_context, - txid_list: None, + txdata: None, validated: false, // this is only set to true on JdResponse::Success }; self.downstream_state From 9860375276528def9e3f707b0adebd8e69df8d36 Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 17:44:56 -0300 Subject: [PATCH 06/19] add v32 IPC runtime and align JDP wiring across versions Add a full v32x unix-capnp runtime for both Job Declaration Protocol and Template Distribution Protocol, and wire V32X through common enum dispatch and version selection. Include the v32 bitcoin-capnp-types dependency pin, lockfile updates, and docs/examples updates so version 32 is discoverable and selectable. Also align shared JDP interfaces across versions by switching JdRequest::PushSolution to carry a fully assembled Block. v30/v31 handlers are updated to the new request shape for consistency, while remaining functionally unchanged (PushSolution stays unimplemented there because those IPC schemas do not expose submitBlock). --- bitcoin-core-sv2/Cargo.toml | 4 + bitcoin-core-sv2/README.md | 4 +- bitcoin-core-sv2/examples/tdp_logger_v32x.rs | 178 ++++ .../src/common/job_declaration_protocol/io.rs | 11 +- .../common/job_declaration_protocol/mod.rs | 15 +- bitcoin-core-sv2/src/common/mod.rs | 3 + .../template_distribution_protocol/mod.rs | 17 +- bitcoin-core-sv2/src/lib.rs | 1 + bitcoin-core-sv2/src/unix_capnp/mod.rs | 1 + .../v30x/job_declaration_protocol/handlers.rs | 8 +- .../v30x/job_declaration_protocol/mod.rs | 4 +- .../v31x/job_declaration_protocol/handlers.rs | 8 +- .../v31x/job_declaration_protocol/mod.rs | 4 +- .../v32x/job_declaration_protocol/error.rs | 38 + .../v32x/job_declaration_protocol/handlers.rs | 427 +++++++++ .../v32x/job_declaration_protocol/mempool.rs | 126 +++ .../v32x/job_declaration_protocol/mod.rs | 481 ++++++++++ .../v32x/job_declaration_protocol/monitors.rs | 158 ++++ bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs | 10 + .../template_distribution_protocol/error.rs | 130 +++ .../handlers.rs | 234 +++++ .../template_distribution_protocol/mod.rs | 819 ++++++++++++++++++ .../monitors.rs | 291 +++++++ .../template_data.rs | 410 +++++++++ integration-tests/Cargo.lock | 13 +- integration-tests/lib/template_provider.rs | 1 + miner-apps/Cargo.lock | 13 +- pool-apps/Cargo.lock | 13 +- .../job_validation/bitcoin_core_ipc.rs | 21 +- stratum-apps/Cargo.lock | 13 +- stratum-apps/src/tp_type.rs | 10 +- 31 files changed, 3429 insertions(+), 37 deletions(-) create mode 100644 bitcoin-core-sv2/examples/tdp_logger_v32x.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs diff --git a/bitcoin-core-sv2/Cargo.toml b/bitcoin-core-sv2/Cargo.toml index fc9c065d0..14a8aebcb 100644 --- a/bitcoin-core-sv2/Cargo.toml +++ b/bitcoin-core-sv2/Cargo.toml @@ -23,6 +23,10 @@ bitcoin_capnp_types_v30 = { package = "bitcoin-capnp-types", version = "0.1.2" } # Bitcoin Core v31.x IPC dependencies bitcoin_capnp_types_v31 = { package = "bitcoin-capnp-types", version = "0.2.1" } +# todo: fetch from crates.io +# Bitcoin Core v32.x IPC dependencies +bitcoin_capnp_types_v32 = { package = "bitcoin-capnp-types", git = "https://github.com/2140-dev/bitcoin-capnp-types.git", rev = "6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" } + # fetching from github enables synchronizing development workflows across sv2-apps and stratum repos # it MUST be changed before bitcoin-core-sv2 is published to crates.io # with the proper version of stratum-core being fetched from crates.io as well diff --git a/bitcoin-core-sv2/README.md b/bitcoin-core-sv2/README.md index fb6463959..16c620b29 100644 --- a/bitcoin-core-sv2/README.md +++ b/bitcoin-core-sv2/README.md @@ -8,11 +8,12 @@ A Rust library that integrates [Bitcoin Core](https://bitcoin.org/en/bitcoin-cor - building Sv2 applications that act as a Client under the Template Distribution Protocol (e.g.: Pool or JDC) while connecting directly to the Bitcoin Core node. - building a Sv2 Template Provider application that acts as a Template Distribution Protocol Server while creating templates from a Bitcoin Core node. -The crate exposes three module families: +The crate exposes four module families: - `bitcoin_core_sv2::common` - version-agnostic enum-dispatch runtime handles and protocol-specific `new(version, ...)` factories. - `bitcoin_core_sv2::unix_capnp::v30x` - Bitcoin Core v30.x IPC implementation. - `bitcoin_core_sv2::unix_capnp::v31x` - Bitcoin Core v31.x IPC implementation. +- `bitcoin_core_sv2::unix_capnp::v32x` - Bitcoin Core v32.x IPC implementation. ### Flavor naming rationale @@ -59,6 +60,7 @@ The `min_interval` parameter (in seconds) determines the minimum amount of time - `tdp_logger_v30x` - Template Distribution Protocol logger wired to `bitcoin_core_sv2::unix_capnp::v30x`. - `tdp_logger_v31x` - Template Distribution Protocol logger wired to `bitcoin_core_sv2::unix_capnp::v31x`. +- `tdp_logger_v32x` - Template Distribution Protocol logger wired to `bitcoin_core_sv2::unix_capnp::v32x`. ## License diff --git a/bitcoin-core-sv2/examples/tdp_logger_v32x.rs b/bitcoin-core-sv2/examples/tdp_logger_v32x.rs new file mode 100644 index 000000000..5097d1c1d --- /dev/null +++ b/bitcoin-core-sv2/examples/tdp_logger_v32x.rs @@ -0,0 +1,178 @@ +//! A simple example of how to use `BitcoinCoreSv2TDP`. +//! +//! This example demonstrates the pattern used in applications where `BitcoinCoreSv2TDP` is +//! spawned in a dedicated thread with its own Tokio runtime and `LocalSet`. This allows the +//! main application to run in a separate async context while `BitcoinCoreSv2TDP` runs in its +//! own isolated thread. +//! +//! We connect to the Bitcoin Core UNIX socket, and log the received Sv2 Template Distribution +//! Protocol messages. +//! +//! We send a `CoinbaseOutputConstraints` message to the `BitcoinCoreSv2TDP` instance once at +//! startup. +//! +//! `BitcoinCoreSv2TDP` will not start distributing new templates until it receives the first +//! `CoinbaseOutputConstraints` message. + +use bitcoin_core_sv2::unix_capnp::v32x::template_distribution_protocol::BitcoinCoreSv2TDP; +use std::path::Path; + +use async_channel::unbounded; +use stratum_core::{ + parsers_sv2::TemplateDistribution, + template_distribution_sv2::{CoinbaseOutputConstraints, RequestTransactionData}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + // the user must provide the path to the Bitcoin Core UNIX socket + let args: Vec = std::env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} /path/to/bitcoin/regtest/node.sock", args[0]); + std::process::exit(1); + } + + let bitcoin_core_unix_socket_path = Path::new(&args[1]); + + // `BitcoinCoreSv2TDP` uses this to cancel internally spawned tasks + let cancellation_token = CancellationToken::new(); + + // get new templates whenever the mempool has changed by more than 100 sats + let fee_threshold = 100; + + // the minimum interval between template updates in seconds + let min_interval = 5; + + // these messages are sent into the `BitcoinCoreSv2TDP` instance + let (msg_sender_into_bitcoin_core_sv2, msg_receiver_into_bitcoin_core_sv2) = unbounded(); + // these messages are received from the `BitcoinCoreSv2TDP` instance + let (msg_sender_from_bitcoin_core_sv2, msg_receiver_from_bitcoin_core_sv2) = unbounded(); + + // clone so we can move it into the thread + let cancellation_token_clone = cancellation_token.clone(); + let bitcoin_core_unix_socket_path_clone = bitcoin_core_unix_socket_path.to_path_buf(); + + // spawn a dedicated thread to run the BitcoinCoreSv2TDP instance + // because we're limited to tokio::task::LocalSet + // + // please note that it's important to keep a reference to the join handle so we can wait for it + // to finish shutdown this will no longer be a pre-requisite once https://github.com/bitcoin/bitcoin/pull/33676 lands in a release + // see https://github.com/stratum-mining/sv2-apps/issues/81 for more details + let join_handle = std::thread::spawn(move || { + // we need a dedicated runtime so we can spawn an async task inside the LocalSet + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + error!("Failed to create Tokio runtime: {:?}", e); + cancellation_token_clone.cancel(); + return; + } + }; + let tokio_local_set = tokio::task::LocalSet::new(); + + tokio_local_set.block_on(&rt, async move { + // create a new `BitcoinCoreSv2TDP` instance + let mut sv2_bitcoin_core = match BitcoinCoreSv2TDP::new( + &bitcoin_core_unix_socket_path_clone, + fee_threshold, + min_interval, + msg_receiver_into_bitcoin_core_sv2, + msg_sender_from_bitcoin_core_sv2, + cancellation_token_clone.clone(), + ) + .await + { + Ok(sv2_bitcoin_core) => sv2_bitcoin_core, + Err(e) => { + error!("Failed to create BitcoinCoreToSv2: {:?}", e); + cancellation_token_clone.cancel(); + return; + } + }; + + // run the `BitcoinCoreSv2TDP` instance, which will block until the cancellation token + // is activated + sv2_bitcoin_core.run().await; + }); + }); + + // clone so we can move it + let cancellation_token_clone = cancellation_token.clone(); + + // clone so we can move it + let msg_sender_into_bitcoin_core_sv2_clone = msg_sender_into_bitcoin_core_sv2.clone(); + + // a task to consume and log the received Sv2 Template Distribution Protocol messages + tokio::spawn(async move { + loop { + tokio::select! { + // monitor for Ctrl+C, activating the cancellation token and exiting the loop + _ = tokio::signal::ctrl_c() => { + info!("Ctrl+C received"); + cancellation_token_clone.cancel(); + return; + } + // monitor potential internal activations of the cancellation token for exiting the loop + _ = cancellation_token_clone.cancelled() => { + info!("Cancellation token activated"); + return; + } + // monitor for Sv2 Template Distribution Protocol messages + // coming from `BitcoinCoreSv2TDP` + Ok(template_distribution_message) = msg_receiver_from_bitcoin_core_sv2.recv() => { + // log the message + info!("Message received: {}", template_distribution_message); + + // send a RequestTransactionData every time a NewTemplate message is received + if let TemplateDistribution::NewTemplate(new_template) = template_distribution_message { + let template_id = new_template.template_id; + let request_transaction_data = TemplateDistribution::RequestTransactionData(RequestTransactionData { + template_id, + }); + + match msg_sender_into_bitcoin_core_sv2_clone.send(request_transaction_data).await { + Ok(_) => (), + Err(e) => { + error!("Failed to send request transaction data: {}", e); + cancellation_token_clone.cancel(); + return; + } + } + } + } + } + } + }); + + // send CoinbaseOutputConstraints once at startup + // + // `BitcoinCoreSv2TDP` will not start distributing new templates until it receives the first + // `CoinbaseOutputConstraints` message. + let new_coinbase_output_constraints = + TemplateDistribution::CoinbaseOutputConstraints(CoinbaseOutputConstraints { + coinbase_output_max_additional_size: 2, + coinbase_output_max_additional_sigops: 2, + }); + + if let Err(e) = msg_sender_into_bitcoin_core_sv2 + .send(new_coinbase_output_constraints) + .await + { + error!("Failed to send coinbase output constraints: {}", e); + cancellation_token.cancel(); + } + info!("Sent CoinbaseOutputConstraints"); + + // wait for the cancellation token to be activated + cancellation_token.cancelled().await; + info!("Shutting down..."); + + // wait for the dedicated thread to finish shutdown + join_handle.join().unwrap(); + info!("BitcoinCoreSv2TDP dedicated thread shutdown complete."); +} diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs index 54d69b152..1f9d03ecc 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs @@ -1,9 +1,6 @@ //! Request / response types exchanged between `jd-server` and the Bitcoin Core IPC thread. -use stratum_core::{ - bitcoin::{BlockHash, CompactTarget, Transaction, Wtxid, block::Version}, - job_declaration_sv2::PushSolution, -}; +use stratum_core::bitcoin::{Block, BlockHash, CompactTarget, Transaction, Wtxid, block::Version}; use tokio::sync::oneshot; /// Snapshot of the template parameters used by the validator at decision time. @@ -33,10 +30,8 @@ pub enum JdRequest { missing_txs: Vec, response_tx: oneshot::Sender, }, - /// Submit a mining solution to Bitcoin Core (fire-and-forget). - PushSolution { - push_solution: PushSolution<'static>, - }, + /// Submit a fully assembled block to Bitcoin Core (fire-and-forget). + PushSolution { block: Block }, } /// The result of trying to handle a DeclareMiningJob request. diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs index d2b45671f..f9670f7ab 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs @@ -14,7 +14,7 @@ pub mod io; use crate::{ common::{BitcoinCoreSv2Error, BitcoinCoreSv2Protocol, BitcoinCoreVersion}, - unix_capnp::{v30x, v31x}, + unix_capnp::{v30x, v31x, v32x}, }; use async_channel::Receiver; use io::JdRequest; @@ -28,6 +28,7 @@ pub use tokio_util::sync::CancellationToken; pub enum BitcoinCoreSv2JDP { V30X(v30x::job_declaration_protocol::BitcoinCoreSv2JDP), V31X(v31x::job_declaration_protocol::BitcoinCoreSv2JDP), + V32X(v32x::job_declaration_protocol::BitcoinCoreSv2JDP), } impl BitcoinCoreSv2JDP { @@ -35,6 +36,7 @@ impl BitcoinCoreSv2JDP { match self { Self::V30X(runtime) => runtime.run().await, Self::V31X(runtime) => runtime.run().await, + Self::V32X(runtime) => runtime.run().await, } } } @@ -75,5 +77,16 @@ where .map_err(|error| { BitcoinCoreSv2JDPError::from_debug(version, BitcoinCoreSv2Protocol::JDP, error) }), + BitcoinCoreVersion::V32X => v32x::job_declaration_protocol::BitcoinCoreSv2JDP::new( + bitcoin_core_unix_socket_path, + incoming_requests, + cancellation_token, + ready_tx, + ) + .await + .map(BitcoinCoreSv2JDP::V32X) + .map_err(|error| { + BitcoinCoreSv2JDPError::from_debug(version, BitcoinCoreSv2Protocol::JDP, error) + }), } } diff --git a/bitcoin-core-sv2/src/common/mod.rs b/bitcoin-core-sv2/src/common/mod.rs index ab537dd03..5e51b334e 100644 --- a/bitcoin-core-sv2/src/common/mod.rs +++ b/bitcoin-core-sv2/src/common/mod.rs @@ -10,6 +10,7 @@ use std::fmt; pub enum BitcoinCoreVersion { V30X, V31X, + V32X, } impl BitcoinCoreVersion { @@ -17,6 +18,7 @@ impl BitcoinCoreVersion { match self { Self::V30X => 30, Self::V31X => 31, + Self::V32X => 32, } } } @@ -28,6 +30,7 @@ impl TryFrom for BitcoinCoreVersion { match value { 30 => Ok(Self::V30X), 31 => Ok(Self::V31X), + 32 => Ok(Self::V32X), _ => Err(value), } } diff --git a/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs index 78b8f9633..d5b69119c 100644 --- a/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs +++ b/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs @@ -14,7 +14,7 @@ use crate::{ common::{BitcoinCoreSv2Error, BitcoinCoreSv2Protocol, BitcoinCoreVersion}, - unix_capnp::{v30x, v31x}, + unix_capnp::{v30x, v31x, v32x}, }; use async_channel::{Receiver, Sender}; use std::path::Path; @@ -28,6 +28,7 @@ pub use tokio_util::sync::CancellationToken; pub enum BitcoinCoreSv2TDP { V30X(v30x::template_distribution_protocol::BitcoinCoreSv2TDP), V31X(v31x::template_distribution_protocol::BitcoinCoreSv2TDP), + V32X(v32x::template_distribution_protocol::BitcoinCoreSv2TDP), } impl BitcoinCoreSv2TDP { @@ -35,6 +36,7 @@ impl BitcoinCoreSv2TDP { match self { Self::V30X(runtime) => runtime.run().await, Self::V31X(runtime) => runtime.run().await, + Self::V32X(runtime) => runtime.run().await, } } } @@ -82,5 +84,18 @@ where .map_err(|error| { BitcoinCoreSv2TDPError::from_debug(version, BitcoinCoreSv2Protocol::TDP, error) }), + BitcoinCoreVersion::V32X => v32x::template_distribution_protocol::BitcoinCoreSv2TDP::new( + bitcoin_core_unix_socket_path, + fee_threshold, + min_interval, + incoming_messages, + outgoing_messages, + global_cancellation_token, + ) + .await + .map(BitcoinCoreSv2TDP::V32X) + .map_err(|error| { + BitcoinCoreSv2TDPError::from_debug(version, BitcoinCoreSv2Protocol::TDP, error) + }), } } diff --git a/bitcoin-core-sv2/src/lib.rs b/bitcoin-core-sv2/src/lib.rs index a3842b310..49dc3ee09 100644 --- a/bitcoin-core-sv2/src/lib.rs +++ b/bitcoin-core-sv2/src/lib.rs @@ -19,6 +19,7 @@ //! factories with enum dispatch across backend versions. //! - [`unix_capnp::v30x`] contains the Bitcoin Core v30.x IPC implementation. //! - [`unix_capnp::v31x`] contains the Bitcoin Core v31.x IPC implementation. +//! - [`unix_capnp::v32x`] contains the Bitcoin Core v32.x IPC implementation. //! //! ## Flavor direction //! diff --git a/bitcoin-core-sv2/src/unix_capnp/mod.rs b/bitcoin-core-sv2/src/unix_capnp/mod.rs index 9ef418203..81c153365 100644 --- a/bitcoin-core-sv2/src/unix_capnp/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/mod.rs @@ -9,3 +9,4 @@ pub mod v30x; pub mod v31x; +pub mod v32x; diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs index 4d9c59d64..d223cb5d9 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs @@ -15,7 +15,7 @@ use stratum_core::{ }, job_declaration_sv2::{ ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, - ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, PushSolution, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, }, }; use tokio::sync::oneshot; @@ -310,10 +310,10 @@ impl BitcoinCoreSv2JDP { let _ = response_tx.send(response); } - /// Submits a mining solution to Bitcoin Core. + /// Submits a solved block to Bitcoin Core. /// - /// Not yet implemented — deliberately left as a stub for future work. - pub(crate) async fn handle_push_solution(&self, _push_solution: PushSolution<'_>) { + /// Not yet implemented for v30.x IPC, which does not expose `submitBlock`. + pub(crate) async fn handle_push_solution(&self, _block: Block) { // todo } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs index e8adee931..c5e4ff6c4 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs @@ -369,8 +369,8 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { push_solution } => { - self.handle_push_solution(push_solution).await; + JdRequest::PushSolution { block } => { + self.handle_push_solution(block).await; } } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs index 75699a17b..6b887e3bc 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs @@ -15,7 +15,7 @@ use stratum_core::{ }, job_declaration_sv2::{ ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, - ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, PushSolution, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, }, }; use tokio::sync::oneshot; @@ -325,10 +325,10 @@ impl BitcoinCoreSv2JDP { let _ = response_tx.send(response); } - /// Submits a mining solution to Bitcoin Core. + /// Submits a solved block to Bitcoin Core. /// - /// Not yet implemented — deliberately left as a stub for future work. - pub(crate) async fn handle_push_solution(&self, _push_solution: PushSolution<'_>) { + /// Not yet implemented for v31.x IPC, which does not expose `submitBlock`. + pub(crate) async fn handle_push_solution(&self, _block: Block) { // todo } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs index 524dbfd12..8d8d8b4d1 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs @@ -430,8 +430,8 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { push_solution } => { - self.handle_push_solution(push_solution).await; + JdRequest::PushSolution { block } => { + self.handle_push_solution(block).await; } } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs new file mode 100644 index 000000000..792c76af3 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs @@ -0,0 +1,38 @@ +//! Error types for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX socket. + +use std::path::PathBuf; +use stratum_core::bitcoin::consensus; + +use bitcoin_capnp_types_v32::capnp; + +/// Errors from the [`crate::unix_capnp::v32x::job_declaration_protocol::BitcoinCoreSv2JDP`] layer. +#[derive(Debug)] +pub enum BitcoinCoreSv2JDPError { + /// Cap'n Proto RPC error. + CapnpError(capnp::Error), + /// Failed to create a dedicated thread IPC client, capturing the underlying context. + FailedToCreateThreadIpcClient(String), + /// Failed to connect to the Bitcoin Core Unix socket. + CannotConnectToUnixSocket(PathBuf, String), + /// Failed to deserialize a block from the IPC response. + FailedToDeserializeBlock(consensus::encode::Error), + /// Readiness signal receiver was dropped before bootstrap completed. + ReadinessSignalFailed, +} + +impl BitcoinCoreSv2JDPError { + /// Returns true when the error indicates transient IPC contention in Bitcoin Core. + pub fn is_thread_busy(&self) -> bool { + matches!( + self, + BitcoinCoreSv2JDPError::CapnpError(capnp_error) + if capnp_error.to_string().contains("thread busy") + ) + } +} + +impl From for BitcoinCoreSv2JDPError { + fn from(error: capnp::Error) -> Self { + BitcoinCoreSv2JDPError::CapnpError(error) + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs new file mode 100644 index 000000000..c9d8a5148 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -0,0 +1,427 @@ +//! Handlers for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX socket. + +use crate::{ + common::job_declaration_protocol::io::{JdResponse, ValidationContext}, + unix_capnp::v32x::job_declaration_protocol::{ + BitcoinCoreSv2JDP, error::BitcoinCoreSv2JDPError, + mempool::decode_bip34_height_from_coinbase_script_sig, + }, +}; +use stratum_core::{ + bitcoin::{ + Block, Transaction, TxMerkleNode, Wtxid, + block::{Header, Version}, + consensus::serialize, + hashes::Hash, + }, + job_declaration_sv2::{ + ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + }, +}; +use tokio::sync::oneshot; +use tracing::{debug, error, info, warn}; + +const MAX_SUBMIT_BLOCK_ATTEMPTS: usize = 3; +const SUBMIT_BLOCK_RETRY_BACKOFF_MS: u64 = 15; + +impl BitcoinCoreSv2JDP { + /// Validates a declared mining job by checking transaction availability and block structure. + /// + /// Adds missing transactions to the mempool mirror, verifies all transactions are available, + /// assembles a test block, sets IPC thread context, and uses Bitcoin Core's `checkBlock` to + /// validate the block structure. Returns success with current template parameters or an error + /// if validation fails. + pub(crate) async fn handle_declare_mining_job( + &self, + version: Version, + coinbase_tx: Transaction, + wtxid_list: Vec, + missing_txs: Vec, + response_tx: oneshot::Sender, + ) { + info!( + "Validating DeclareMiningJob - version: {:?}, coinbase inputs: {}, outputs: {}, locktime: {}", + version, + coinbase_tx.input.len(), + coinbase_tx.output.len(), + coinbase_tx.lock_time.to_consensus_u32() + ); + debug!( + "Declared coinbase scriptSig: {:?}", + coinbase_tx.input[0].script_sig + ); + + let declared_bip34_height = coinbase_tx + .input + .first() + .and_then(|input| { + decode_bip34_height_from_coinbase_script_sig(input.script_sig.as_bytes()) + }) + // Some templates/coinbase formats do not expose BIP34 height in canonical + // scriptSig push form (e.g. opcode-encoded small integers in tests/regtest). + // Fall back to coinbase lock_time to avoid panics and keep a stable + // stale-tip comparison signal. + .unwrap_or_else(|| coinbase_tx.lock_time.to_consensus_u32()); + + let (initial_validation_context, initial_bip34_height, txdata) = { + let mut mempool_mirror = self.mempool_mirror.borrow_mut(); + + // Add the missing transactions to the mempool mirror + mempool_mirror.add_transactions(missing_txs); + + let prev_hash = mempool_mirror + .get_current_prev_hash() + .expect("current_prev_hash must be set"); + let nbits = mempool_mirror + .get_current_nbits() + .expect("current_nbits must be set"); + let min_ntime = mempool_mirror + .get_current_min_ntime() + .expect("current_min_ntime must be set"); + + let initial_validation_context = ValidationContext { + prev_hash, + nbits, + min_ntime, + }; + + let initial_bip34_height = mempool_mirror + .get_current_bip34_height() + .expect("current_bip34_height must be set"); + + // Now verify that all wtxids from the declared job are available + let missing_wtxids = mempool_mirror.verify(&wtxid_list); + if !missing_wtxids.is_empty() { + // deliberately ignore potential errors + // we don't care if the receiver dropped the channel + let _ = response_tx.send(JdResponse::MissingTransactions { + missing_wtxids, + validation_context: initial_validation_context, + }); + return; + } + + let txdata = mempool_mirror.get_txdata(&wtxid_list); + + info!( + "Using prevhash: {:?}, nbits: {:?}, min_ntime: {}, bip34_height: {} from mempool mirror", + initial_validation_context.prev_hash, + initial_validation_context.nbits, + initial_validation_context.min_ntime, + initial_bip34_height + ); + + (initial_validation_context, initial_bip34_height, txdata) + }; // mempool_mirror dropped here, we don't want to hold it across await points + + let txdata_for_response = txdata.clone(); + + let valid_job = { + let mut all_transactions = Vec::with_capacity(1 + txdata.len()); + all_transactions.push(coinbase_tx.clone()); + all_transactions.extend(txdata); + + let num_transactions = all_transactions.len(); + + // Use the min_ntime from the template as the block timestamp + // This ensures we meet Bitcoin Core's timestamp validation rules + let block_time = initial_validation_context.min_ntime; + + let header = Header { + version, + prev_blockhash: initial_validation_context.prev_hash, + merkle_root: TxMerkleNode::all_zeros(), // doesn't matter + time: block_time, + bits: initial_validation_context.nbits, + nonce: 0, // doesn't matter + }; + + let block = Block { + header, + txdata: all_transactions, + }; + + let block_bytes: Vec = serialize(&block); + + debug!( + "Assembled block for checkBlock: {} bytes, {} transactions", + block_bytes.len(), + num_transactions + ); + + let mut check_block_request = self.mining_ipc_client.check_block_request(); + + match check_block_request.get().get_context() { + Ok(mut context) => context.set_thread(self.thread_ipc_client.clone()), + Err(e) => { + error!("Failed to set check block request thread context: {e}"); + // send error response to the client + // deliberately ignore potential send errors + let _ = response_tx.send(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, + validation_context: initial_validation_context, + }); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + } + + check_block_request.get().set_block(&block_bytes); + + let mut options = match check_block_request.get().get_options() { + Ok(options) => options, + Err(e) => { + error!("Failed to get check block options: {e}"); + // send error response to the client + // deliberately ignore potential send errors + let _ = response_tx.send(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, + validation_context: initial_validation_context, + }); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + options.set_check_merkle_root(false); + options.set_check_pow(false); + + let check_block_response = match check_block_request.send().promise.await { + Ok(response) => response, + Err(e) => { + error!("Failed to send check block request: {e}"); + // send error response to the client + // deliberately ignore potential send errors + let _ = response_tx.send(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, + validation_context: initial_validation_context, + }); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + let check_block_result = match check_block_response.get() { + Ok(result) => result, + Err(e) => { + error!("Failed to get check block result: {e}"); + // send error response to the client + // deliberately ignore potential send errors + let _ = response_tx.send(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, + validation_context: initial_validation_context, + }); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + let result = check_block_result.get_result(); + let check_block_reason = check_block_result.get_reason(); + let check_block_debug = check_block_result.get_debug(); + + debug!("checkBlock returned: {}", result); + if !result { + error!( + reason = ?check_block_reason, + debug = ?check_block_debug, + "Bitcoin Core rejected the block via checkBlock" + ); + debug!( + "Block details - version: {:?}, prev_blockhash: {:?}, bits: {:?}, num_txs: {}", + version, + initial_validation_context.prev_hash, + initial_validation_context.nbits, + num_transactions + ); + debug!( + "Coinbase tx inputs: {}, outputs: {}", + coinbase_tx.input.len(), + coinbase_tx.output.len() + ); + debug!( + "Block header time: {}, merkle_root: {:?}", + header.time, header.merkle_root + ); + } + result + }; + + if !valid_job { + // On checkBlock failure, force-refresh template + mirror before classifying the error. + // The template monitor updates mempool_mirror asynchronously, so we need to avoid races + // where validation can run on context A while chain tip has already moved to context B. + // Refreshing here narrows this TOCTOU window and lets us correctly emit + // `stale-chain-tip` instead of generic `invalid-job` when context drift occurred. + if let Err(e) = self.force_update_mempool_mirror().await { + debug!( + error = ?e, + "Failed to force-refresh template/mempool mirror after checkBlock failure; continuing with current validation context" + ); + } + } + + let (latest_validation_context, latest_bip34_height) = { + let mempool_mirror = self.mempool_mirror.borrow(); + let latest_validation_context = ValidationContext { + prev_hash: mempool_mirror + .get_current_prev_hash() + .expect("current_prev_hash must be set"), + nbits: mempool_mirror + .get_current_nbits() + .expect("current_nbits must be set"), + min_ntime: mempool_mirror + .get_current_min_ntime() + .expect("current_min_ntime must be set"), + }; + let latest_bip34_height = mempool_mirror + .get_current_bip34_height() + .expect("current_bip34_height must be set"); + (latest_validation_context, latest_bip34_height) + }; + + let response = if valid_job { + JdResponse::Success { + prev_hash: initial_validation_context.prev_hash, + nbits: initial_validation_context.nbits, + min_ntime: initial_validation_context.min_ntime, + txdata: txdata_for_response, + } + } else { + let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; + let context_drifted = initial_validation_context.prev_hash + != latest_validation_context.prev_hash + || initial_validation_context.nbits != latest_validation_context.nbits + || initial_validation_context.min_ntime != latest_validation_context.min_ntime + || initial_bip34_height != latest_bip34_height + || stale_at_arrival_by_bip34; + + let error_code = if context_drifted { + debug!( + initial_prev_hash = ?initial_validation_context.prev_hash, + initial_nbits = ?initial_validation_context.nbits, + initial_min_ntime = initial_validation_context.min_ntime, + initial_bip34_height, + declared_bip34_height, + latest_prev_hash = ?latest_validation_context.prev_hash, + latest_nbits = ?latest_validation_context.nbits, + latest_min_ntime = latest_validation_context.min_ntime, + latest_bip34_height, + stale_at_arrival_by_bip34, + "Detected stale chain tip during DeclareMiningJob validation; classifying error as stale-chain-tip" + ); + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP + } else { + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB + }; + + JdResponse::Error { + error_code, + validation_context: latest_validation_context, + } + }; + + // deliberately ignore potential send errors + // we don't care if the receiver dropped the channel + let _ = response_tx.send(response); + } + + /// Submits a solved block to Bitcoin Core via `submitBlock`. + pub(crate) async fn handle_push_solution(&self, block: Block) { + let block_bytes: Vec = serialize(&block); + debug!( + block_bytes_len = block_bytes.len(), + tx_count = block.txdata.len(), + "Submitting solved block via submitBlock" + ); + + // a dedicated thread is used to submit blocks to Bitcoin Core + // therefore retries should be extremely rare + for attempt in 1..=MAX_SUBMIT_BLOCK_ATTEMPTS { + let mut submit_block_request = self.mining_ipc_client.submit_block_request(); + + match submit_block_request.get().get_context() { + Ok(mut context) => context.set_thread(self.submit_block_thread_ipc_client.clone()), + Err(e) => { + error!("Failed to set submitBlock request thread context: {e}"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + } + + submit_block_request.get().set_block(&block_bytes); + + let submit_block_response = match submit_block_request.send().promise.await { + Ok(response) => response, + Err(e) => { + let err: BitcoinCoreSv2JDPError = e.into(); + if err.is_thread_busy() && attempt < MAX_SUBMIT_BLOCK_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_SUBMIT_BLOCK_ATTEMPTS, + "Transient IPC contention during submitBlock (thread busy); retrying" + ); + tokio::time::sleep(std::time::Duration::from_millis( + SUBMIT_BLOCK_RETRY_BACKOFF_MS, + )) + .await; + continue; + } + + error!("Failed to send submitBlock request: {err:?}"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + let submit_block_result = match submit_block_response.get() { + Ok(result) => result, + Err(e) => { + let err: BitcoinCoreSv2JDPError = e.into(); + if err.is_thread_busy() && attempt < MAX_SUBMIT_BLOCK_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_SUBMIT_BLOCK_ATTEMPTS, + "Transient IPC contention while reading submitBlock response (thread busy); retrying" + ); + tokio::time::sleep(std::time::Duration::from_millis( + SUBMIT_BLOCK_RETRY_BACKOFF_MS, + )) + .await; + continue; + } + + error!("Failed to get submitBlock result: {err:?}"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + let accepted = submit_block_result.get_result(); + let reason = submit_block_result.get_reason(); + let debug_msg = submit_block_result.get_debug(); + + if accepted { + info!( + reason = ?reason, + debug = ?debug_msg, + "Bitcoin Core accepted block via submitBlock" + ); + } else { + warn!( + reason = ?reason, + debug = ?debug_msg, + "Bitcoin Core rejected block via submitBlock" + ); + } + + return; + } + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs new file mode 100644 index 000000000..ed6a0b863 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs @@ -0,0 +1,126 @@ +//! Local mempool mirror for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX +//! socket. + +use std::collections::HashMap; +use stratum_core::bitcoin::{Block, BlockHash, CompactTarget, Transaction, Wtxid}; + +/// Local cache of mempool transactions and current template parameters. +/// +/// Tracks transactions by wtxid and maintains the current prev_hash, nbits, +/// and min_ntime from the most recent block template. +#[derive(Default)] +pub struct MempoolMirror { + txdata: HashMap, + current_prev_hash: Option, + current_nbits: Option, + current_min_ntime: Option, + current_bip34_height: Option, +} + +impl MempoolMirror { + /// Creates a new empty mempool mirror. + pub fn new() -> Self { + Default::default() + } + + /// Updates the mirror with transactions from a block template. + /// + /// Clears stale transactions if the prev_hash changes. + pub fn update(&mut self, block: &Block) { + let prev_hash = block.header.prev_blockhash; + if self.current_prev_hash != Some(prev_hash) { + self.txdata.clear(); + } + self.current_prev_hash = Some(prev_hash); + self.current_nbits = Some(block.header.bits); + self.current_min_ntime = Some(block.header.time); + self.current_bip34_height = block.txdata.first().map(|coinbase| { + coinbase + .input + .first() + .and_then(|input| { + decode_bip34_height_from_coinbase_script_sig(input.script_sig.as_bytes()) + }) + // Fallback for non-canonical/missing BIP34 encoding in some templates. + .unwrap_or_else(|| coinbase.lock_time.to_consensus_u32()) + }); + + // skip the coinbase transaction + for tx in block.txdata.iter().skip(1) { + let wtxid = tx.compute_wtxid(); + self.txdata.insert(wtxid, tx.clone()); + } + } + + /// Adds transactions to the mempool mirror. + /// + /// Used to add missing transactions from ProvideMissingTransactionsSuccess messages. + pub fn add_transactions(&mut self, transactions: Vec) { + for tx in transactions { + let wtxid = tx.compute_wtxid(); + self.txdata.insert(wtxid, tx); + } + } + + /// Retrieves transactions by wtxid. + pub fn get_txdata(&self, wtxids: &[Wtxid]) -> Vec { + wtxids + .iter() + .filter_map(|wtxid| self.txdata.get(wtxid).cloned()) + .collect() + } + + /// Returns wtxids that are not present in the mempool. + pub fn verify(&self, wtxids: &[Wtxid]) -> Vec { + wtxids + .iter() + .filter(|&wtxid| !self.txdata.contains_key(wtxid)) + .copied() + .collect() + } + + /// Returns the current template's prev_hash. + pub fn get_current_prev_hash(&self) -> Option { + self.current_prev_hash + } + + /// Returns the current template's difficulty target (nbits). + pub fn get_current_nbits(&self) -> Option { + self.current_nbits + } + + /// Returns the current template's minimum timestamp (min_ntime). + pub fn get_current_min_ntime(&self) -> Option { + self.current_min_ntime + } + + /// Returns the current template's BIP34 height decoded from coinbase scriptSig. + pub fn get_current_bip34_height(&self) -> Option { + self.current_bip34_height + } +} + +/// Decodes BIP34 height from the first push in coinbase scriptSig. +/// Returns None if scriptSig does not start with a canonical small push. +/// Shared by JDP components that need to compare declared vs current chain context. +pub(crate) fn decode_bip34_height_from_coinbase_script_sig(script_sig: &[u8]) -> Option { + let first = *script_sig.first()?; + + // Support small-integer opcodes (OP_0, OP_1..OP_16) used by some templates. + if first == 0x00 { + return Some(0); + } + if (0x51..=0x60).contains(&first) { + return Some((first - 0x50) as u32); + } + + // Canonical small push form: first byte is push length (1..=4). + let push_len = first as usize; + if push_len == 0 || push_len > 4 || script_sig.len() < 1 + push_len { + return None; + } + + let mut height_bytes = [0u8; 4]; + height_bytes[..push_len].copy_from_slice(&script_sig[1..1 + push_len]); + Some(u32::from_le_bytes(height_bytes)) +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs new file mode 100644 index 000000000..3336066fc --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs @@ -0,0 +1,481 @@ +//! Module for interacting with Bitcoin Core v32.x via Sv2 Job Declaration Protocol via capnp over +//! UNIX socket. + +use crate::{ + common::job_declaration_protocol::io::JdRequest, + unix_capnp::v32x::job_declaration_protocol::{ + error::BitcoinCoreSv2JDPError, mempool::MempoolMirror, + }, +}; +use async_channel::Receiver; +use bitcoin_capnp_types::{ + capnp, + capnp_rpc::{RpcSystem, rpc_twoparty_capnp, twoparty}, + init_capnp::init::Client as InitIpcClient, + mining_capnp::{ + block_template::Client as BlockTemplateIpcClient, mining::Client as MiningIpcClient, + }, + proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use std::{cell::RefCell, path::Path, rc::Rc}; +use stratum_core::bitcoin::{Block, consensus::deserialize}; +use tokio::net::UnixStream; +use tokio_util::compat::*; +pub use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +pub mod error; +mod handlers; +mod mempool; +mod monitors; + +/// The main abstraction for interacting with Bitcoin Core via Sv2 Job Declaration Protocol. +/// +/// It is instantiated with: +/// - A `&`[`std::path::Path`] to the Bitcoin Core UNIX socket +/// - A [`async_channel::Receiver`] for incoming [`JdRequest`] messages (handles +/// [`DeclareMiningJob`] and [`PushSolution`] requests) +/// - A [`tokio_util::sync::CancellationToken`] to stop the internally spawned tasks +/// +/// The instance bootstraps its internal mempool state by fetching the current block template +/// from Bitcoin Core before accepting requests. It then spawns a background monitor task that +/// tracks mempool changes via `waitNext` requests. +/// +/// Incoming [`DeclareMiningJob`] requests are validated by: +/// - Verifying all transactions exist in the mempool +/// - Assembling a test block with the declared coinbase and transactions +/// - Using Bitcoin Core's `checkBlock` to validate block structure +/// +/// If transactions are missing, a [`MissingTransactions`] response is sent. If validation +/// succeeds, a [`Success`] response with current template parameters is sent. +/// +/// Incoming [`PushSolution`] requests are used to submit mining solutions to Bitcoin Core. +#[derive(Clone)] +pub struct BitcoinCoreSv2JDP { + thread_map: ThreadMapIpcClient, + thread_ipc_client: ThreadIpcClient, + submit_block_thread_ipc_client: ThreadIpcClient, + mining_ipc_client: MiningIpcClient, + current_template_ipc_client: Rc>, + cancellation_token: CancellationToken, + mempool_mirror: Rc>, + incoming_requests: Receiver, +} + +impl BitcoinCoreSv2JDP { + /// Creates a new [`BitcoinCoreSv2JDP`] instance. + /// + /// Bootstraps the mempool mirror and signals readiness before returning. + pub async fn new

( + bitcoin_core_unix_socket_path: P, + incoming_requests: Receiver, + cancellation_token: CancellationToken, + ready_tx: tokio::sync::oneshot::Sender<()>, + ) -> Result + where + P: AsRef, + { + let bitcoin_core_unix_socket_path = bitcoin_core_unix_socket_path.as_ref(); + + info!( + "Creating new BitcoinCoreSv2JDP via IPC over UNIX socket: {}", + bitcoin_core_unix_socket_path.display() + ); + + let stream = UnixStream::connect(bitcoin_core_unix_socket_path) + .await + .map_err(|e| { + BitcoinCoreSv2JDPError::CannotConnectToUnixSocket( + bitcoin_core_unix_socket_path.into(), + e.to_string(), + ) + })?; + let (reader, writer) = stream.into_split(); + let reader_compat = reader.compat(); + let writer_compat = writer.compat_write(); + + let rpc_network = Box::new(twoparty::VatNetwork::new( + reader_compat, + writer_compat, + rpc_twoparty_capnp::Side::Client, + Default::default(), + )); + + let mut rpc_system = RpcSystem::new(rpc_network, None); + let bootstrap_client: InitIpcClient = + rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server); + + tokio::task::spawn_local(rpc_system); + + let construct_response = bootstrap_client.construct_request().send().promise.await?; + + let thread_map: ThreadMapIpcClient = construct_response.get()?.get_thread_map()?; + let thread_request = thread_map.make_thread_request(); + let thread_response = thread_request.send().promise.await?; + + let thread_ipc_client: ThreadIpcClient = thread_response.get()?.get_result()?; + + info!("IPC execution thread client successfully created."); + + let submit_block_thread_request = thread_map.make_thread_request(); + let submit_block_thread_response = submit_block_thread_request + .send() + .promise + .await + .map_err(|e| { + let details = + format!("Failed to send make_thread request for submitBlock thread: {e}"); + error!("{}", details); + BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) + })?; + let submit_block_thread_ipc_client: ThreadIpcClient = submit_block_thread_response + .get() + .map_err(|e| { + let details = + format!("Failed to read make_thread response for submitBlock thread: {e}"); + error!("{}", details); + BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) + })? + .get_result() + .map_err(|e| { + let details = format!("Failed to get submitBlock thread IPC client: {e}"); + error!("{}", details); + BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) + })?; + + info!("IPC submitBlock thread client successfully created."); + + let mut mining_client_request = bootstrap_client.make_mining_request(); + mining_client_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + let mining_client_response = mining_client_request.send().promise.await?; + let mining_ipc_client: MiningIpcClient = mining_client_response.get()?.get_result()?; + + let mut template_ipc_client_request = mining_ipc_client.create_new_block_request(); + template_ipc_client_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + let mut template_ipc_client_request_options = template_ipc_client_request + .get() + .get_options() + .map_err(|e| { + error!("Failed to get template IPC client request options: {e}"); + e + })?; + template_ipc_client_request_options.set_use_mempool(true); + + debug!("Sending createNewBlock request to Bitcoin Core"); + let create_new_block_promise = template_ipc_client_request.send().promise; + // During IBD this startup call can block for a long time, so shutdown must interrupt the + // in-flight request instead of only abandoning the outer wait loop. + let template_ipc_client_response = tokio::select! { + template_ipc_client_response = create_new_block_promise => { + template_ipc_client_response.map_err(|e| { + error!("Failed to send template IPC client request: {}", e); + e + })? + } + _ = cancellation_token.cancelled() => { + debug!("Interrupting initial createNewBlock request"); + Self::interrupt_create_new_block_request(&mining_ipc_client).await?; + return Err(capnp::Error::failed( + "createNewBlock request interrupted during shutdown".to_string(), + ) + .into()); + } + }; + + let template_ipc_client_result = template_ipc_client_response.get().map_err(|e| { + error!("Failed to get template IPC client result: {}", e); + e + })?; + + let template_ipc_client = template_ipc_client_result.get_result().map_err(|e| { + error!("Failed to get template IPC client result: {}", e); + e + })?; + + info!("IPC JDP client successfully created."); + + let self_ = Self { + thread_map, + thread_ipc_client, + submit_block_thread_ipc_client, + mining_ipc_client, + current_template_ipc_client: Rc::new(RefCell::new(template_ipc_client)), + cancellation_token, + mempool_mirror: Rc::new(RefCell::new(MempoolMirror::new())), + incoming_requests, + }; + + // Bootstrap initial mempool state before signaling readiness + debug!("Bootstrapping initial mempool state"); + if let Err(e) = self_.update_mempool_mirror().await { + error!("Failed to bootstrap mempool mirror: {:?}", e); + // Don't send readiness signal on failure (ready_tx dropped) + return Err(e); + } + debug!("Initial mempool state bootstrapped successfully"); + + // Signal that we're ready to accept requests + ready_tx.send(()).map_err(|_| { + error!("Ready signal receiver dropped - caller gave up waiting"); + BitcoinCoreSv2JDPError::ReadinessSignalFailed + })?; + + Ok(self_) + } + + /// Creates a new dedicated thread IPC client. + async fn new_thread_ipc_client(&self) -> Result { + let thread_request = self.thread_map.make_thread_request(); + let thread_response = thread_request.send().promise.await.map_err(|e| { + let details = format!("Failed to send make_thread request: {e}"); + error!("{}", details); + BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) + })?; + + let thread_ipc_client = thread_response + .get() + .map_err(|e| { + let details = format!("Failed to read make_thread response: {e}"); + error!("{}", details); + BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) + })? + .get_result() + .map_err(|e| { + let details = format!("Failed to get thread IPC client: {e}"); + error!("{}", details); + BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) + })?; + + Ok(thread_ipc_client) + } + + /// Interrupts an in-flight `createNewBlock` request during startup shutdown. + async fn interrupt_create_new_block_request( + mining_ipc_client: &MiningIpcClient, + ) -> Result<(), BitcoinCoreSv2JDPError> { + let interrupt_request = mining_ipc_client.interrupt_request(); + if let Err(e) = interrupt_request.send().promise.await { + error!("Failed to send interrupt createNewBlock request: {}", e); + return Err(BitcoinCoreSv2JDPError::CapnpError(e)); + } + + Ok(()) + } + + /// Main event loop - runs in a LocalSet on dedicated thread. + /// + /// Spawns the monitor task and processes incoming job declaration requests until shutdown. + pub async fn run(&self) { + // spawn mempool mirror monitor task + let monitor_handle = self.monitor_and_update_mempool_mirror(); + + // Main request processing loop + loop { + tokio::select! { + // Handle shutdown + _ = self.cancellation_token.cancelled() => { + info!("BitcoinCoreSv2JDP shutting down"); + break; + } + + // Process incoming requests. + // Requests are handled sequentially because this loop awaits each request before + // reading the next one. + // Pending requests are unboundedly buffered in the async_channel. + request = self.incoming_requests.recv() => { + match request { + Ok(request) => { + self.process_request(request).await; + } + Err(_) => { + info!("Incoming requests channel closed"); + self.cancellation_token.cancel(); + break; + } + } + } + } + } + + // Wait for the monitor_mempool_mirror task to finish gracefully + debug!("Waiting for monitor_mempool_mirror() task to finish"); + match monitor_handle.await { + Ok(()) => { + debug!("monitor_mempool_mirror() task finished successfully"); + } + Err(e) => { + error!( + "error waiting for monitor_mempool_mirror task to finish: {:?}", + e + ); + } + } + } + + /// Updates the mempool mirror with the current block template from Bitcoin Core. + async fn update_mempool_mirror(&self) -> Result<(), BitcoinCoreSv2JDPError> { + let mut get_block_request = self + .current_template_ipc_client + .borrow() + .get_block_request(); + get_block_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + + let block_bytes = get_block_request + .send() + .promise + .await? + .get()? + .get_result()? + .to_vec(); + debug!("Deserializing block ({} bytes)", block_bytes.len()); + let block: Block = + deserialize(&block_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock)?; + + self.mempool_mirror.borrow_mut().update(&block); + + Ok(()) + } + + /// Forces a synchronous template refresh from Bitcoin Core, then refreshes the mempool mirror. + /// + /// This is useful after `checkBlock` failures to reduce classification races where the async + /// `waitNext` monitor has not yet advanced `current_template_ipc_client`. + /// + /// It differs from update_mempool_mirror in the sense that it doesn't assume a new template is + /// available. It forces the template refresh before updating MempoolMirror. + /// + /// On transient `"thread busy"` IPC contention, this method retries a few times with + /// a short backoff before returning the error. + pub(crate) async fn force_update_mempool_mirror(&self) -> Result<(), BitcoinCoreSv2JDPError> { + const MAX_ATTEMPTS: usize = 3; + const RETRY_BACKOFF_MS: u64 = 25; + + let mut last_error: Option = None; + + for attempt in 1..=MAX_ATTEMPTS { + let result = async { + let mut create_new_block_request = + self.mining_ipc_client.create_new_block_request(); + + create_new_block_request + .get() + .get_context() + .map_err(|e| { + error!("Failed to get template IPC client request context: {e}"); + e + })? + .set_thread(self.thread_ipc_client.clone()); + + let mut create_new_block_options = + create_new_block_request.get().get_options().map_err(|e| { + error!("Failed to get createNewBlock options: {e}"); + e + })?; + + create_new_block_options.set_use_mempool(true); + + let create_new_block_response = + create_new_block_request.send().promise.await.map_err(|e| { + error!("Failed to send createNewBlock request: {e}"); + e + })?; + + let new_template_ipc_client = create_new_block_response + .get() + .map_err(|e| { + error!("Failed to read createNewBlock response: {e}"); + e + })? + .get_result() + .map_err(|e| { + error!("Failed to get BlockTemplate from createNewBlock: {e}"); + e + })?; + + { + let mut current_template_ipc_client = + self.current_template_ipc_client.borrow_mut(); + *current_template_ipc_client = new_template_ipc_client; + } + + self.update_mempool_mirror().await + } + .await; + + match result { + Ok(()) => return Ok(()), + Err(e) if e.is_thread_busy() && attempt < MAX_ATTEMPTS => { + warn!( + error = ?e, + attempt, + max_attempts = MAX_ATTEMPTS, + "Transient IPC contention during force_update_mempool_mirror (thread busy); retrying" + ); + last_error = Some(e); + tokio::time::sleep(std::time::Duration::from_millis(RETRY_BACKOFF_MS)).await; + } + Err(e) => return Err(e), + } + } + + // ideally the retry logic should never allow execution to reach here + // but if it does, we just bubble up the error + Err(last_error.unwrap_or_else(|| { + BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed( + "force_update_mempool_mirror exhausted retries without a terminal error" + .to_string(), + )) + })) + } + + /// Processes a single job declaration request and dispatches to the appropriate handler. + async fn process_request(&self, request: JdRequest) { + match request { + // Handle DeclareMiningJob requests + JdRequest::DeclareMiningJob { + version, + coinbase_tx, + wtxid_list, + missing_txs, + response_tx, + } => { + self.handle_declare_mining_job( + version, + coinbase_tx, + wtxid_list, + missing_txs, + response_tx, + ) + .await; + } + + // Handle PushSolution requests (no response needed) + JdRequest::PushSolution { block } => { + self.handle_push_solution(block).await; + } + } + } + + /// Interrupts the current `waitNext` request to Bitcoin Core for graceful shutdown. + async fn interrupt_wait_request(&self) -> Result<(), BitcoinCoreSv2JDPError> { + let template_ipc_client = self.current_template_ipc_client.borrow().clone(); + + let interrupt_wait_request = template_ipc_client.interrupt_wait_request(); + if let Err(e) = interrupt_wait_request.send().promise.await { + error!("Failed to send interrupt wait request: {}", e); + return Err(BitcoinCoreSv2JDPError::CapnpError(e)); + } + + Ok(()) + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs new file mode 100644 index 000000000..07fcc9c49 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs @@ -0,0 +1,158 @@ +//! Background monitors for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX +//! socket. + +use crate::unix_capnp::v32x::job_declaration_protocol::BitcoinCoreSv2JDP; +use bitcoin_capnp_types_v32::capnp; +use tokio::task::JoinHandle; +use tracing::{debug, error, warn}; + +impl BitcoinCoreSv2JDP { + /// Spawns a `spawn_local` task that issues `waitNext` requests to Bitcoin Core and + /// refreshes the [`MempoolMirror`](super::mempool::MempoolMirror) whenever the template + /// changes. Returns the [`JoinHandle`] so the caller can await clean shutdown. + pub fn monitor_and_update_mempool_mirror(&self) -> JoinHandle<()> { + let self_clone = self.clone(); + + tokio::task::spawn_local(async move { + debug!("monitor_mempool_mirror() task started"); + debug!("Creating dedicated blocking_thread_ipc_client for waitNext requests"); + let blocking_thread_ipc_client = match self_clone.new_thread_ipc_client().await { + Ok(blocking_thread_ipc_client) => blocking_thread_ipc_client, + Err(e) => { + error!("Failed to create blocking thread IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.cancellation_token.cancel(); + return; + } + }; + debug!("monitor_mempool_mirror() entering main loop"); + + loop { + // Create a new waitNext request for each iteration + let mut wait_next_request = self_clone + .current_template_ipc_client + .borrow_mut() + .wait_next_request(); + + match wait_next_request.get().get_context() { + Ok(mut context) => context.set_thread(blocking_thread_ipc_client.clone()), + Err(e) => { + error!("Failed to set thread: {}", e); + self_clone.cancellation_token.cancel(); + break; + } + } + + let mut wait_next_request_options = match wait_next_request.get().get_options() { + Ok(options) => options, + Err(e) => { + error!("Failed to get waitNext request options: {}", e); + self_clone.cancellation_token.cancel(); + break; + } + }; + + // Rebuild aggressively instead of waiting only for tip changes. + // Bitcoin Core reevaluates fee growth on a 1s tick, and with + // fee_threshold = 0 it returns any candidate whose total fees + // are not lower than the current template. In steady state this + // usually produces a new BlockTemplate about once per second. + wait_next_request_options.set_fee_threshold(0); + + // Bound how long a single waitNext call can stay attached to + // one BlockTemplate before the loop recreates it from the + // latest current_template_ipc_client when Bitcoin Core does not + // produce a returnable candidate. This is a fallback, not the + // expected cadence of template updates. + wait_next_request_options.set_timeout(3_000.0); + + tokio::select! { + _ = self_clone.cancellation_token.cancelled() => { + debug!("Interrupting waitNext request"); + if let Err(e) = self_clone.interrupt_wait_request().await { + error!("Failed to interrupt waitNext request: {:?}", e); + } + warn!("Exiting mempool mirror loop"); + debug!("monitor_mempool_mirror() exiting due to cancellation"); + break; + } + wait_next_request_response = wait_next_request.send().promise => { + match wait_next_request_response { + Ok(response) => { + let result = match response.get() { + Ok(result) => result, + Err(e) => { + error!("Failed to get response: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.cancellation_token.cancel(); + break; + } + }; + + let new_template_ipc_client = match result.get_result() { + Ok(new_template_ipc_client) => { + debug!("waitNext returned new template IPC client"); + new_template_ipc_client + }, + Err(e) => { + match e.kind { + capnp::ErrorKind::MessageContainsNullCapabilityPointer => { + debug!("waitNext timed out (no mempool changes)"); + debug!("Continuing to next waitNext iteration"); + continue; + } + _ => { + error!("Failed to get new template IPC client: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.cancellation_token.cancel(); + break; + } + } + } + }; + + // update the current template IPC client + { + let mut current_template_ipc_client_guard = self_clone.current_template_ipc_client.borrow_mut(); + *current_template_ipc_client_guard = new_template_ipc_client; + debug!("Updated current_template_ipc_client with new template"); + } + + // update the mempool mirror + if let Err(e) = self_clone.update_mempool_mirror().await { + if e.is_thread_busy() { + warn!( + error = ?e, + "Transient IPC contention while updating mempool mirror (thread busy); retrying" + ); + continue; + } + + error!("Failed to update mempool mirror: {:?}", e); + self_clone.cancellation_token.cancel(); + break; + } + } + Err(e) => { + let err: super::error::BitcoinCoreSv2JDPError = e.into(); + if err.is_thread_busy() { + warn!( + error = ?err, + "Transient IPC contention during waitNext (thread busy); retrying" + ); + continue; + } + debug!("waitNext request failed with error: {:?}", err); + error!("Failed to get response: {:?}", err); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.cancellation_token.cancel(); + break; + } + } + } + } + } + debug!("monitor_mempool_mirror() task exiting"); + }) + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs new file mode 100644 index 000000000..a0c0309c2 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs @@ -0,0 +1,10 @@ +//! Bitcoin Core v32.x IPC implementation modules. +//! +//! This namespace contains the concrete v32.x runtime implementations used when +//! [`crate::common::BitcoinCoreVersion::V32X`] is selected. +//! +//! It is wired against `bitcoin_capnp_types_v32`, which re-exports the matching `capnp` +//! and `capnp-rpc` APIs. + +pub mod job_declaration_protocol; +pub mod template_distribution_protocol; diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs new file mode 100644 index 000000000..b101784d5 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs @@ -0,0 +1,130 @@ +//! Error types for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over UNIX +//! socket. + +use std::path::Path; +use stratum_core::bitcoin::{ + block::ValidationError, consensus, consensus::encode::Error as ConsensusEncodeError, +}; + +use bitcoin_capnp_types_v32::capnp; + +/// Error type for [`crate::BitcoinCoreSv2TDP`] +#[derive(Debug)] +pub enum BitcoinCoreSv2TDPError { + CapnpError(capnp::Error), + CannotConnectToUnixSocket(Box, String), + InvalidTemplateHeader(consensus::encode::Error), + InvalidTemplateHeaderLength, + FailedToSerializeCoinbasePrefix, + FailedToSerializeCoinbaseOutputs, + TemplateNotFound, + TemplateIpcClientNotFound, + FailedToSendNewTemplateMessage, + FailedToSendSetNewPrevHashMessage, + FailedToFetchTemplateTxData, + FailedToSendRequestTransactionDataResponseMessage, + FailedToRecvTemplateDistributionMessage, + FailedToSendTemplateDistributionMessage, + FailedToSubmitSolution, + FailedToSetThread, + FailedToGetWaitNextRequestOptions, + CreateNewBlockRequestInterrupted, + FailedToSendInterruptCreateNewBlockRequest, + FailedToSendInterruptWaitRequest, + FailedToWaitForMonitorIpcTemplatesTask, + FailedToCreateSolutionDir, + InvalidBlockRewardRemaining(i64), +} + +impl From for BitcoinCoreSv2TDPError { + fn from(error: capnp::Error) -> Self { + BitcoinCoreSv2TDPError::CapnpError(error) + } +} + +impl From for BitcoinCoreSv2TDPError { + fn from(error: consensus::encode::Error) -> Self { + BitcoinCoreSv2TDPError::InvalidTemplateHeader(error) + } +} + +#[derive(Debug)] +pub enum TemplateDataError { + InvalidCoinbaseTx(ConsensusEncodeError), + InvalidSolution, + InvalidSolutionPoW(ValidationError), + InvalidMerkleRoot, + InvalidBlockVersion, + InvalidCoinbaseTxVersion, + InvalidCoinbaseScriptSig, + FailedToSumCoinbaseOutputs, + CapnpError(capnp::Error), + FailedIpcSubmitSolution, + FailedToSerializeEmptyCoinbaseOutputs, + FailedToSerializeCoinbaseOutputs, + FailedToConvertMerklePathHashToU256, + FailedToCreateMerklePathSeq, + BitcoinCoreSv2TDPError(BitcoinCoreSv2TDPError), +} + +impl From for TemplateDataError { + fn from(error: BitcoinCoreSv2TDPError) -> Self { + TemplateDataError::BitcoinCoreSv2TDPError(error) + } +} + +impl From for TemplateDataError { + fn from(error: ConsensusEncodeError) -> Self { + TemplateDataError::InvalidCoinbaseTx(error) + } +} + +impl From for TemplateDataError { + fn from(error: capnp::Error) -> Self { + TemplateDataError::CapnpError(error) + } +} + +impl std::fmt::Display for TemplateDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TemplateDataError::InvalidCoinbaseTx(e) => { + write!(f, "Invalid coinbase transaction: {e}") + } + TemplateDataError::InvalidSolution => write!(f, "Invalid solution"), + TemplateDataError::InvalidSolutionPoW(e) => write!(f, "Invalid solution: {e}"), + TemplateDataError::InvalidMerkleRoot => write!(f, "Invalid merkle root"), + TemplateDataError::InvalidBlockVersion => write!(f, "Invalid block version"), + TemplateDataError::InvalidCoinbaseTxVersion => { + write!(f, "Invalid coinbase transaction version") + } + TemplateDataError::InvalidCoinbaseScriptSig => { + write!(f, "Invalid coinbase script signature") + } + TemplateDataError::FailedToSerializeEmptyCoinbaseOutputs => { + write!(f, "Failed to serialize empty coinbase outputs") + } + TemplateDataError::FailedToSerializeCoinbaseOutputs => { + write!(f, "Failed to serialize coinbase outputs") + } + TemplateDataError::FailedToSumCoinbaseOutputs => { + write!(f, "Failed to sum coinbase outputs") + } + TemplateDataError::CapnpError(e) => write!(f, "Cap'n Proto error: {e}"), + TemplateDataError::FailedIpcSubmitSolution => { + write!(f, "Failed to submit solution via IPC") + } + TemplateDataError::FailedToConvertMerklePathHashToU256 => { + write!(f, "Failed to convert merkle path hash to U256") + } + TemplateDataError::FailedToCreateMerklePathSeq => { + write!(f, "Failed to create merkle path sequence") + } + TemplateDataError::BitcoinCoreSv2TDPError(error) => { + write!(f, "Bitcoin Core Sv2 error: {error:?}") + } + } + } +} + +impl std::error::Error for TemplateDataError {} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs new file mode 100644 index 000000000..3ddda4863 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs @@ -0,0 +1,234 @@ +//! Handlers for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::{ + BitcoinCoreSv2TDP, error::BitcoinCoreSv2TDPError, +}; +use stratum_core::{ + parsers_sv2::TemplateDistribution, + template_distribution_sv2::{ + CoinbaseOutputConstraints, ERROR_CODE_REQUEST_TRANSACTION_DATA_STALE_TEMPLATE_ID, + ERROR_CODE_REQUEST_TRANSACTION_DATA_TEMPLATE_ID_NOT_FOUND, RequestTransactionData, + RequestTransactionDataError, SubmitSolution, + }, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error}; + +impl BitcoinCoreSv2TDP { + pub(crate) async fn handle_coinbase_output_constraints( + &mut self, + coinbase_output_constraints: CoinbaseOutputConstraints, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!("handle_coinbase_output_constraints() called"); + + // Break the loop in monitor_ipc_templates() and spawn a new one after bootstrapping the + // new template IPC client. We no longer care about templates created under previous + // constraints for future template monitoring. + debug!("Cancelling template_ipc_client_cancellation_token"); + self.template_ipc_client_cancellation_token.cancel(); + + // Wait for the old monitor_ipc_templates task to finish before bootstrapping a new + // template IPC client. + // + // This keeps template monitoring scoped to one coinbase-output constraint set at a time: + // the old monitor interrupts its own in-flight waitNext request and exits before the + // replacement client is published and monitored. + debug!("Waiting for current monitor_ipc_templates() task to finish"); + let handle = self.monitor_ipc_templates_handle.borrow_mut().take(); + #[allow(clippy::collapsible_if)] + if let Some(handle) = handle { + if let Err(e) = handle.await { + error!("monitor_ipc_templates task panicked: {:?}", e); + return Err(BitcoinCoreSv2TDPError::FailedToWaitForMonitorIpcTemplatesTask); + } + } + + self.template_ipc_client_cancellation_token = CancellationToken::new(); + debug!("Created new template_ipc_client_cancellation_token"); + + debug!("Bootstrapping new template IPC client with new constraints"); + self.bootstrap_template_ipc_client_from_coinbase_output_constraints( + coinbase_output_constraints, + ) + .await + .map_err(|e| { + error!("Failed to bootstrap new template IPC client: {:?}", e); + e + })?; + + debug!("Spawning new monitor_ipc_templates() task"); + self.monitor_ipc_templates(); + + Ok(()) + } + + pub(crate) async fn handle_request_transaction_data( + &self, + request_transaction_data: RequestTransactionData, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!( + "handle_request_transaction_data() called for template_id: {}", + request_transaction_data.template_id + ); + + let is_stale = { + let stale_template_ids_guard = self.stale_template_ids.read().map_err(|e| { + error!("Failed to acquire read lock on stale_template_ids: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage + })?; + stale_template_ids_guard.contains(&request_transaction_data.template_id) + }; + if is_stale { + debug!( + "Template {} is stale, sending error response", + request_transaction_data.template_id + ); + let request_transaction_data_error = RequestTransactionDataError { + template_id: request_transaction_data.template_id, + error_code: ERROR_CODE_REQUEST_TRANSACTION_DATA_STALE_TEMPLATE_ID + .to_string() + .try_into() + .expect("error code must be valid string"), + }; + + if let Err(e) = self + .outgoing_messages + .send(TemplateDistribution::RequestTransactionDataError( + request_transaction_data_error.clone(), + )) + .await + { + error!( + "Failed to send RequestTransactionDataError message: {:?}", + e + ); + return Err( + BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage, + ); + } + + return Ok(()); + } + + let template_data = { + let template_data_guard = self.template_data.read().map_err(|e| { + error!("Failed to acquire read lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage + })?; + + // clone so we can drop the read lock and avoid holding it across the await + template_data_guard + .get(&request_transaction_data.template_id) + .cloned() + }; + + let response_message = { + match template_data { + Some(template_data) => { + debug!( + "Template {} found, sending success response", + request_transaction_data.template_id + ); + + let request_transaction_data_success = match template_data + .get_request_transaction_data_success_message(self.thread_map.clone()) + .await + { + Ok(request_transaction_data_success) => request_transaction_data_success, + Err(e) => { + error!("Failed to fetch template tx data: {:?}", e); + return Err(BitcoinCoreSv2TDPError::FailedToFetchTemplateTxData); + } + }; + TemplateDistribution::RequestTransactionDataSuccess( + request_transaction_data_success, + ) + } + None => { + debug!( + "Template {} not found, sending error response", + request_transaction_data.template_id + ); + TemplateDistribution::RequestTransactionDataError(RequestTransactionDataError { + template_id: request_transaction_data.template_id, + error_code: ERROR_CODE_REQUEST_TRANSACTION_DATA_TEMPLATE_ID_NOT_FOUND + .to_string() + .try_into() + .expect("error code must be valid string"), + }) + } + } + }; + + if let Err(e) = self.outgoing_messages.send(response_message.clone()).await { + error!("Failed to send message: {:?}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage); + } + + Ok(()) + } + + pub(crate) async fn handle_submit_solution( + &self, + submit_solution: SubmitSolution<'static>, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!( + "handle_submit_solution() called for template_id: {}", + submit_solution.template_id + ); + let template_data = { + let template_data_guard = self.template_data.read().map_err(|e| { + error!("Failed to acquire read lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::TemplateNotFound + })?; + + let Some(template_data) = template_data_guard.get(&submit_solution.template_id) else { + error!( + "Template data not found for template id: {}", + submit_solution.template_id + ); + debug!( + "Available template IDs: {:?}", + template_data_guard.keys().collect::>() + ); + return Err(BitcoinCoreSv2TDPError::TemplateNotFound); + }; + template_data.clone() + }; + debug!("Found template data for solution submission"); + + let solution_block_dir = self + .unix_socket_path + .parent() + .expect("unix_socket_path must have a parent"); + + let solutions_dir = solution_block_dir.join("solutions"); + + if !solutions_dir.exists() { + std::fs::create_dir_all(&solutions_dir).map_err(|e| { + error!("Failed to create solutions directory: {:?}", e); + BitcoinCoreSv2TDPError::FailedToCreateSolutionDir + })?; + } + + debug!("Submitting solution to Bitcoin Core"); + match template_data + .submit_solution( + submit_solution, + self.thread_ipc_client.clone(), + self.thread_map.clone(), + &solutions_dir, + ) + .await + { + Ok(_) => { + debug!("Solution submitted successfully"); + Ok(()) + } + Err(e) => { + error!("Failed to submit solution: {:?}", e); + Err(BitcoinCoreSv2TDPError::FailedToSubmitSolution) + } + } + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs new file mode 100644 index 000000000..69c52467a --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs @@ -0,0 +1,819 @@ +//! Module for interacting with Bitcoin Core v32.x via Sv2 Template Distribution Protocol via +//! capnp over UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::template_data::TemplateData; +use async_channel::{Receiver, Sender}; +use bitcoin_capnp_types::{ + capnp, + capnp_rpc::{RpcSystem, rpc_twoparty_capnp, twoparty}, + init_capnp::init::Client as InitIpcClient, + mining_capnp::{ + block_template::{ + Client as BlockTemplateIpcClient, wait_next_params::Owned as WaitNextParams, + wait_next_results::Owned as WaitNextResults, + }, + coinbase_tx, + mining::Client as MiningIpcClient, + }, + proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use capnp::capability::Request; +use error::BitcoinCoreSv2TDPError; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + rc::Rc, + sync::atomic::{AtomicU64, Ordering}, + time::Instant, +}; +use stratum_core::{ + binary_sv2::U256, + bitcoin::{ + OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, + absolute::LockTime, + block::Header, + consensus::{Decodable, deserialize}, + transaction::Version as TransactionVersion, + }, + parsers_sv2::TemplateDistribution, + template_distribution_sv2::CoinbaseOutputConstraints, +}; + +use std::sync::RwLock; +use tokio::{net::UnixStream, task::JoinHandle}; +use tokio_util::compat::*; +pub use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +pub mod error; +mod handlers; +mod monitors; +mod template_data; + +const WEIGHT_FACTOR: u32 = 4; +const MIN_BLOCK_RESERVED_WEIGHT: u64 = 2000; + +/// The main abstraction for interacting with Bitcoin Core via Sv2 Template Distribution Protocol. +/// +/// It is instantiated with: +/// - A `&`[`std::path::Path`] to the Bitcoin Core UNIX socket +/// - A `u64` for the fee delta threshold in satoshis +/// - A `u8` for the minimum interval in seconds between template updates +/// - A [`async_channel::Receiver`] for incoming [`TemplateDistribution`] messages (handles +/// [`CoinbaseOutputConstraints`], [`RequestTransactionData`], and [`SubmitSolution`]) +/// - A [`async_channel::Sender`] for outgoing [`TemplateDistribution`] messages +/// - A [`tokio_util::sync::CancellationToken`] to stop the internally spawned tasks +/// +/// The instance waits for the first [`CoinbaseOutputConstraints`] message to be received via the +/// incoming channel before initializing the template IPC client. Upon receiving this message and +/// successfully initializing, the [`BitcoinCoreSv2TDP`] instance sends a `NewTemplate` followed by +/// a corresponding `SetNewPrevHash` message over the outgoing channel. +/// +/// As configured via `fee_threshold`, the [`BitcoinCoreSv2TDP`] instance will monitor the mempool +/// for changes and send a `NewTemplate` message if the fee delta is greater than the configured +/// threshold. +/// +/// When there's a new Chain Tip, the [`BitcoinCoreSv2TDP`] instance will send a `NewTemplate` +/// followed by a corresponding `SetNewPrevHash` message over the outgoing channel. +/// +/// Incoming [`RequestTransactionData`] messages are used to request transactions relative to a +/// specific template, for which a corresponding `RequestTransactionDataSuccess` or +/// `RequestTransactionDataError` message is sent over the outgoing channel. +/// +/// Incoming [`SubmitSolution`] messages are used to submit solutions to a specific template. +#[derive(Clone)] +pub struct BitcoinCoreSv2TDP { + fee_threshold: u64, + min_interval: u8, + thread_map: ThreadMapIpcClient, + thread_ipc_client: ThreadIpcClient, + mining_ipc_client: MiningIpcClient, + monitor_ipc_templates_handle: Rc>>>, + current_template_ipc_client: Rc>>, + current_prev_hash: Rc>>>, + template_data: Rc>>, + stale_template_ids: Rc>>, + template_id_factory: Rc, + incoming_messages: Receiver>, + outgoing_messages: Sender>, + global_cancellation_token: CancellationToken, + template_ipc_client_cancellation_token: CancellationToken, + last_sent_template_instant: Option, + unix_socket_path: PathBuf, +} + +impl BitcoinCoreSv2TDP { + /// Creates a new [`BitcoinCoreSv2TDP`] instance. + #[allow(clippy::too_many_arguments)] + pub async fn new

( + bitcoin_core_unix_socket_path: P, + fee_threshold: u64, + min_interval: u8, + incoming_messages: Receiver>, + outgoing_messages: Sender>, + global_cancellation_token: CancellationToken, + ) -> Result + where + P: AsRef, + { + let bitcoin_core_unix_socket_path = bitcoin_core_unix_socket_path.as_ref(); + info!( + "Creating new BitcoinCoreSv2TDP via IPC over UNIX socket: {}", + bitcoin_core_unix_socket_path.display() + ); + + let stream = UnixStream::connect(bitcoin_core_unix_socket_path) + .await + .map_err(|e| { + BitcoinCoreSv2TDPError::CannotConnectToUnixSocket( + bitcoin_core_unix_socket_path.into(), + e.to_string(), + ) + })?; + let (reader, writer) = stream.into_split(); + let reader_compat = reader.compat(); + let writer_compat = writer.compat_write(); + + let rpc_network = Box::new(twoparty::VatNetwork::new( + reader_compat, + writer_compat, + rpc_twoparty_capnp::Side::Client, + Default::default(), + )); + + let mut rpc_system = RpcSystem::new(rpc_network, None); + let bootstrap_client: InitIpcClient = + rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server); + + tokio::task::spawn_local(rpc_system); + + let construct_response = bootstrap_client.construct_request().send().promise.await?; + + let thread_map: ThreadMapIpcClient = construct_response.get()?.get_thread_map()?; + let thread_request = thread_map.make_thread_request(); + let thread_response = thread_request.send().promise.await?; + + let thread_ipc_client: ThreadIpcClient = thread_response.get()?.get_result()?; + + info!("IPC execution thread client successfully created."); + + let mut mining_client_request = bootstrap_client.make_mining_request(); + mining_client_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + let mining_client_response = mining_client_request.send().promise.await?; + let mining_ipc_client: MiningIpcClient = mining_client_response.get()?.get_result()?; + + info!("IPC mining client successfully created."); + + let template_ipc_client_cancellation_token = CancellationToken::new(); + + Ok(Self { + fee_threshold, + min_interval, + thread_map, + thread_ipc_client, + mining_ipc_client, + monitor_ipc_templates_handle: Rc::new(RefCell::new(None)), + template_id_factory: Rc::new(AtomicU64::new(0)), + current_template_ipc_client: Rc::new(RefCell::new(None)), + current_prev_hash: Rc::new(RefCell::new(None)), + template_data: Rc::new(RwLock::new(HashMap::new())), + stale_template_ids: Rc::new(RwLock::new(HashSet::new())), + global_cancellation_token, + incoming_messages, + outgoing_messages, + template_ipc_client_cancellation_token, + last_sent_template_instant: None, + unix_socket_path: bitcoin_core_unix_socket_path.to_path_buf(), + }) + } + + /// Runs the [`BitcoinCoreSv2TDP`] instance, monitoring for: + /// - Chain Tip changes, for which it will send a `NewTemplate` message, followed by a + /// `SetNewPrevHash` message + /// - incoming [`RequestTransactionData`] messages, for which it will send a + /// `RequestTransactionDataSuccess` or `RequestTransactionDataError` message as a response + /// - incoming [`SubmitSolution`] messages, for which it will submit the solution to the Bitcoin + /// Core IPC client + /// - incoming [`CoinbaseOutputConstraints`] messages, for which it will update the coinbase + /// output constraints + /// + /// Blocks until the cancellation token is activated. + pub async fn run(&mut self) { + // wait for first CoinbaseOutputConstraints message + info!("Waiting for first CoinbaseOutputConstraints message"); + debug!("run() started, waiting for initial CoinbaseOutputConstraints"); + loop { + tokio::select! { + _ = self.global_cancellation_token.cancelled() => { + warn!("Exiting run"); + debug!("run() early exit - global cancellation token activated before first CoinbaseOutputConstraints"); + return; + } + Ok(message) = self.incoming_messages.recv() => { + debug!("run() received message during initial loop: {:?}", message); + match message { + TemplateDistribution::CoinbaseOutputConstraints(coinbase_output_constraints) => { + info!("Received: {:?}", coinbase_output_constraints); + debug!("First CoinbaseOutputConstraints received - max_additional_size: {}, max_additional_sigops: {}", + coinbase_output_constraints.coinbase_output_max_additional_size, + coinbase_output_constraints.coinbase_output_max_additional_sigops); + + match self + .bootstrap_template_ipc_client_from_coinbase_output_constraints( + coinbase_output_constraints, + ) + .await + { + Ok(()) => { + debug!( + "Successfully bootstrapped initial template IPC client" + ); + break; + } + Err(BitcoinCoreSv2TDPError::CreateNewBlockRequestInterrupted) => { + debug!( + "Initial createNewBlock request interrupted during shutdown" + ); + return; + } + Err(e) => { + error!( + "Failed to bootstrap initial template IPC client: {:?}", + e + ); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.global_cancellation_token.cancel(); + return; + } + } + } + _ => { + warn!("Received unexpected message: {:?}", message); + warn!("Ignoring..."); + continue; + } + } + } + } + } + + // spawn the monitoring tasks + debug!("Spawning monitoring tasks..."); + self.monitor_ipc_templates(); + debug!("monitor_ipc_templates() spawned"); + self.monitor_incoming_messages(); + debug!("monitor_incoming_messages() spawned"); + + // block until the global cancellation token is activated + debug!("run() entering main blocking wait for global_cancellation_token"); + self.global_cancellation_token.cancelled().await; + debug!("global_cancellation_token cancelled - beginning shutdown sequence"); + + // Wait for the monitor_ipc_templates task to finish gracefully + debug!("Waiting for monitor_ipc_templates() task to finish"); + let handle = self.monitor_ipc_templates_handle.borrow_mut().take(); + if let Some(handle) = handle { + match handle.await { + Ok(()) => { + debug!("monitor_ipc_templates() task finished successfully"); + } + Err(e) => { + error!( + "error waiting for monitor_ipc_templates task to finish: {:?}", + e + ); + } + } + } + + debug!("run() exiting"); + } + + async fn fetch_template_data( + &self, + template_ipc_client: BlockTemplateIpcClient, + thread_ipc_client: ThreadIpcClient, + ) -> Result { + debug!("Fetching template data over IPC"); + let template_id = self.template_id_factory.fetch_add(1, Ordering::Relaxed); + debug!( + "fetch_template_data() - assigned template_id: {}", + template_id + ); + + let mut template_header_request = template_ipc_client.get_block_header_request(); + template_header_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let template_header_bytes = template_header_request + .send() + .promise + .await? + .get()? + .get_result()? + .to_vec(); + + // Deserialize the template header from Bitcoin Core's serialization format + debug!( + "Deserializing template header ({} bytes)", + template_header_bytes.len() + ); + let header: Header = deserialize(&template_header_bytes)?; + debug!( + "Template header deserialized - prev_hash: {:?}", + header.prev_blockhash + ); + + let mut coinbase_tx_request = template_ipc_client.get_coinbase_tx_request(); + coinbase_tx_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let coinbase_tx_response = coinbase_tx_request.send().promise.await?; + let coinbase_tx_result = coinbase_tx_response.get()?; + let coinbase_tx_reader = coinbase_tx_result.get_result()?; + let (coinbase_tx, block_reward_remaining) = coinbase_tx_from_ipc(coinbase_tx_reader)?; + debug!( + "Coinbase tx built from getCoinbaseTx result: {:?}", + coinbase_tx + ); + + let mut merkle_path_request = template_ipc_client.get_coinbase_merkle_path_request(); + merkle_path_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let merkle_path: Vec> = merkle_path_request + .send() + .promise + .await? + .get()? + .get_result()? + .iter() + .map(|x| x.map(|slice| slice.to_vec())) + .collect::, _>>()?; + + // Create the template data structure + let template_data = TemplateData::new( + template_id, + header, + coinbase_tx, + block_reward_remaining, + merkle_path, + template_ipc_client, + ); + debug!("TemplateData created successfully"); + + Ok(template_data) + } + + async fn new_thread_ipc_client(&self) -> Result { + debug!("Creating new thread IPC client"); + let thread_ipc_client_request = self.thread_map.make_thread_request(); + let thread_ipc_client_response = thread_ipc_client_request.send().promise.await?; + let thread_ipc_client = thread_ipc_client_response.get()?.get_result()?; + + Ok(thread_ipc_client) + } + + fn set_current_template_ipc_client(&self, template_ipc_client: BlockTemplateIpcClient) { + let mut current_template_ipc_client_guard = self.current_template_ipc_client.borrow_mut(); + *current_template_ipc_client_guard = Some(template_ipc_client); + debug!("Updated current_template_ipc_client"); + } + + fn current_template_ipc_client( + &self, + ) -> Result { + match self.current_template_ipc_client.borrow().clone() { + Some(template_ipc_client) => Ok(template_ipc_client), + None => { + error!("Template IPC client not found"); + Err(BitcoinCoreSv2TDPError::TemplateIpcClientNotFound) + } + } + } + + fn store_template_data( + &self, + template_data: &TemplateData, + ) -> Result<(), BitcoinCoreSv2TDPError> { + let mut template_data_guard = self.template_data.write().map_err(|e| { + error!("Failed to acquire write lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + + template_data_guard.insert(template_data.get_template_id(), template_data.clone()); + debug!( + "Saved template data with template_id: {}", + template_data.get_template_id() + ); + + Ok(()) + } + + fn current_template_ids(&self) -> Result, BitcoinCoreSv2TDPError> { + let template_data_guard = self.template_data.read().map_err(|e| { + error!("Failed to acquire read lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + + Ok(template_data_guard.keys().copied().collect()) + } + + async fn publish_template( + &mut self, + template_data: TemplateData, + future_template: bool, + send_set_new_prev_hash: bool, + update_last_sent_template_instant: bool, + ) -> Result<(), BitcoinCoreSv2TDPError> { + let new_template = template_data + .get_new_template_message(future_template) + .map_err(|e| { + error!("Failed to get NewTemplate message: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + let set_new_prev_hash = if send_set_new_prev_hash { + Some(template_data.get_set_new_prev_hash_message()) + } else { + None + }; + + self.store_template_data(&template_data)?; + + if send_set_new_prev_hash { + self.current_prev_hash + .replace(Some(template_data.get_prev_hash())); + debug!( + "Set current_prev_hash to: {}", + template_data.get_prev_hash() + ); + } + + debug!( + "Sending NewTemplate (future={}) with template_id: {}", + future_template, + template_data.get_template_id() + ); + self.outgoing_messages + .send(TemplateDistribution::NewTemplate(new_template)) + .await + .map_err(|e| { + error!("Failed to send NewTemplate message: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + debug!("Successfully sent NewTemplate message"); + + if let Some(set_new_prev_hash) = set_new_prev_hash { + debug!( + "Sending SetNewPrevHash with prev_hash: {}", + template_data.get_prev_hash() + ); + self.outgoing_messages + .send(TemplateDistribution::SetNewPrevHash(set_new_prev_hash)) + .await + .map_err(|e| { + error!("Failed to send SetNewPrevHash message: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendSetNewPrevHashMessage + })?; + debug!("Successfully sent SetNewPrevHash message"); + } + + if update_last_sent_template_instant { + self.last_sent_template_instant = Some(Instant::now()); + } + + Ok(()) + } + + /// Creates a fresh Bitcoin Core Template IPC client from the given + /// [`CoinbaseOutputConstraints`] and immediately sends a `NewTemplate` + `SetNewPrevHash`. + /// + /// This method intentionally couples these operations because every constraints update should + /// make a newly constrained template visible to the Sv2 side right away. On success, it: + /// + /// - creates a new `BlockTemplateIpcClient` configured with the provided constraints + /// - fetches the corresponding `TemplateData` + /// - stores the fetched `TemplateData` + /// - sends `NewTemplate(future_template = true)` + /// - sends the matching `SetNewPrevHash` + /// - updates `current_prev_hash` and `last_sent_template_instant` + /// - stores the client as `current_template_ipc_client` + async fn bootstrap_template_ipc_client_from_coinbase_output_constraints( + &mut self, + coinbase_output_constraints: CoinbaseOutputConstraints, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!( + "bootstrap_template_ipc_client_from_coinbase_output_constraints() called - max_size: {}, max_sigops: {}", + coinbase_output_constraints.coinbase_output_max_additional_size, + coinbase_output_constraints.coinbase_output_max_additional_sigops + ); + + let mut template_ipc_client_request = self.mining_ipc_client.create_new_block_request(); + + template_ipc_client_request + .get() + .get_context() + .map_err(|e| { + error!("Failed to get template IPC client request context: {e}"); + e + })? + .set_thread(self.thread_ipc_client.clone()); + + let mut template_ipc_client_request_options = template_ipc_client_request + .get() + .get_options() + .map_err(|e| { + error!("Failed to get template IPC client request options: {e}"); + e + })?; + + let coinbase_weight = (coinbase_output_constraints.coinbase_output_max_additional_size + * WEIGHT_FACTOR) as u64; + let block_reserved_weight = coinbase_weight.max(MIN_BLOCK_RESERVED_WEIGHT); // 2000 is the minimum block reserved weight + debug!("Setting block_reserved_weight: {block_reserved_weight}"); + template_ipc_client_request_options.set_block_reserved_weight(block_reserved_weight); + template_ipc_client_request_options.set_coinbase_output_max_additional_sigops( + coinbase_output_constraints.coinbase_output_max_additional_sigops as u64, + ); + template_ipc_client_request_options.set_use_mempool(true); + + debug!("Sending createNewBlock request to Bitcoin Core"); + let create_new_block_promise = template_ipc_client_request.send().promise; + let template_ipc_client_response = tokio::select! { + template_ipc_client_response = create_new_block_promise => { + template_ipc_client_response.map_err(|e| { + error!("Failed to send template IPC client request: {}", e); + e + })? + } + _ = self.global_cancellation_token.cancelled() => { + debug!("Interrupting createNewBlock request"); + self.interrupt_create_new_block_request().await?; + return Err(BitcoinCoreSv2TDPError::CreateNewBlockRequestInterrupted); + } + }; + + let template_ipc_client_result = template_ipc_client_response.get().map_err(|e| { + error!("Failed to get template IPC client result: {}", e); + e + })?; + + let template_ipc_client = template_ipc_client_result.get_result().map_err(|e| { + error!("Failed to get template IPC client result: {}", e); + e + })?; + + debug!("Fetching template data from bootstrapped template IPC client"); + let template_data = self + .fetch_template_data(template_ipc_client.clone(), self.thread_ipc_client.clone()) + .await + .map_err(|e| { + error!("Failed to fetch template data: {:?}", e); + e + })?; + + self.publish_template(template_data, true, true, true) + .await?; + self.set_current_template_ipc_client(template_ipc_client); + + Ok(()) + } + + async fn interrupt_wait_request( + &self, + template_ipc_client: &BlockTemplateIpcClient, + ) -> Result<(), BitcoinCoreSv2TDPError> { + let interrupt_wait_request = template_ipc_client.interrupt_wait_request(); + if let Err(e) = interrupt_wait_request.send().promise.await { + error!("Failed to send interrupt wait request: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSendInterruptWaitRequest); + } + + Ok(()) + } + + async fn interrupt_create_new_block_request(&self) -> Result<(), BitcoinCoreSv2TDPError> { + let interrupt_request = self.mining_ipc_client.interrupt_request(); + if let Err(e) = interrupt_request.send().promise.await { + error!("Failed to send interrupt createNewBlock request: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSendInterruptCreateNewBlockRequest); + } + + Ok(()) + } + + async fn new_wait_next_request( + &self, + template_ipc_client: &BlockTemplateIpcClient, + thread_ipc_client: ThreadIpcClient, + ) -> Result, BitcoinCoreSv2TDPError> { + let mut wait_next_request = template_ipc_client.wait_next_request(); + + match wait_next_request.get().get_context() { + Ok(mut context) => context.set_thread(thread_ipc_client.clone()), + Err(e) => { + error!("Failed to set thread: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSetThread); + } + } + + let mut wait_next_request_options = match wait_next_request.get().get_options() { + Ok(options) => options, + Err(e) => { + error!("Failed to get waitNext request options: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToGetWaitNextRequestOptions); + } + }; + + wait_next_request_options.set_fee_threshold(self.fee_threshold as i64); + + // 10 seconds timeout for waitNext requests + // please note that this is NOT how often we expect to get new templates + // it's just the max time we'll wait for the current waitNext request to complete + wait_next_request_options.set_timeout(10_000.0); + + Ok(wait_next_request) + } + + // spawns a task that processes the stale template data after 10s + // we wait 10s in case there's any incoming RequestTransactionData referring to stale templates + // immediately after the chain tip change + async fn process_stale_template_data(&self, stale_template_ids: HashSet) { + let self_clone = self.clone(); + tokio::task::spawn_local(async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // update the stale template ids + { + let mut stale_template_ids_guard = match self_clone.stale_template_ids.write() { + Ok(guard) => guard, + Err(e) => { + error!( + "Failed to acquire write lock on stale_template_ids: {:?}", + e + ); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + *stale_template_ids_guard = stale_template_ids.clone(); + + debug!( + "Marked {} templates as stale: {:?}", + stale_template_ids.len(), + stale_template_ids + ); + } + + // remove the stale template data from the template_data HashMap + let removed_template_data = { + let mut template_data_guard = match self_clone.template_data.write() { + Ok(guard) => guard, + Err(e) => { + error!("Failed to acquire write lock on template_data: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + let mut removed_template_data: Vec = Vec::new(); + + for stale_template_id in &stale_template_ids { + if let Some(template_data) = template_data_guard.remove(stale_template_id) { + removed_template_data.push(template_data); + } + } + + removed_template_data + }; + + debug!("Creating a dedicated thread IPC client for destroy_ipc_client"); + let thread_ipc_client = match self_clone.new_thread_ipc_client().await { + Ok(thread_ipc_client) => thread_ipc_client, + Err(e) => { + error!("Failed to create thread IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + for template_data in removed_template_data { + match template_data + .destroy_ipc_client(thread_ipc_client.clone()) + .await + { + Ok(()) => (), + Err(e) => { + error!("Failed to destroy template IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + } + } + }); + } +} + +fn coinbase_tx_from_ipc( + coinbase_tx: coinbase_tx::Reader<'_>, +) -> Result<(Transaction, u64), BitcoinCoreSv2TDPError> { + let block_reward_remaining: i64 = coinbase_tx.get_block_reward_remaining(); + let block_reward_remaining: u64 = block_reward_remaining + .try_into() + .map_err(|_| BitcoinCoreSv2TDPError::InvalidBlockRewardRemaining(block_reward_remaining))?; + + let witness = { + let witness_bytes = coinbase_tx.get_witness()?; + let mut witness = Witness::new(); + if !witness_bytes.is_empty() { + witness.push(witness_bytes); + } + witness + }; + + let mut required_outputs = Vec::new(); + for output_bytes in coinbase_tx.get_required_outputs()?.iter() { + let output_bytes = output_bytes?; + required_outputs.push(TxOut::consensus_decode(&mut &output_bytes[..])?); + } + + let transaction = Transaction { + version: TransactionVersion::non_standard(coinbase_tx.get_version() as i32), + lock_time: LockTime::from_consensus(coinbase_tx.get_lock_time()), + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(coinbase_tx.get_script_sig_prefix()?.to_vec()), + sequence: Sequence::from_consensus(coinbase_tx.get_sequence()), + witness, + }], + output: required_outputs, + }; + + Ok((transaction, block_reward_remaining)) +} + +#[cfg(test)] +mod tests { + use super::*; + use stratum_core::bitcoin::{Amount, consensus::serialize}; + + #[test] + fn coinbase_tx_from_ipc_builds_transaction_from_struct_fields() { + let required_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(vec![0x6a, 0x24]), + }; + let required_output_bytes = serialize(&required_output); + + let mut message = capnp::message::Builder::new_default(); + let mut coinbase_tx_builder: coinbase_tx::Builder<'_> = message.init_root(); + coinbase_tx_builder.set_version(2); + coinbase_tx_builder.set_sequence(0xffff_fffe); + coinbase_tx_builder.set_script_sig_prefix(&[0x03, 0xaa, 0xbb, 0xcc]); + coinbase_tx_builder.set_witness(&[0x42; 32]); + coinbase_tx_builder.set_block_reward_remaining(5_000_000_000); + coinbase_tx_builder.set_lock_time(840_000); + { + let mut required_outputs = coinbase_tx_builder.reborrow().init_required_outputs(1); + required_outputs.set(0, &required_output_bytes); + } + + let coinbase_tx_reader = coinbase_tx_builder.into_reader(); + let (coinbase_tx, value_remaining) = + coinbase_tx_from_ipc(coinbase_tx_reader).expect("coinbase tx should convert"); + + println!("coinbase_tx: {:?}", coinbase_tx); + + assert_eq!(value_remaining, 5_000_000_000); + assert_eq!(coinbase_tx.version, TransactionVersion::TWO); + assert_eq!(coinbase_tx.lock_time.to_consensus_u32(), 840_000); + assert_eq!(coinbase_tx.input.len(), 1); + assert_eq!(coinbase_tx.input[0].previous_output, OutPoint::null()); + assert_eq!( + coinbase_tx.input[0].sequence, + Sequence::from_consensus(0xffff_fffe) + ); + assert_eq!( + coinbase_tx.input[0].script_sig.as_bytes(), + &[0x03, 0xaa, 0xbb, 0xcc] + ); + assert_eq!(coinbase_tx.input[0].witness.len(), 1); + assert_eq!(&coinbase_tx.input[0].witness[0], &[0x42; 32]); + assert_eq!(coinbase_tx.output, vec![required_output]); + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs new file mode 100644 index 000000000..bf75ae50f --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs @@ -0,0 +1,291 @@ +//! Background monitors for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over +//! UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::BitcoinCoreSv2TDP; + +use bitcoin_capnp_types_v32::capnp; +use stratum_core::parsers_sv2::TemplateDistribution; +use tracing::{debug, error, info, warn}; + +impl BitcoinCoreSv2TDP { + /// Spawns a new task to monitor the IPC templates + /// + /// This task is responsible for: + /// - Creating a dedicated blocking_thread_ipc_client for waitNext requests + /// - Entering a loop to handle waitNext requests + /// - Handling the response from the waitNext request + /// - Updating the current template data + /// - Sending the NewTemplate message + pub fn monitor_ipc_templates(&self) { + let mut self_clone = self.clone(); + + let handle = tokio::task::spawn_local(async move { + debug!("monitor_ipc_templates() task started"); + // a dedicated thread_ipc_client is used for waitNext requests + // this is because waitNext requests are blocking, and we don't want to block the main + // thread where other requests are handled + // + // as soon as this task is cancelled, the blocking_thread_ipc_client is dropped, + // which cleans up the thread on the Bitcoin Core side + debug!("Creating dedicated blocking_thread_ipc_client for waitNext requests"); + let blocking_thread_ipc_client = match self_clone.new_thread_ipc_client().await { + Ok(blocking_thread_ipc_client) => blocking_thread_ipc_client, + Err(e) => { + error!("Failed to create blocking thread IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + let mut template_ipc_client = match self_clone.current_template_ipc_client() { + Ok(template_ipc_client) => template_ipc_client, + Err(e) => { + error!("Failed to get current template IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + debug!("monitor_ipc_templates() entering main loop"); + loop { + debug!("monitor_ipc_templates() loop iteration start"); + + // Create a new request for each iteration + let wait_next_request = match self_clone + .new_wait_next_request(&template_ipc_client, blocking_thread_ipc_client.clone()) + .await + { + Ok(wait_next_request) => wait_next_request, + Err(e) => { + error!("Failed to create waitNext request: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + tokio::select! { + _ = self_clone.global_cancellation_token.cancelled() => { + debug!("Interrupting waitNext request"); + if let Err(e) = self_clone.interrupt_wait_request(&template_ipc_client).await { + error!("Failed to interrupt waitNext request during shutdown: {:?}", e); + } + warn!("Exiting mempool change monitoring loop"); + break; + } + _ = self_clone.template_ipc_client_cancellation_token.cancelled() => { + debug!("Interrupting waitNext request"); + if let Err(e) = self_clone.interrupt_wait_request(&template_ipc_client).await { + error!("Failed to interrupt waitNext request: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + warn!("Exiting mempool change monitoring loop"); + break; + } + wait_next_request_response = wait_next_request.send().promise => { + match wait_next_request_response { + Ok(response) => { + let result = match response.get() { + Ok(result) => result, + Err(e) => { + error!("Failed to get response: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + let new_template_ipc_client = match result.get_result() { + Ok(new_template_ipc_client) => { + debug!("waitNext returned new template IPC client"); + new_template_ipc_client + }, + Err(e) => { + match e.kind { + capnp::ErrorKind::MessageContainsNullCapabilityPointer => { + debug!("waitNext timed out (no mempool changes)"); + debug!("Continuing to next waitNext iteration"); + continue; // Go back to the start of the loop + } + _ => { + error!("Failed to get new template IPC client: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + } + }; + + debug!("Fetching new template data..."); + let new_template_data = match self_clone.fetch_template_data( + new_template_ipc_client.clone(), + blocking_thread_ipc_client.clone(), + ).await { + Ok(new_template_data) => new_template_data, + Err(e) => { + error!("Failed to fetch template data: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + let new_prev_hash = new_template_data.get_prev_hash(); + let current_prev_hash = match self_clone.current_prev_hash.borrow().clone() { + Some(prev_hash) => prev_hash, + None => { + error!("current_prev_hash is not set"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + if new_prev_hash != current_prev_hash { + info!("⛓️ Chain Tip changed! New prev_hash: {}", new_prev_hash); + debug!("CHAIN TIP CHANGE DETECTED - old: {}, new: {}", current_prev_hash, new_prev_hash); + + let stale_template_ids = match self_clone.current_template_ids() { + Ok(stale_template_ids) => stale_template_ids, + Err(e) => { + error!("Failed to collect stale template ids: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + match self_clone.publish_template(new_template_data, true, true, false).await { + Ok(()) => { + self_clone.set_current_template_ipc_client(new_template_ipc_client.clone()); + template_ipc_client = new_template_ipc_client; + } + Err(e) => { + error!("Failed to publish chain-tip template: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + + // process the stale template data after 10s + self_clone.process_stale_template_data(stale_template_ids).await; + } else { + // check if the minimum interval has been reached + if let Some(last_sent_template_instant) = self_clone.last_sent_template_instant { + let elapsed = last_sent_template_instant.elapsed().as_millis(); + let min_interval_millis = self_clone.min_interval as u128 * 1_000; + + // if the minimum interval has not been reached, sleep for the remaining time + if elapsed < min_interval_millis { + let sleep_duration = min_interval_millis - elapsed; + // Safe cast: min_interval is u8 (max 255), so sleep_duration is at most 255,000 ms, + // which fits comfortably in u64 (max: 18,446,744,073,709,551,615) + debug!("Sleeping for {} milliseconds to reach the minimum interval", sleep_duration); + tokio::time::sleep(std::time::Duration::from_millis(sleep_duration as u64)).await; + } + } + + info!("💹 Mempool fees increased! Sending NewTemplate message."); + debug!("MEMPOOL FEE CHANGE DETECTED - sending non-future template"); + + match self_clone.publish_template(new_template_data, false, false, true).await { + Ok(()) => { + self_clone.set_current_template_ipc_client(new_template_ipc_client.clone()); + template_ipc_client = new_template_ipc_client; + } + Err(e) => { + error!("Failed to publish fee-update template: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + } + } + Err(e) => { + debug!("waitNext request failed with error: {}", e); + error!("Failed to get response: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + } + } + } + + debug!("monitor_ipc_templates() task exiting"); + }); + + // Store the handle so we can wait for this task to finish before spawning a new one + // when handle_coinbase_output_constraints is called + *self.monitor_ipc_templates_handle.borrow_mut() = Some(handle); + } + + /// Spawns a new task to monitor the incoming messages + /// + /// This task is responsible for: + /// - Entering a loop to listen for incoming messages + /// - Routing incoming messages to the appropriate handler + pub fn monitor_incoming_messages(&self) { + let mut self_clone = self.clone(); + + tokio::task::spawn_local(async move { + debug!("monitor_incoming_messages() task started"); + loop { + tokio::select! { + _ = self_clone.global_cancellation_token.cancelled() => { + warn!("Exiting incoming messages loop"); + debug!("monitor_incoming_messages() exiting due to cancellation"); + break; + } + Ok(incoming_message) = self_clone.incoming_messages.recv() => { + info!("Received: {}", incoming_message); + debug!("monitor_incoming_messages() processing message"); + + match incoming_message { + TemplateDistribution::CoinbaseOutputConstraints(coinbase_output_constraints) => { + debug!("Received CoinbaseOutputConstraints - max_additional_size: {}, max_additional_sigops: {}", + coinbase_output_constraints.coinbase_output_max_additional_size, + coinbase_output_constraints.coinbase_output_max_additional_sigops); + if let Err(e) = self_clone.handle_coinbase_output_constraints(coinbase_output_constraints).await { + error!("Failed to handle coinbase output constraints: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + TemplateDistribution::RequestTransactionData(request_transaction_data) => { + debug!("Received RequestTransactionData for template_id: {}", request_transaction_data.template_id); + if let Err(e) = self_clone.handle_request_transaction_data(request_transaction_data).await { + error!("Failed to handle request transaction data: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + TemplateDistribution::SubmitSolution(submit_solution) => { + debug!("Received SubmitSolution for template_id: {}", submit_solution.template_id); + if let Err(e) = self_clone.handle_submit_solution(submit_solution).await { + error!("Failed to handle submit solution: {:?}", e); + // no need to activate the global cancellation token here + } + } + _ => { + error!("Received unexpected message: {}", incoming_message); + warn!("Ignoring message"); + continue; + } + } + } + } + } + }); + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs new file mode 100644 index 000000000..5800e6871 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs @@ -0,0 +1,410 @@ +//! Template-data helpers for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over +//! UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::error::TemplateDataError; + +use bitcoin_capnp_types::{ + mining_capnp::block_template::Client as BlockTemplateIpcClient, + proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use std::{fs::File, io::Write, path::Path}; +use stratum_core::bitcoin::{ + Target, Transaction, TxOut, + block::{Block, Header, Version}, + consensus::{deserialize, serialize}, + hashes::{Hash, HashEngine, sha256d}, +}; + +use stratum_core::{ + binary_sv2::{B016M, B064K, B0255, Seq064K, Seq0255, U256}, + template_distribution_sv2::{ + NewTemplate, RequestTransactionDataSuccess, SetNewPrevHash, SubmitSolution, + }, +}; +use tracing::{debug, error, info}; + +#[derive(Clone)] +pub struct TemplateData { + template_id: u64, + header: Header, + coinbase_tx: Transaction, + block_reward_remaining: u64, + merkle_path: Vec>, + template_ipc_client: BlockTemplateIpcClient, +} + +// impl block for public methods +impl TemplateData { + pub fn new( + template_id: u64, + header: Header, + coinbase_tx: Transaction, + block_reward_remaining: u64, + merkle_path: Vec>, + template_ipc_client: BlockTemplateIpcClient, + ) -> Self { + Self { + template_id, + header, + coinbase_tx, + block_reward_remaining, + merkle_path, + template_ipc_client, + } + } + + /// Destroys the template IPC client, cleaning up the resources on the Bitcoin Core side + pub async fn destroy_ipc_client( + &self, + thread_ipc_client: ThreadIpcClient, + ) -> Result<(), TemplateDataError> { + debug!("Destroying template IPC client: {}", self.template_id); + let mut destroy_ipc_client_request = self.template_ipc_client.destroy_request(); + let destroy_ipc_client_request_params = destroy_ipc_client_request.get(); + + destroy_ipc_client_request_params + .get_context()? + .set_thread(thread_ipc_client); + + destroy_ipc_client_request.send().promise.await?; + + Ok(()) + } + + pub fn get_template_id(&self) -> u64 { + self.template_id + } + + pub fn get_new_template_message( + &self, + future_template: bool, + ) -> Result, TemplateDataError> { + let new_template = NewTemplate { + template_id: self.template_id, + future_template, + version: self.get_version()?, + coinbase_tx_version: self.get_coinbase_tx_version()?, + coinbase_prefix: self.get_coinbase_script_sig()?, + coinbase_tx_input_sequence: self.get_coinbase_input_sequence(), + coinbase_tx_value_remaining: self.block_reward_remaining, + coinbase_tx_outputs_count: self.get_required_coinbase_outputs().len() as u32, + coinbase_tx_outputs: self.get_serialized_required_coinbase_outputs()?, + coinbase_tx_locktime: self.get_coinbase_tx_lock_time(), + merkle_path: self.get_merkle_path()?, + }; + Ok(new_template.into_static()) + } + + // please note that `SetNewPrevHash.target` is consensus and not weak-block + // so it's essentially redundant with `SetNewPrevHash.n_bits` + pub fn get_set_new_prev_hash_message(&self) -> SetNewPrevHash<'static> { + let set_new_prev_hash = SetNewPrevHash { + template_id: self.template_id, + prev_hash: self.get_prev_hash(), + header_timestamp: self.get_ntime(), + n_bits: self.get_nbits(), + target: self.get_target(), + }; + set_new_prev_hash.into_static() + } + + pub async fn get_request_transaction_data_success_message( + &self, + thread_map: ThreadMapIpcClient, + ) -> Result, TemplateDataError> { + let request_transaction_data_success = RequestTransactionDataSuccess { + template_id: self.template_id, + transaction_list: self.get_tx_data(thread_map).await?, + excess_data: vec![] + .try_into() + .expect("empty vec should always be valid for B064K"), + }; + Ok(request_transaction_data_success.into_static()) + } + + pub fn get_prev_hash(&self) -> U256<'static> { + self.header.prev_blockhash.to_byte_array().into() + } + + async fn dump_solution_to_disk( + &self, + thread_map: ThreadMapIpcClient, + solution_coinbase_tx: Transaction, + solution_header_version: u32, + solution_header_timestamp: u32, + solution_header_nonce: u32, + path_dir: &Path, + ) { + let self_clone = self.clone(); + let path_dir = path_dir.to_path_buf(); + tokio::task::spawn_local(async move { + debug!("Creating a dedicated thread IPC client for getBlock request"); + + // validate the solution + let solution_header = { + if solution_coinbase_tx.version != self_clone.coinbase_tx.version + || solution_coinbase_tx.lock_time != self_clone.coinbase_tx.lock_time + || solution_coinbase_tx.input.len() != 1 + || solution_coinbase_tx.input[0].sequence + != self_clone.coinbase_tx.input[0].sequence + || solution_coinbase_tx.input[0].witness + != self_clone.coinbase_tx.input[0].witness + || solution_coinbase_tx.input[0].previous_output + != self_clone.coinbase_tx.input[0].previous_output + { + error!("Solution coinbase tx is not congruent with original coinbase tx"); + return; + } + + // Compute merkle root from coinbase transaction and merkle path + let coinbase_txid = solution_coinbase_tx.compute_txid(); + let mut current_hash = *coinbase_txid.as_byte_array(); + + // Combine with each sibling hash in the merkle path + for sibling_hash_bytes in &self_clone.merkle_path { + // Combine current hash with sibling hash and double SHA256 + let mut hasher = sha256d::Hash::engine(); + HashEngine::input(&mut hasher, ¤t_hash); + HashEngine::input(&mut hasher, sibling_hash_bytes); + current_hash = *sha256d::Hash::from_engine(hasher).as_byte_array(); + } + + let solution_header = Header { + version: Version::from_consensus(solution_header_version as i32), + prev_blockhash: self_clone.header.prev_blockhash, + merkle_root: sha256d::Hash::from_byte_array(current_hash).into(), + time: solution_header_timestamp, + nonce: solution_header_nonce, + bits: self_clone.header.bits, + }; + + if let Err(e) = solution_header.validate_pow(solution_header.target()) { + error!("Solution header is not valid: {}", e); + return; + } + + solution_header + }; + + let thread_ipc_client = thread_map + .make_thread_request() + .send() + .promise + .await + .expect("Failed to send thread IPC client request") + .get() + .expect("Failed to get thread IPC client reader") + .get_result() + .expect("Failed to get thread IPC client result"); + + let mut template_block_request = self_clone.template_ipc_client.get_block_request(); + let mut template_block_request_context = template_block_request + .get() + .get_context() + .expect("Failed to get template block request context"); + template_block_request_context.set_thread(thread_ipc_client.clone()); + + let template_block_response = template_block_request + .send() + .promise + .await + .expect("Failed to send template block request"); + let template_block_reader = template_block_response + .get() + .expect("Failed to get template block response"); + let template_block_bytes = template_block_reader + .get_result() + .expect("Failed to get template block result"); + + // Deserialize the complete block template from Bitcoin Core's serialization format + let mut solution_block: Block = + deserialize(template_block_bytes).expect("Failed to deserialize block template"); + + solution_block.txdata[0] = solution_coinbase_tx; + solution_block.header = solution_header; + + let solution_block_bytes = serialize(&solution_block); + let solution_block_hash = solution_block.block_hash().to_string(); + let solution_block_path = path_dir.join(format!("{solution_block_hash}.dat")); + + let mut file = + File::create(&solution_block_path).expect("Failed to create solution block file"); + file.write_all(&solution_block_bytes) + .expect("Failed to write solution block to file"); + info!( + "Solution block dumped to: {}", + solution_block_path.display() + ); + }); + } + + pub async fn submit_solution( + &self, + submit_solution: SubmitSolution<'static>, + thread_ipc_client: ThreadIpcClient, + thread_map: ThreadMapIpcClient, + path_dir: &Path, + ) -> Result<(), TemplateDataError> { + let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.as_ref().to_vec(); + + let solution_coinbase_tx: Transaction = + deserialize(&solution_coinbase_tx_bytes).map_err(|e| { + error!("SubmitSolution.coinbase_tx is invalid: {}", e); + TemplateDataError::InvalidCoinbaseTx(e) + })?; + + // spawn a task to dump the solution to disk + self.dump_solution_to_disk( + thread_map.clone(), + solution_coinbase_tx, + submit_solution.version, + submit_solution.header_timestamp, + submit_solution.header_nonce, + path_dir, + ) + .await; + + let mut submit_solution_request = self.template_ipc_client.submit_solution_request(); + let mut submit_solution_request_params = submit_solution_request.get(); + + submit_solution_request_params.set_version(submit_solution.version); + submit_solution_request_params.set_timestamp(submit_solution.header_timestamp); + submit_solution_request_params.set_nonce(submit_solution.header_nonce); + submit_solution_request_params.set_coinbase(&solution_coinbase_tx_bytes); + + submit_solution_request_params + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let submit_solution_response = submit_solution_request.send().promise.await?; + + if !submit_solution_response.get()?.get_result() { + return Err(TemplateDataError::FailedIpcSubmitSolution); + } + + Ok(()) + } +} + +// impl block for private methods +impl TemplateData { + fn get_nbits(&self) -> u32 { + self.header.bits.to_consensus() + } + + fn get_target(&self) -> U256<'_> { + let target = Target::from(self.header.bits); + let target_bytes: [u8; 32] = target.to_le_bytes(); + U256::from(target_bytes) + } + + fn get_ntime(&self) -> u32 { + self.header.time + } + + fn get_version(&self) -> Result { + self.header + .version + .to_consensus() + .try_into() + .map_err(|_| TemplateDataError::InvalidBlockVersion) + } + + fn get_coinbase_tx_version(&self) -> Result { + self.coinbase_tx + .version + .0 + .try_into() + .map_err(|_| TemplateDataError::InvalidCoinbaseTxVersion) + } + + fn get_coinbase_script_sig(&self) -> Result, TemplateDataError> { + let coinbase_script_sig: B0255 = self.coinbase_tx.input[0] + .script_sig + .to_bytes() + .try_into() + .map_err(|_| TemplateDataError::InvalidCoinbaseScriptSig)?; + Ok(coinbase_script_sig) + } + + fn get_coinbase_input_sequence(&self) -> u32 { + self.coinbase_tx.input[0].sequence.to_consensus_u32() + } + + fn get_required_coinbase_outputs(&self) -> &[TxOut] { + &self.coinbase_tx.output + } + + fn get_serialized_required_coinbase_outputs(&self) -> Result, TemplateDataError> { + let mut serialized_required_coinbase_outputs = Vec::new(); + for output in self.get_required_coinbase_outputs() { + serialized_required_coinbase_outputs.extend_from_slice(&serialize(output)); + } + let serialized_required_coinbase_outputs: B064K = serialized_required_coinbase_outputs + .try_into() + .map_err(|_| TemplateDataError::FailedToSerializeCoinbaseOutputs)?; + Ok(serialized_required_coinbase_outputs) + } + + fn get_coinbase_tx_lock_time(&self) -> u32 { + self.coinbase_tx.lock_time.to_consensus_u32() + } + + async fn get_tx_data( + &self, + thread_map: ThreadMapIpcClient, + ) -> Result>, TemplateDataError> { + debug!("Creating a dedicated thread IPC client for get_tx_data"); + let thread_ipc_client_request = thread_map.make_thread_request(); + let thread_ipc_client_response = thread_ipc_client_request.send().promise.await?; + let thread_ipc_client = thread_ipc_client_response.get()?.get_result()?; + + let mut template_block_request = self.template_ipc_client.get_block_request(); + template_block_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let template_block_response = template_block_request.send().promise.await?; + let template_block_bytes = template_block_response.get()?.get_result()?; + + // Deserialize the complete block template from Bitcoin Core's serialization format + debug!( + "Deserializing block template ({} bytes)", + template_block_bytes.len() + ); + let block: Block = deserialize(template_block_bytes)?; + debug!( + "Block deserialized - prev_hash from header: {:?}", + block.header.prev_blockhash + ); + + let tx_data: Vec> = block + .txdata + .iter() + .skip(1) // skip coinbase tx + .map(|tx| { + serialize(tx) + .try_into() + .expect("tx data should always be valid for B016M") + }) + .collect(); + Ok(Seq064K::new(tx_data).expect("tx data should always be valid for Seq064K")) + } + + fn get_merkle_path(&self) -> Result>, TemplateDataError> { + // Convert each Vec in the merkle path to U256 + let merkle_path_u256: Vec> = self + .merkle_path + .iter() + .map(|hash_bytes| { + // Convert Vec to U256 + U256::try_from(hash_bytes.clone()) + .map_err(|_| TemplateDataError::FailedToConvertMerklePathHashToU256) + }) + .collect::, _>>()?; + + Seq0255::new(merkle_path_u256).map_err(|_| TemplateDataError::FailedToCreateMerklePathSeq) + } +} diff --git a/integration-tests/Cargo.lock b/integration-tests/Cargo.lock index d72747484..2f1a01633 100644 --- a/integration-tests/Cargo.lock +++ b/integration-tests/Cargo.lock @@ -1072,6 +1072,16 @@ dependencies = [ "capnpc", ] +[[package]] +name = "bitcoin-capnp-types" +version = "0.2.1" +source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -1103,7 +1113,8 @@ version = "0.4.0" dependencies = [ "async-channel 1.9.0", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", "stratum-core", "tokio", "tokio-util", diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index 9fa14b820..426330e19 100644 --- a/integration-tests/lib/template_provider.rs +++ b/integration-tests/lib/template_provider.rs @@ -64,6 +64,7 @@ fn release_version(version: BitcoinCoreVersion) -> &'static str { match version { BitcoinCoreVersion::V30X => BITCOIN_CORE_V30X, BitcoinCoreVersion::V31X => BITCOIN_CORE_V31X, + BitcoinCoreVersion::V32X => BITCOIN_CORE_V31X, } } diff --git a/miner-apps/Cargo.lock b/miner-apps/Cargo.lock index be79dc9c6..6a2fc7218 100644 --- a/miner-apps/Cargo.lock +++ b/miner-apps/Cargo.lock @@ -884,6 +884,16 @@ dependencies = [ "capnpc", ] +[[package]] +name = "bitcoin-capnp-types" +version = "0.2.1" +source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -917,7 +927,8 @@ version = "0.4.0" dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", "stratum-core", "tokio", "tokio-util", diff --git a/pool-apps/Cargo.lock b/pool-apps/Cargo.lock index 902f8d41c..ea562e5f2 100644 --- a/pool-apps/Cargo.lock +++ b/pool-apps/Cargo.lock @@ -318,6 +318,16 @@ dependencies = [ "capnpc", ] +[[package]] +name = "bitcoin-capnp-types" +version = "0.2.1" +source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -351,7 +361,8 @@ version = "0.4.0" dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", "stratum-core", "tokio", "tokio-util", diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 2cacc73f3..627d18c3b 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -26,10 +26,10 @@ use stratum_apps::{ stratum_core::{ bitcoin::{ self, - block::Version, + block::{Header, Version}, consensus::{Decodable, Encodable}, hashes::Hash, - BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, + Block, BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, }, job_declaration_sv2::{ DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution, @@ -677,12 +677,19 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { downstream_id: DownstreamId, push_solution: PushSolution<'_>, ) { - // Convert to static lifetime for channel transfer - let push_solution_static = push_solution.into_static(); - - // Send request to BitcoinCoreSv2JDP (fire-and-forget) + // TODO(#441): replace placeholder with full block reconstruction from declared job state. let request = JdRequest::PushSolution { - push_solution: push_solution_static, + block: Block { + header: Header { + version: Version::from_consensus(push_solution.version as i32), + prev_blockhash: BlockHash::from_byte_array(push_solution.prev_hash.to_array()), + merkle_root: TxMerkleNode::all_zeros(), + time: push_solution.ntime, + bits: CompactTarget::from_consensus(push_solution.nbits), + nonce: push_solution.nonce, + }, + txdata: Vec::new(), + }, }; if let Err(e) = self.request_sender.send(request).await { diff --git a/stratum-apps/Cargo.lock b/stratum-apps/Cargo.lock index cbc7f9c38..7b8337907 100644 --- a/stratum-apps/Cargo.lock +++ b/stratum-apps/Cargo.lock @@ -819,6 +819,16 @@ dependencies = [ "capnpc", ] +[[package]] +name = "bitcoin-capnp-types" +version = "0.2.1" +source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -852,7 +862,8 @@ version = "0.4.0" dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", "stratum-core", "tokio", "tokio-util", diff --git a/stratum-apps/src/tp_type.rs b/stratum-apps/src/tp_type.rs index 03ca1bac7..9d88dfb3a 100644 --- a/stratum-apps/src/tp_type.rs +++ b/stratum-apps/src/tp_type.rs @@ -25,7 +25,7 @@ where let major = ::deserialize(deserializer)?; BitcoinCoreVersion::try_from(major).map_err(|unsupported| { serde::de::Error::custom(format!( - "unsupported Bitcoin Core IPC version: {unsupported}. expected 30 or 31" + "unsupported Bitcoin Core IPC version: {unsupported}. expected 30, 31, or 32" )) }) } @@ -142,7 +142,7 @@ mod tests { #[cfg(feature = "bitcoin-core-sv2")] #[test] - fn bitcoin_core_version_accepts_30_and_31() { + fn bitcoin_core_version_accepts_30_31_and_32() { assert!(matches!( BitcoinCoreVersion::try_from(30), Ok(BitcoinCoreVersion::V30X) @@ -151,12 +151,16 @@ mod tests { BitcoinCoreVersion::try_from(31), Ok(BitcoinCoreVersion::V31X) )); + assert!(matches!( + BitcoinCoreVersion::try_from(32), + Ok(BitcoinCoreVersion::V32X) + )); } #[cfg(feature = "bitcoin-core-sv2")] #[test] fn bitcoin_core_version_rejects_unsupported_values() { assert!(BitcoinCoreVersion::try_from(29).is_err()); - assert!(BitcoinCoreVersion::try_from(32).is_err()); + assert!(BitcoinCoreVersion::try_from(33).is_err()); } } From cde48856ab94b35b1d82410db0278754c9c9be16 Mon Sep 17 00:00:00 2001 From: plebhash Date: Thu, 2 Jul 2026 13:30:15 -0300 Subject: [PATCH 07/19] reconstruct and submit full blocks on PushSolution Reconstruct a full block at JDS when receiving PushSolution and submit it to Bitcoin Core via submitBlock. Follow the clarified Job Declaration semantics from sv2-spec. Reference: https://github.com/stratum-mining/sv2-spec/issues/188 Reference: https://github.com/stratum-mining/sv2-spec/pull/189 Design choice (KISS): JDS keeps only the latest declared custom job per downstream connection and only attempts PushSolution propagation against that entry. It does not attempt propagation for previously declared jobs. --- .../job_validation/bitcoin_core_ipc.rs | 188 ++++++++++++++---- .../lib/job_declarator/job_validation/mod.rs | 3 + 2 files changed, 156 insertions(+), 35 deletions(-) diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 627d18c3b..8440b26d4 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -59,6 +59,10 @@ use stratum_apps::{ utils::types::{DownstreamId, JdToken, RequestId}, }; +// Accept version rolling in PushSolution by ignoring these bits when comparing with the +// declared job version. +const PUSH_SOLUTION_VERSION_ROLLING_MASK: u32 = 0x1fff_ffe0; + /// Snapshot of a previously declared mining job, stored after a `DeclareMiningJob` is /// successfully validated (or while waiting for missing transactions). /// @@ -72,6 +76,9 @@ struct DeclaredCustomJob { validated: bool, } +/// Latest `DeclaredCustomJob` accepted via `SetCustomMiningJob` for a downstream. +type ActiveCustomJob = DeclaredCustomJob; + #[derive(Clone, Copy)] struct AllocatedTokenEntry { request_id: RequestId, @@ -82,6 +89,7 @@ struct AllocatedTokenEntry { struct DownstreamState { declared_custom_jobs: HashMap, allocated_token_entries: HashMap, + active_custom_job: Option, } #[cfg_attr(not(test), hotpath::measure_all)] @@ -101,14 +109,14 @@ impl DeclaredCustomJob { self.validation_context.prev_hash } - /// Reconstructs the declared coinbase transaction by concatenating prefix, extranonce (zeros), - /// and suffix. + /// Reconstructs the declared coinbase transaction by concatenating prefix, extranonce, and + /// suffix. /// /// The extranonce size is calculated from the scriptSig size in the coinbase_tx_prefix /// /// Error type is () because we don't need extra granularity for error_code = /// "invalid-coinbase-tx" - fn get_coinbase_tx(&self) -> Result { + fn get_coinbase_tx(&self, extranonce: Option<&[u8]>) -> Result { let declared_coinbase_tx_prefix: Vec = self.declare_mining_job.coinbase_tx_prefix.to_owned_bytes(); let declared_coinbase_tx_suffix: Vec = @@ -141,10 +149,24 @@ impl DeclaredCustomJob { // The full extranonce fills the remaining space in scriptSig let full_extranonce_size: usize = script_sig_size - script_sig_bytes_in_prefix; - // Concatenate prefix + full extranonce (zeros) + suffix to form the complete transaction - // bytes + let extranonce_bytes = match extranonce { + Some(bytes) => { + if bytes.len() != full_extranonce_size { + tracing::error!( + "PushSolution extranonce size mismatch: expected {}, got {}", + full_extranonce_size, + bytes.len() + ); + return Err(()); + } + bytes.to_vec() + } + None => vec![0; full_extranonce_size], + }; + + // Concatenate prefix + extranonce + suffix to form the complete transaction bytes. let mut declared_coinbase_tx = declared_coinbase_tx_prefix; - declared_coinbase_tx.extend_from_slice(&vec![0; full_extranonce_size]); + declared_coinbase_tx.extend_from_slice(&extranonce_bytes); declared_coinbase_tx.extend_from_slice(&declared_coinbase_tx_suffix); // Deserialize the transaction @@ -176,7 +198,7 @@ impl DeclaredCustomJob { let txdata = self.txdata.as_ref().ok_or(())?; let coinbase_tx = self - .get_coinbase_tx() + .get_coinbase_tx(None) .expect("coinbase tx already validated"); let coinbase_txid: TxMerkleNode = coinbase_tx.compute_txid().into(); @@ -221,7 +243,7 @@ impl DeclaredCustomJob { #[derive(Clone)] pub struct BitcoinCoreIPCEngine { request_sender: async_channel::Sender, - downstream_state: SharedMap, + downstream_states: SharedMap, cancellation_token: CancellationToken, jdp_thread_handle: Arc>>>, } @@ -233,7 +255,7 @@ impl BitcoinCoreIPCEngine { /// Spawns a dedicated thread running BitcoinCoreSv2JDP in a LocalSet for handling /// the !Send Cap'n Proto client. /// - /// `version` selects the Bitcoin Core IPC schema family (v30.x or v31.x). + /// `version` selects the Bitcoin Core IPC schema family (v30.x, v31.x, or v32.x). /// /// Blocks until the mempool mirror is bootstrapped and ready to process requests. pub async fn new( @@ -356,11 +378,11 @@ impl BitcoinCoreIPCEngine { } } - let downstream_state = SharedMap::::new(); + let downstream_states = SharedMap::::new(); // Spawn janitor task to clean up stale declared jobs that were never // consumed by SetCustomMiningJob. - let janitor_downstream_state = downstream_state.clone(); + let janitor_downstream_states = downstream_states.clone(); let janitor_cancellation = cancellation_token.clone(); tokio::spawn(async move { let janitor_interval = Duration::from_secs(JANITOR_INTERVAL_SECS); @@ -370,7 +392,7 @@ impl BitcoinCoreIPCEngine { _ = janitor_cancellation.cancelled() => break, _ = tokio::time::sleep(janitor_interval) => { let now = Instant::now(); - janitor_downstream_state.for_each_mut(|downstream_id, state| { + janitor_downstream_states.for_each_mut(|downstream_id, state| { let expired_tokens: Vec = state .allocated_token_entries .iter() @@ -404,7 +426,7 @@ impl BitcoinCoreIPCEngine { Ok(Self { request_sender, - downstream_state, + downstream_states, cancellation_token, jdp_thread_handle: Arc::new(Mutex::new(Some(jdp_thread_handle))), }) @@ -435,7 +457,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } fn cleanup_downstream(&self, downstream_id: DownstreamId) { - self.downstream_state.remove(&downstream_id); + self.downstream_states.remove(&downstream_id); } /// Validates a `DeclareMiningJob` by forwarding it to Bitcoin Core over IPC. @@ -480,7 +502,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { validated: false, // irrelevant for coinbase tx validation }; - match temp_job.get_coinbase_tx() { + match temp_job.get_coinbase_tx(None) { Ok(tx) => { tracing::debug!("Declared coinbase transaction validated successfully"); tx @@ -530,7 +552,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { let previous_pending_validation_context = provide_missing_transactions_success.as_ref().and_then(|_| { - self.downstream_state + self.downstream_states .with(&downstream_id, |state| { state .declared_custom_jobs @@ -586,7 +608,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { txdata: Some(txdata), validated: true, }; - self.downstream_state + self.downstream_states .with_mut_or_default(downstream_id, |state| { state .declared_custom_jobs @@ -605,7 +627,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { error_code, validation_context, } => { - self.downstream_state.with_mut(&downstream_id, |state| { + self.downstream_states.with_mut(&downstream_id, |state| { state .declared_custom_jobs .remove(&declare_mining_job.request_id); @@ -637,7 +659,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // If this is a retry after ProvideMissingTransactionsSuccess and context drifted, // classify as stale-chain-tip instead of asking for yet another missing-txs round. if provide_missing_transactions_success.is_some() && tip_drifted { - self.downstream_state.with_mut(&downstream_id, |state| { + self.downstream_states.with_mut(&downstream_id, |state| { state .declared_custom_jobs .remove(&declare_mining_job.request_id); @@ -652,7 +674,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { txdata: None, validated: false, // this is only set to true on JdResponse::Success }; - self.downstream_state + self.downstream_states .with_mut_or_default(downstream_id, |state| { state .declared_custom_jobs @@ -677,20 +699,102 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { downstream_id: DownstreamId, push_solution: PushSolution<'_>, ) { - // TODO(#441): replace placeholder with full block reconstruction from declared job state. - let request = JdRequest::PushSolution { - block: Block { - header: Header { - version: Version::from_consensus(push_solution.version as i32), - prev_blockhash: BlockHash::from_byte_array(push_solution.prev_hash.to_array()), - merkle_root: TxMerkleNode::all_zeros(), - time: push_solution.ntime, - bits: CompactTarget::from_consensus(push_solution.nbits), - nonce: push_solution.nonce, - }, - txdata: Vec::new(), + let prev_hash = BlockHash::from_byte_array(push_solution.prev_hash.to_array()); + + // Validate PushSolution fields and consume the matching active custom job atomically. + // prev_hash and nbits must match exactly; version is matched on non-rollable bits only. + let active_job = self + .downstream_states + .with_mut(&downstream_id, |state| { + let (declared_prev_hash, declared_nbits, declared_version) = + match state.active_custom_job.as_ref() { + Some(active_job) => ( + active_job.get_prev_hash(), + active_job.get_nbits(), + active_job.get_version(), + ), + None => { + tracing::error!( + "No active custom job found for PushSolution on downstream {}", + downstream_id, + ); + return None; + } + }; + + let declared_fixed_version_bits = + declared_version & !PUSH_SOLUTION_VERSION_ROLLING_MASK; + let solved_fixed_version_bits = + push_solution.version & !PUSH_SOLUTION_VERSION_ROLLING_MASK; + + if prev_hash != declared_prev_hash + || push_solution.nbits != declared_nbits + || solved_fixed_version_bits != declared_fixed_version_bits + { + tracing::error!( + "Ignoring PushSolution that does not match latest declared custom job on downstream {}: expected prev_hash={:?}, nbits={}, version={}, got prev_hash={:?}, nbits={}, version={} (mask=0x{:08x}, expected_fixed_version_bits=0x{:08x}, got_fixed_version_bits=0x{:08x})", + downstream_id, + declared_prev_hash, + declared_nbits, + declared_version, + prev_hash, + push_solution.nbits, + push_solution.version, + PUSH_SOLUTION_VERSION_ROLLING_MASK, + declared_fixed_version_bits, + solved_fixed_version_bits + ); + return None; + } + + state.active_custom_job.take() + }) + .flatten(); + + let Some(active_job) = active_job else { + return; + }; + + let declared_prev_hash = active_job.get_prev_hash(); + + let mut txdata = match active_job.txdata.clone() { + Some(txdata) => txdata, + None => { + tracing::error!("Active custom job is missing transaction data"); + return; + } + }; + + let coinbase_tx = match active_job.get_coinbase_tx(Some(push_solution.extranonce.as_ref())) + { + Ok(coinbase_tx) => coinbase_tx, + Err(_) => { + tracing::error!("Failed to reconstruct solved coinbase transaction"); + return; + } + }; + + txdata.insert(0, coinbase_tx); + + let mut block = Block { + header: Header { + version: Version::from_consensus(push_solution.version as i32), + prev_blockhash: declared_prev_hash, + merkle_root: TxMerkleNode::all_zeros(), + time: push_solution.ntime, + bits: CompactTarget::from_consensus(push_solution.nbits), + nonce: push_solution.nonce, }, + txdata, + }; + + let Some(merkle_root) = block.compute_merkle_root() else { + tracing::error!("Failed to compute merkle root for PushSolution block"); + return; }; + block.header.merkle_root = merkle_root; + + let request = JdRequest::PushSolution { block }; if let Err(e) = self.request_sender.send(request).await { tracing::error!(downstream_id, "Failed to send PushSolution request: {}", e); @@ -716,7 +820,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { ) -> SetCustomMiningJobResult { // Look up request_id using the allocated token let request_id = match self - .downstream_state + .downstream_states .with(&downstream_id, |state| { state .allocated_token_entries @@ -739,7 +843,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { }; let declared_custom_job = match self - .downstream_state + .downstream_states .with_mut(&downstream_id, |state| { // Clean up immediately - the job is being consumed regardless of validation result. state.allocated_token_entries.remove(&allocated_token); @@ -826,7 +930,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // validate coinbase tx { - let declared_coinbase_tx = match declared_custom_job.get_coinbase_tx() { + let declared_coinbase_tx = match declared_custom_job.get_coinbase_tx(None) { Ok(tx) => tx, Err(_) => { return SetCustomMiningJobResult::Error( @@ -920,6 +1024,20 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } + self.downstream_states + .with_mut_or_default(downstream_id, |state| { + if state + .active_custom_job + .replace(declared_custom_job) + .is_some() + { + tracing::debug!( + "Replaced previous active custom job for downstream {} with newer SetCustomMiningJob", + downstream_id, + ); + } + }); + SetCustomMiningJobResult::Success } } diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs index 39065ff0d..ee4ba326c 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs @@ -36,6 +36,9 @@ pub trait JobValidationEngine: Send + Sync { ) -> DeclareMiningJobResult; /// Submits a mining solution to the backend. + /// + /// Implementations should treat `prev_hash` and `nbits` as exact-match fields, and may allow + /// version rolling by validating only non-rollable bits of `PushSolution.version`. async fn handle_push_solution( &self, downstream_id: DownstreamId, From 9ea44465807281c271a96bd9109b4f86b01b7e00 Mon Sep 17 00:00:00 2001 From: plebhash Date: Thu, 2 Jul 2026 13:30:30 -0300 Subject: [PATCH 08/19] WIP: prepare v32 integration test harness --- integration-tests/lib/template_provider.rs | 139 ++++++++++++++------- 1 file changed, 91 insertions(+), 48 deletions(-) diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index 426330e19..6cafa7a90 100644 --- a/integration-tests/lib/template_provider.rs +++ b/integration-tests/lib/template_provider.rs @@ -16,6 +16,12 @@ use crate::utils::{fs_utils, http, tarball}; const VERSION_SV2_TP: &str = "1.1.0"; const BITCOIN_CORE_V30X: &str = "30.2"; const BITCOIN_CORE_V31X: &str = "31.0"; +const BITCOIN_CORE_V32X: &str = "32.0"; +// TEMPORARY: keep BITCOIN_CORE_V32_BINARY_ENV / BITCOIN_CORE_V32_BINARY_DEFAULT only +// while v32 tests depend on a local Bitcoin Core build path. Remove once v32 follows the +// standard binary resolution flow used by other versions. +const BITCOIN_CORE_V32_BINARY_ENV: &str = "BITCOIN_CORE_V32_BINARY"; +const BITCOIN_CORE_V32_BINARY_DEFAULT: &str = "/Users/plebhash/develop/bitcoin/build/bin/bitcoin"; /// Allow static signet fixtures to leave IBD without freezing Bitcoin Core's /// clock, so mined blocks still use wall-clock timestamps. /// @@ -58,16 +64,38 @@ fn get_bitcoin_core_filename(os: &str, arch: &str, bitcoin_core_version: &str) - } } -pub const BITCOIN_CORE_LATEST: BitcoinCoreVersion = BitcoinCoreVersion::V31X; +pub const BITCOIN_CORE_LATEST: BitcoinCoreVersion = BitcoinCoreVersion::V32X; fn release_version(version: BitcoinCoreVersion) -> &'static str { match version { BitcoinCoreVersion::V30X => BITCOIN_CORE_V30X, BitcoinCoreVersion::V31X => BITCOIN_CORE_V31X, - BitcoinCoreVersion::V32X => BITCOIN_CORE_V31X, + BitcoinCoreVersion::V32X => BITCOIN_CORE_V32X, } } +fn resolve_v32_node_binary() -> PathBuf { + let configured_path = env::var(BITCOIN_CORE_V32_BINARY_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(BITCOIN_CORE_V32_BINARY_DEFAULT)); + + if configured_path.file_name().and_then(|name| name.to_str()) == Some("bitcoin") { + if let Some(parent) = configured_path.parent() { + let bitcoin_node = parent.join("bitcoin-node"); + if bitcoin_node.exists() { + return bitcoin_node; + } + + let bitcoind = parent.join("bitcoind"); + if bitcoind.exists() { + return bitcoind; + } + } + } + + configured_path +} + /// Represents the consensus difficulty level of the network. /// /// Low: regtest mode (every share is a block) @@ -175,58 +203,73 @@ impl BitcoinCore { } } - // Download and setup Bitcoin Core with IPC support - let bitcoin_core_version = release_version(node_version); + // Download and setup Bitcoin Core with IPC support. + // During the v32 draft phase, we use a local placeholder binary until + // official Bitcoin Core v32 release artifacts are available. let os = env::consts::OS; - let arch = env::consts::ARCH; - let bitcoin_filename = get_bitcoin_core_filename(os, arch, bitcoin_core_version); - let bitcoin_home = bin_dir.join(format!("bitcoin-{bitcoin_core_version}")); - let bitcoin_node_bin = bitcoin_home.join("libexec").join("bitcoin-node"); - let bitcoin_cli_bin = bitcoin_home.join("bin").join("bitcoin-cli"); - - if !bitcoin_node_bin.exists() { - let tarball_bytes = match env::var("BITCOIN_CORE_TARBALL_FILE") { - Ok(path) => tarball::read_from_file(&path), - Err(_) => { - warn!( - "Downloading Bitcoin Core {} for the testing session. This could take a while...", - bitcoin_core_version - ); - let download_endpoint = env::var("BITCOIN_CORE_DOWNLOAD_ENDPOINT") - .unwrap_or_else(|_| { - format!( - "https://bitcoincore.org/bin/bitcoin-core-{bitcoin_core_version}" - ) - }); - let url = format!("{download_endpoint}/{bitcoin_filename}"); - http::make_get_request(&url, 5) - } - }; - - if let Some(parent) = bitcoin_home.parent() { - create_dir_all(parent).unwrap(); - } - - tarball::unpack(&tarball_bytes, &bin_dir); - + let bitcoin_node_bin = if node_version == BitcoinCoreVersion::V32X { + let binary = resolve_v32_node_binary(); assert!( - bitcoin_node_bin.exists(), - "Bitcoin Core node binary not found after unpack in {}", - bitcoin_home.display() + binary.exists(), + "Bitcoin Core v32 placeholder binary not found at {}. Set {} to override.", + binary.display(), + BITCOIN_CORE_V32_BINARY_ENV, ); + binary + } else { + let bitcoin_core_version = release_version(node_version); + let arch = env::consts::ARCH; + let bitcoin_filename = get_bitcoin_core_filename(os, arch, bitcoin_core_version); + let bitcoin_home = bin_dir.join(format!("bitcoin-{bitcoin_core_version}")); + let bitcoin_node_bin = bitcoin_home.join("libexec").join("bitcoin-node"); + let bitcoin_cli_bin = bitcoin_home.join("bin").join("bitcoin-cli"); + + if !bitcoin_node_bin.exists() { + let tarball_bytes = match env::var("BITCOIN_CORE_TARBALL_FILE") { + Ok(path) => tarball::read_from_file(&path), + Err(_) => { + warn!( + "Downloading Bitcoin Core {} for the testing session. This could take a while...", + bitcoin_core_version + ); + let download_endpoint = env::var("BITCOIN_CORE_DOWNLOAD_ENDPOINT") + .unwrap_or_else(|_| { + format!( + "https://bitcoincore.org/bin/bitcoin-core-{bitcoin_core_version}" + ) + }); + let url = format!("{download_endpoint}/{bitcoin_filename}"); + http::make_get_request(&url, 5) + } + }; - // Sign the binaries on macOS - if os == "macos" { - for bin in &[&bitcoin_node_bin, &bitcoin_cli_bin] { - std::process::Command::new("codesign") - .arg("--sign") - .arg("-") - .arg(bin) - .output() - .expect("Failed to sign Bitcoin Core binary"); + if let Some(parent) = bitcoin_home.parent() { + create_dir_all(parent).unwrap(); + } + + tarball::unpack(&tarball_bytes, &bin_dir); + + assert!( + bitcoin_node_bin.exists(), + "Bitcoin Core node binary not found after unpack in {}", + bitcoin_home.display() + ); + + // Sign the binaries on macOS + if os == "macos" { + for bin in &[&bitcoin_node_bin, &bitcoin_cli_bin] { + std::process::Command::new("codesign") + .arg("--sign") + .arg("-") + .arg(bin) + .output() + .expect("Failed to sign Bitcoin Core binary"); + } } } - } + + bitcoin_node_bin + }; // Add IPC and basic args conf.args.extend(vec![ From 82fa2989cd6753f7c7c1ee299dd26ecdd20a53d2 Mon Sep 17 00:00:00 2001 From: plebhash Date: Thu, 2 Jul 2026 13:30:34 -0300 Subject: [PATCH 09/19] enable v32 integration coverage --- integration-tests/tests/bitcoin_core_ipc_jdp_io.rs | 5 +++++ integration-tests/tests/bitcoin_core_ipc_tdp_io.rs | 5 +++++ integration-tests/tests/jds_block_propagation.rs | 2 -- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index 0a84410df..58eaac46d 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -44,6 +44,11 @@ async fn jdp_io_integration_v31x() { assert_jdp_io_integration_for_version(BitcoinCoreVersion::V31X).await; } +#[tokio::test] +async fn jdp_io_integration_v32x() { + assert_jdp_io_integration_for_version(BitcoinCoreVersion::V32X).await; +} + async fn assert_jdp_io_integration_for_version(version: BitcoinCoreVersion) { start_tracing(); diff --git a/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs index 7c2bfe0f7..509b4d707 100644 --- a/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs @@ -41,6 +41,11 @@ async fn tdp_io_integration_v31x() { assert_tdp_io_integration(BitcoinCoreVersion::V31X).await; } +#[tokio::test] +async fn tdp_io_integration_v32x() { + assert_tdp_io_integration(BitcoinCoreVersion::V32X).await; +} + async fn assert_tdp_io_integration(version: BitcoinCoreVersion) { start_tracing(); diff --git a/integration-tests/tests/jds_block_propagation.rs b/integration-tests/tests/jds_block_propagation.rs index c2fb4bee7..71e2117c4 100644 --- a/integration-tests/tests/jds_block_propagation.rs +++ b/integration-tests/tests/jds_block_propagation.rs @@ -6,8 +6,6 @@ use integration_tests_sv2::{ use stratum_apps::stratum_core::{job_declaration_sv2::*, template_distribution_sv2::*}; // Block propagated from JDS to TP -// Currently disabled, see https://github.com/stratum-mining/sv2-apps/issues/322 -#[ignore] #[tokio::test] async fn propagated_from_jds_to_tp() { start_tracing(); From e394a7085dd76139ac21c4259a9a22cf89a8caec Mon Sep 17 00:00:00 2001 From: plebhash Date: Wed, 1 Jul 2026 17:01:23 -0300 Subject: [PATCH 10/19] switch ipc config defaults to version 32 --- docker/README.md | 4 ++-- docker/docker_env.example | 4 ++-- miner-apps/jd-client/README.md | 2 +- .../jdc-config-bitcoin-core-ipc-hosted-infra-example.toml | 2 +- .../jdc-config-bitcoin-core-ipc-local-infra-example.toml | 2 +- .../jdc-config-bitcoin-core-ipc-local-infra-example.toml | 2 +- .../jdc-config-bitcoin-core-ipc-hosted-infra-example.toml | 2 +- .../jdc-config-bitcoin-core-ipc-local-infra-example.toml | 2 +- pool-apps/pool/README.md | 2 +- .../mainnet/pool-config-bitcoin-core-ipc-example.toml | 2 +- .../mainnet/pool-jds-config-bitcoin-core-ipc-example.toml | 2 +- .../signet/pool-config-bitcoin-core-ipc-example.toml | 2 +- .../signet/pool-jds-config-bitcoin-core-ipc-example.toml | 2 +- .../testnet4/pool-config-bitcoin-core-ipc-example.toml | 2 +- .../testnet4/pool-jds-config-bitcoin-core-ipc-example.toml | 2 +- 15 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docker/README.md b/docker/README.md index 193ae6241..ed95863c8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -74,8 +74,8 @@ In the same directory as `docker-compose.yml`, create a `docker_env` file: ``` BITCOIN_SOCKET_PATH=/absolute/path/to/your/node.sock -POOL_BITCOIN_CORE_IPC_VERSION=31 -JDC_BITCOIN_CORE_IPC_VERSION=31 +POOL_BITCOIN_CORE_IPC_VERSION=32 +JDC_BITCOIN_CORE_IPC_VERSION=32 ``` Make sure the path is correct, if there are spaces (like `Application Support`), keep the value unquoted. diff --git a/docker/docker_env.example b/docker/docker_env.example index 6629d2e53..d9a2582fb 100644 --- a/docker/docker_env.example +++ b/docker/docker_env.example @@ -7,7 +7,7 @@ POOL_SHARES_PER_MINUTE=6.0 POOL_SHARE_BATCH_SIZE=10 POOL_FEE_THRESHOLD=100 POOL_MIN_INTERVAL=5 -POOL_BITCOIN_CORE_IPC_VERSION=31 +POOL_BITCOIN_CORE_IPC_VERSION=32 #JDC Settings JDC_USER_IDENTITY=your_username_here @@ -17,7 +17,7 @@ JDC_SIGNATURE=Sv2MinerSignature JDC_COINBASE_REWARD_SCRIPT=addr(tb1qr8xjkrx46yfsch7q2ts2g007haufq48n9pe6qc) JDC_FEE_THRESHOLD=100 JDC_MIN_INTERVAL=5 -JDC_BITCOIN_CORE_IPC_VERSION=31 +JDC_BITCOIN_CORE_IPC_VERSION=32 JDC_UPSTREAM_AUTHORITY_PUBKEY=9auqWEzQDVyd2oe1JVGFLMLHZtCo2FFqZwtKA5gd9xbuEu7PH72 JDC_POOL_ADDRESS=pool_sv2 JDC_POOL_PORT=3333 diff --git a/miner-apps/jd-client/README.md b/miner-apps/jd-client/README.md index c1d302dd5..428cf10f2 100644 --- a/miner-apps/jd-client/README.md +++ b/miner-apps/jd-client/README.md @@ -77,7 +77,7 @@ The configuration file contains the following information: - `address` - The Template Provider's network address - `public_key` - (Optional) The TP's authority public key for connection verification - `[template_provider_type.BitcoinCoreIpc]` - Connects directly to Bitcoin Core via IPC, with the following parameters: - - `version` - Required Bitcoin Core IPC schema major version (`30` or `31`, any other value fails startup) + - `version` - Required Bitcoin Core IPC schema major version (`30`, `31`, or `32`, any other value fails startup) - `network` - Bitcoin network (mainnet, testnet4, signet, regtest) for determining socket path - `data_dir` - (Optional) Custom Bitcoin data directory. Uses OS default if not set - `fee_threshold` - Minimum fee threshold to trigger new templates diff --git a/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml b/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml index 59eb60f8c..61c67b386 100644 --- a/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml +++ b/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml @@ -75,7 +75,7 @@ user_identity = "your_primary_username_here" # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "mainnet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-local-infra-example.toml b/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-local-infra-example.toml index 146d7a7f8..d3b850444 100644 --- a/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-local-infra-example.toml +++ b/miner-apps/jd-client/config-examples/mainnet/jdc-config-bitcoin-core-ipc-local-infra-example.toml @@ -75,7 +75,7 @@ user_identity = "your_primary_username_here" # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "mainnet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/miner-apps/jd-client/config-examples/signet/jdc-config-bitcoin-core-ipc-local-infra-example.toml b/miner-apps/jd-client/config-examples/signet/jdc-config-bitcoin-core-ipc-local-infra-example.toml index 2045f8ba2..f9f7e3bd3 100644 --- a/miner-apps/jd-client/config-examples/signet/jdc-config-bitcoin-core-ipc-local-infra-example.toml +++ b/miner-apps/jd-client/config-examples/signet/jdc-config-bitcoin-core-ipc-local-infra-example.toml @@ -75,7 +75,7 @@ user_identity = "your_primary_username_here" # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "signet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml b/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml index 375005cac..11c4cb21e 100644 --- a/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml +++ b/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-hosted-infra-example.toml @@ -75,7 +75,7 @@ user_identity = "your_primary_username_here" # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "testnet4" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-local-infra-example.toml b/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-local-infra-example.toml index d8d40af6c..50e6279a5 100644 --- a/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-local-infra-example.toml +++ b/miner-apps/jd-client/config-examples/testnet4/jdc-config-bitcoin-core-ipc-local-infra-example.toml @@ -75,7 +75,7 @@ user_identity = "your_primary_username_here" # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "testnet4" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/pool-apps/pool/README.md b/pool-apps/pool/README.md index 35823ddc1..c9dff9f3f 100644 --- a/pool-apps/pool/README.md +++ b/pool-apps/pool/README.md @@ -59,7 +59,7 @@ The configuration file contains the following information: - `address` - The Template Provider's network address - `public_key` - (Optional) The TP's authority public key for connection verification - `[template_provider_type.BitcoinCoreIpc]` - Connects directly to Bitcoin Core via IPC, with the following parameters: - - `version` - Required Bitcoin Core IPC schema major version (`30` or `31`, any other value fails startup) + - `version` - Required Bitcoin Core IPC schema major version (`30`, `31`, or `32`, any other value fails startup) - `network` - Bitcoin network (mainnet, testnet4, signet, regtest) for determining socket path - `data_dir` - (Optional) Custom Bitcoin data directory. Uses OS default if not set - `fee_threshold` - Minimum fee threshold to trigger new templates diff --git a/pool-apps/pool/config-examples/mainnet/pool-config-bitcoin-core-ipc-example.toml b/pool-apps/pool/config-examples/mainnet/pool-config-bitcoin-core-ipc-example.toml index b4a220b9f..b291571be 100644 --- a/pool-apps/pool/config-examples/mainnet/pool-config-bitcoin-core-ipc-example.toml +++ b/pool-apps/pool/config-examples/mainnet/pool-config-bitcoin-core-ipc-example.toml @@ -34,7 +34,7 @@ monitoring_cache_refresh_secs = 15 # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "mainnet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/pool-apps/pool/config-examples/mainnet/pool-jds-config-bitcoin-core-ipc-example.toml b/pool-apps/pool/config-examples/mainnet/pool-jds-config-bitcoin-core-ipc-example.toml index 88da338db..99f9bab51 100644 --- a/pool-apps/pool/config-examples/mainnet/pool-jds-config-bitcoin-core-ipc-example.toml +++ b/pool-apps/pool/config-examples/mainnet/pool-jds-config-bitcoin-core-ipc-example.toml @@ -48,7 +48,7 @@ monitoring_cache_refresh_secs = 15 # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "mainnet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/pool-apps/pool/config-examples/signet/pool-config-bitcoin-core-ipc-example.toml b/pool-apps/pool/config-examples/signet/pool-config-bitcoin-core-ipc-example.toml index 17ac307f1..b828264d7 100644 --- a/pool-apps/pool/config-examples/signet/pool-config-bitcoin-core-ipc-example.toml +++ b/pool-apps/pool/config-examples/signet/pool-config-bitcoin-core-ipc-example.toml @@ -33,7 +33,7 @@ monitoring_cache_refresh_secs = 15 # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "signet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/pool-apps/pool/config-examples/signet/pool-jds-config-bitcoin-core-ipc-example.toml b/pool-apps/pool/config-examples/signet/pool-jds-config-bitcoin-core-ipc-example.toml index 4b07bd9da..95afd86a1 100644 --- a/pool-apps/pool/config-examples/signet/pool-jds-config-bitcoin-core-ipc-example.toml +++ b/pool-apps/pool/config-examples/signet/pool-jds-config-bitcoin-core-ipc-example.toml @@ -47,7 +47,7 @@ monitoring_cache_refresh_secs = 15 # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "signet" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/pool-apps/pool/config-examples/testnet4/pool-config-bitcoin-core-ipc-example.toml b/pool-apps/pool/config-examples/testnet4/pool-config-bitcoin-core-ipc-example.toml index a29be049d..aef0322db 100644 --- a/pool-apps/pool/config-examples/testnet4/pool-config-bitcoin-core-ipc-example.toml +++ b/pool-apps/pool/config-examples/testnet4/pool-config-bitcoin-core-ipc-example.toml @@ -34,7 +34,7 @@ monitoring_cache_refresh_secs = 15 # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "testnet4" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 diff --git a/pool-apps/pool/config-examples/testnet4/pool-jds-config-bitcoin-core-ipc-example.toml b/pool-apps/pool/config-examples/testnet4/pool-jds-config-bitcoin-core-ipc-example.toml index 033299581..02325fad6 100644 --- a/pool-apps/pool/config-examples/testnet4/pool-jds-config-bitcoin-core-ipc-example.toml +++ b/pool-apps/pool/config-examples/testnet4/pool-jds-config-bitcoin-core-ipc-example.toml @@ -48,7 +48,7 @@ monitoring_cache_refresh_secs = 15 # Supported networks: mainnet, testnet4, signet, regtest # Default data_dir: ~/.bitcoin (Linux) or ~/Library/Application Support/Bitcoin (macOS) [template_provider_type.BitcoinCoreIpc] -version = 31 +version = 32 network = "testnet4" # data_dir = "/custom/bitcoin/data" # Optional: override default data directory fee_threshold = 100 From 487f2e365b3912bb99c22dc5b81233cd6a18f80a Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 10:24:51 +0200 Subject: [PATCH 11/19] TEMPORARY: pin bitcoin-capnp-types v32 to TxCollection PR branch Point bitcoin_capnp_types_v32 at Sjors/bitcoin-capnp-types 2026/07/tx-collection (2140-dev/bitcoin-capnp-types#29), which adds the capnp bindings for the TxCollection interface introduced by bitcoin/bitcoin#35671. Revert to the 2140-dev repository (or a crates.io release) once that PR is merged. --- bitcoin-core-sv2/Cargo.toml | 4 +++- integration-tests/Cargo.lock | 4 ++-- miner-apps/Cargo.lock | 4 ++-- pool-apps/Cargo.lock | 4 ++-- stratum-apps/Cargo.lock | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/bitcoin-core-sv2/Cargo.toml b/bitcoin-core-sv2/Cargo.toml index 14a8aebcb..0b166bdba 100644 --- a/bitcoin-core-sv2/Cargo.toml +++ b/bitcoin-core-sv2/Cargo.toml @@ -25,7 +25,9 @@ bitcoin_capnp_types_v31 = { package = "bitcoin-capnp-types", version = "0.2.1" } # todo: fetch from crates.io # Bitcoin Core v32.x IPC dependencies -bitcoin_capnp_types_v32 = { package = "bitcoin-capnp-types", git = "https://github.com/2140-dev/bitcoin-capnp-types.git", rev = "6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" } +# TEMPORARY: pinned to https://github.com/2140-dev/bitcoin-capnp-types/pull/29 (TxCollection) +# switch back to 2140-dev once that PR is merged and released +bitcoin_capnp_types_v32 = { package = "bitcoin-capnp-types", git = "https://github.com/Sjors/bitcoin-capnp-types.git", branch = "2026/07/tx-collection" } # fetching from github enables synchronizing development workflows across sv2-apps and stratum repos # it MUST be changed before bitcoin-core-sv2 is published to crates.io diff --git a/integration-tests/Cargo.lock b/integration-tests/Cargo.lock index 2f1a01633..57773ca3e 100644 --- a/integration-tests/Cargo.lock +++ b/integration-tests/Cargo.lock @@ -1075,7 +1075,7 @@ dependencies = [ [[package]] name = "bitcoin-capnp-types" version = "0.2.1" -source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +source = "git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" dependencies = [ "capnp", "capnp-rpc", @@ -1114,7 +1114,7 @@ dependencies = [ "async-channel 1.9.0", "bitcoin-capnp-types 0.1.2", "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", diff --git a/miner-apps/Cargo.lock b/miner-apps/Cargo.lock index 6a2fc7218..f84969f1c 100644 --- a/miner-apps/Cargo.lock +++ b/miner-apps/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "bitcoin-capnp-types" version = "0.2.1" -source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +source = "git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" dependencies = [ "capnp", "capnp-rpc", @@ -928,7 +928,7 @@ dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", diff --git a/pool-apps/Cargo.lock b/pool-apps/Cargo.lock index ea562e5f2..ad392e288 100644 --- a/pool-apps/Cargo.lock +++ b/pool-apps/Cargo.lock @@ -321,7 +321,7 @@ dependencies = [ [[package]] name = "bitcoin-capnp-types" version = "0.2.1" -source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +source = "git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" dependencies = [ "capnp", "capnp-rpc", @@ -362,7 +362,7 @@ dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", diff --git a/stratum-apps/Cargo.lock b/stratum-apps/Cargo.lock index 7b8337907..8724bbebc 100644 --- a/stratum-apps/Cargo.lock +++ b/stratum-apps/Cargo.lock @@ -822,7 +822,7 @@ dependencies = [ [[package]] name = "bitcoin-capnp-types" version = "0.2.1" -source = "git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc#6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc" +source = "git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" dependencies = [ "capnp", "capnp-rpc", @@ -863,7 +863,7 @@ dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "bitcoin-capnp-types 0.2.1 (git+https://github.com/2140-dev/bitcoin-capnp-types.git?rev=6f811c8e346d1dcebf4ea005f13b3cedec6fb8cc)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", From ddadd4bde72fed82f355788d921be5838b8d6cb2 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 10:40:36 +0200 Subject: [PATCH 12/19] TEMPORARY: require BITCOIN_CORE_V32_BINARY for integration tests The v32 integration test harness previously fell back to a hardcoded local build path. Since the TxCollection work depends on a Bitcoin Core build of bitcoin/bitcoin#35671, require the BITCOIN_CORE_V32_BINARY environment variable instead and explain how to produce the binary. Remove once an official Bitcoin Core release ships TxCollection and v32 follows the standard release-binary download flow. --- integration-tests/lib/template_provider.rs | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index 6cafa7a90..5d4d6a2b5 100644 --- a/integration-tests/lib/template_provider.rs +++ b/integration-tests/lib/template_provider.rs @@ -17,11 +17,10 @@ const VERSION_SV2_TP: &str = "1.1.0"; const BITCOIN_CORE_V30X: &str = "30.2"; const BITCOIN_CORE_V31X: &str = "31.0"; const BITCOIN_CORE_V32X: &str = "32.0"; -// TEMPORARY: keep BITCOIN_CORE_V32_BINARY_ENV / BITCOIN_CORE_V32_BINARY_DEFAULT only -// while v32 tests depend on a local Bitcoin Core build path. Remove once v32 follows the -// standard binary resolution flow used by other versions. +// TEMPORARY: keep BITCOIN_CORE_V32_BINARY_ENV only while v32 tests depend on a Bitcoin Core +// build of https://github.com/bitcoin/bitcoin/pull/35671 (TxCollection). Remove once v32 is +// released and follows the standard binary resolution flow used by other versions. const BITCOIN_CORE_V32_BINARY_ENV: &str = "BITCOIN_CORE_V32_BINARY"; -const BITCOIN_CORE_V32_BINARY_DEFAULT: &str = "/Users/plebhash/develop/bitcoin/build/bin/bitcoin"; /// Allow static signet fixtures to leave IBD without freezing Bitcoin Core's /// clock, so mined blocks still use wall-clock timestamps. /// @@ -77,7 +76,14 @@ fn release_version(version: BitcoinCoreVersion) -> &'static str { fn resolve_v32_node_binary() -> PathBuf { let configured_path = env::var(BITCOIN_CORE_V32_BINARY_ENV) .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(BITCOIN_CORE_V32_BINARY_DEFAULT)); + .unwrap_or_else(|_| { + panic!( + "Bitcoin Core v32 is not released yet. Build \ + https://github.com/bitcoin/bitcoin/pull/35671 from source and point \ + {BITCOIN_CORE_V32_BINARY_ENV} at the resulting bitcoin-node binary \ + (e.g. /build/bin/bitcoin-node)." + ) + }); if configured_path.file_name().and_then(|name| name.to_str()) == Some("bitcoin") { if let Some(parent) = configured_path.parent() { @@ -204,14 +210,15 @@ impl BitcoinCore { } // Download and setup Bitcoin Core with IPC support. - // During the v32 draft phase, we use a local placeholder binary until - // official Bitcoin Core v32 release artifacts are available. + // During the v32 draft phase, we use a locally built bitcoin/bitcoin#35671 binary + // (via BITCOIN_CORE_V32_BINARY) until official Bitcoin Core v32 release artifacts + // are available. let os = env::consts::OS; let bitcoin_node_bin = if node_version == BitcoinCoreVersion::V32X { let binary = resolve_v32_node_binary(); assert!( binary.exists(), - "Bitcoin Core v32 placeholder binary not found at {}. Set {} to override.", + "Bitcoin Core v32 binary not found at {} (from {}).", binary.display(), BITCOIN_CORE_V32_BINARY_ENV, ); From f20d0ce98be5d955f968bb2132075931f905d117 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 10:40:36 +0200 Subject: [PATCH 13/19] TEMPORARY: CI builds Bitcoin Core from bitcoin#35671 Instead of stubbing the v32 binary, build Bitcoin Core from the bitcoin/bitcoin#35671 (TxCollection) PR branch in the Integration Tests workflow, with ccache to keep warm-cache runs fast. This follows the approach used by 2140-dev/bitcoin-capnp-types#29. Remove once an official Bitcoin Core release ships TxCollection. --- .github/workflows/integration-tests.yaml | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 68bb35be9..a196f4238 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -35,6 +35,38 @@ jobs: if: matrix.os == 'macos-latest' run: brew install capnp + # TEMPORARY: build Bitcoin Core from the bitcoin/bitcoin#35671 PR branch + # (TxCollection) until an official v32 release with TxCollection support is + # available. Remove these steps (and BITCOIN_CORE_V32_BINARY below) once v32 + # follows the standard release-binary download flow. + - name: Install Bitcoin Core build dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install -y build-essential cmake pkgconf python3 libevent-dev libboost-dev ccache + + - name: Install Bitcoin Core build dependencies (macOS) + if: matrix.os == 'macos-latest' + run: brew install cmake boost pkgconf ccache + + - name: ccache + uses: actions/cache@v4 + with: + path: ${{ matrix.os == 'macos-latest' && '~/Library/Caches/ccache' || '~/.cache/ccache' }} + key: ccache-bitcoin-${{ runner.os }}-${{ github.sha }} + restore-keys: ccache-bitcoin-${{ runner.os }}- + + - name: Checkout Bitcoin Core (bitcoin/bitcoin#35671) + run: | + git clone --depth 1 https://github.com/bitcoin/bitcoin.git + cd bitcoin + git fetch --depth 1 origin pull/35671/head:pr-35671 + git checkout pr-35671 + + - name: Build Bitcoin Core + run: | + cd bitcoin + cmake -B build -DENABLE_WALLET=ON -DBUILD_TESTS=OFF -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + cmake --build build -j $(nproc 2>/dev/null || sysctl -n hw.ncpu) + # Install cargo-binstall so tool installs below use prebuilt binaries. - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@v1.20.0 @@ -49,5 +81,7 @@ jobs: run: cargo binstall --no-confirm --disable-telemetry --disable-strategies quick-install --locked cargo-nextest@0.9.100 - name: Integration Tests + env: + BITCOIN_CORE_V32_BINARY: ${{ github.workspace }}/bitcoin/build/bin/bitcoin-node run: | RUST_BACKTRACE=1 RUST_LOG=debug cargo nextest run --manifest-path=integration-tests/Cargo.toml --nocapture From 19e996d8c90dd559fdabbef6e17a2cfd245b79d2 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 11:18:56 +0200 Subject: [PATCH 14/19] JDS: validate DeclareMiningJob wtxid list and provided transactions Reject declared jobs whose wtxid list contains duplicates (invalid-job) and silently drop ProvideMissingTransactions.Success transactions that are not part of the declared job; any wtxid left uncovered is simply reported as missing again. Both are protocol violations that the mirror-based validators happened to tolerate. Enforcing them at the jd-server layer establishes an invariant the upcoming TxCollection-based v32.x backend depends on: Bitcoin Core's collectTxs rejects duplicate wtxids and addMissingTxs rejects out-of-set transactions with RPC-level errors, which would otherwise let a misbehaving downstream tear down the shared IPC connection. --- .../job_validation/bitcoin_core_ipc.rs | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 8440b26d4..f72227eb9 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -8,7 +8,7 @@ use crate::{ }, }; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, path::PathBuf, sync::{Arc, Mutex}, thread::JoinHandle, @@ -36,6 +36,7 @@ use stratum_apps::{ ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, ERROR_CODE_DECLARE_MINING_JOB_INVALID_COINBASE_TX, ERROR_CODE_DECLARE_MINING_JOB_INVALID_COINBASE_TX_INPUT, + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, ERROR_CODE_DECLARE_MINING_JOB_INVALID_MINING_JOB_TOKEN, ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, }, @@ -531,10 +532,25 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { .map(|u256| Wtxid::from_byte_array(u256.to_array())) .collect(); - // Parse missing transactions from ProvideMissingTransactionsSuccess - let missing_txs: Vec = - if let Some(ref pmts) = provide_missing_transactions_success { - pmts.transaction_list + // A declared job must not list the same transaction twice. + let declared_wtxids: HashSet = wtxid_list.iter().copied().collect(); + if declared_wtxids.len() != wtxid_list.len() { + tracing::debug!( + downstream_id, + request_id = declare_mining_job.request_id, + "DeclareMiningJob wtxid list contains duplicates" + ); + return DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB); + } + + // Parse missing transactions from ProvideMissingTransactionsSuccess, ignoring any + // transaction that is not part of the declared job. Anything the validator still + // considers missing afterwards is reported through another + // ProvideMissingTransactions round. + let missing_txs: Vec = if let Some(ref pmts) = + provide_missing_transactions_success + { + pmts.transaction_list .iter_bytes() .filter_map(|tx_bytes| { match bitcoin::consensus::Decodable::consensus_decode(&mut &tx_bytes[..]) { @@ -545,10 +561,21 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } }) + .filter(|tx: &Transaction| { + let declared = declared_wtxids.contains(&tx.compute_wtxid()); + if !declared { + tracing::warn!( + downstream_id, + request_id = declare_mining_job.request_id, + "Ignoring provided missing transaction that is not part of the declared job" + ); + } + declared + }) .collect() - } else { - Vec::new() - }; + } else { + Vec::new() + }; let previous_pending_validation_context = provide_missing_transactions_success.as_ref().and_then(|_| { From 78064bf816aceeb63349a91f3ea1484ad6bdb4a2 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 10:37:15 +0200 Subject: [PATCH 15/19] JDP: restrict stale-tip classification to prev_hash DeclareMiningJob stale classification at the jd-server layer previously compared the full validation context (prev_hash, nbits, min_ntime), which can misclassify errors: min_ntime can increase without a tip change, and nbits comparison is unnecessary for stale-tip detection. JdResponse::Error and JdResponse::MissingTransactions now carry only the chain tip the validator operated against. Error carries an Option so internal failures (where no tip was established) are never misclassified as stale-chain-tip. On the jd-server side, DeclaredCustomJob now stores the declared-against prev_hash directly plus an Option (nbits + txdata), replacing the validation_context/txdata/validated field triple whose consistency was only enforced by convention. This is a partial implementation of #597 (the version-specific handlers still use their internal validation-context drift heuristics) and prepares for the v32.x TxCollection backend, which has no template to take nbits/min_ntime from when transactions are missing. --- .../src/common/job_declaration_protocol/io.rs | 15 +- .../v30x/job_declaration_protocol/handlers.rs | 10 +- .../v31x/job_declaration_protocol/handlers.rs | 12 +- .../v32x/job_declaration_protocol/handlers.rs | 12 +- .../job_validation/bitcoin_core_ipc.rs | 145 ++++++++---------- 5 files changed, 94 insertions(+), 100 deletions(-) diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs index 1f9d03ecc..aebd8c037 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs @@ -3,9 +3,10 @@ use stratum_core::bitcoin::{Block, BlockHash, CompactTarget, Transaction, Wtxid, block::Version}; use tokio::sync::oneshot; -/// Snapshot of the template parameters used by the validator at decision time. +/// Snapshot of the template parameters used by the mirror-based validators (v30.x/v31.x) +/// at decision time. /// -/// This lets callers distinguish stale-tip races from other validation failures. +/// This lets those backends distinguish stale-tip races from other validation failures. /// /// Please check https://github.com/stratum-mining/sv2-apps/issues/364 /// for more details on the regression that motivated this field. @@ -35,6 +36,10 @@ pub enum JdRequest { } /// The result of trying to handle a DeclareMiningJob request. +/// +/// `Error` and `MissingTransactions` carry only the chain tip (`prev_hash`) the validator +/// operated against: stale-tip classification is restricted to `prev_hash` comparison +/// (see https://github.com/stratum-mining/sv2-apps/issues/597). #[derive(Debug, Clone)] pub enum JdResponse { Success { @@ -49,10 +54,12 @@ pub enum JdResponse { }, Error { error_code: &'static str, - validation_context: ValidationContext, + /// Chain tip at decision time; `None` when the failure happened before the validator + /// could establish one (e.g. internal IPC errors). + prev_hash: Option, }, MissingTransactions { missing_wtxids: Vec, - validation_context: ValidationContext, + prev_hash: BlockHash, }, } diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs index d223cb5d9..ae51eb355 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs @@ -93,7 +93,7 @@ impl BitcoinCoreSv2JDP { // we don't care if the receiver dropped the channel let _ = response_tx.send(JdResponse::MissingTransactions { missing_wtxids, - validation_context: initial_validation_context, + prev_hash: initial_validation_context.prev_hash, }); return; } @@ -159,7 +159,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -177,7 +177,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -192,7 +192,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -301,7 +301,7 @@ impl BitcoinCoreSv2JDP { JdResponse::Error { error_code, - validation_context: latest_validation_context, + prev_hash: Some(latest_validation_context.prev_hash), } }; diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs index 6b887e3bc..5b61ebac7 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs @@ -93,7 +93,7 @@ impl BitcoinCoreSv2JDP { // we don't care if the receiver dropped the channel let _ = response_tx.send(JdResponse::MissingTransactions { missing_wtxids, - validation_context: initial_validation_context, + prev_hash: initial_validation_context.prev_hash, }); return; } @@ -156,7 +156,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -174,7 +174,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -192,7 +192,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -207,7 +207,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -316,7 +316,7 @@ impl BitcoinCoreSv2JDP { JdResponse::Error { error_code, - validation_context: latest_validation_context, + prev_hash: Some(latest_validation_context.prev_hash), } }; diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs index c9d8a5148..a3915aa35 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -97,7 +97,7 @@ impl BitcoinCoreSv2JDP { // we don't care if the receiver dropped the channel let _ = response_tx.send(JdResponse::MissingTransactions { missing_wtxids, - validation_context: initial_validation_context, + prev_hash: initial_validation_context.prev_hash, }); return; } @@ -160,7 +160,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -178,7 +178,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -196,7 +196,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -211,7 +211,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -320,7 +320,7 @@ impl BitcoinCoreSv2JDP { JdResponse::Error { error_code, - validation_context: latest_validation_context, + prev_hash: Some(latest_validation_context.prev_hash), } }; diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index f72227eb9..9d27a738f 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -18,7 +18,7 @@ use stratum_apps::{ bitcoin_core_sv2::common::{ job_declaration_protocol::{ self, - io::{JdRequest, JdResponse, ValidationContext}, + io::{JdRequest, JdResponse}, CancellationToken, }, BitcoinCoreVersion, @@ -72,9 +72,21 @@ const PUSH_SOLUTION_VERSION_ROLLING_MASK: u32 = 0x1fff_ffe0; #[derive(Clone)] struct DeclaredCustomJob { declare_mining_job: DeclareMiningJob<'static>, - validation_context: ValidationContext, // committed at the time we receive DeclareMiningJob - txdata: Option>, // populated only on JdResponse::Success - validated: bool, + /// Chain tip the declaration was evaluated against; used for stale-tip classification + /// (see https://github.com/stratum-mining/sv2-apps/issues/597). + prev_hash: BlockHash, + /// Populated once the job passes Bitcoin Core validation ([`JdResponse::Success`]); + /// `None` while waiting for missing transactions. + validated: Option, +} + +/// Template parameters and transaction data of a fully validated declared job. +#[derive(Clone)] +struct ValidatedJobData { + nbits: CompactTarget, + /// Full non-coinbase transaction list in declaration order, used for merkle-path + /// validation and solved block reconstruction. + txdata: Vec, } /// Latest `DeclaredCustomJob` accepted via `SetCustomMiningJob` for a downstream. @@ -100,16 +112,6 @@ impl DeclaredCustomJob { self.declare_mining_job.version } - /// Returns `nbits` (difficulty target). - fn get_nbits(&self) -> u32 { - self.validation_context.nbits.to_consensus() - } - - /// Returns `prev_hash`. - fn get_prev_hash(&self) -> BlockHash { - self.validation_context.prev_hash - } - /// Reconstructs the declared coinbase transaction by concatenating prefix, extranonce, and /// suffix. /// @@ -183,7 +185,7 @@ impl DeclaredCustomJob { /// Returns the sibling hashes at each level from leaf to root, needed to /// reconstruct the block header's merkle root from the coinbase position (index 0). /// - /// Requires `txdata` to have been populated via `JdResponse::Success`. + /// Requires the job to have been validated via `JdResponse::Success`. /// The coinbase txid is derived from the declared coinbase prefix/suffix. /// /// Used to compare with a `SetCustomMiningJob.merkle_path`. @@ -192,11 +194,7 @@ impl DeclaredCustomJob { /// so error_code = "declared-job-not-yet-validated" /// therefore () error type is sufficient. fn get_merkle_path(&self) -> Result, ()> { - if !self.validated { - return Err(()); - } - - let txdata = self.txdata.as_ref().ok_or(())?; + let txdata = &self.validated.as_ref().ok_or(())?.txdata; let coinbase_tx = self .get_coinbase_tx(None) @@ -258,7 +256,8 @@ impl BitcoinCoreIPCEngine { /// /// `version` selects the Bitcoin Core IPC schema family (v30.x, v31.x, or v32.x). /// - /// Blocks until the mempool mirror is bootstrapped and ready to process requests. + /// Blocks until the backend is ready to process requests (mempool mirror bootstrapped for + /// v30.x/v31.x, IBD finished for v32.x). pub async fn new( version: BitcoinCoreVersion, network: BitcoinNetwork, @@ -342,8 +341,9 @@ impl BitcoinCoreIPCEngine { }); }); - // Wait for BitcoinCoreSv2JDP to complete mempool bootstrap, mirroring the - // pool's Template Provider startup behavior during IBD. + // Wait for BitcoinCoreSv2JDP to become ready (mempool bootstrap or IBD wait, + // depending on the backend), mirroring the pool's Template Provider startup + // behavior during IBD. // Until `new()` succeeds, this function is still the only owner of the spawned JDP // thread handle, so cancellation/bootstrap failure must join here rather than detach it. let mut ready_rx = ready_rx; @@ -358,18 +358,18 @@ impl BitcoinCoreIPCEngine { } return Err(JDSErrorKind::BitcoinCoreIPC( - "Mempool bootstrap did not complete".to_string(), + "Bitcoin Core JDP backend did not become ready".to_string(), )); } } } _ = cancellation_token.cancelled() => { - tracing::info!("BitcoinCoreIPCEngine stopped before mempool bootstrap completed"); + tracing::info!("BitcoinCoreIPCEngine stopped before the JDP backend became ready"); if let Err(e) = jdp_thread_handle.join() { tracing::warn!("BitcoinCoreSv2JDP thread join failed during startup cancellation: {e:?}"); } return Err(JDSErrorKind::BitcoinCoreIPC( - "Mempool bootstrap did not complete".to_string(), + "Bitcoin Core JDP backend did not become ready".to_string(), )); } _ = tokio::time::sleep(Duration::from_secs(1)) => { @@ -434,15 +434,6 @@ impl BitcoinCoreIPCEngine { } } -fn validation_context_drifted( - previous_ctx: ValidationContext, - current_ctx: ValidationContext, -) -> bool { - previous_ctx.prev_hash != current_ctx.prev_hash - || previous_ctx.nbits != current_ctx.nbits - || previous_ctx.min_ntime != current_ctx.min_ntime -} - #[cfg_attr(not(test), hotpath::measure_all)] #[async_trait::async_trait] impl JobValidationEngine for BitcoinCoreIPCEngine { @@ -493,14 +484,8 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { let declared_coinbase_tx = { let temp_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static.clone(), - validation_context: ValidationContext { - prev_hash: BlockHash::all_zeros(), // irrelevant for coinbase tx validation - nbits: CompactTarget::from_consensus(0), /* irrelevant for coinbase tx - * validation */ - min_ntime: 0, // irrelevant for coinbase tx validation - }, - txdata: None, // irrelevant for coinbase tx validation - validated: false, // irrelevant for coinbase tx validation + prev_hash: BlockHash::all_zeros(), // irrelevant for coinbase tx validation + validated: None, // irrelevant for coinbase tx validation }; match temp_job.get_coinbase_tx(None) { @@ -577,14 +562,14 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { Vec::new() }; - let previous_pending_validation_context = + let previous_pending_prev_hash = provide_missing_transactions_success.as_ref().and_then(|_| { self.downstream_states .with(&downstream_id, |state| { state .declared_custom_jobs .get(&declare_mining_job.request_id) - .map(|job| job.validation_context) + .map(|job| job.prev_hash) }) .flatten() }); @@ -622,18 +607,13 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { JdResponse::Success { prev_hash, nbits, - min_ntime, + min_ntime: _, txdata, } => { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, - validation_context: ValidationContext { - prev_hash, - nbits, - min_ntime, - }, - txdata: Some(txdata), - validated: true, + prev_hash, + validated: Some(ValidatedJobData { nbits, txdata }), }; self.downstream_states .with_mut_or_default(downstream_id, |state| { @@ -652,7 +632,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } JdResponse::Error { error_code, - validation_context, + prev_hash, } => { self.downstream_states.with_mut(&downstream_id, |state| { state @@ -661,11 +641,12 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { state.allocated_token_entries.remove(&allocated_token); }); - let tip_drifted = previous_pending_validation_context - .map(|previous_ctx| { - validation_context_drifted(previous_ctx, validation_context) - }) - .unwrap_or(false); + let tip_drifted = match (previous_pending_prev_hash, prev_hash) { + (Some(previous_prev_hash), Some(current_prev_hash)) => { + previous_prev_hash != current_prev_hash + } + _ => false, + }; if tip_drifted { DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP) @@ -675,12 +656,10 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } JdResponse::MissingTransactions { missing_wtxids, - validation_context, + prev_hash, } => { - let tip_drifted = previous_pending_validation_context - .map(|previous_ctx| { - validation_context_drifted(previous_ctx, validation_context) - }) + let tip_drifted = previous_pending_prev_hash + .map(|previous_prev_hash| previous_prev_hash != prev_hash) .unwrap_or(false); // If this is a retry after ProvideMissingTransactionsSuccess and context drifted, @@ -697,9 +676,8 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } else { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, - validation_context, - txdata: None, - validated: false, // this is only set to true on JdResponse::Success + prev_hash, + validated: None, // this is only populated on JdResponse::Success }; self.downstream_states .with_mut_or_default(downstream_id, |state| { @@ -735,11 +713,20 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { .with_mut(&downstream_id, |state| { let (declared_prev_hash, declared_nbits, declared_version) = match state.active_custom_job.as_ref() { - Some(active_job) => ( - active_job.get_prev_hash(), - active_job.get_nbits(), - active_job.get_version(), - ), + Some(active_job) => match active_job.validated.as_ref() { + Some(validated) => ( + active_job.prev_hash, + validated.nbits.to_consensus(), + active_job.get_version(), + ), + None => { + tracing::error!( + "Active custom job on downstream {} was never validated", + downstream_id, + ); + return None; + } + }, None => { tracing::error!( "No active custom job found for PushSolution on downstream {}", @@ -782,10 +769,10 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { return; }; - let declared_prev_hash = active_job.get_prev_hash(); + let declared_prev_hash = active_job.prev_hash; - let mut txdata = match active_job.txdata.clone() { - Some(txdata) => txdata, + let mut txdata = match active_job.validated { + Some(ref validated) => validated.txdata.clone(), None => { tracing::error!("Active custom job is missing transaction data"); return; @@ -893,16 +880,16 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { }; // Job may be pending retry after missing txs and not fully validated yet. - if !declared_custom_job.validated { + let Some(validated) = declared_custom_job.validated.as_ref() else { tracing::error!("Job not yet validated"); return SetCustomMiningJobResult::Error( ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED, ); - } + }; // Get declared values from stored job - let declared_prev_hash = declared_custom_job.get_prev_hash(); - let declared_nbits = declared_custom_job.get_nbits(); + let declared_prev_hash = declared_custom_job.prev_hash; + let declared_nbits = validated.nbits.to_consensus(); let declared_version: u32 = declared_custom_job.get_version(); // Extract values from SetCustomMiningJob message From 1d65d7bbf5d46790156adb47408468c86046ee4d Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 10:37:49 +0200 Subject: [PATCH 16/19] v32x JDP: validate declared jobs with TxCollection, drop mempool mirror Bitcoin Core's TxCollection interface (bitcoin/bitcoin#35671) lets the JDS validate an external block template directly against the node's mempool, so the v32.x JDP backend no longer needs to keep an optimistic local mempool mirror in sync via a waitNext monitor loop. This removes the mirror, the monitor task, the startup createNewBlock call, and the force-refresh retry logic, along with their staleness races (#268). DeclareMiningJob validation now: 1. fetches the current tip via getTip 2. calls collectTxs with the declared wtxids 3. completes the collection via addMissingTxs when the client provided missing transactions (ProvideMissingTransactions.Success) 4. asks unknownTxPos which transactions are still unknown and, if any, responds with MissingTransactions 5. calls makeTemplate with the declared coinbase (zeroed extranonce, which no contextual check depends on), so Bitcoin Core reconstructs and fully validates the block including the coinbase (BIP34 height, output value, sigops, weight, witness commitment) and returns a BlockTemplate The Success response parameters (prev_hash, nbits, min_ntime) come from the validated template's header (getBlockHeader) and the full transaction list from getBlock, preserving the response contract with jd-server for SetCustomMiningJob validation and PushSolution block reconstruction. The collection and template are destroyed once the response is assembled. Stale-tip classification compares the tip before and after validation, plus a JDS-side check of the declared BIP34 height against the expected next height so a stale job is reported as stale-chain-tip rather than the generic invalid-job that bad-cb-height would map to. Behavioral notes: - readiness now waits for IBD to finish by polling isInitialBlockDownload instead of relying on createNewBlock blocking during IBD. - a future optimization could retain the BlockTemplate and use its submitSolution method for PushSolution instead of reconstructing the full block in jd-server, which would also remove txdata from the Success response. --- .../src/common/job_declaration_protocol/io.rs | 6 +- .../v32x/job_declaration_protocol/error.rs | 2 + .../v32x/job_declaration_protocol/handlers.rs | 591 ++++++++++-------- .../v32x/job_declaration_protocol/mempool.rs | 126 ---- .../v32x/job_declaration_protocol/mod.rs | 329 ++-------- .../v32x/job_declaration_protocol/monitors.rs | 158 ----- 6 files changed, 413 insertions(+), 799 deletions(-) delete mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs delete mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs index aebd8c037..0c5ff1fbc 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs @@ -23,7 +23,11 @@ pub struct ValidationContext { /// Built from a `DeclareMiningJob` (plus an optional `ProvideMissingTransactionsSuccess`) /// or a `PushSolution`. pub enum JdRequest { - /// Validate a declared mining job via Bitcoin Core's `checkBlock`. + /// Validate a declared mining job. + /// + /// Invariants (enforced by `jd-server`): `wtxid_list` contains no duplicates and every + /// transaction in `missing_txs` is part of `wtxid_list`. The v32.x `TxCollection` + /// backend relies on these; Bitcoin Core rejects violations with RPC-level errors. DeclareMiningJob { version: Version, coinbase_tx: Transaction, diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs index 792c76af3..330a375be 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs @@ -16,6 +16,8 @@ pub enum BitcoinCoreSv2JDPError { CannotConnectToUnixSocket(PathBuf, String), /// Failed to deserialize a block from the IPC response. FailedToDeserializeBlock(consensus::encode::Error), + /// Bitcoin Core reported no chain tip (or a malformed one) via `getTip`. + NoChainTip, /// Readiness signal receiver was dropped before bootstrap completed. ReadinessSignalFailed, } diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs index a3915aa35..27c306bfc 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -1,17 +1,21 @@ //! Handlers for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX socket. use crate::{ - common::job_declaration_protocol::io::{JdResponse, ValidationContext}, + common::job_declaration_protocol::io::JdResponse, unix_capnp::v32x::job_declaration_protocol::{ BitcoinCoreSv2JDP, error::BitcoinCoreSv2JDPError, - mempool::decode_bip34_height_from_coinbase_script_sig, }, }; +use bitcoin_capnp_types::mining_capnp::{ + block_template::Client as BlockTemplateIpcClient, + tx_collection::Client as TxCollectionIpcClient, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; use stratum_core::{ bitcoin::{ - Block, Transaction, TxMerkleNode, Wtxid, + Block, BlockHash, Transaction, Wtxid, block::{Header, Version}, - consensus::serialize, + consensus::{deserialize, serialize}, hashes::Hash, }, job_declaration_sv2::{ @@ -25,13 +29,21 @@ use tracing::{debug, error, info, warn}; const MAX_SUBMIT_BLOCK_ATTEMPTS: usize = 3; const SUBMIT_BLOCK_RETRY_BACKOFF_MS: u64 = 15; +/// `reason` returned by `TxCollection::makeTemplate` when the collection is still incomplete. +const MAKE_TEMPLATE_REASON_MISSING_TXS: &str = "missing-txs"; + impl BitcoinCoreSv2JDP { - /// Validates a declared mining job by checking transaction availability and block structure. + /// Validates a declared mining job via Bitcoin Core's `TxCollection` interface. + /// + /// The declared wtxids are collected with `collectTxs`, completed with `addMissingTxs` + /// (transactions from `ProvideMissingTransactions.Success`), and checked with + /// `unknownTxPos`. Once complete, `makeTemplate` reconstructs the block inside Bitcoin + /// Core and validates it, so no local mempool mirror is needed. Returns success with the + /// template parameters or an error if validation fails. /// - /// Adds missing transactions to the mempool mirror, verifies all transactions are available, - /// assembles a test block, sets IPC thread context, and uses Bitcoin Core's `checkBlock` to - /// validate the block structure. Returns success with current template parameters or an error - /// if validation fails. + /// The declared coinbase (with a zeroed extranonce, which no contextual check depends + /// on) is passed to `makeTemplate`, so it is fully validated at declaration time (BIP34 + /// height, output value, sigops, weight, witness commitment). pub(crate) async fn handle_declare_mining_job( &self, version: Version, @@ -52,6 +64,63 @@ impl BitcoinCoreSv2JDP { coinbase_tx.input[0].script_sig ); + let response = match self + .validate_declare_mining_job(&coinbase_tx, &wtxid_list, &missing_txs) + .await + { + Ok(response) => response, + Err(e) => { + error!("DeclareMiningJob validation failed with IPC error: {e:?}"); + // deliberately ignore potential send errors + // we don't care if the receiver dropped the channel + let _ = response_tx.send(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, + prev_hash: None, + }); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + // deliberately ignore potential send errors + // we don't care if the receiver dropped the channel + let _ = response_tx.send(response); + } + + /// Runs the `TxCollection` validation flow and maps the outcome to a [`JdResponse`]. + /// + /// Returns `Err` only for IPC-level failures; validation failures are expressed as + /// [`JdResponse::Error`] or [`JdResponse::MissingTransactions`]. + async fn validate_declare_mining_job( + &self, + coinbase_tx: &Transaction, + wtxid_list: &[Wtxid], + missing_txs: &[Transaction], + ) -> Result { + let (tip_hash, tip_height) = self.get_tip().await?; + + let collection = self.collect_txs(wtxid_list).await?; + + // Complete the collection with transactions from ProvideMissingTransactions.Success + if !missing_txs.is_empty() { + self.add_missing_txs(&collection, missing_txs).await?; + } + + // Ask Bitcoin Core which declared transactions it still doesn't know about + let missing_positions = self.unknown_tx_pos(&collection).await?; + if !missing_positions.is_empty() { + let missing_wtxids = Self::wtxids_at_positions(wtxid_list, &missing_positions); + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::MissingTransactions { + missing_wtxids, + prev_hash: tip_hash, + }); + } + + // A BIP34 height mismatch would also be caught by makeTemplate (bad-cb-height), but + // checking it here lets us classify the error as a stale chain tip instead of a + // generically invalid job. let declared_bip34_height = coinbase_tx .input .first() @@ -63,270 +132,272 @@ impl BitcoinCoreSv2JDP { // Fall back to coinbase lock_time to avoid panics and keep a stable // stale-tip comparison signal. .unwrap_or_else(|| coinbase_tx.lock_time.to_consensus_u32()); + let next_height = (tip_height + 1) as u32; + if declared_bip34_height != next_height { + debug!( + ?tip_hash, + tip_height, + declared_bip34_height, + "Declared BIP34 height does not match the next block height; classifying error as stale-chain-tip" + ); + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + prev_hash: Some(tip_hash), + }); + } - let (initial_validation_context, initial_bip34_height, txdata) = { - let mut mempool_mirror = self.mempool_mirror.borrow_mut(); - - // Add the missing transactions to the mempool mirror - mempool_mirror.add_transactions(missing_txs); - - let prev_hash = mempool_mirror - .get_current_prev_hash() - .expect("current_prev_hash must be set"); - let nbits = mempool_mirror - .get_current_nbits() - .expect("current_nbits must be set"); - let min_ntime = mempool_mirror - .get_current_min_ntime() - .expect("current_min_ntime must be set"); - - let initial_validation_context = ValidationContext { - prev_hash, - nbits, - min_ntime, - }; - - let initial_bip34_height = mempool_mirror - .get_current_bip34_height() - .expect("current_bip34_height must be set"); - - // Now verify that all wtxids from the declared job are available - let missing_wtxids = mempool_mirror.verify(&wtxid_list); - if !missing_wtxids.is_empty() { - // deliberately ignore potential errors - // we don't care if the receiver dropped the channel - let _ = response_tx.send(JdResponse::MissingTransactions { - missing_wtxids, - prev_hash: initial_validation_context.prev_hash, - }); - return; + // Reconstruct and validate the block, including the declared coinbase, inside + // Bitcoin Core + let (reason, debug_msg, template) = self + .make_template(&collection, tip_hash, coinbase_tx) + .await?; + + let Some(template) = template else { + // Transactions can disappear from the mempool between unknownTxPos and + // makeTemplate (e.g. eviction, replacement, new block). Give the client a chance + // to provide them instead of failing the declaration outright. + if reason == MAKE_TEMPLATE_REASON_MISSING_TXS { + let missing_positions = self.unknown_tx_pos(&collection).await?; + if !missing_positions.is_empty() { + let missing_wtxids = Self::wtxids_at_positions(wtxid_list, &missing_positions); + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::MissingTransactions { + missing_wtxids, + prev_hash: tip_hash, + }); + } } - let txdata = mempool_mirror.get_txdata(&wtxid_list); + self.destroy_tx_collection(&collection).await; - info!( - "Using prevhash: {:?}, nbits: {:?}, min_ntime: {}, bip34_height: {} from mempool mirror", - initial_validation_context.prev_hash, - initial_validation_context.nbits, - initial_validation_context.min_ntime, - initial_bip34_height + error!( + reason, + debug = debug_msg, + "Bitcoin Core rejected the declared job via TxCollection::makeTemplate" ); - (initial_validation_context, initial_bip34_height, txdata) - }; // mempool_mirror dropped here, we don't want to hold it across await points - - let txdata_for_response = txdata.clone(); - - let valid_job = { - let mut all_transactions = Vec::with_capacity(1 + txdata.len()); - all_transactions.push(coinbase_tx.clone()); - all_transactions.extend(txdata); - - let num_transactions = all_transactions.len(); - - // Use the min_ntime from the template as the block timestamp - // This ensures we meet Bitcoin Core's timestamp validation rules - let block_time = initial_validation_context.min_ntime; - - let header = Header { - version, - prev_blockhash: initial_validation_context.prev_hash, - merkle_root: TxMerkleNode::all_zeros(), // doesn't matter - time: block_time, - bits: initial_validation_context.nbits, - nonce: 0, // doesn't matter - }; - - let block = Block { - header, - txdata: all_transactions, + // The declared BIP34 height matched the tip at arrival, so a makeTemplate + // failure is either a stale-tip race (tip moved while we were validating) or a + // genuinely invalid job. + let (latest_tip_hash, latest_tip_height) = self.get_tip().await?; + let error_code = if latest_tip_hash != tip_hash { + debug!( + ?tip_hash, + tip_height, + ?latest_tip_hash, + latest_tip_height, + "Detected stale chain tip during DeclareMiningJob validation; classifying error as stale-chain-tip" + ); + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP + } else { + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB }; - let block_bytes: Vec = serialize(&block); - - debug!( - "Assembled block for checkBlock: {} bytes, {} transactions", - block_bytes.len(), - num_transactions - ); + return Ok(JdResponse::Error { + error_code, + prev_hash: Some(latest_tip_hash), + }); + }; - let mut check_block_request = self.mining_ipc_client.check_block_request(); + // The validated template provides the parameters (header) and the full transaction + // list (block) that jd-server needs for SetCustomMiningJob validation and solved + // block reconstruction. + let header = self.get_template_header(&template).await?; + let block = self.get_template_block(&template).await?; - match check_block_request.get().get_context() { - Ok(mut context) => context.set_thread(self.thread_ipc_client.clone()), - Err(e) => { - error!("Failed to set check block request thread context: {e}"); - // send error response to the client - // deliberately ignore potential send errors - let _ = response_tx.send(JdResponse::Error { - error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - prev_hash: Some(initial_validation_context.prev_hash), - }); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self.cancellation_token.cancel(); - return; - } - } + self.destroy_template(&template).await; + self.destroy_tx_collection(&collection).await; - check_block_request.get().set_block(&block_bytes); + // skip the node-generated dummy coinbase + let txdata: Vec = block.txdata.into_iter().skip(1).collect(); - let mut options = match check_block_request.get().get_options() { - Ok(options) => options, - Err(e) => { - error!("Failed to get check block options: {e}"); - // send error response to the client - // deliberately ignore potential send errors - let _ = response_tx.send(JdResponse::Error { - error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - prev_hash: Some(initial_validation_context.prev_hash), - }); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self.cancellation_token.cancel(); - return; - } - }; - options.set_check_merkle_root(false); - options.set_check_pow(false); + debug!( + prev_hash = ?header.prev_blockhash, + nbits = ?header.bits, + min_ntime = header.time, + tx_count = txdata.len(), + "TxCollection::makeTemplate validated the declared job" + ); - let check_block_response = match check_block_request.send().promise.await { - Ok(response) => response, - Err(e) => { - error!("Failed to send check block request: {e}"); - // send error response to the client - // deliberately ignore potential send errors - let _ = response_tx.send(JdResponse::Error { - error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - prev_hash: Some(initial_validation_context.prev_hash), - }); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self.cancellation_token.cancel(); - return; - } - }; - let check_block_result = match check_block_response.get() { - Ok(result) => result, - Err(e) => { - error!("Failed to get check block result: {e}"); - // send error response to the client - // deliberately ignore potential send errors - let _ = response_tx.send(JdResponse::Error { - error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - prev_hash: Some(initial_validation_context.prev_hash), - }); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self.cancellation_token.cancel(); - return; - } - }; + Ok(JdResponse::Success { + prev_hash: header.prev_blockhash, + nbits: header.bits, + min_ntime: header.time, + txdata, + }) + } - let result = check_block_result.get_result(); - let check_block_reason = check_block_result.get_reason(); - let check_block_debug = check_block_result.get_debug(); + /// Maps `unknownTxPos` positions back to the declared wtxids. + fn wtxids_at_positions(wtxid_list: &[Wtxid], positions: &[u32]) -> Vec { + positions + .iter() + .filter_map(|&pos| wtxid_list.get(pos as usize).copied()) + .collect() + } - debug!("checkBlock returned: {}", result); - if !result { - error!( - reason = ?check_block_reason, - debug = ?check_block_debug, - "Bitcoin Core rejected the block via checkBlock" - ); - debug!( - "Block details - version: {:?}, prev_blockhash: {:?}, bits: {:?}, num_txs: {}", - version, - initial_validation_context.prev_hash, - initial_validation_context.nbits, - num_transactions - ); - debug!( - "Coinbase tx inputs: {}, outputs: {}", - coinbase_tx.input.len(), - coinbase_tx.output.len() - ); - debug!( - "Block header time: {}, merkle_root: {:?}", - header.time, header.merkle_root - ); + /// Calls `Mining::collectTxs` with the declared wtxids. + async fn collect_txs( + &self, + wtxid_list: &[Wtxid], + ) -> Result { + let mut collect_txs_request = self.mining_ipc_client.collect_txs_request(); + collect_txs_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + { + let mut wtxids = collect_txs_request + .get() + .init_wtxids(wtxid_list.len() as u32); + for (pos, wtxid) in wtxid_list.iter().enumerate() { + wtxids.set(pos as u32, wtxid.as_byte_array()); } - result - }; + } + let collect_txs_response = collect_txs_request.send().promise.await?; + Ok(collect_txs_response.get()?.get_result()?) + } - if !valid_job { - // On checkBlock failure, force-refresh template + mirror before classifying the error. - // The template monitor updates mempool_mirror asynchronously, so we need to avoid races - // where validation can run on context A while chain tip has already moved to context B. - // Refreshing here narrows this TOCTOU window and lets us correctly emit - // `stale-chain-tip` instead of generic `invalid-job` when context drift occurred. - if let Err(e) = self.force_update_mempool_mirror().await { - debug!( - error = ?e, - "Failed to force-refresh template/mempool mirror after checkBlock failure; continuing with current validation context" - ); + /// Calls `TxCollection::addMissingTxs` with client-provided transactions. + /// + /// The caller must only pass transactions whose wtxid is part of the collection + /// (see the [`JdRequest::DeclareMiningJob`] invariants); Bitcoin Core rejects the + /// whole call otherwise. + async fn add_missing_txs( + &self, + collection: &TxCollectionIpcClient, + missing_txs: &[Transaction], + ) -> Result<(), BitcoinCoreSv2JDPError> { + let mut add_missing_txs_request = collection.add_missing_txs_request(); + add_missing_txs_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + { + let mut txs = add_missing_txs_request + .get() + .init_txs(missing_txs.len() as u32); + for (pos, tx) in missing_txs.iter().enumerate() { + txs.set(pos as u32, &serialize(tx)); } } + add_missing_txs_request.send().promise.await?; + Ok(()) + } - let (latest_validation_context, latest_bip34_height) = { - let mempool_mirror = self.mempool_mirror.borrow(); - let latest_validation_context = ValidationContext { - prev_hash: mempool_mirror - .get_current_prev_hash() - .expect("current_prev_hash must be set"), - nbits: mempool_mirror - .get_current_nbits() - .expect("current_nbits must be set"), - min_ntime: mempool_mirror - .get_current_min_ntime() - .expect("current_min_ntime must be set"), - }; - let latest_bip34_height = mempool_mirror - .get_current_bip34_height() - .expect("current_bip34_height must be set"); - (latest_validation_context, latest_bip34_height) - }; + /// Calls `TxCollection::unknownTxPos`, returning the positions of transactions Bitcoin + /// Core does not know about. + async fn unknown_tx_pos( + &self, + collection: &TxCollectionIpcClient, + ) -> Result, BitcoinCoreSv2JDPError> { + let mut unknown_tx_pos_request = collection.unknown_tx_pos_request(); + unknown_tx_pos_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let unknown_tx_pos_response = unknown_tx_pos_request.send().promise.await?; + let positions = unknown_tx_pos_response.get()?.get_result()?; + Ok((0..positions.len()).map(|pos| positions.get(pos)).collect()) + } - let response = if valid_job { - JdResponse::Success { - prev_hash: initial_validation_context.prev_hash, - nbits: initial_validation_context.nbits, - min_ntime: initial_validation_context.min_ntime, - txdata: txdata_for_response, - } + /// Calls `TxCollection::makeTemplate` with the declared coinbase, returning the BIP-22 + /// style `reason`/`debug` strings and, on success, the validated + /// [`BlockTemplateIpcClient`]. + async fn make_template( + &self, + collection: &TxCollectionIpcClient, + prev_hash: BlockHash, + coinbase_tx: &Transaction, + ) -> Result<(String, String, Option), BitcoinCoreSv2JDPError> { + let mut make_template_request = collection.make_template_request(); + make_template_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + make_template_request + .get() + .set_prevhash(prev_hash.as_byte_array()); + make_template_request + .get() + .set_coinbase(&serialize(coinbase_tx)); + let make_template_response = make_template_request.send().promise.await?; + let make_template_result = make_template_response.get()?; + let reason = make_template_result + .get_reason()? + .to_string() + .map_err(bitcoin_capnp_types::capnp::Error::from)?; + let debug_msg = make_template_result + .get_debug()? + .to_string() + .map_err(bitcoin_capnp_types::capnp::Error::from)?; + let template = if make_template_result.has_result() { + Some(make_template_result.get_result()?) } else { - let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; - let context_drifted = initial_validation_context.prev_hash - != latest_validation_context.prev_hash - || initial_validation_context.nbits != latest_validation_context.nbits - || initial_validation_context.min_ntime != latest_validation_context.min_ntime - || initial_bip34_height != latest_bip34_height - || stale_at_arrival_by_bip34; - - let error_code = if context_drifted { - debug!( - initial_prev_hash = ?initial_validation_context.prev_hash, - initial_nbits = ?initial_validation_context.nbits, - initial_min_ntime = initial_validation_context.min_ntime, - initial_bip34_height, - declared_bip34_height, - latest_prev_hash = ?latest_validation_context.prev_hash, - latest_nbits = ?latest_validation_context.nbits, - latest_min_ntime = latest_validation_context.min_ntime, - latest_bip34_height, - stale_at_arrival_by_bip34, - "Detected stale chain tip during DeclareMiningJob validation; classifying error as stale-chain-tip" - ); - ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP - } else { - ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB - }; + None + }; + Ok((reason, debug_msg, template)) + } - JdResponse::Error { - error_code, - prev_hash: Some(latest_validation_context.prev_hash), + /// Fetches the header of a validated template via `BlockTemplate::getBlockHeader`. + async fn get_template_header( + &self, + template: &BlockTemplateIpcClient, + ) -> Result { + let mut get_block_header_request = template.get_block_header_request(); + get_block_header_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let get_block_header_response = get_block_header_request.send().promise.await?; + let header_bytes = get_block_header_response.get()?.get_result()?; + deserialize(header_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock) + } + + /// Fetches the full block of a validated template via `BlockTemplate::getBlock`. + async fn get_template_block( + &self, + template: &BlockTemplateIpcClient, + ) -> Result { + let mut get_block_request = template.get_block_request(); + get_block_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let get_block_response = get_block_request.send().promise.await?; + let block_bytes = get_block_response.get()?.get_result()?; + debug!("Deserializing block ({} bytes)", block_bytes.len()); + deserialize(block_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock) + } + + /// Best-effort `TxCollection::destroy` so Bitcoin Core can free the collection early. + async fn destroy_tx_collection(&self, collection: &TxCollectionIpcClient) { + let mut destroy_request = collection.destroy_request(); + match destroy_request.get().get_context() { + Ok(mut context) => context.set_thread(self.thread_ipc_client.clone()), + Err(e) => { + debug!("Failed to set TxCollection destroy request thread context: {e}"); + return; } - }; + } + if let Err(e) = destroy_request.send().promise.await { + debug!("Failed to destroy TxCollection: {e}"); + } + } - // deliberately ignore potential send errors - // we don't care if the receiver dropped the channel - let _ = response_tx.send(response); + /// Best-effort `BlockTemplate::destroy` so Bitcoin Core can free the template early. + async fn destroy_template(&self, template: &BlockTemplateIpcClient) { + let mut destroy_request = template.destroy_request(); + match destroy_request.get().get_context() { + Ok(mut context) => context.set_thread(self.thread_ipc_client.clone()), + Err(e) => { + debug!("Failed to set BlockTemplate destroy request thread context: {e}"); + return; + } + } + if let Err(e) = destroy_request.send().promise.await { + debug!("Failed to destroy BlockTemplate: {e}"); + } } /// Submits a solved block to Bitcoin Core via `submitBlock`. @@ -425,3 +496,27 @@ impl BitcoinCoreSv2JDP { } } } + +/// Decodes BIP34 height from the first push in coinbase scriptSig. +/// Returns None if scriptSig does not start with a canonical small push. +pub(crate) fn decode_bip34_height_from_coinbase_script_sig(script_sig: &[u8]) -> Option { + let first = *script_sig.first()?; + + // Support small-integer opcodes (OP_0, OP_1..OP_16) used by some templates. + if first == 0x00 { + return Some(0); + } + if (0x51..=0x60).contains(&first) { + return Some((first - 0x50) as u32); + } + + // Canonical small push form: first byte is push length (1..=4). + let push_len = first as usize; + if push_len == 0 || push_len > 4 || script_sig.len() < 1 + push_len { + return None; + } + + let mut height_bytes = [0u8; 4]; + height_bytes[..push_len].copy_from_slice(&script_sig[1..1 + push_len]); + Some(u32::from_le_bytes(height_bytes)) +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs deleted file mode 100644 index ed6a0b863..000000000 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mempool.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Local mempool mirror for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX -//! socket. - -use std::collections::HashMap; -use stratum_core::bitcoin::{Block, BlockHash, CompactTarget, Transaction, Wtxid}; - -/// Local cache of mempool transactions and current template parameters. -/// -/// Tracks transactions by wtxid and maintains the current prev_hash, nbits, -/// and min_ntime from the most recent block template. -#[derive(Default)] -pub struct MempoolMirror { - txdata: HashMap, - current_prev_hash: Option, - current_nbits: Option, - current_min_ntime: Option, - current_bip34_height: Option, -} - -impl MempoolMirror { - /// Creates a new empty mempool mirror. - pub fn new() -> Self { - Default::default() - } - - /// Updates the mirror with transactions from a block template. - /// - /// Clears stale transactions if the prev_hash changes. - pub fn update(&mut self, block: &Block) { - let prev_hash = block.header.prev_blockhash; - if self.current_prev_hash != Some(prev_hash) { - self.txdata.clear(); - } - self.current_prev_hash = Some(prev_hash); - self.current_nbits = Some(block.header.bits); - self.current_min_ntime = Some(block.header.time); - self.current_bip34_height = block.txdata.first().map(|coinbase| { - coinbase - .input - .first() - .and_then(|input| { - decode_bip34_height_from_coinbase_script_sig(input.script_sig.as_bytes()) - }) - // Fallback for non-canonical/missing BIP34 encoding in some templates. - .unwrap_or_else(|| coinbase.lock_time.to_consensus_u32()) - }); - - // skip the coinbase transaction - for tx in block.txdata.iter().skip(1) { - let wtxid = tx.compute_wtxid(); - self.txdata.insert(wtxid, tx.clone()); - } - } - - /// Adds transactions to the mempool mirror. - /// - /// Used to add missing transactions from ProvideMissingTransactionsSuccess messages. - pub fn add_transactions(&mut self, transactions: Vec) { - for tx in transactions { - let wtxid = tx.compute_wtxid(); - self.txdata.insert(wtxid, tx); - } - } - - /// Retrieves transactions by wtxid. - pub fn get_txdata(&self, wtxids: &[Wtxid]) -> Vec { - wtxids - .iter() - .filter_map(|wtxid| self.txdata.get(wtxid).cloned()) - .collect() - } - - /// Returns wtxids that are not present in the mempool. - pub fn verify(&self, wtxids: &[Wtxid]) -> Vec { - wtxids - .iter() - .filter(|&wtxid| !self.txdata.contains_key(wtxid)) - .copied() - .collect() - } - - /// Returns the current template's prev_hash. - pub fn get_current_prev_hash(&self) -> Option { - self.current_prev_hash - } - - /// Returns the current template's difficulty target (nbits). - pub fn get_current_nbits(&self) -> Option { - self.current_nbits - } - - /// Returns the current template's minimum timestamp (min_ntime). - pub fn get_current_min_ntime(&self) -> Option { - self.current_min_ntime - } - - /// Returns the current template's BIP34 height decoded from coinbase scriptSig. - pub fn get_current_bip34_height(&self) -> Option { - self.current_bip34_height - } -} - -/// Decodes BIP34 height from the first push in coinbase scriptSig. -/// Returns None if scriptSig does not start with a canonical small push. -/// Shared by JDP components that need to compare declared vs current chain context. -pub(crate) fn decode_bip34_height_from_coinbase_script_sig(script_sig: &[u8]) -> Option { - let first = *script_sig.first()?; - - // Support small-integer opcodes (OP_0, OP_1..OP_16) used by some templates. - if first == 0x00 { - return Some(0); - } - if (0x51..=0x60).contains(&first) { - return Some((first - 0x50) as u32); - } - - // Canonical small push form: first byte is push length (1..=4). - let push_len = first as usize; - if push_len == 0 || push_len > 4 || script_sig.len() < 1 + push_len { - return None; - } - - let mut height_bytes = [0u8; 4]; - height_bytes[..push_len].copy_from_slice(&script_sig[1..1 + push_len]); - Some(u32::from_le_bytes(height_bytes)) -} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs index 3336066fc..af3ae82ad 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs @@ -3,23 +3,18 @@ use crate::{ common::job_declaration_protocol::io::JdRequest, - unix_capnp::v32x::job_declaration_protocol::{ - error::BitcoinCoreSv2JDPError, mempool::MempoolMirror, - }, + unix_capnp::v32x::job_declaration_protocol::error::BitcoinCoreSv2JDPError, }; use async_channel::Receiver; use bitcoin_capnp_types::{ - capnp, capnp_rpc::{RpcSystem, rpc_twoparty_capnp, twoparty}, init_capnp::init::Client as InitIpcClient, - mining_capnp::{ - block_template::Client as BlockTemplateIpcClient, mining::Client as MiningIpcClient, - }, + mining_capnp::mining::Client as MiningIpcClient, proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, }; use bitcoin_capnp_types_v32 as bitcoin_capnp_types; -use std::{cell::RefCell, path::Path, rc::Rc}; -use stratum_core::bitcoin::{Block, consensus::deserialize}; +use std::path::Path; +use stratum_core::bitcoin::{BlockHash, hashes::Hash}; use tokio::net::UnixStream; use tokio_util::compat::*; pub use tokio_util::sync::CancellationToken; @@ -27,8 +22,9 @@ use tracing::{debug, error, info, warn}; pub mod error; mod handlers; -mod mempool; -mod monitors; + +/// How often to poll `isInitialBlockDownload` while waiting for IBD to finish during startup. +const IBD_POLL_INTERVAL_SECS: u64 = 1; /// The main abstraction for interacting with Bitcoin Core via Sv2 Job Declaration Protocol. /// @@ -38,35 +34,31 @@ mod monitors; /// [`DeclareMiningJob`] and [`PushSolution`] requests) /// - A [`tokio_util::sync::CancellationToken`] to stop the internally spawned tasks /// -/// The instance bootstraps its internal mempool state by fetching the current block template -/// from Bitcoin Core before accepting requests. It then spawns a background monitor task that -/// tracks mempool changes via `waitNext` requests. -/// -/// Incoming [`DeclareMiningJob`] requests are validated by: -/// - Verifying all transactions exist in the mempool -/// - Assembling a test block with the declared coinbase and transactions -/// - Using Bitcoin Core's `checkBlock` to validate block structure +/// Unlike the v30.x/v31.x backends, this implementation does not keep a local mempool mirror. +/// Incoming [`DeclareMiningJob`] requests are validated with Bitcoin Core's `TxCollection` +/// interface (https://github.com/bitcoin/bitcoin/pull/35671): +/// - `collectTxs` references the declared transactions directly in Bitcoin Core's mempool +/// - `unknownTxPos` reports which declared transactions Bitcoin Core does not know about +/// - `addMissingTxs` completes the collection with transactions provided by the client +/// - `makeTemplate` reconstructs and validates the block, returning a `BlockTemplate` /// /// If transactions are missing, a [`MissingTransactions`] response is sent. If validation -/// succeeds, a [`Success`] response with current template parameters is sent. +/// succeeds, a [`Success`] response with the template parameters is sent. /// /// Incoming [`PushSolution`] requests are used to submit mining solutions to Bitcoin Core. #[derive(Clone)] pub struct BitcoinCoreSv2JDP { - thread_map: ThreadMapIpcClient, thread_ipc_client: ThreadIpcClient, submit_block_thread_ipc_client: ThreadIpcClient, mining_ipc_client: MiningIpcClient, - current_template_ipc_client: Rc>, cancellation_token: CancellationToken, - mempool_mirror: Rc>, incoming_requests: Receiver, } impl BitcoinCoreSv2JDP { /// Creates a new [`BitcoinCoreSv2JDP`] instance. /// - /// Bootstraps the mempool mirror and signals readiness before returning. + /// Waits for Bitcoin Core to leave IBD and signals readiness before returning. pub async fn new

( bitcoin_core_unix_socket_path: P, incoming_requests: Receiver, @@ -154,72 +146,31 @@ impl BitcoinCoreSv2JDP { let mining_client_response = mining_client_request.send().promise.await?; let mining_ipc_client: MiningIpcClient = mining_client_response.get()?.get_result()?; - let mut template_ipc_client_request = mining_ipc_client.create_new_block_request(); - template_ipc_client_request - .get() - .get_context()? - .set_thread(thread_ipc_client.clone()); - let mut template_ipc_client_request_options = template_ipc_client_request - .get() - .get_options() - .map_err(|e| { - error!("Failed to get template IPC client request options: {e}"); - e - })?; - template_ipc_client_request_options.set_use_mempool(true); - - debug!("Sending createNewBlock request to Bitcoin Core"); - let create_new_block_promise = template_ipc_client_request.send().promise; - // During IBD this startup call can block for a long time, so shutdown must interrupt the - // in-flight request instead of only abandoning the outer wait loop. - let template_ipc_client_response = tokio::select! { - template_ipc_client_response = create_new_block_promise => { - template_ipc_client_response.map_err(|e| { - error!("Failed to send template IPC client request: {}", e); - e - })? - } - _ = cancellation_token.cancelled() => { - debug!("Interrupting initial createNewBlock request"); - Self::interrupt_create_new_block_request(&mining_ipc_client).await?; - return Err(capnp::Error::failed( - "createNewBlock request interrupted during shutdown".to_string(), - ) - .into()); - } - }; - - let template_ipc_client_result = template_ipc_client_response.get().map_err(|e| { - error!("Failed to get template IPC client result: {}", e); - e - })?; - - let template_ipc_client = template_ipc_client_result.get_result().map_err(|e| { - error!("Failed to get template IPC client result: {}", e); - e - })?; - - info!("IPC JDP client successfully created."); - let self_ = Self { - thread_map, thread_ipc_client, submit_block_thread_ipc_client, mining_ipc_client, - current_template_ipc_client: Rc::new(RefCell::new(template_ipc_client)), - cancellation_token, - mempool_mirror: Rc::new(RefCell::new(MempoolMirror::new())), + cancellation_token: cancellation_token.clone(), incoming_requests, }; - // Bootstrap initial mempool state before signaling readiness - debug!("Bootstrapping initial mempool state"); - if let Err(e) = self_.update_mempool_mirror().await { - error!("Failed to bootstrap mempool mirror: {:?}", e); - // Don't send readiness signal on failure (ready_tx dropped) - return Err(e); + // Wait for IBD to finish before signaling readiness, mirroring the behavior of the + // mirror-based backends (whose initial createNewBlock blocks during IBD). + loop { + if self_.is_initial_block_download().await? { + debug!("Bitcoin Core is in IBD; waiting before accepting JDP requests"); + tokio::select! { + _ = cancellation_token.cancelled() => { + return Err(BitcoinCoreSv2JDPError::ReadinessSignalFailed); + } + _ = tokio::time::sleep(std::time::Duration::from_secs(IBD_POLL_INTERVAL_SECS)) => {} + } + } else { + break; + } } - debug!("Initial mempool state bootstrapped successfully"); + + info!("IPC JDP client successfully created."); // Signal that we're ready to accept requests ready_tx.send(()).map_err(|_| { @@ -230,53 +181,44 @@ impl BitcoinCoreSv2JDP { Ok(self_) } - /// Creates a new dedicated thread IPC client. - async fn new_thread_ipc_client(&self) -> Result { - let thread_request = self.thread_map.make_thread_request(); - let thread_response = thread_request.send().promise.await.map_err(|e| { - let details = format!("Failed to send make_thread request: {e}"); - error!("{}", details); - BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) - })?; - - let thread_ipc_client = thread_response + /// Returns whether Bitcoin Core is still in Initial Block Download. + async fn is_initial_block_download(&self) -> Result { + let mut ibd_request = self.mining_ipc_client.is_initial_block_download_request(); + ibd_request .get() - .map_err(|e| { - let details = format!("Failed to read make_thread response: {e}"); - error!("{}", details); - BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) - })? - .get_result() - .map_err(|e| { - let details = format!("Failed to get thread IPC client: {e}"); - error!("{}", details); - BitcoinCoreSv2JDPError::FailedToCreateThreadIpcClient(details) - })?; - - Ok(thread_ipc_client) + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let ibd_response = ibd_request.send().promise.await?; + Ok(ibd_response.get()?.get_result()) } - /// Interrupts an in-flight `createNewBlock` request during startup shutdown. - async fn interrupt_create_new_block_request( - mining_ipc_client: &MiningIpcClient, - ) -> Result<(), BitcoinCoreSv2JDPError> { - let interrupt_request = mining_ipc_client.interrupt_request(); - if let Err(e) = interrupt_request.send().promise.await { - error!("Failed to send interrupt createNewBlock request: {}", e); - return Err(BitcoinCoreSv2JDPError::CapnpError(e)); + /// Returns the current chain tip as `(prev_hash, height)`. + pub(crate) async fn get_tip(&self) -> Result<(BlockHash, i32), BitcoinCoreSv2JDPError> { + let mut get_tip_request = self.mining_ipc_client.get_tip_request(); + get_tip_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let get_tip_response = get_tip_request.send().promise.await?; + let get_tip_result = get_tip_response.get()?; + if !get_tip_result.get_has_result() { + return Err(BitcoinCoreSv2JDPError::NoChainTip); } - - Ok(()) + let block_ref = get_tip_result.get_result()?; + let hash_bytes: [u8; 32] = block_ref + .get_hash()? + .try_into() + .map_err(|_| BitcoinCoreSv2JDPError::NoChainTip)?; + Ok(( + BlockHash::from_byte_array(hash_bytes), + block_ref.get_height(), + )) } /// Main event loop - runs in a LocalSet on dedicated thread. /// - /// Spawns the monitor task and processes incoming job declaration requests until shutdown. + /// Processes incoming job declaration requests until shutdown. pub async fn run(&self) { - // spawn mempool mirror monitor task - let monitor_handle = self.monitor_and_update_mempool_mirror(); - - // Main request processing loop loop { tokio::select! { // Handle shutdown @@ -303,139 +245,7 @@ impl BitcoinCoreSv2JDP { } } } - - // Wait for the monitor_mempool_mirror task to finish gracefully - debug!("Waiting for monitor_mempool_mirror() task to finish"); - match monitor_handle.await { - Ok(()) => { - debug!("monitor_mempool_mirror() task finished successfully"); - } - Err(e) => { - error!( - "error waiting for monitor_mempool_mirror task to finish: {:?}", - e - ); - } - } - } - - /// Updates the mempool mirror with the current block template from Bitcoin Core. - async fn update_mempool_mirror(&self) -> Result<(), BitcoinCoreSv2JDPError> { - let mut get_block_request = self - .current_template_ipc_client - .borrow() - .get_block_request(); - get_block_request - .get() - .get_context()? - .set_thread(self.thread_ipc_client.clone()); - - let block_bytes = get_block_request - .send() - .promise - .await? - .get()? - .get_result()? - .to_vec(); - debug!("Deserializing block ({} bytes)", block_bytes.len()); - let block: Block = - deserialize(&block_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock)?; - - self.mempool_mirror.borrow_mut().update(&block); - - Ok(()) - } - - /// Forces a synchronous template refresh from Bitcoin Core, then refreshes the mempool mirror. - /// - /// This is useful after `checkBlock` failures to reduce classification races where the async - /// `waitNext` monitor has not yet advanced `current_template_ipc_client`. - /// - /// It differs from update_mempool_mirror in the sense that it doesn't assume a new template is - /// available. It forces the template refresh before updating MempoolMirror. - /// - /// On transient `"thread busy"` IPC contention, this method retries a few times with - /// a short backoff before returning the error. - pub(crate) async fn force_update_mempool_mirror(&self) -> Result<(), BitcoinCoreSv2JDPError> { - const MAX_ATTEMPTS: usize = 3; - const RETRY_BACKOFF_MS: u64 = 25; - - let mut last_error: Option = None; - - for attempt in 1..=MAX_ATTEMPTS { - let result = async { - let mut create_new_block_request = - self.mining_ipc_client.create_new_block_request(); - - create_new_block_request - .get() - .get_context() - .map_err(|e| { - error!("Failed to get template IPC client request context: {e}"); - e - })? - .set_thread(self.thread_ipc_client.clone()); - - let mut create_new_block_options = - create_new_block_request.get().get_options().map_err(|e| { - error!("Failed to get createNewBlock options: {e}"); - e - })?; - - create_new_block_options.set_use_mempool(true); - - let create_new_block_response = - create_new_block_request.send().promise.await.map_err(|e| { - error!("Failed to send createNewBlock request: {e}"); - e - })?; - - let new_template_ipc_client = create_new_block_response - .get() - .map_err(|e| { - error!("Failed to read createNewBlock response: {e}"); - e - })? - .get_result() - .map_err(|e| { - error!("Failed to get BlockTemplate from createNewBlock: {e}"); - e - })?; - - { - let mut current_template_ipc_client = - self.current_template_ipc_client.borrow_mut(); - *current_template_ipc_client = new_template_ipc_client; - } - - self.update_mempool_mirror().await - } - .await; - - match result { - Ok(()) => return Ok(()), - Err(e) if e.is_thread_busy() && attempt < MAX_ATTEMPTS => { - warn!( - error = ?e, - attempt, - max_attempts = MAX_ATTEMPTS, - "Transient IPC contention during force_update_mempool_mirror (thread busy); retrying" - ); - last_error = Some(e); - tokio::time::sleep(std::time::Duration::from_millis(RETRY_BACKOFF_MS)).await; - } - Err(e) => return Err(e), - } - } - - // ideally the retry logic should never allow execution to reach here - // but if it does, we just bubble up the error - Err(last_error.unwrap_or_else(|| { - BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed( - "force_update_mempool_mirror exhausted retries without a terminal error" - .to_string(), - )) - })) + warn!("Exiting BitcoinCoreSv2JDP request loop"); } /// Processes a single job declaration request and dispatches to the appropriate handler. @@ -465,17 +275,4 @@ impl BitcoinCoreSv2JDP { } } } - - /// Interrupts the current `waitNext` request to Bitcoin Core for graceful shutdown. - async fn interrupt_wait_request(&self) -> Result<(), BitcoinCoreSv2JDPError> { - let template_ipc_client = self.current_template_ipc_client.borrow().clone(); - - let interrupt_wait_request = template_ipc_client.interrupt_wait_request(); - if let Err(e) = interrupt_wait_request.send().promise.await { - error!("Failed to send interrupt wait request: {}", e); - return Err(BitcoinCoreSv2JDPError::CapnpError(e)); - } - - Ok(()) - } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs deleted file mode 100644 index 07fcc9c49..000000000 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/monitors.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Background monitors for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX -//! socket. - -use crate::unix_capnp::v32x::job_declaration_protocol::BitcoinCoreSv2JDP; -use bitcoin_capnp_types_v32::capnp; -use tokio::task::JoinHandle; -use tracing::{debug, error, warn}; - -impl BitcoinCoreSv2JDP { - /// Spawns a `spawn_local` task that issues `waitNext` requests to Bitcoin Core and - /// refreshes the [`MempoolMirror`](super::mempool::MempoolMirror) whenever the template - /// changes. Returns the [`JoinHandle`] so the caller can await clean shutdown. - pub fn monitor_and_update_mempool_mirror(&self) -> JoinHandle<()> { - let self_clone = self.clone(); - - tokio::task::spawn_local(async move { - debug!("monitor_mempool_mirror() task started"); - debug!("Creating dedicated blocking_thread_ipc_client for waitNext requests"); - let blocking_thread_ipc_client = match self_clone.new_thread_ipc_client().await { - Ok(blocking_thread_ipc_client) => blocking_thread_ipc_client, - Err(e) => { - error!("Failed to create blocking thread IPC client: {:?}", e); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self_clone.cancellation_token.cancel(); - return; - } - }; - debug!("monitor_mempool_mirror() entering main loop"); - - loop { - // Create a new waitNext request for each iteration - let mut wait_next_request = self_clone - .current_template_ipc_client - .borrow_mut() - .wait_next_request(); - - match wait_next_request.get().get_context() { - Ok(mut context) => context.set_thread(blocking_thread_ipc_client.clone()), - Err(e) => { - error!("Failed to set thread: {}", e); - self_clone.cancellation_token.cancel(); - break; - } - } - - let mut wait_next_request_options = match wait_next_request.get().get_options() { - Ok(options) => options, - Err(e) => { - error!("Failed to get waitNext request options: {}", e); - self_clone.cancellation_token.cancel(); - break; - } - }; - - // Rebuild aggressively instead of waiting only for tip changes. - // Bitcoin Core reevaluates fee growth on a 1s tick, and with - // fee_threshold = 0 it returns any candidate whose total fees - // are not lower than the current template. In steady state this - // usually produces a new BlockTemplate about once per second. - wait_next_request_options.set_fee_threshold(0); - - // Bound how long a single waitNext call can stay attached to - // one BlockTemplate before the loop recreates it from the - // latest current_template_ipc_client when Bitcoin Core does not - // produce a returnable candidate. This is a fallback, not the - // expected cadence of template updates. - wait_next_request_options.set_timeout(3_000.0); - - tokio::select! { - _ = self_clone.cancellation_token.cancelled() => { - debug!("Interrupting waitNext request"); - if let Err(e) = self_clone.interrupt_wait_request().await { - error!("Failed to interrupt waitNext request: {:?}", e); - } - warn!("Exiting mempool mirror loop"); - debug!("monitor_mempool_mirror() exiting due to cancellation"); - break; - } - wait_next_request_response = wait_next_request.send().promise => { - match wait_next_request_response { - Ok(response) => { - let result = match response.get() { - Ok(result) => result, - Err(e) => { - error!("Failed to get response: {}", e); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self_clone.cancellation_token.cancel(); - break; - } - }; - - let new_template_ipc_client = match result.get_result() { - Ok(new_template_ipc_client) => { - debug!("waitNext returned new template IPC client"); - new_template_ipc_client - }, - Err(e) => { - match e.kind { - capnp::ErrorKind::MessageContainsNullCapabilityPointer => { - debug!("waitNext timed out (no mempool changes)"); - debug!("Continuing to next waitNext iteration"); - continue; - } - _ => { - error!("Failed to get new template IPC client: {}", e); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self_clone.cancellation_token.cancel(); - break; - } - } - } - }; - - // update the current template IPC client - { - let mut current_template_ipc_client_guard = self_clone.current_template_ipc_client.borrow_mut(); - *current_template_ipc_client_guard = new_template_ipc_client; - debug!("Updated current_template_ipc_client with new template"); - } - - // update the mempool mirror - if let Err(e) = self_clone.update_mempool_mirror().await { - if e.is_thread_busy() { - warn!( - error = ?e, - "Transient IPC contention while updating mempool mirror (thread busy); retrying" - ); - continue; - } - - error!("Failed to update mempool mirror: {:?}", e); - self_clone.cancellation_token.cancel(); - break; - } - } - Err(e) => { - let err: super::error::BitcoinCoreSv2JDPError = e.into(); - if err.is_thread_busy() { - warn!( - error = ?err, - "Transient IPC contention during waitNext (thread busy); retrying" - ); - continue; - } - debug!("waitNext request failed with error: {:?}", err); - error!("Failed to get response: {:?}", err); - warn!("Terminating Sv2 Bitcoin Core IPC Connection"); - self_clone.cancellation_token.cancel(); - break; - } - } - } - } - } - debug!("monitor_mempool_mirror() task exiting"); - }) - } -} From b7ce927807809f03ef01ce2f4be3d5c160fd2031 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 11:37:34 +0200 Subject: [PATCH 17/19] v32x JDP: submit solutions via retained BlockTemplate Instead of destroying the BlockTemplate returned by TxCollection::makeTemplate, the v32.x backend retains it keyed by (downstream_id, request_id). A PushSolution now sends only the header fields and the solved coinbase to Bitcoin Core, which reconstructs the block from the retained template via BlockTemplate::submitSolution -- the block itself never crosses the IPC boundary in either direction. This removes the full-block transfer (getBlock) at declaration time and the block reconstruction in jd-server, along with the per-job txdata copy jd-server used to hold. SetCustomMiningJob merkle-path validation now uses the coinbase merkle branch reported by the validator: getCoinbaseMerklePath on v32.x (the branch is independent of the template's dummy coinbase), and a branch computed from the mirror's txids on v30.x/v31.x. Since the backend now holds per-job node-side state, jd-server mirrors its job lifecycle over the request channel so retained templates are freed when they can no longer receive a solution: - ReleaseDeclaredJob on declaration errors, expired tokens (janitor), failed SetCustomMiningJob validation, and active-job replacement - CleanupDownstream on downstream disconnect - a re-declared request id replaces (and destroys) the old template - PushSolution consumes the template v30.x/v31.x ignore the new lifecycle requests; their PushSolution handling remains unimplemented (their IPC interface has no way to submit blocks). --- .../src/common/job_declaration_protocol/io.rs | 51 +- .../common/job_declaration_protocol/mod.rs | 45 ++ .../v30x/job_declaration_protocol/handlers.rs | 14 +- .../v30x/job_declaration_protocol/mod.rs | 8 +- .../v31x/job_declaration_protocol/handlers.rs | 14 +- .../v31x/job_declaration_protocol/mod.rs | 8 +- .../v32x/job_declaration_protocol/handlers.rs | 221 ++++++--- .../v32x/job_declaration_protocol/mod.rs | 56 ++- .../tests/bitcoin_core_ipc_jdp_io.rs | 9 +- .../job_validation/bitcoin_core_ipc.rs | 458 ++++++++---------- 10 files changed, 533 insertions(+), 351 deletions(-) diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs index 0c5ff1fbc..8d27f4862 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs @@ -1,8 +1,16 @@ //! Request / response types exchanged between `jd-server` and the Bitcoin Core IPC thread. -use stratum_core::bitcoin::{Block, BlockHash, CompactTarget, Transaction, Wtxid, block::Version}; +use stratum_core::bitcoin::{ + BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, block::Version, +}; use tokio::sync::oneshot; +/// Identifies a downstream client of `jd-server`. +pub type DownstreamId = usize; + +/// Identifies a `DeclareMiningJob` request within a downstream connection. +pub type RequestId = u32; + /// Snapshot of the template parameters used by the mirror-based validators (v30.x/v31.x) /// at decision time. /// @@ -20,8 +28,11 @@ pub struct ValidationContext { /// A request sent from `jd-server` to the [`BitcoinCoreSv2JDP`](super::BitcoinCoreSv2JDP) IPC /// thread. /// -/// Built from a `DeclareMiningJob` (plus an optional `ProvideMissingTransactionsSuccess`) -/// or a `PushSolution`. +/// `DeclareMiningJob` is built from a `DeclareMiningJob` message (plus an optional +/// `ProvideMissingTransactionsSuccess`), `PushSolution` from a `PushSolution` message. The +/// remaining variants let `jd-server` mirror its job lifecycle so backends that retain +/// per-job state (the v32.x `TxCollection` backend keeps a validated `BlockTemplate` per +/// declared job) can release it. pub enum JdRequest { /// Validate a declared mining job. /// @@ -29,14 +40,35 @@ pub enum JdRequest { /// transaction in `missing_txs` is part of `wtxid_list`. The v32.x `TxCollection` /// backend relies on these; Bitcoin Core rejects violations with RPC-level errors. DeclareMiningJob { + downstream_id: DownstreamId, + request_id: RequestId, version: Version, coinbase_tx: Transaction, wtxid_list: Vec, missing_txs: Vec, response_tx: oneshot::Sender, }, - /// Submit a fully assembled block to Bitcoin Core (fire-and-forget). - PushSolution { block: Block }, + /// Submit a solution for a previously declared job to Bitcoin Core (fire-and-forget). + /// + /// `downstream_id`/`request_id` identify the `DeclareMiningJob` the solution belongs + /// to. `coinbase_tx` is the declared coinbase with the solution's extranonce applied. + PushSolution { + downstream_id: DownstreamId, + request_id: RequestId, + version: u32, + ntime: u32, + nonce: u32, + coinbase_tx: Transaction, + }, + /// Release any backend state retained for a declared job that will never be used + /// (consumed with an error, expired, or superseded). Fire-and-forget; harmless when the + /// backend retained nothing. + ReleaseDeclaredJob { + downstream_id: DownstreamId, + request_id: RequestId, + }, + /// Release all backend state retained for a disconnected downstream (fire-and-forget). + CleanupDownstream { downstream_id: DownstreamId }, } /// The result of trying to handle a DeclareMiningJob request. @@ -50,11 +82,10 @@ pub enum JdResponse { prev_hash: BlockHash, nbits: CompactTarget, min_ntime: u32, - /// Full non-coinbase transaction list in declaration order. - /// - /// This is used by `jd-server` both to reconstruct solved blocks when handling - /// `PushSolution` and to derive txids for merkle-root/merkle-path validation. - txdata: Vec, + /// Coinbase merkle branch (sibling hashes from leaf to root at position 0) in the + /// txid merkle tree, used by `jd-server` to validate a `SetCustomMiningJob`'s + /// `merkle_path`. It does not depend on the coinbase transaction itself. + merkle_path: Vec, }, Error { error_code: &'static str, diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs index f9670f7ab..ce6d356e7 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs @@ -19,6 +19,7 @@ use crate::{ use async_channel::Receiver; use io::JdRequest; use std::path::Path; +use stratum_core::bitcoin::{TxMerkleNode, Txid, consensus::Encodable, hashes::Hash}; pub use tokio_util::sync::CancellationToken; /// Version-agnostic JDP runtime handle. @@ -90,3 +91,47 @@ where }), } } + +/// Computes the coinbase merkle branch in the txid merkle tree for a block whose +/// non-coinbase transactions have the given txids (in block order). +/// +/// Returns the sibling hashes at each level from leaf to root, needed to reconstruct the +/// block header's merkle root from the coinbase position (index 0). The branch does not +/// depend on the coinbase transaction itself, so a placeholder leaf is used. +/// +/// Used to compare with a `SetCustomMiningJob.merkle_path`. +pub fn coinbase_merkle_branch(txids: &[Txid]) -> Vec { + let mut hashes: Vec = Vec::with_capacity(1 + txids.len()); + // placeholder for the coinbase txid; position-0 branches never include their own leaf + hashes.push(TxMerkleNode::all_zeros()); + for txid in txids { + hashes.push((*txid).into()); + } + + if hashes.len() == 1 { + return Vec::new(); + } + + let mut branch = Vec::new(); + + while hashes.len() > 1 { + branch.push(hashes[1]); + + let half = hashes.len().div_ceil(2); + let mut next_level = Vec::with_capacity(half); + for idx in 0..half { + let left = hashes[2 * idx]; + let right = hashes[std::cmp::min(2 * idx + 1, hashes.len() - 1)]; + let mut engine = TxMerkleNode::engine(); + left.consensus_encode(&mut engine) + .expect("in-memory writers don't error"); + right + .consensus_encode(&mut engine) + .expect("in-memory writers don't error"); + next_level.push(TxMerkleNode::from_engine(engine)); + } + hashes = next_level; + } + + branch +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs index ae51eb355..051b31c62 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs @@ -1,7 +1,10 @@ //! Handlers for Bitcoin Core v30.x Sv2 Job Declaration Protocol via capnp over UNIX socket. use crate::{ - common::job_declaration_protocol::io::{JdResponse, ValidationContext}, + common::job_declaration_protocol::{ + coinbase_merkle_branch, + io::{JdResponse, ValidationContext}, + }, unix_capnp::v30x::job_declaration_protocol::{ BitcoinCoreSv2JDP, mempool::decode_bip34_height_from_coinbase_script_sig, }, @@ -269,7 +272,12 @@ impl BitcoinCoreSv2JDP { prev_hash: initial_validation_context.prev_hash, nbits: initial_validation_context.nbits, min_ntime: initial_validation_context.min_ntime, - txdata: txdata_for_response, + merkle_path: coinbase_merkle_branch( + &txdata_for_response + .iter() + .map(|tx| tx.compute_txid()) + .collect::>(), + ), } } else { let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; @@ -313,7 +321,7 @@ impl BitcoinCoreSv2JDP { /// Submits a solved block to Bitcoin Core. /// /// Not yet implemented for v30.x IPC, which does not expose `submitBlock`. - pub(crate) async fn handle_push_solution(&self, _block: Block) { + pub(crate) async fn handle_push_solution(&self) { // todo } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs index c5e4ff6c4..04e60aa4c 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs @@ -357,6 +357,7 @@ impl BitcoinCoreSv2JDP { wtxid_list, missing_txs, response_tx, + .. } => { self.handle_declare_mining_job( version, @@ -369,9 +370,12 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { block } => { - self.handle_push_solution(block).await; + JdRequest::PushSolution { .. } => { + self.handle_push_solution().await; } + + // This backend retains no per-job state, so there is nothing to release. + JdRequest::ReleaseDeclaredJob { .. } | JdRequest::CleanupDownstream { .. } => {} } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs index 5b61ebac7..5fece988e 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs @@ -1,7 +1,10 @@ //! Handlers for Bitcoin Core v31.x Sv2 Job Declaration Protocol via capnp over UNIX socket. use crate::{ - common::job_declaration_protocol::io::{JdResponse, ValidationContext}, + common::job_declaration_protocol::{ + coinbase_merkle_branch, + io::{JdResponse, ValidationContext}, + }, unix_capnp::v31x::job_declaration_protocol::{ BitcoinCoreSv2JDP, mempool::decode_bip34_height_from_coinbase_script_sig, }, @@ -284,7 +287,12 @@ impl BitcoinCoreSv2JDP { prev_hash: initial_validation_context.prev_hash, nbits: initial_validation_context.nbits, min_ntime: initial_validation_context.min_ntime, - txdata: txdata_for_response, + merkle_path: coinbase_merkle_branch( + &txdata_for_response + .iter() + .map(|tx| tx.compute_txid()) + .collect::>(), + ), } } else { let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; @@ -328,7 +336,7 @@ impl BitcoinCoreSv2JDP { /// Submits a solved block to Bitcoin Core. /// /// Not yet implemented for v31.x IPC, which does not expose `submitBlock`. - pub(crate) async fn handle_push_solution(&self, _block: Block) { + pub(crate) async fn handle_push_solution(&self) { // todo } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs index 8d8d8b4d1..251057650 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs @@ -418,6 +418,7 @@ impl BitcoinCoreSv2JDP { wtxid_list, missing_txs, response_tx, + .. } => { self.handle_declare_mining_job( version, @@ -430,9 +431,12 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { block } => { - self.handle_push_solution(block).await; + JdRequest::PushSolution { .. } => { + self.handle_push_solution().await; } + + // This backend retains no per-job state, so there is nothing to release. + JdRequest::ReleaseDeclaredJob { .. } | JdRequest::CleanupDownstream { .. } => {} } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs index 27c306bfc..f0e9b1c95 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -1,7 +1,7 @@ //! Handlers for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX socket. use crate::{ - common::job_declaration_protocol::io::JdResponse, + common::job_declaration_protocol::io::{DownstreamId, JdResponse, RequestId}, unix_capnp::v32x::job_declaration_protocol::{ BitcoinCoreSv2JDP, error::BitcoinCoreSv2JDPError, }, @@ -13,7 +13,7 @@ use bitcoin_capnp_types::mining_capnp::{ use bitcoin_capnp_types_v32 as bitcoin_capnp_types; use stratum_core::{ bitcoin::{ - Block, BlockHash, Transaction, Wtxid, + BlockHash, Transaction, TxMerkleNode, Wtxid, block::{Header, Version}, consensus::{deserialize, serialize}, hashes::Hash, @@ -26,8 +26,8 @@ use stratum_core::{ use tokio::sync::oneshot; use tracing::{debug, error, info, warn}; -const MAX_SUBMIT_BLOCK_ATTEMPTS: usize = 3; -const SUBMIT_BLOCK_RETRY_BACKOFF_MS: u64 = 15; +const MAX_SUBMIT_SOLUTION_ATTEMPTS: usize = 3; +const SUBMIT_SOLUTION_RETRY_BACKOFF_MS: u64 = 15; /// `reason` returned by `TxCollection::makeTemplate` when the collection is still incomplete. const MAKE_TEMPLATE_REASON_MISSING_TXS: &str = "missing-txs"; @@ -44,8 +44,14 @@ impl BitcoinCoreSv2JDP { /// The declared coinbase (with a zeroed extranonce, which no contextual check depends /// on) is passed to `makeTemplate`, so it is fully validated at declaration time (BIP34 /// height, output value, sigops, weight, witness commitment). + /// + /// On success the validated template is retained under `(downstream_id, request_id)` + /// so a later [`JdRequest::PushSolution`] can submit a solution against it. + #[allow(clippy::too_many_arguments)] pub(crate) async fn handle_declare_mining_job( &self, + downstream_id: DownstreamId, + request_id: RequestId, version: Version, coinbase_tx: Transaction, wtxid_list: Vec, @@ -65,7 +71,13 @@ impl BitcoinCoreSv2JDP { ); let response = match self - .validate_declare_mining_job(&coinbase_tx, &wtxid_list, &missing_txs) + .validate_declare_mining_job( + downstream_id, + request_id, + &coinbase_tx, + &wtxid_list, + &missing_txs, + ) .await { Ok(response) => response, @@ -94,6 +106,8 @@ impl BitcoinCoreSv2JDP { /// [`JdResponse::Error`] or [`JdResponse::MissingTransactions`]. async fn validate_declare_mining_job( &self, + downstream_id: DownstreamId, + request_id: RequestId, coinbase_tx: &Transaction, wtxid_list: &[Wtxid], missing_txs: &[Transaction], @@ -200,34 +214,81 @@ impl BitcoinCoreSv2JDP { }); }; - // The validated template provides the parameters (header) and the full transaction - // list (block) that jd-server needs for SetCustomMiningJob validation and solved - // block reconstruction. + // The validated template provides the parameters (header) and the coinbase merkle + // branch that jd-server needs for SetCustomMiningJob validation. The block itself + // stays inside Bitcoin Core: the template is retained so a later PushSolution can + // be submitted against it via submitSolution. let header = self.get_template_header(&template).await?; - let block = self.get_template_block(&template).await?; + let merkle_path = self.get_coinbase_merkle_path(&template).await?; - self.destroy_template(&template).await; self.destroy_tx_collection(&collection).await; - // skip the node-generated dummy coinbase - let txdata: Vec = block.txdata.into_iter().skip(1).collect(); - debug!( prev_hash = ?header.prev_blockhash, nbits = ?header.bits, min_ntime = header.time, - tx_count = txdata.len(), - "TxCollection::makeTemplate validated the declared job" + merkle_path_len = merkle_path.len(), + "TxCollection::makeTemplate validated the declared job; retaining template" ); + let replaced_template = self + .declared_templates + .borrow_mut() + .insert((downstream_id, request_id), template); + if let Some(replaced_template) = replaced_template { + // A re-declared request id supersedes the previous declaration. + self.destroy_template(&replaced_template).await; + } + Ok(JdResponse::Success { prev_hash: header.prev_blockhash, nbits: header.bits, min_ntime: header.time, - txdata, + merkle_path, }) } + /// Discards the retained template of a declared job, if any. + pub(crate) async fn release_declared_job( + &self, + downstream_id: DownstreamId, + request_id: RequestId, + ) { + let template = self + .declared_templates + .borrow_mut() + .remove(&(downstream_id, request_id)); + if let Some(template) = template { + debug!(downstream_id, request_id, "Releasing retained template"); + self.destroy_template(&template).await; + } + } + + /// Discards all retained templates of a disconnected downstream. + pub(crate) async fn cleanup_downstream(&self, downstream_id: DownstreamId) { + let templates: Vec = { + let mut declared_templates = self.declared_templates.borrow_mut(); + let keys: Vec<(DownstreamId, RequestId)> = declared_templates + .keys() + .filter(|(id, _)| *id == downstream_id) + .copied() + .collect(); + keys.iter() + .filter_map(|key| declared_templates.remove(key)) + .collect() + }; + if !templates.is_empty() { + debug!( + downstream_id, + count = templates.len(), + "Releasing retained templates for disconnected downstream" + ); + } + for template in &templates { + self.destroy_template(template).await; + } + } + /// Maps `unknownTxPos` positions back to the declared wtxids. fn wtxids_at_positions(wtxid_list: &[Wtxid], positions: &[u32]) -> Vec { positions @@ -354,20 +415,34 @@ impl BitcoinCoreSv2JDP { deserialize(header_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock) } - /// Fetches the full block of a validated template via `BlockTemplate::getBlock`. - async fn get_template_block( + /// Fetches the coinbase merkle branch of a validated template via + /// `BlockTemplate::getCoinbaseMerklePath`. + /// + /// The branch does not depend on the template's (dummy) coinbase, so it also applies to + /// the declared coinbase. + async fn get_coinbase_merkle_path( &self, template: &BlockTemplateIpcClient, - ) -> Result { - let mut get_block_request = template.get_block_request(); - get_block_request + ) -> Result, BitcoinCoreSv2JDPError> { + let mut get_coinbase_merkle_path_request = template.get_coinbase_merkle_path_request(); + get_coinbase_merkle_path_request .get() .get_context()? .set_thread(self.thread_ipc_client.clone()); - let get_block_response = get_block_request.send().promise.await?; - let block_bytes = get_block_response.get()?.get_result()?; - debug!("Deserializing block ({} bytes)", block_bytes.len()); - deserialize(block_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock) + let get_coinbase_merkle_path_response = + get_coinbase_merkle_path_request.send().promise.await?; + let get_coinbase_merkle_path_result = get_coinbase_merkle_path_response.get()?; + let path = get_coinbase_merkle_path_result.get_result()?; + let mut merkle_path = Vec::with_capacity(path.len() as usize); + for pos in 0..path.len() { + let node_bytes: [u8; 32] = path.get(pos)?.try_into().map_err(|_| { + bitcoin_capnp_types::capnp::Error::failed( + "coinbase merkle path node is not 32 bytes".to_string(), + ) + })?; + merkle_path.push(TxMerkleNode::from_byte_array(node_bytes)); + } + Ok(merkle_path) } /// Best-effort `TxCollection::destroy` so Bitcoin Core can free the collection early. @@ -400,100 +475,128 @@ impl BitcoinCoreSv2JDP { } } - /// Submits a solved block to Bitcoin Core via `submitBlock`. - pub(crate) async fn handle_push_solution(&self, block: Block) { - let block_bytes: Vec = serialize(&block); + /// Submits a solution for a previously declared job via the retained template's + /// `submitSolution` method. Bitcoin Core reconstructs the solved block from the + /// template's transactions plus the given header fields and coinbase, so the block + /// never crosses the IPC boundary. + pub(crate) async fn handle_push_solution( + &self, + downstream_id: DownstreamId, + request_id: RequestId, + version: u32, + ntime: u32, + nonce: u32, + coinbase_tx: Transaction, + ) { + let template = self + .declared_templates + .borrow_mut() + .remove(&(downstream_id, request_id)); + let Some(template) = template else { + error!( + downstream_id, + request_id, "No retained template for PushSolution; dropping solution" + ); + return; + }; + + let coinbase_bytes: Vec = serialize(&coinbase_tx); debug!( - block_bytes_len = block_bytes.len(), - tx_count = block.txdata.len(), - "Submitting solved block via submitBlock" + downstream_id, + request_id, + version, + ntime, + nonce, + coinbase_bytes_len = coinbase_bytes.len(), + "Submitting solution via BlockTemplate::submitSolution" ); - // a dedicated thread is used to submit blocks to Bitcoin Core + // a dedicated thread is used to submit solutions to Bitcoin Core // therefore retries should be extremely rare - for attempt in 1..=MAX_SUBMIT_BLOCK_ATTEMPTS { - let mut submit_block_request = self.mining_ipc_client.submit_block_request(); + for attempt in 1..=MAX_SUBMIT_SOLUTION_ATTEMPTS { + let mut submit_solution_request = template.submit_solution_request(); - match submit_block_request.get().get_context() { + match submit_solution_request.get().get_context() { Ok(mut context) => context.set_thread(self.submit_block_thread_ipc_client.clone()), Err(e) => { - error!("Failed to set submitBlock request thread context: {e}"); + error!("Failed to set submitSolution request thread context: {e}"); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); return; } } - submit_block_request.get().set_block(&block_bytes); + submit_solution_request.get().set_version(version); + submit_solution_request.get().set_timestamp(ntime); + submit_solution_request.get().set_nonce(nonce); + submit_solution_request.get().set_coinbase(&coinbase_bytes); - let submit_block_response = match submit_block_request.send().promise.await { + let submit_solution_response = match submit_solution_request.send().promise.await { Ok(response) => response, Err(e) => { let err: BitcoinCoreSv2JDPError = e.into(); - if err.is_thread_busy() && attempt < MAX_SUBMIT_BLOCK_ATTEMPTS { + if err.is_thread_busy() && attempt < MAX_SUBMIT_SOLUTION_ATTEMPTS { warn!( attempt, - max_attempts = MAX_SUBMIT_BLOCK_ATTEMPTS, - "Transient IPC contention during submitBlock (thread busy); retrying" + max_attempts = MAX_SUBMIT_SOLUTION_ATTEMPTS, + "Transient IPC contention during submitSolution (thread busy); retrying" ); tokio::time::sleep(std::time::Duration::from_millis( - SUBMIT_BLOCK_RETRY_BACKOFF_MS, + SUBMIT_SOLUTION_RETRY_BACKOFF_MS, )) .await; continue; } - error!("Failed to send submitBlock request: {err:?}"); + error!("Failed to send submitSolution request: {err:?}"); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); return; } }; - let submit_block_result = match submit_block_response.get() { + let submit_solution_result = match submit_solution_response.get() { Ok(result) => result, Err(e) => { let err: BitcoinCoreSv2JDPError = e.into(); - if err.is_thread_busy() && attempt < MAX_SUBMIT_BLOCK_ATTEMPTS { + if err.is_thread_busy() && attempt < MAX_SUBMIT_SOLUTION_ATTEMPTS { warn!( attempt, - max_attempts = MAX_SUBMIT_BLOCK_ATTEMPTS, - "Transient IPC contention while reading submitBlock response (thread busy); retrying" + max_attempts = MAX_SUBMIT_SOLUTION_ATTEMPTS, + "Transient IPC contention while reading submitSolution response (thread busy); retrying" ); tokio::time::sleep(std::time::Duration::from_millis( - SUBMIT_BLOCK_RETRY_BACKOFF_MS, + SUBMIT_SOLUTION_RETRY_BACKOFF_MS, )) .await; continue; } - error!("Failed to get submitBlock result: {err:?}"); + error!("Failed to get submitSolution result: {err:?}"); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); return; } }; - let accepted = submit_block_result.get_result(); - let reason = submit_block_result.get_reason(); - let debug_msg = submit_block_result.get_debug(); + let accepted = submit_solution_result.get_result(); if accepted { info!( - reason = ?reason, - debug = ?debug_msg, - "Bitcoin Core accepted block via submitBlock" + downstream_id, + request_id, "Bitcoin Core accepted solution via submitSolution" ); } else { warn!( - reason = ?reason, - debug = ?debug_msg, - "Bitcoin Core rejected block via submitBlock" + downstream_id, + request_id, "Bitcoin Core rejected solution via submitSolution" ); } - return; + break; } + + self.destroy_template(&template).await; } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs index af3ae82ad..33a722b95 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs @@ -2,18 +2,20 @@ //! UNIX socket. use crate::{ - common::job_declaration_protocol::io::JdRequest, + common::job_declaration_protocol::io::{DownstreamId, JdRequest, RequestId}, unix_capnp::v32x::job_declaration_protocol::error::BitcoinCoreSv2JDPError, }; use async_channel::Receiver; use bitcoin_capnp_types::{ capnp_rpc::{RpcSystem, rpc_twoparty_capnp, twoparty}, init_capnp::init::Client as InitIpcClient, - mining_capnp::mining::Client as MiningIpcClient, + mining_capnp::{ + block_template::Client as BlockTemplateIpcClient, mining::Client as MiningIpcClient, + }, proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, }; use bitcoin_capnp_types_v32 as bitcoin_capnp_types; -use std::path::Path; +use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc}; use stratum_core::bitcoin::{BlockHash, hashes::Hash}; use tokio::net::UnixStream; use tokio_util::compat::*; @@ -43,15 +45,24 @@ const IBD_POLL_INTERVAL_SECS: u64 = 1; /// - `makeTemplate` reconstructs and validates the block, returning a `BlockTemplate` /// /// If transactions are missing, a [`MissingTransactions`] response is sent. If validation -/// succeeds, a [`Success`] response with the template parameters is sent. +/// succeeds, a [`Success`] response with the template parameters is sent and the validated +/// `BlockTemplate` is retained, keyed by `(downstream_id, request_id)`. /// -/// Incoming [`PushSolution`] requests are used to submit mining solutions to Bitcoin Core. +/// Incoming [`PushSolution`] requests submit mining solutions to Bitcoin Core via the +/// retained template's `submitSolution` method; the block itself never leaves the node. +/// [`ReleaseDeclaredJob`] and [`CleanupDownstream`] requests discard retained templates +/// that will no longer be used. #[derive(Clone)] pub struct BitcoinCoreSv2JDP { thread_ipc_client: ThreadIpcClient, submit_block_thread_ipc_client: ThreadIpcClient, mining_ipc_client: MiningIpcClient, cancellation_token: CancellationToken, + /// Validated templates of declared jobs, retained for `PushSolution`. + /// + /// Dropping a client releases the corresponding `BlockTemplate` inside Bitcoin Core, + /// but an explicit `destroy` is preferred for prompt cleanup. + declared_templates: Rc>>, incoming_requests: Receiver, } @@ -151,6 +162,7 @@ impl BitcoinCoreSv2JDP { submit_block_thread_ipc_client, mining_ipc_client, cancellation_token: cancellation_token.clone(), + declared_templates: Rc::new(RefCell::new(HashMap::new())), incoming_requests, }; @@ -253,6 +265,8 @@ impl BitcoinCoreSv2JDP { match request { // Handle DeclareMiningJob requests JdRequest::DeclareMiningJob { + downstream_id, + request_id, version, coinbase_tx, wtxid_list, @@ -260,6 +274,8 @@ impl BitcoinCoreSv2JDP { response_tx, } => { self.handle_declare_mining_job( + downstream_id, + request_id, version, coinbase_tx, wtxid_list, @@ -270,8 +286,34 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { block } => { - self.handle_push_solution(block).await; + JdRequest::PushSolution { + downstream_id, + request_id, + version, + ntime, + nonce, + coinbase_tx, + } => { + self.handle_push_solution( + downstream_id, + request_id, + version, + ntime, + nonce, + coinbase_tx, + ) + .await; + } + + // Discard retained templates that will no longer be used + JdRequest::ReleaseDeclaredJob { + downstream_id, + request_id, + } => { + self.release_declared_job(downstream_id, request_id).await; + } + JdRequest::CleanupDownstream { downstream_id } => { + self.cleanup_downstream(downstream_id).await; } } } diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index 58eaac46d..6d946ee0d 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -149,10 +149,10 @@ async fn assert_jdp_success_scenario( .await; match response { - JdResponse::Success { txdata, .. } => { + JdResponse::Success { merkle_path, .. } => { assert!( - txdata.is_empty(), - "txdata should be empty when no non-coinbase txs were declared" + merkle_path.is_empty(), + "merkle path should be empty when no non-coinbase txs were declared" ); } response => panic!("expected Success, got: {response:?}"), @@ -193,6 +193,9 @@ async fn send_declare_mining_job_and_recv_response( let (response_tx, response_rx) = tokio::sync::oneshot::channel(); incoming_sender .send(JdRequest::DeclareMiningJob { + // This test drives a single logical downstream; ids only need to be consistent. + downstream_id: 0, + request_id: 0, // Use a fixed, valid block version across scenarios so assertions focus on IO paths. version: BlockVersion::from_consensus(0x2000_0000), coinbase_tx, diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 9d27a738f..0f6201bd6 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -25,11 +25,8 @@ use stratum_apps::{ }, stratum_core::{ bitcoin::{ - self, - block::{Header, Version}, - consensus::{Decodable, Encodable}, - hashes::Hash, - Block, BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, + self, block::Version, consensus::Decodable, hashes::Hash, BlockHash, CompactTarget, + Transaction, TxMerkleNode, Wtxid, }, job_declaration_sv2::{ DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution, @@ -80,13 +77,13 @@ struct DeclaredCustomJob { validated: Option, } -/// Template parameters and transaction data of a fully validated declared job. +/// Template parameters of a fully validated declared job. #[derive(Clone)] struct ValidatedJobData { nbits: CompactTarget, - /// Full non-coinbase transaction list in declaration order, used for merkle-path - /// validation and solved block reconstruction. - txdata: Vec, + /// Coinbase merkle branch reported by the validator, used to validate a + /// `SetCustomMiningJob.merkle_path`. + merkle_path: Vec, } /// Latest `DeclaredCustomJob` accepted via `SetCustomMiningJob` for a downstream. @@ -179,61 +176,6 @@ impl DeclaredCustomJob { }, ) } - - /// Computes the coinbase merkle branch in the txid merkle tree. - /// - /// Returns the sibling hashes at each level from leaf to root, needed to - /// reconstruct the block header's merkle root from the coinbase position (index 0). - /// - /// Requires the job to have been validated via `JdResponse::Success`. - /// The coinbase txid is derived from the declared coinbase prefix/suffix. - /// - /// Used to compare with a `SetCustomMiningJob.merkle_path`. - /// - /// Internally, errors may come from missing txdata - /// so error_code = "declared-job-not-yet-validated" - /// therefore () error type is sufficient. - fn get_merkle_path(&self) -> Result, ()> { - let txdata = &self.validated.as_ref().ok_or(())?.txdata; - - let coinbase_tx = self - .get_coinbase_tx(None) - .expect("coinbase tx already validated"); - let coinbase_txid: TxMerkleNode = coinbase_tx.compute_txid().into(); - - let mut hashes: Vec = Vec::with_capacity(1 + txdata.len()); - hashes.push(coinbase_txid); - for tx in txdata { - hashes.push(tx.compute_txid().into()); - } - - if hashes.len() == 1 { - return Ok(Vec::new()); - } - - let mut branch = Vec::new(); - - while hashes.len() > 1 { - branch.push(hashes[1]); - - let half = hashes.len().div_ceil(2); - let mut next_level = Vec::with_capacity(half); - for idx in 0..half { - let left = hashes[2 * idx]; - let right = hashes[std::cmp::min(2 * idx + 1, hashes.len() - 1)]; - let mut engine = TxMerkleNode::engine(); - left.consensus_encode(&mut engine) - .expect("in-memory writers don't error"); - right - .consensus_encode(&mut engine) - .expect("in-memory writers don't error"); - next_level.push(TxMerkleNode::from_engine(engine)); - } - hashes = next_level; - } - - Ok(branch) - } } /// Engine for validating and propagating solutions for Custom Jobs using Bitcoin Core over IPC. @@ -385,6 +327,7 @@ impl BitcoinCoreIPCEngine { // consumed by SetCustomMiningJob. let janitor_downstream_states = downstream_states.clone(); let janitor_cancellation = cancellation_token.clone(); + let janitor_request_sender = request_sender.clone(); tokio::spawn(async move { let janitor_interval = Duration::from_secs(JANITOR_INTERVAL_SECS); let token_timeout = Duration::from_secs(ALLOCATED_TOKEN_TIMEOUT_SECS); @@ -411,6 +354,13 @@ impl BitcoinCoreIPCEngine { for token in expired_tokens { if let Some(entry) = state.allocated_token_entries.remove(&token) { state.declared_custom_jobs.remove(&entry.request_id); + // let the validator release any state retained for the job + let _ = janitor_request_sender.try_send( + JdRequest::ReleaseDeclaredJob { + downstream_id, + request_id: entry.request_id, + }, + ); tracing::debug!( downstream_id, token, @@ -432,6 +382,144 @@ impl BitcoinCoreIPCEngine { jdp_thread_handle: Arc::new(Mutex::new(Some(jdp_thread_handle))), }) } + + /// Tells the validator to release any state retained for a declared job that will + /// never receive a solution (fire-and-forget). + fn release_declared_job(&self, downstream_id: DownstreamId, request_id: RequestId) { + let _ = self.request_sender.try_send(JdRequest::ReleaseDeclaredJob { + downstream_id, + request_id, + }); + } + + /// Cross-checks a `SetCustomMiningJob` against the stored declaration, returning the + /// `SetCustomMiningJobError` error code on mismatch. + fn validate_set_custom_mining_job( + declared_custom_job: &DeclaredCustomJob, + set_custom_mining_job: &SetCustomMiningJob<'_>, + ) -> Result<(), &'static str> { + // Job may be pending retry after missing txs and not fully validated yet. + let Some(validated) = declared_custom_job.validated.as_ref() else { + tracing::error!("Job not yet validated"); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED); + }; + + // Get declared values from stored job + let declared_prev_hash = declared_custom_job.prev_hash; + let declared_nbits = validated.nbits.to_consensus(); + let declared_version: u32 = declared_custom_job.get_version(); + + // Extract values from SetCustomMiningJob message + let custom_job_prev_hash = { + let bytes = set_custom_mining_job.prev_hash.to_array(); + BlockHash::from_byte_array(bytes) + }; + let custom_job_nbits: u32 = set_custom_mining_job.nbits; + let custom_job_version: u32 = set_custom_mining_job.version; + + // Validate prev_hash + if custom_job_prev_hash != declared_prev_hash { + tracing::debug!( + "prev_hash mismatch: custom={:?}, declared={:?}", + custom_job_prev_hash, + declared_prev_hash + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_STALE_CHAIN_TIP); + } + + // Validate nbits + if custom_job_nbits != declared_nbits { + tracing::debug!( + "nbits mismatch: custom={}, declared={}", + custom_job_nbits, + declared_nbits + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_NBITS); + } + + // Validate version + if custom_job_version != declared_version { + tracing::debug!( + "version mismatch: custom={}, declared={}", + custom_job_version, + declared_version + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_VERSION); + } + + // validate coinbase tx + { + let declared_coinbase_tx = match declared_custom_job.get_coinbase_tx(None) { + Ok(tx) => tx, + Err(_) => return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX), + }; + + if declared_coinbase_tx.version.0 != set_custom_mining_job.coinbase_tx_version as i32 { + tracing::debug!( + "coinbase version mismatch: custom={}, declared={}", + set_custom_mining_job.coinbase_tx_version, + declared_coinbase_tx.version.0 + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_VERSION); + } + + let script_sig = declared_coinbase_tx.input[0].script_sig.as_bytes(); + let coinbase_prefix = set_custom_mining_job.coinbase_prefix.as_bytes(); + if !script_sig.starts_with(coinbase_prefix) { + tracing::debug!("coinbase prefix mismatch"); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_PREFIX); + } + + if declared_coinbase_tx.input[0].sequence.0 + != set_custom_mining_job.coinbase_tx_input_n_sequence + { + tracing::debug!( + "coinbase input sequence mismatch: custom={}, declared={}", + set_custom_mining_job.coinbase_tx_input_n_sequence, + declared_coinbase_tx.input[0].sequence.0 + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_INPUT_N_SEQUENCE); + } + + let declared_outputs_bytes = + bitcoin::consensus::serialize(&declared_coinbase_tx.output); + if declared_outputs_bytes != set_custom_mining_job.coinbase_tx_outputs.as_bytes() { + tracing::debug!("coinbase outputs mismatch"); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_OUTPUTS); + } + + if declared_coinbase_tx.lock_time.to_consensus_u32() + != set_custom_mining_job.coinbase_tx_locktime + { + tracing::debug!( + "coinbase locktime mismatch: custom={}, declared={}", + set_custom_mining_job.coinbase_tx_locktime, + declared_coinbase_tx.lock_time.to_consensus_u32() + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_LOCKTIME); + } + } + + // validate merkle path + { + let custom_merkle_path: Vec = set_custom_mining_job + .merkle_path + .iter() + .map(|u256| TxMerkleNode::from_byte_array(u256.to_array())) + .collect(); + + if validated.merkle_path != custom_merkle_path { + tracing::debug!( + "merkle path mismatch: custom={:?}, declared={:?}", + custom_merkle_path, + validated.merkle_path + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MERKLE_PATH); + } + } + + Ok(()) + } } #[cfg_attr(not(test), hotpath::measure_all)] @@ -450,6 +538,10 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { fn cleanup_downstream(&self, downstream_id: DownstreamId) { self.downstream_states.remove(&downstream_id); + // let the validator release any state retained for the downstream's jobs + let _ = self + .request_sender + .try_send(JdRequest::CleanupDownstream { downstream_id }); } /// Validates a `DeclareMiningJob` by forwarding it to Bitcoin Core over IPC. @@ -579,6 +671,8 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // Send request to BitcoinCoreSv2JDP (clone wtxid_list since we need it for error handling) let request = JdRequest::DeclareMiningJob { + downstream_id, + request_id: declare_mining_job.request_id, version: Version::from_consensus(declare_mining_job.version as i32), coinbase_tx: declared_coinbase_tx, wtxid_list: wtxid_list.clone(), @@ -608,12 +702,12 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { prev_hash, nbits, min_ntime: _, - txdata, + merkle_path, } => { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, prev_hash, - validated: Some(ValidatedJobData { nbits, txdata }), + validated: Some(ValidatedJobData { nbits, merkle_path }), }; self.downstream_states .with_mut_or_default(downstream_id, |state| { @@ -640,6 +734,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { .remove(&declare_mining_job.request_id); state.allocated_token_entries.remove(&allocated_token); }); + self.release_declared_job(downstream_id, declare_mining_job.request_id); let tip_drifted = match (previous_pending_prev_hash, prev_hash) { (Some(previous_prev_hash), Some(current_prev_hash)) => { @@ -671,6 +766,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { .remove(&declare_mining_job.request_id); state.allocated_token_entries.remove(&allocated_token); }); + self.release_declared_job(downstream_id, declare_mining_job.request_id); DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP) } else { @@ -769,46 +865,28 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { return; }; - let declared_prev_hash = active_job.prev_hash; - - let mut txdata = match active_job.validated { - Some(ref validated) => validated.txdata.clone(), - None => { - tracing::error!("Active custom job is missing transaction data"); - return; - } - }; + let request_id = active_job.declare_mining_job.request_id; + // Reconstruct the declared coinbase with the solution's extranonce; the validator + // reconstructs and submits the full block from its retained template. let coinbase_tx = match active_job.get_coinbase_tx(Some(push_solution.extranonce.as_ref())) { Ok(coinbase_tx) => coinbase_tx, Err(_) => { tracing::error!("Failed to reconstruct solved coinbase transaction"); + self.release_declared_job(downstream_id, request_id); return; } }; - txdata.insert(0, coinbase_tx); - - let mut block = Block { - header: Header { - version: Version::from_consensus(push_solution.version as i32), - prev_blockhash: declared_prev_hash, - merkle_root: TxMerkleNode::all_zeros(), - time: push_solution.ntime, - bits: CompactTarget::from_consensus(push_solution.nbits), - nonce: push_solution.nonce, - }, - txdata, - }; - - let Some(merkle_root) = block.compute_merkle_root() else { - tracing::error!("Failed to compute merkle root for PushSolution block"); - return; + let request = JdRequest::PushSolution { + downstream_id, + request_id, + version: push_solution.version, + ntime: push_solution.ntime, + nonce: push_solution.nonce, + coinbase_tx, }; - block.header.merkle_root = merkle_root; - - let request = JdRequest::PushSolution { block }; if let Err(e) = self.request_sender.send(request).await { tracing::error!(downstream_id, "Failed to send PushSolution request: {}", e); @@ -879,179 +957,35 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } }; - // Job may be pending retry after missing txs and not fully validated yet. - let Some(validated) = declared_custom_job.validated.as_ref() else { - tracing::error!("Job not yet validated"); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED, - ); - }; - - // Get declared values from stored job - let declared_prev_hash = declared_custom_job.prev_hash; - let declared_nbits = validated.nbits.to_consensus(); - let declared_version: u32 = declared_custom_job.get_version(); - - // Extract values from SetCustomMiningJob message - let custom_job_prev_hash = { - let bytes = set_custom_mining_job.prev_hash.to_array(); - BlockHash::from_byte_array(bytes) - }; - let custom_job_nbits: u32 = set_custom_mining_job.nbits; - let custom_job_version: u32 = set_custom_mining_job.version; - - // Validate prev_hash + // The job is consumed at this point, so a validation failure must also release any + // validator state retained for it (e.g. the v32.x backend's BlockTemplate). + if let Err(error_code) = + Self::validate_set_custom_mining_job(&declared_custom_job, &set_custom_mining_job) { - if custom_job_prev_hash != declared_prev_hash { - tracing::debug!( - "prev_hash mismatch: custom={:?}, declared={:?}", - custom_job_prev_hash, - declared_prev_hash - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_STALE_CHAIN_TIP, - ); - } - } - - // Validate nbits - { - if custom_job_nbits != declared_nbits { - tracing::debug!( - "nbits mismatch: custom={}, declared={}", - custom_job_nbits, - declared_nbits - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_NBITS, - ); - } + self.release_declared_job(downstream_id, request_id); + return SetCustomMiningJobResult::Error(error_code); } - // Validate version - { - if custom_job_version != declared_version { - tracing::debug!( - "version mismatch: custom={}, declared={}", - custom_job_version, - declared_version - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_VERSION, - ); - } - } - - // validate coinbase tx - { - let declared_coinbase_tx = match declared_custom_job.get_coinbase_tx(None) { - Ok(tx) => tx, - Err(_) => { - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX, - ) - } - }; - - if declared_coinbase_tx.version.0 != set_custom_mining_job.coinbase_tx_version as i32 { - tracing::debug!( - "coinbase version mismatch: custom={}, declared={}", - set_custom_mining_job.coinbase_tx_version, - declared_coinbase_tx.version.0 - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_VERSION, - ); - } - - let script_sig = declared_coinbase_tx.input[0].script_sig.as_bytes(); - let coinbase_prefix = set_custom_mining_job.coinbase_prefix.as_bytes(); - if !script_sig.starts_with(coinbase_prefix) { - tracing::debug!("coinbase prefix mismatch"); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_PREFIX, - ); - } - - if declared_coinbase_tx.input[0].sequence.0 - != set_custom_mining_job.coinbase_tx_input_n_sequence - { - tracing::debug!( - "coinbase input sequence mismatch: custom={}, declared={}", - set_custom_mining_job.coinbase_tx_input_n_sequence, - declared_coinbase_tx.input[0].sequence.0 - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_INPUT_N_SEQUENCE, - ); - } - - let declared_outputs_bytes = - bitcoin::consensus::serialize(&declared_coinbase_tx.output); - if declared_outputs_bytes != set_custom_mining_job.coinbase_tx_outputs.as_bytes() { - tracing::debug!("coinbase outputs mismatch"); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_OUTPUTS, - ); - } - - if declared_coinbase_tx.lock_time.to_consensus_u32() - != set_custom_mining_job.coinbase_tx_locktime - { - tracing::debug!( - "coinbase locktime mismatch: custom={}, declared={}", - set_custom_mining_job.coinbase_tx_locktime, - declared_coinbase_tx.lock_time.to_consensus_u32() - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_LOCKTIME, - ); - } - } - - // validate merkle path - { - let declared_merkle_path = match declared_custom_job.get_merkle_path() { - Ok(path) => path, - Err(_) => { - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED, - ) - } - }; - - let custom_merkle_path: Vec = set_custom_mining_job - .merkle_path - .iter() - .map(|u256| TxMerkleNode::from_byte_array(u256.to_array())) - .collect(); - - if declared_merkle_path != custom_merkle_path { - tracing::debug!( - "merkle path mismatch: custom={:?}, declared={:?}", - custom_merkle_path, - declared_merkle_path - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MERKLE_PATH, - ); + let replaced_request_id = + self.downstream_states + .with_mut_or_default(downstream_id, |state| { + state + .active_custom_job + .replace(declared_custom_job) + .map(|replaced_job| replaced_job.declare_mining_job.request_id) + }); + if let Some(replaced_request_id) = replaced_request_id { + tracing::debug!( + "Replaced previous active custom job for downstream {} with newer SetCustomMiningJob", + downstream_id, + ); + // The replaced job can no longer receive a solution; release its retained state + // (unless the same request id was just re-activated). + if replaced_request_id != request_id { + self.release_declared_job(downstream_id, replaced_request_id); } } - self.downstream_states - .with_mut_or_default(downstream_id, |state| { - if state - .active_custom_job - .replace(declared_custom_job) - .is_some() - { - tracing::debug!( - "Replaced previous active custom job for downstream {} with newer SetCustomMiningJob", - downstream_id, - ); - } - }); - SetCustomMiningJobResult::Success } } From f39f942315dc156339ac02575cefe5c10b9cc4e0 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 12:24:28 +0200 Subject: [PATCH 18/19] integration-tests: cover missing-transactions success flow and coinbase validation Two gaps in the tp -> jdc -> jds end-to-end coverage: - The ProvideMissingTransactions round was only tested on failure paths: jds_ask_for_missing_transactions runs the JDC and JDS against nodes with incompatible chains, so the declaration is expected to fail after the missing transactions are provided, and no existing test mined a block containing a non-coinbase transaction. propagated_from_jds_to_tp_with_missing_transactions runs the JDC and JDS against separate nodes sharing the same chain (via a new sync_chain_from test helper that copies blocks over RPC), with a transaction only in the JDC node's mempool. The declaration must complete a ProvideMissingTransactions round, and the solved block -- which reaches the JDS node exclusively through the JDS, as the JDC -> TP SubmitSolution path is blocked -- must contain that transaction. This exercises collectTxs/addMissingTxs/makeTemplate and the retained-template submitSolution reconstruction end to end. - Nothing checked that a consensus-invalid declared coinbase is rejected at declaration time. The new overpaying-coinbase scenario in bitcoin_core_ipc_jdp_io asserts that a coinbase paying more than the block subsidy is rejected as invalid-job on all supported Bitcoin Core versions (via checkBlock on v30.x/v31.x and via makeTemplate's coinbase validation on v32.x). --- integration-tests/lib/template_provider.rs | 58 ++++++++++ .../tests/bitcoin_core_ipc_jdp_io.rs | 38 ++++++- .../tests/jds_block_propagation.rs | 102 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index 5d4d6a2b5..0ceef507b 100644 --- a/integration-tests/lib/template_provider.rs +++ b/integration-tests/lib/template_provider.rs @@ -382,6 +382,64 @@ impl BitcoinCore { Ok(block_hash) } + /// Copy all blocks from `source` to this node via RPC. + /// + /// The integration test nodes are not connected over P2P, so this is the way to give + /// two nodes the same chain while keeping their mempools independent. The node reorgs + /// to the source chain, so the source must have more work than any leftover local + /// chain (test datadirs persist across runs). + pub fn sync_chain_from(&self, source: &BitcoinCore) -> Result<(), corepc_node::Error> { + let source_client = &source.bitcoind.client; + let target_client = &self.bitcoind.client; + let source_height: u64 = source_client.call("getblockcount", &[])?; + for height in 1..=source_height { + let hash: String = source_client.call("getblockhash", &[height.into()])?; + let block_hex: String = source_client.call("getblock", &[hash.into(), 0.into()])?; + let result: serde_json::Value = + target_client.call("submitblock", &[block_hex.into()])?; + // Blocks the node already knows about are reported as duplicates, and blocks + // that do not (yet) beat a leftover local chain as inconclusive. + assert!( + result.is_null() + || result + .as_str() + .is_some_and(|r| r.starts_with("duplicate") || r == "inconclusive"), + "submitblock rejected block at height {height}: {result}" + ); + } + assert_eq!( + self.get_best_block_hash()?, + source.get_best_block_hash()?, + "chain sync should leave both nodes on the same tip" + ); + Ok(()) + } + + /// Return the hash of the block at the given height. + pub fn get_block_hash(&self, height: u64) -> Result { + Ok(self + .bitcoind + .client + .call("getblockhash", &[height.into()])?) + } + + /// Return the txids of the block with the given hash. + pub fn get_block_txids(&self, block_hash: &str) -> Result, corepc_node::Error> { + let block: serde_json::Value = self + .bitcoind + .client + .call("getblock", &[block_hash.into(), 1.into()])?; + Ok(block["tx"] + .as_array() + .map(|txids| { + txids + .iter() + .filter_map(|txid| txid.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default()) + } + /// Return the IPC socket path for connecting to this node. pub fn ipc_socket_path(&self) -> PathBuf { let network_dir = if self.is_signet { "signet" } else { "regtest" }; diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index 6d946ee0d..301962383 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -3,6 +3,8 @@ //! Flow covered per Bitcoin Core Sv2 runtime behavior and Sv2 JDP expectations: //! - `DeclareMiningJob` returns `MissingTransactions` when unknown wtxids are declared. //! - `DeclareMiningJob` returns `Success` for a minimal valid declaration. +//! - `DeclareMiningJob` returns `Error(invalid-job)` when the declared coinbase pays more +//! than the block subsidy. //! - `DeclareMiningJob` returns `Error(stale-chain-tip)` when the declared BIP34 height is //! intentionally mismatched. //! @@ -30,7 +32,10 @@ use stratum_apps::{ transaction::Version as TxVersion, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, Wtxid, }, - job_declaration_sv2::ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + job_declaration_sv2::{ + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + }, }, }; @@ -105,6 +110,7 @@ async fn assert_jdp_io_integration_for_version(version: BitcoinCoreVersion) { assert_jdp_missing_transactions_scenario(&incoming_sender, coinbase_tx.clone(), missing_wtxid) .await; assert_jdp_success_scenario(&incoming_sender, coinbase_tx).await; + assert_jdp_overpaying_coinbase_scenario(&incoming_sender, next_height).await; assert_jdp_stale_chain_tip_scenario(&incoming_sender, next_height).await; cancellation_token.cancel(); @@ -159,6 +165,36 @@ async fn assert_jdp_success_scenario( } } +async fn assert_jdp_overpaying_coinbase_scenario( + incoming_sender: &Sender, + next_height: u32, +) { + // Regtest initial block subsidy is 50 BTC; with an empty transaction list there are no + // fees, so paying one extra satoshi must fail contextual coinbase validation + // (bad-cb-amount). + let mut coinbase_tx = build_valid_coinbase_tx(next_height); + coinbase_tx.output[0].value = Amount::from_sat(50 * 100_000_000 + 1); + + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + coinbase_tx, + vec![], + vec![], + "jdp/overpaying-coinbase", + ) + .await; + + match response { + JdResponse::Error { error_code, .. } => { + assert_eq!( + error_code, ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, + "expected invalid-job error for overpaying coinbase" + ); + } + response => panic!("expected Error(invalid-job), got: {response:?}"), + } +} + async fn assert_jdp_stale_chain_tip_scenario( incoming_sender: &Sender, next_height: u32, diff --git a/integration-tests/tests/jds_block_propagation.rs b/integration-tests/tests/jds_block_propagation.rs index 71e2117c4..b2386be19 100644 --- a/integration-tests/tests/jds_block_propagation.rs +++ b/integration-tests/tests/jds_block_propagation.rs @@ -48,3 +48,105 @@ async fn propagated_from_jds_to_tp() { assert_ne!(current_block_hash, new_block_hash); shutdown_all!(translator, jdc, pool); } + +// Block containing a transaction the JDS node only learned about through +// ProvideMissingTransactions is propagated from JDS to its node. +// +// The JDC and JDS use separate nodes sharing the same chain, but only the JDC's node has +// the transaction in its mempool. The declared job therefore requires a +// ProvideMissingTransactions round before it validates, and the solved block reaches the +// JDS node purely through the JDS (the JDC -> TP SubmitSolution path is blocked). +#[tokio::test] +async fn propagated_from_jds_to_tp_with_missing_transactions() { + start_tracing(); + let (tp_1, _tp_addr_1) = start_template_provider(None, DifficultyLevel::Low); // JDS node + let (tp_2, tp_addr_2) = start_template_provider(None, DifficultyLevel::Low); // JDC node + + // Give both nodes the same chain, then add a transaction only to the JDC node's + // mempool. + assert!(tp_2.fund_wallet().is_ok()); + tp_1.bitcoin_core() + .sync_chain_from(tp_2.bitcoin_core()) + .unwrap(); + let (_address, txid) = tp_2.create_mempool_transaction().unwrap(); + + let current_block_hash = tp_1.get_best_block_hash().unwrap(); + let current_height = tp_1.get_blockchain_info().unwrap().blocks as u64; + assert_eq!(current_block_hash, tp_2.get_best_block_hash().unwrap()); + + let (pool, pool_addr, jds_addr, _) = + start_pool_with_jds(tp_1.bitcoin_core(), vec![], vec![], false).await; + let (jdc_jds_sniffer, jdc_jds_sniffer_addr) = start_sniffer("0", jds_addr, false, vec![], None); + let ignore_submit_solution = + IgnoreMessage::new(MessageDirection::ToUpstream, MESSAGE_TYPE_SUBMIT_SOLUTION); + let (_jdc_tp_sniffer, jdc_tp_sniffer_addr) = start_sniffer( + "1", + tp_addr_2, + false, + vec![ignore_submit_solution.into()], + None, + ); + let (jdc, jdc_addr, _) = start_jdc( + &[(pool_addr, jdc_jds_sniffer_addr)], + sv2_tp_config(jdc_tp_sniffer_addr), + vec![], + vec![], + false, + None, + ); + let (translator, tproxy_addr, _) = + start_sv2_translator(&[jdc_addr], false, vec![], vec![], None, false).await; + let (_minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + + // The declaration must complete a ProvideMissingTransactions round before it succeeds. + jdc_jds_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_PROVIDE_MISSING_TRANSACTIONS, + ) + .await; + jdc_jds_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_PROVIDE_MISSING_TRANSACTIONS_SUCCESS, + ) + .await; + jdc_jds_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_DECLARE_MINING_JOB_SUCCESS, + ) + .await; + jdc_jds_sniffer + .wait_for_message_type(MessageDirection::ToUpstream, MESSAGE_TYPE_PUSH_SOLUTION) + .await; + + // PushSolution is fire-and-forget, so poll the JDS node for the new tip. + let mut new_block_hash = tp_1.get_best_block_hash().unwrap(); + for _ in 0..100 { + if new_block_hash != current_block_hash { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + new_block_hash = tp_1.get_best_block_hash().unwrap(); + } + assert_ne!( + current_block_hash, new_block_hash, + "JDS node should have accepted the solved block" + ); + + // Check the first block after the old tip in case more than one was found. + let solved_block_hash = tp_1 + .bitcoin_core() + .get_block_hash(current_height + 1) + .unwrap(); + let txids = tp_1 + .bitcoin_core() + .get_block_txids(&solved_block_hash) + .unwrap(); + assert!( + txids.contains(&txid.to_string()), + "solved block should contain the transaction provided via ProvideMissingTransactions; got {txids:?}" + ); + shutdown_all!(translator, jdc, pool); +} From 31d3c510dbae0930a269bb4373d51c31df1e9be7 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Tue, 7 Jul 2026 15:53:02 +0200 Subject: [PATCH 19/19] v32x JDP: cache client-provided transactions across declarations Transactions supplied through ProvideMissingTransactions.Success are typically ones the JDS node will never learn over P2P (prioritized or out-of-band transactions that don't meet its mempool policy). Without a cache, every downstream declaring a template with such a transaction -- and every retry -- pays its own ProvideMissingTransactions round. Remember them in a bounded (32 MB), least-recently-used cache keyed by wtxid, consulted only after unknownTxPos reports a transaction missing. Unlike the retired mempool mirror this involves no background synchronization and is never the source of truth: Bitcoin Core decides what is missing, and the cache merely supplies it without asking the client again. The wtxid key commits to the full serialized transaction, so one downstream cannot misrepresent a transaction requested by another; the worst a malicious downstream can do is churn the cache, which degrades to today's behavior. Confirmed transactions stop being looked up and age out on their own. Bitcoin Core itself remembering TxCollection-provided transactions (which would additionally improve relay and compact-block propagation of the solved block) is a possible future replacement; see the discussion in bitcoin/bitcoin#35671. The new bitcoin_core_ipc_jdp_io scenario asserts that a transaction provided once is remembered: a second downstream declaring it succeeds without a missing-transactions round. It runs against all backends, since the v30.x/v31.x mirror already behaves this way (previously untested). The test-harness wallet helpers build a witness-free transaction from an explicitly selected legacy coinbase output so the minimal test coinbase needs no witness commitment, and mine funding blocks in batches to stay under the RPC client timeout when several test nodes mine concurrently. --- .../v32x/job_declaration_protocol/handlers.rs | 46 +++- .../v32x/job_declaration_protocol/mod.rs | 17 +- .../provided_tx_cache.rs | 215 ++++++++++++++++++ integration-tests/lib/template_provider.rs | 72 ++++++ .../tests/bitcoin_core_ipc_jdp_io.rs | 126 +++++++++- 5 files changed, 466 insertions(+), 10 deletions(-) create mode 100644 bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs index f0e9b1c95..89f7c811d 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -114,6 +114,16 @@ impl BitcoinCoreSv2JDP { ) -> Result { let (tip_hash, tip_height) = self.get_tip().await?; + // Remember client-provided transactions before completing this request's collection + // with them, so other downstreams (and retries) declaring the same transactions can + // be served from the cache instead of another ProvideMissingTransactions round. + if !missing_txs.is_empty() { + let mut provided_txs = self.provided_txs.borrow_mut(); + for tx in missing_txs { + provided_txs.insert(tx.clone()); + } + } + let collection = self.collect_txs(wtxid_list).await?; // Complete the collection with transactions from ProvideMissingTransactions.Success @@ -121,15 +131,39 @@ impl BitcoinCoreSv2JDP { self.add_missing_txs(&collection, missing_txs).await?; } - // Ask Bitcoin Core which declared transactions it still doesn't know about + // Ask Bitcoin Core which declared transactions it still doesn't know about, and try + // to supply them from the provided-transaction cache before asking the client. let missing_positions = self.unknown_tx_pos(&collection).await?; if !missing_positions.is_empty() { let missing_wtxids = Self::wtxids_at_positions(wtxid_list, &missing_positions); - self.destroy_tx_collection(&collection).await; - return Ok(JdResponse::MissingTransactions { - missing_wtxids, - prev_hash: tip_hash, - }); + let (cached_txs, missing_wtxids) = { + let mut provided_txs = self.provided_txs.borrow_mut(); + let mut cached_txs = Vec::new(); + let mut still_missing = Vec::new(); + for wtxid in missing_wtxids { + match provided_txs.get(&wtxid) { + Some(tx) => cached_txs.push(tx), + None => still_missing.push(wtxid), + } + } + (cached_txs, still_missing) + }; + + if !cached_txs.is_empty() { + debug!( + count = cached_txs.len(), + "Supplying missing transactions from the provided-transaction cache" + ); + self.add_missing_txs(&collection, &cached_txs).await?; + } + + if !missing_wtxids.is_empty() { + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::MissingTransactions { + missing_wtxids, + prev_hash: tip_hash, + }); + } } // A BIP34 height mismatch would also be caught by makeTemplate (bad-cb-height), but diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs index 33a722b95..4c6a9d880 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs @@ -3,7 +3,10 @@ use crate::{ common::job_declaration_protocol::io::{DownstreamId, JdRequest, RequestId}, - unix_capnp::v32x::job_declaration_protocol::error::BitcoinCoreSv2JDPError, + unix_capnp::v32x::job_declaration_protocol::{ + error::BitcoinCoreSv2JDPError, + provided_tx_cache::{DEFAULT_PROVIDED_TX_CACHE_MAX_BYTES, ProvidedTxCache}, + }, }; use async_channel::Receiver; use bitcoin_capnp_types::{ @@ -24,6 +27,7 @@ use tracing::{debug, error, info, warn}; pub mod error; mod handlers; +mod provided_tx_cache; /// How often to poll `isInitialBlockDownload` while waiting for IBD to finish during startup. const IBD_POLL_INTERVAL_SECS: u64 = 1; @@ -48,6 +52,10 @@ const IBD_POLL_INTERVAL_SECS: u64 = 1; /// succeeds, a [`Success`] response with the template parameters is sent and the validated /// `BlockTemplate` is retained, keyed by `(downstream_id, request_id)`. /// +/// Client-provided transactions are additionally remembered in a bounded +/// [`ProvidedTxCache`], so later declarations of the same transactions — by any downstream — +/// complete without another `ProvideMissingTransactions` round. +/// /// Incoming [`PushSolution`] requests submit mining solutions to Bitcoin Core via the /// retained template's `submitSolution` method; the block itself never leaves the node. /// [`ReleaseDeclaredJob`] and [`CleanupDownstream`] requests discard retained templates @@ -63,6 +71,10 @@ pub struct BitcoinCoreSv2JDP { /// Dropping a client releases the corresponding `BlockTemplate` inside Bitcoin Core, /// but an explicit `destroy` is preferred for prompt cleanup. declared_templates: Rc>>, + /// Client-provided transactions remembered across declarations, so other downstreams + /// (or retries) declaring the same transactions skip the `ProvideMissingTransactions` + /// round. + provided_txs: Rc>, incoming_requests: Receiver, } @@ -163,6 +175,9 @@ impl BitcoinCoreSv2JDP { mining_ipc_client, cancellation_token: cancellation_token.clone(), declared_templates: Rc::new(RefCell::new(HashMap::new())), + provided_txs: Rc::new(RefCell::new(ProvidedTxCache::new( + DEFAULT_PROVIDED_TX_CACHE_MAX_BYTES, + ))), incoming_requests, }; diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs new file mode 100644 index 000000000..e8447519f --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs @@ -0,0 +1,215 @@ +//! Cache of client-provided transactions for the Bitcoin Core v32.x Sv2 Job Declaration +//! Protocol. +//! +//! Transactions supplied through `ProvideMissingTransactions.Success` are typically ones the +//! JDS node will never learn over P2P (prioritized or out-of-band transactions that don't +//! meet its mempool policy). Without a cache, every downstream declaring a template with such +//! a transaction — and every retry — pays its own `ProvideMissingTransactions` round trip. +//! +//! Unlike the mempool mirror of the v30.x/v31.x backends, this cache involves no background +//! synchronization and is never the source of truth: Bitcoin Core's `unknownTxPos` decides +//! what is missing, and the cache is only consulted to supply those transactions without +//! asking the client again. Entries are keyed by wtxid, which commits to the full serialized +//! transaction, so an entry inserted by one downstream cannot misrepresent a transaction +//! requested by another. +//! +//! The cache is bounded by serialized size with least-recently-used eviction. Confirmed +//! transactions stop appearing in declared templates, stop being looked up, and age out on +//! their own. A cache miss is never an error; it merely costs the round trip that would have +//! happened anyway. +//! +//! This cache becomes unnecessary if Bitcoin Core grows a node-side store for +//! `TxCollection`-provided transactions (see the discussion in +//! https://github.com/bitcoin/bitcoin/pull/35671). + +use std::collections::HashMap; +use stratum_core::bitcoin::{Transaction, Wtxid, consensus::serialize}; +use tracing::{debug, warn}; + +/// Default cache budget. Generous compared to the worst case of one full block (~4 MB) of +/// exotic transactions, while remaining far below what the retired mempool mirror held. +pub const DEFAULT_PROVIDED_TX_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; + +struct CacheEntry { + tx: Transaction, + size: usize, + last_used: u64, +} + +/// Bounded, least-recently-used cache of client-provided transactions keyed by wtxid. +pub struct ProvidedTxCache { + max_bytes: usize, + total_bytes: usize, + tick: u64, + entries: HashMap, +} + +impl ProvidedTxCache { + /// Creates an empty cache holding at most `max_bytes` of serialized transactions. + pub fn new(max_bytes: usize) -> Self { + Self { + max_bytes, + total_bytes: 0, + tick: 0, + entries: HashMap::new(), + } + } + + fn next_tick(&mut self) -> u64 { + self.tick += 1; + self.tick + } + + /// Inserts a transaction, evicting least-recently-used entries if the budget is + /// exceeded. A transaction larger than the whole budget is ignored. + pub fn insert(&mut self, tx: Transaction) { + let wtxid = tx.compute_wtxid(); + let tick = self.next_tick(); + if let Some(entry) = self.entries.get_mut(&wtxid) { + entry.last_used = tick; + return; + } + + let size = serialize(&tx).len(); + if size > self.max_bytes { + warn!( + %wtxid, + size, + max_bytes = self.max_bytes, + "Ignoring provided transaction larger than the whole cache budget" + ); + return; + } + + self.total_bytes += size; + self.entries.insert( + wtxid, + CacheEntry { + tx, + size, + last_used: tick, + }, + ); + self.evict_to_fit(); + } + + /// Returns a copy of the transaction with the given wtxid, marking it recently used. + pub fn get(&mut self, wtxid: &Wtxid) -> Option { + let tick = self.next_tick(); + let entry = self.entries.get_mut(wtxid)?; + entry.last_used = tick; + Some(entry.tx.clone()) + } + + fn evict_to_fit(&mut self) { + while self.total_bytes > self.max_bytes { + let Some(oldest_wtxid) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(wtxid, _)| *wtxid) + else { + return; + }; + if let Some(entry) = self.entries.remove(&oldest_wtxid) { + self.total_bytes -= entry.size; + debug!(wtxid = %oldest_wtxid, size = entry.size, "Evicted provided transaction from cache"); + } + } + } + + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + fn contains(&self, wtxid: &Wtxid) -> bool { + self.entries.contains_key(wtxid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stratum_core::bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Witness, absolute::LockTime, + transaction::Version, + }; + + /// Builds a distinct dummy transaction; `script_len` pads the output script to control + /// the serialized size. + fn dummy_tx(marker: u32, script_len: usize) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(marker), + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(0), + script_pubkey: ScriptBuf::from_bytes(vec![0x6a; script_len]), + }], + } + } + + #[test] + fn insert_and_get_round_trip() { + let mut cache = ProvidedTxCache::new(1024); + let tx = dummy_tx(1, 10); + let wtxid = tx.compute_wtxid(); + + assert!(cache.get(&wtxid).is_none()); + cache.insert(tx.clone()); + assert_eq!(cache.get(&wtxid), Some(tx)); + } + + #[test] + fn duplicate_insert_does_not_grow_cache() { + let mut cache = ProvidedTxCache::new(1024); + let tx = dummy_tx(1, 10); + cache.insert(tx.clone()); + let bytes_after_first = cache.total_bytes; + cache.insert(tx); + assert_eq!(cache.len(), 1); + assert_eq!(cache.total_bytes, bytes_after_first); + } + + #[test] + fn evicts_least_recently_used_first() { + let tx_size = serialize(&dummy_tx(0, 100)).len(); + // Room for exactly two transactions. + let mut cache = ProvidedTxCache::new(2 * tx_size); + + let tx_a = dummy_tx(1, 100); + let tx_b = dummy_tx(2, 100); + let tx_c = dummy_tx(3, 100); + let wtxid_a = tx_a.compute_wtxid(); + let wtxid_b = tx_b.compute_wtxid(); + let wtxid_c = tx_c.compute_wtxid(); + + cache.insert(tx_a); + cache.insert(tx_b); + // Touch A so B becomes the least recently used entry. + assert!(cache.get(&wtxid_a).is_some()); + cache.insert(tx_c); + + assert!(cache.contains(&wtxid_a)); + assert!(!cache.contains(&wtxid_b)); + assert!(cache.contains(&wtxid_c)); + assert!(cache.total_bytes <= 2 * tx_size); + } + + #[test] + fn ignores_transaction_larger_than_budget() { + let mut cache = ProvidedTxCache::new(64); + let tx = dummy_tx(1, 1000); + let wtxid = tx.compute_wtxid(); + cache.insert(tx); + assert!(!cache.contains(&wtxid)); + assert_eq!(cache.total_bytes, 0); + } +} diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index 0ceef507b..13f3ddbb3 100644 --- a/integration-tests/lib/template_provider.rs +++ b/integration-tests/lib/template_provider.rs @@ -375,6 +375,78 @@ impl BitcoinCore { Ok(()) } + /// Fund the node's wallet with spendable *legacy* (pre-segwit) outputs by mining 101 + /// blocks to a legacy address, which is returned for later use. + /// + /// Useful for building witness-free transactions, which can be included in a block + /// whose coinbase carries no witness commitment. + pub fn fund_wallet_legacy(&self) -> Result { + let client = &self.bitcoind.client; + let address: String = client.call("getnewaddress", &["".into(), "legacy".into()])?; + // Mine in batches: a single 101-block generatetoaddress can exceed the RPC + // client's 15s timeout when several test nodes mine concurrently. + let mut remaining = 101u32; + while remaining > 0 { + let batch = remaining.min(20); + let _: serde_json::Value = + client.call("generatetoaddress", &[batch.into(), address.clone().into()])?; + remaining -= batch; + } + Ok(address) + } + + /// Create and sign a legacy (witness-free) self-transfer WITHOUT broadcasting it, and + /// return its consensus-serialized bytes. + /// + /// Explicitly spends a mature coinbase output of `funded_address` (from + /// [`BitcoinCore::fund_wallet_legacy`]) so no segwit input sneaks in through wallet + /// coin selection, keeping the transaction free of witness data. + pub fn create_unbroadcast_legacy_transaction( + &self, + funded_address: &str, + ) -> Result, corepc_node::Error> { + let client = &self.bitcoind.client; + let unspent: serde_json::Value = client.call( + "listunspent", + &[ + 100.into(), + 9_999_999.into(), + serde_json::json!([funded_address]), + ], + )?; + let utxo = unspent + .as_array() + .and_then(|utxos| utxos.first()) + .unwrap_or_else(|| { + panic!("expected a mature UTXO on the legacy funding address {funded_address}") + }) + .clone(); + let txid = utxo["txid"].as_str().expect("listunspent entry has txid"); + let vout = utxo["vout"].as_u64().expect("listunspent entry has vout"); + let amount = utxo["amount"] + .as_f64() + .expect("listunspent entry has amount"); + + let destination: String = client.call("getnewaddress", &["".into(), "legacy".into()])?; + let raw: String = client.call( + "createrawtransaction", + &[ + serde_json::json!([{ "txid": txid, "vout": vout }]), + serde_json::json!({ destination: amount - 0.0001 }), + ], + )?; + let signed: serde_json::Value = + client.call("signrawtransactionwithwallet", &[raw.into()])?; + assert!( + signed["complete"].as_bool().unwrap_or(false), + "wallet should fully sign the self-transfer: {signed}" + ); + let signed_hex = signed["hex"] + .as_str() + .expect("signrawtransactionwithwallet should return hex"); + Ok(hex::decode(signed_hex).expect("signed transaction should be valid hex")) + } + /// Return the hash of the most recent block. pub fn get_best_block_hash(&self) -> Result { let client = &self.bitcoind.client; diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index 301962383..2e7999f1d 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -7,6 +7,9 @@ //! than the block subsidy. //! - `DeclareMiningJob` returns `Error(stale-chain-tip)` when the declared BIP34 height is //! intentionally mismatched. +//! - A transaction provided once via missing-transactions is remembered, so a later +//! declaration of the same transaction (by another downstream) succeeds without a +//! missing-transactions round. //! //! File structure: //! - top: version-specific `#[tokio::test]` wrappers. @@ -112,6 +115,7 @@ async fn assert_jdp_io_integration_for_version(version: BitcoinCoreVersion) { assert_jdp_success_scenario(&incoming_sender, coinbase_tx).await; assert_jdp_overpaying_coinbase_scenario(&incoming_sender, next_height).await; assert_jdp_stale_chain_tip_scenario(&incoming_sender, next_height).await; + assert_jdp_provided_tx_remembered_scenario(&incoming_sender, &bitcoin_core).await; cancellation_token.cancel(); jdp_thread @@ -126,6 +130,8 @@ async fn assert_jdp_missing_transactions_scenario( ) { let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, coinbase_tx, vec![missing_wtxid], vec![], @@ -147,6 +153,8 @@ async fn assert_jdp_success_scenario( ) { let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, coinbase_tx, vec![], vec![], @@ -177,6 +185,8 @@ async fn assert_jdp_overpaying_coinbase_scenario( let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, coinbase_tx, vec![], vec![], @@ -201,6 +211,8 @@ async fn assert_jdp_stale_chain_tip_scenario( ) { let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, build_valid_coinbase_tx(next_height.saturating_add(10_000)), vec![], vec![], @@ -219,8 +231,117 @@ async fn assert_jdp_stale_chain_tip_scenario( } } +/// A transaction provided via the missing-transactions flow must be remembered, so a later +/// declaration of the same transaction by a *different* downstream succeeds without another +/// missing-transactions round (served by the provided-transaction cache on v32.x and by the +/// mempool mirror on v30.x/v31.x). +async fn assert_jdp_provided_tx_remembered_scenario( + incoming_sender: &Sender, + bitcoin_core: &integration_tests_sv2::template_provider::BitcoinCore, +) { + // Fund with legacy outputs and build a witness-free transaction that is NOT in the + // node's mempool, so the block needs no witness commitment in its minimal coinbase. + let funded_address = bitcoin_core + .fund_wallet_legacy() + .unwrap_or_else(|e| panic!("failed to fund wallet with legacy outputs: {e}")); + let tx_bytes = bitcoin_core + .create_unbroadcast_legacy_transaction(&funded_address) + .unwrap_or_else(|e| panic!("failed to create unbroadcast transaction: {e}")); + let tx: Transaction = stratum_apps::stratum_core::bitcoin::consensus::deserialize(&tx_bytes) + .expect("failed to decode unbroadcast transaction"); + let wtxid = tx.compute_wtxid(); + + let next_height = u32::try_from( + bitcoin_core + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + + 1, + ) + .expect("next height should fit in u32"); + + // Wait until the backend's chain context reflects the newly mined blocks (the + // mirror-based backends refresh asynchronously). + let mut caught_up = false; + for _ in 0..30 { + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 1, + build_valid_coinbase_tx(next_height), + vec![], + vec![], + "jdp/provided-tx-catchup", + ) + .await; + if matches!(response, JdResponse::Success { .. }) { + caught_up = true; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + assert!( + caught_up, + "backend did not catch up to the funded chain tip" + ); + + // Round 1: the transaction is unknown to the node, so it must be requested. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 2, + build_valid_coinbase_tx(next_height), + vec![wtxid], + vec![], + "jdp/provided-tx-round-1", + ) + .await; + match response { + JdResponse::MissingTransactions { missing_wtxids, .. } => { + assert_eq!(missing_wtxids, vec![wtxid]); + } + response => panic!("expected MissingTransactions, got: {response:?}"), + } + + // Round 2: provide the transaction; the declaration must now validate. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 2, + build_valid_coinbase_tx(next_height), + vec![wtxid], + vec![tx], + "jdp/provided-tx-round-2", + ) + .await; + assert!( + matches!(response, JdResponse::Success { .. }), + "expected Success after providing the missing transaction, got: {response:?}" + ); + + // Round 3: a different downstream declares the same transaction WITHOUT providing it; + // the remembered copy must be used instead of another missing-transactions round. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 1, + 3, + build_valid_coinbase_tx(next_height), + vec![wtxid], + vec![], + "jdp/provided-tx-round-3", + ) + .await; + assert!( + matches!(response, JdResponse::Success { .. }), + "expected Success from the remembered transaction, got: {response:?}" + ); +} + +#[allow(clippy::too_many_arguments)] async fn send_declare_mining_job_and_recv_response( incoming_sender: &Sender, + downstream_id: usize, + request_id: u32, coinbase_tx: Transaction, wtxid_list: Vec, missing_txs: Vec, @@ -229,9 +350,8 @@ async fn send_declare_mining_job_and_recv_response( let (response_tx, response_rx) = tokio::sync::oneshot::channel(); incoming_sender .send(JdRequest::DeclareMiningJob { - // This test drives a single logical downstream; ids only need to be consistent. - downstream_id: 0, - request_id: 0, + downstream_id, + request_id, // Use a fixed, valid block version across scenarios so assertions focus on IO paths. version: BlockVersion::from_consensus(0x2000_0000), coinbase_tx,