diff --git a/bitcoin-core-sv2/Cargo.toml b/bitcoin-core-sv2/Cargo.toml index 9228a9272..32c94b8bc 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 = "6e4c99834df947df3a0f733d2a2dc6e12100e441" } + # 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/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/lib.rs b/bitcoin-core-sv2/src/lib.rs index 51abd747f..619c2ece6 100644 --- a/bitcoin-core-sv2/src/lib.rs +++ b/bitcoin-core-sv2/src/lib.rs @@ -21,6 +21,7 @@ //! protocols. //! - [`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/runtime_api/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/runtime_api/job_declaration_protocol/io.rs index d9788d0dd..448159ecf 100644 --- a/bitcoin-core-sv2/src/runtime_api/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/runtime_api/job_declaration_protocol/io.rs @@ -1,14 +1,12 @@ //! Request / response types exchanged between `jd-server` and the Bitcoin Core IPC thread. -use stratum_core::{ - bitcoin::{BlockHash, CompactTarget, Transaction, Txid, 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. /// -/// This lets callers distinguish stale-tip races from other validation failures. +/// Stale-chain-tip detection relies solely on [`prev_hash`](Self::prev_hash) +/// drift; nbits is used for validation of SetCustomMiningJob. /// /// Please check /// for more details on the regression that motivated this field. @@ -16,7 +14,6 @@ use tokio::sync::oneshot; pub struct ValidationContext { pub prev_hash: BlockHash, pub nbits: CompactTarget, - pub min_ntime: u32, } /// A request sent from `jd-server` to the [`BitcoinCoreSv2JDP`](super::BitcoinCoreSv2JDP) IPC @@ -33,10 +30,11 @@ 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). + /// + /// This request is currently stubbed as a warning-only no-op by the v30.x and v31.x + /// backends. + PushSolution { block: Block }, } /// The result of trying to handle a DeclareMiningJob request. @@ -45,11 +43,11 @@ pub enum JdResponse { Success { 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/runtime_api/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/runtime_api/job_declaration_protocol/mod.rs index 0c0e64016..a48454b5e 100644 --- a/bitcoin-core-sv2/src/runtime_api/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/runtime_api/job_declaration_protocol/mod.rs @@ -5,7 +5,7 @@ //! //! The request channel covers the two base JDP flows: //! - `DeclareMiningJob` -//! - `PushSolution` +//! - `PushSolution` (currently stubbed as a warning-only no-op on v30.x and v31.x backends) //! //! Token lifecycle and higher-level protocol state remain the caller responsibility (for example, //! associating `AllocateMiningJobToken`/`DeclareMiningJob`/`SetCustomMiningJob` state). @@ -14,7 +14,7 @@ pub mod io; use crate::{ runtime_api::{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/runtime_api/mod.rs b/bitcoin-core-sv2/src/runtime_api/mod.rs index ab537dd03..5e51b334e 100644 --- a/bitcoin-core-sv2/src/runtime_api/mod.rs +++ b/bitcoin-core-sv2/src/runtime_api/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/runtime_api/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/runtime_api/template_distribution_protocol/mod.rs index 82555c6f6..1715635cc 100644 --- a/bitcoin-core-sv2/src/runtime_api/template_distribution_protocol/mod.rs +++ b/bitcoin-core-sv2/src/runtime_api/template_distribution_protocol/mod.rs @@ -14,7 +14,7 @@ use crate::{ runtime_api::{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/unix_capnp/mod.rs b/bitcoin-core-sv2/src/unix_capnp/mod.rs index d990115f8..5df0fc3d2 100644 --- a/bitcoin-core-sv2/src/unix_capnp/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/mod.rs @@ -9,6 +9,8 @@ pub mod v30x; pub mod v31x; +pub mod v32x; // Shared cross-version implementation modules are internal plumbing, not stable public API. -pub(crate) mod v31x_v30x; +pub(crate) mod v32x_v31x; +pub(crate) mod v32x_v31x_v30x; 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 078730363..f362ef8c7 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 @@ -2,21 +2,18 @@ use crate::{ runtime_api::job_declaration_protocol::io::{JdResponse, ValidationContext}, - unix_capnp::{ - v30x::job_declaration_protocol::BitcoinCoreSv2JDP, - v31x_v30x::job_declaration_protocol::mempool::decode_bip34_height_from_coinbase_script_sig, - }, + unix_capnp::v30x::job_declaration_protocol::BitcoinCoreSv2JDP, }; use stratum_core::{ bitcoin::{ - Block, Transaction, TxMerkleNode, Txid, Wtxid, + 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, PushSolution, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, }, }; use tokio::sync::oneshot; @@ -49,19 +46,7 @@ impl BitcoinCoreSv2JDP { 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 (initial_validation_context, ntime, txdata) = { let mut mempool_mirror = self.mempool_mirror.borrow_mut(); // Add the missing transactions to the mempool mirror @@ -73,19 +58,11 @@ impl BitcoinCoreSv2JDP { 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 ntime = mempool_mirror + .get_current_ntime() + .expect("current_ntime must be set"); - let initial_bip34_height = mempool_mirror - .get_current_bip34_height() - .expect("current_bip34_height must be set"); + let initial_validation_context = ValidationContext { prev_hash, nbits }; // Now verify that all wtxids from the declared job are available let missing_wtxids = mempool_mirror.verify(&wtxid_list); @@ -102,17 +79,16 @@ impl BitcoinCoreSv2JDP { 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 + "Using prevhash: {:?}, nbits: {:?}, ntime: {} from mempool mirror", + initial_validation_context.prev_hash, initial_validation_context.nbits, ntime, ); - (initial_validation_context, initial_bip34_height, txdata) + (initial_validation_context, ntime, 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 mut check_block_reason_for_stale: Option = None; let valid_job = { let mut all_transactions = Vec::with_capacity(1 + txdata.len()); @@ -121,9 +97,9 @@ impl BitcoinCoreSv2JDP { let num_transactions = all_transactions.len(); - // Use the min_ntime from the template as the block timestamp + // Use the template ntime as the block timestamp // This ensures we meet Bitcoin Core's timestamp validation rules - let block_time = initial_validation_context.min_ntime; + let block_time = ntime; let header = Header { version, @@ -212,6 +188,8 @@ impl BitcoinCoreSv2JDP { debug = ?check_block_debug, "Bitcoin Core rejected the block via checkBlock" ); + check_block_reason_for_stale = + check_block_reason.ok().and_then(|r| r.to_string().ok()); debug!( "Block details - version: {:?}, prev_blockhash: {:?}, bits: {:?}, num_txs: {}", version, @@ -233,66 +211,54 @@ impl BitcoinCoreSv2JDP { }; if !valid_job { - // On checkBlock failure, force-refresh template + mirror before classifying the error. + // On checkBlock failure, update local prev_hash 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 + // Updating prev_hash 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 { + // + // Additionally, a `bad-cb-height` rejection from checkBlock signals a job that was + // already stale at arrival (coinbase built from an old tip) and is mapped to + // stale-chain-tip even without prev_hash drift. + if let Err(e) = self.force_update_mempool_mirror_prev_hash().await { debug!( error = ?e, - "Failed to force-refresh template/mempool mirror after checkBlock failure; continuing with current validation context" + "Failed to update prev_hash after checkBlock failure; continuing with current validation context" ); } } - let (latest_validation_context, latest_bip34_height) = { + let latest_validation_context = { let mempool_mirror = self.mempool_mirror.borrow(); - let latest_validation_context = ValidationContext { + 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, - txid_list, + 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 { + let context_drifted = + initial_validation_context.prev_hash != latest_validation_context.prev_hash; + + let stale_at_arrival = matches!( + check_block_reason_for_stale.as_deref(), + Some("bad-cb-height") + ); + + let error_code = if context_drifted || stale_at_arrival { 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 @@ -311,10 +277,12 @@ 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<'_>) { - // todo + /// Not yet implemented for v30.x IPC, which does not expose `submitBlock`. + pub(crate) async fn handle_push_solution(&self, _block: Block) { + warn!( + "Ignoring PushSolution for v30.x backend: Bitcoin Core v30.x IPC does not expose submitBlock" + ); } } 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 bbf31a7d7..779577fe6 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 @@ -5,7 +5,7 @@ use crate::{ runtime_api::job_declaration_protocol::io::JdRequest, unix_capnp::{ v30x::job_declaration_protocol::error::BitcoinCoreSv2JDPError, - v31x_v30x::job_declaration_protocol::mempool::MempoolMirror, + v32x_v31x_v30x::job_declaration_protocol::mempool::MempoolMirror, }, }; use async_channel::Receiver; @@ -20,7 +20,7 @@ use bitcoin_capnp_types::{ }; use bitcoin_capnp_types_v30 as bitcoin_capnp_types; use std::{cell::RefCell, path::Path, rc::Rc}; -use stratum_core::bitcoin::{Block, consensus::deserialize}; +use stratum_core::bitcoin::{Block, BlockHash, consensus::deserialize, hashes::Hash}; use tokio::net::UnixStream; use tokio_util::compat::*; pub use tokio_util::sync::CancellationToken; @@ -35,26 +35,22 @@ mod monitors; /// It is instantiated with: /// - A `&`[`std::path::Path`] to the Bitcoin Core UNIX socket /// - A [`async_channel::Receiver`] for incoming [`JdRequest`] messages (handles -/// [`JdRequest::DeclareMiningJob`] and [`JdRequest::PushSolution`] requests) +/// [`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 [`JdRequest::DeclareMiningJob`] requests are validated by: +/// 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 -/// [`crate::common::job_declaration_protocol::io::JdResponse::MissingTransactions`] response is -/// sent. If validation succeeds, a -/// [`crate::common::job_declaration_protocol::io::JdResponse::Success`] response with current -/// template parameters is sent. +/// If transactions are missing, a [`MissingTransactions`] response is sent. If validation +/// succeeds, a [`Success`] response with current template parameters is sent. /// -/// Incoming [`JdRequest::PushSolution`] requests are used to submit mining solutions to Bitcoin -/// Core. +/// Incoming [`PushSolution`] requests are used to submit mining solutions to Bitcoin Core. #[derive(Clone)] pub struct BitcoinCoreSv2JDP { thread_ipc_client: ThreadIpcClient, @@ -268,60 +264,78 @@ impl BitcoinCoreSv2JDP { Ok(()) } - /// Forces a synchronous template refresh from Bitcoin Core, then refreshes the mempool mirror. + /// Checks whether the chain tip has changed via `getTip` and, if so, updates the mempool + /// mirror's prev_hash. /// - /// 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. + /// This is called after `checkBlock` failures to reduce stale-tip classification races. + /// We avoid `createNewBlock` (which is expensive) because only the prev_hash is needed for + /// stale detection. /// /// 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> { + pub(crate) async fn force_update_mempool_mirror_prev_hash( + &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(); + let result: Result<(), BitcoinCoreSv2JDPError> = async { + let mut get_tip_request = self.mining_ipc_client.get_tip_request(); - let mut create_new_block_options = - create_new_block_request.get().get_options().map_err(|e| { - error!("Failed to get createNewBlock options: {e}"); + get_tip_request + .get() + .get_context() + .map_err(|e| { + error!("Failed to get getTip request context: {e}"); e - })?; - - create_new_block_options.set_use_mempool(true); + })? + .set_thread(self.thread_ipc_client.clone()); - let create_new_block_response = - create_new_block_request.send().promise.await.map_err(|e| { - error!("Failed to send createNewBlock request: {e}"); - e - })?; + let get_tip_response = get_tip_request.send().promise.await.map_err(|e| { + error!("Failed to send getTip request: {e}"); + e + })?; - let new_template_ipc_client = create_new_block_response + let tip_hash_bytes = get_tip_response .get() .map_err(|e| { - error!("Failed to read createNewBlock response: {e}"); + error!("Failed to read getTip response: {e}"); e })? .get_result() .map_err(|e| { - error!("Failed to get BlockTemplate from createNewBlock: {e}"); + error!("Failed to get tip result from getTip: {e}"); + e + })? + .get_hash() + .map_err(|e| { + error!("Failed to get tip hash from getTip result: {e}"); e })?; + let tip_hash = BlockHash::from_slice(tip_hash_bytes).map_err(|e| { + error!("Failed to parse tip hash from getTip: {e}"); + BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed(format!( + "Failed to parse tip hash: {e}" + ))) + })?; + { - let mut current_template_ipc_client = - self.current_template_ipc_client.borrow_mut(); - *current_template_ipc_client = new_template_ipc_client; + let mut mempool_mirror = self.mempool_mirror.borrow_mut(); + if mempool_mirror.get_current_prev_hash() != Some(tip_hash) { + debug!( + old_prev_hash = ?mempool_mirror.get_current_prev_hash(), + new_prev_hash = ?tip_hash, + "Chain tip changed; updating mempool mirror prev_hash" + ); + mempool_mirror.set_current_prev_hash(tip_hash); + } } - self.update_mempool_mirror().await + Ok(()) } .await; @@ -332,7 +346,7 @@ impl BitcoinCoreSv2JDP { error = ?e, attempt, max_attempts = MAX_ATTEMPTS, - "Transient IPC contention during force_update_mempool_mirror (thread busy); retrying" + "Transient IPC contention during force_update_mempool_mirror_prev_hash (thread busy); retrying" ); last_error = Some(e); tokio::time::sleep(std::time::Duration::from_millis(RETRY_BACKOFF_MS)).await; @@ -341,11 +355,11 @@ impl BitcoinCoreSv2JDP { } } - // ideally the retry logic should never allow execution to reach here - // but if it does, we just bubble up the error + // Normally one of the retry attempts returns early (Ok or Err). + // If execution reaches here, no terminal error was captured, so synthesize one. Err(last_error.unwrap_or_else(|| { BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed( - "force_update_mempool_mirror exhausted retries without a terminal error" + "force_update_mempool_mirror_prev_hash exhausted retries without a terminal error" .to_string(), )) })) @@ -373,8 +387,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/v30x/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/mod.rs index a76f2c1c8..3c8baf2e1 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/mod.rs @@ -43,7 +43,7 @@ use tracing::{debug, error, info, warn}; pub mod error; mod handlers; #[allow(clippy::duplicate_mod)] -#[path = "../../v31x_v30x/template_distribution_protocol/monitors.rs"] +#[path = "../../v32x_v31x_v30x/template_distribution_protocol/monitors.rs"] mod monitors; mod template_data; @@ -57,9 +57,7 @@ const MIN_BLOCK_RESERVED_WEIGHT: u64 = 2000; /// - 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`], -/// [`stratum_core::template_distribution_sv2::RequestTransactionData`], and -/// [`stratum_core::template_distribution_sv2::SubmitSolution`]) +/// [`CoinbaseOutputConstraints`], [`RequestTransactionData`], and [`SubmitSolution`]) /// - A [`async_channel::Sender`] for outgoing [`TemplateDistribution`] messages /// - A [`tokio_util::sync::CancellationToken`] to stop the internally spawned tasks /// @@ -75,13 +73,11 @@ const MIN_BLOCK_RESERVED_WEIGHT: u64 = 2000; /// 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 [`stratum_core::template_distribution_sv2::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 [`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 [`stratum_core::template_distribution_sv2::SubmitSolution`] messages are used to submit -/// solutions to a specific template. +/// Incoming [`SubmitSolution`] messages are used to submit solutions to a specific template. #[derive(Clone)] pub struct BitcoinCoreSv2TDP { fee_threshold: u64, @@ -194,11 +190,10 @@ impl BitcoinCoreSv2TDP { /// Runs the [`BitcoinCoreSv2TDP`] instance, monitoring for: /// - Chain Tip changes, for which it will send a `NewTemplate` message, followed by a /// `SetNewPrevHash` message - /// - incoming [`stratum_core::template_distribution_sv2::RequestTransactionData`] messages, for - /// which it will send a `RequestTransactionDataSuccess` or `RequestTransactionDataError` - /// message as a response - /// - incoming [`stratum_core::template_distribution_sv2::SubmitSolution`] messages, for which - /// it will submit the solution to the Bitcoin Core IPC client + /// - 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 /// 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 b6f3d7b9c..113dffb61 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,335 +1,16 @@ -//! Handlers for Bitcoin Core v31.x Sv2 Job Declaration Protocol via capnp over UNIX socket. +//! v31.x-specific JDP handlers. -use crate::{ - runtime_api::job_declaration_protocol::io::{JdResponse, ValidationContext}, - unix_capnp::{ - v31x::job_declaration_protocol::BitcoinCoreSv2JDP, - v31x_v30x::job_declaration_protocol::mempool::decode_bip34_height_from_coinbase_script_sig, - }, -}; -use stratum_core::{ - bitcoin::{ - Block, Transaction, TxMerkleNode, Txid, 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, PushSolution, - }, -}; -use tokio::sync::oneshot; -use tracing::{debug, error, info, warn}; +use super::BitcoinCoreSv2JDP; +use stratum_core::bitcoin::Block; +use tracing::warn; 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 txid_list: Vec = txdata.iter().map(|tx| tx.compute_txid()).collect(); - - 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, - txid_list, - } - } 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 mining solution 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<'_>) { - // todo + /// Not yet implemented for v31.x IPC, which does not expose `submitBlock`. + pub(crate) async fn handle_push_solution(&self, _block: Block) { + warn!( + "Ignoring PushSolution for v31.x backend: Bitcoin Core v31.x IPC does not expose submitBlock" + ); } } 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 dc6aa61c0..5fa477075 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 @@ -1,11 +1,12 @@ //! Module for interacting with Bitcoin Core v31.x via Sv2 Job Declaration Protocol via capnp over //! UNIX socket. +use super::bitcoin_capnp_types; use crate::{ runtime_api::job_declaration_protocol::io::JdRequest, unix_capnp::{ v31x::job_declaration_protocol::error::BitcoinCoreSv2JDPError, - v31x_v30x::job_declaration_protocol::mempool::MempoolMirror, + v32x_v31x_v30x::job_declaration_protocol::mempool::MempoolMirror, }, }; use async_channel::Receiver; @@ -18,43 +19,45 @@ use bitcoin_capnp_types::{ }, proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, }; -use bitcoin_capnp_types_v31 as bitcoin_capnp_types; use std::{cell::RefCell, path::Path, rc::Rc}; -use stratum_core::bitcoin::{Block, consensus::deserialize}; +use stratum_core::bitcoin::{Block, BlockHash, consensus::deserialize, hashes::Hash}; use tokio::net::UnixStream; use tokio_util::compat::*; pub use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/job_declaration_protocol/error.rs"] pub mod error; mod handlers; +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/job_declaration_protocol/monitors.rs"] mod monitors; +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/job_declaration_protocol/handlers.rs"] +mod shared_handlers; /// 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 -/// [`JdRequest::DeclareMiningJob`] and [`JdRequest::PushSolution`] requests) +/// [`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 [`JdRequest::DeclareMiningJob`] requests are validated by: +/// 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 -/// [`crate::common::job_declaration_protocol::io::JdResponse::MissingTransactions`] response is -/// sent. If validation succeeds, a -/// [`crate::common::job_declaration_protocol::io::JdResponse::Success`] response with current -/// template parameters is sent. +/// If transactions are missing, a [`MissingTransactions`] response is sent. If validation +/// succeeds, a [`Success`] response with current template parameters is sent. /// -/// Incoming [`JdRequest::PushSolution`] requests are used to submit mining solutions to Bitcoin -/// Core. +/// Incoming [`PushSolution`] requests are used to submit mining solutions to Bitcoin Core. #[derive(Clone)] pub struct BitcoinCoreSv2JDP { thread_map: ThreadMapIpcClient, @@ -320,69 +323,78 @@ impl BitcoinCoreSv2JDP { Ok(()) } - /// Forces a synchronous template refresh from Bitcoin Core, then refreshes the mempool mirror. + /// Checks whether the chain tip has changed via `getTip` and, if so, updates the mempool + /// mirror's prev_hash. /// - /// 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. + /// This is called after `checkBlock` failures to reduce stale-tip classification races. + /// We avoid `createNewBlock` (which is expensive) because only the prev_hash is needed for + /// stale detection. /// /// 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> { + pub(crate) async fn force_update_mempool_mirror_prev_hash( + &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(); + let result: Result<(), BitcoinCoreSv2JDPError> = async { + let mut get_tip_request = self.mining_ipc_client.get_tip_request(); - create_new_block_request + get_tip_request .get() .get_context() .map_err(|e| { - error!("Failed to get template IPC client request context: {e}"); + error!("Failed to get getTip 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 get_tip_response = get_tip_request.send().promise.await.map_err(|e| { + error!("Failed to send getTip request: {e}"); + e + })?; - let new_template_ipc_client = create_new_block_response + let tip_hash_bytes = get_tip_response .get() .map_err(|e| { - error!("Failed to read createNewBlock response: {e}"); + error!("Failed to read getTip response: {e}"); e })? .get_result() .map_err(|e| { - error!("Failed to get BlockTemplate from createNewBlock: {e}"); + error!("Failed to get tip result from getTip: {e}"); + e + })? + .get_hash() + .map_err(|e| { + error!("Failed to get tip hash from getTip result: {e}"); e })?; + let tip_hash = BlockHash::from_slice(tip_hash_bytes).map_err(|e| { + error!("Failed to parse tip hash from getTip: {e}"); + BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed(format!( + "Failed to parse tip hash: {e}" + ))) + })?; + { - let mut current_template_ipc_client = - self.current_template_ipc_client.borrow_mut(); - *current_template_ipc_client = new_template_ipc_client; + let mut mempool_mirror = self.mempool_mirror.borrow_mut(); + if mempool_mirror.get_current_prev_hash() != Some(tip_hash) { + debug!( + old_prev_hash = ?mempool_mirror.get_current_prev_hash(), + new_prev_hash = ?tip_hash, + "Chain tip changed; updating mempool mirror prev_hash" + ); + mempool_mirror.set_current_prev_hash(tip_hash); + } } - self.update_mempool_mirror().await + Ok(()) } .await; @@ -393,7 +405,7 @@ impl BitcoinCoreSv2JDP { error = ?e, attempt, max_attempts = MAX_ATTEMPTS, - "Transient IPC contention during force_update_mempool_mirror (thread busy); retrying" + "Transient IPC contention during force_update_mempool_mirror_prev_hash (thread busy); retrying" ); last_error = Some(e); tokio::time::sleep(std::time::Duration::from_millis(RETRY_BACKOFF_MS)).await; @@ -402,11 +414,11 @@ impl BitcoinCoreSv2JDP { } } - // ideally the retry logic should never allow execution to reach here - // but if it does, we just bubble up the error + // Normally one of the retry attempts returns early (Ok or Err). + // If execution reaches here, no terminal error was captured, so synthesize one. Err(last_error.unwrap_or_else(|| { BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed( - "force_update_mempool_mirror exhausted retries without a terminal error" + "force_update_mempool_mirror_prev_hash exhausted retries without a terminal error" .to_string(), )) })) @@ -434,8 +446,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/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/mod.rs index 1e5753b58..5304ba590 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/mod.rs @@ -6,5 +6,7 @@ //! It is wired against `bitcoin_capnp_types_v31`, which re-exports the matching `capnp` //! and `capnp-rpc` APIs. +pub(crate) use bitcoin_capnp_types_v31 as bitcoin_capnp_types; + pub mod job_declaration_protocol; pub mod template_distribution_protocol; diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/mod.rs index 58dc43062..0101d943e 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/mod.rs @@ -1,826 +1,10 @@ //! Module for interacting with Bitcoin Core v31.x via Sv2 Template Distribution Protocol via //! capnp over UNIX socket. -use crate::unix_capnp::v31x::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_v31 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 super::bitcoin_capnp_types; -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; #[allow(clippy::duplicate_mod)] -#[path = "../../v31x_v30x/template_distribution_protocol/monitors.rs"] -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`], -/// [`stratum_core::template_distribution_sv2::RequestTransactionData`], and -/// [`stratum_core::template_distribution_sv2::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 [`stratum_core::template_distribution_sv2::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 [`stratum_core::template_distribution_sv2::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 [`stratum_core::template_distribution_sv2::RequestTransactionData`] messages, for - /// which it will send a `RequestTransactionDataSuccess` or `RequestTransactionDataError` - /// message as a response - /// - incoming [`stratum_core::template_distribution_sv2::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); +#[path = "../../v32x_v31x/template_distribution_protocol/mod.rs"] +mod shared; - 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]); - } -} +pub use shared::*; diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/mod.rs deleted file mode 100644 index d701d872c..000000000 --- a/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Shared implementation modules reused by Bitcoin Core v30.x and v31.x runtimes. - -pub mod job_declaration_protocol; - -// TDP shared monitors are reused via `#[path = "..."]` from v30x/v31x modules so they compile -// in each version-local `super::*` context; they are not exported from this module tree. 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..466538408 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -0,0 +1,106 @@ +//! v32.x-specific JDP handlers. + +use super::{BitcoinCoreSv2JDP, error::BitcoinCoreSv2JDPError}; +use stratum_core::bitcoin::{Block, consensus::serialize}; +use tracing::{debug, error, info, warn}; + +const MAX_SUBMIT_BLOCK_ATTEMPTS: usize = 3; +const SUBMIT_BLOCK_RETRY_BACKOFF_MS: u64 = 15; + +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); + 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/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs new file mode 100644 index 000000000..40e8f8e02 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs @@ -0,0 +1,497 @@ +//! Module for interacting with Bitcoin Core v32.x via Sv2 Job Declaration Protocol via capnp over +//! UNIX socket. + +use super::bitcoin_capnp_types; +use crate::{ + runtime_api::job_declaration_protocol::io::JdRequest, + unix_capnp::{ + v32x::job_declaration_protocol::error::BitcoinCoreSv2JDPError, + v32x_v31x_v30x::job_declaration_protocol::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 std::{cell::RefCell, path::Path, rc::Rc}; +use stratum_core::bitcoin::{Block, BlockHash, consensus::deserialize, hashes::Hash}; +use tokio::net::UnixStream; +use tokio_util::compat::*; +pub use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/job_declaration_protocol/error.rs"] +pub mod error; +mod handlers; +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/job_declaration_protocol/monitors.rs"] +mod monitors; +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/job_declaration_protocol/handlers.rs"] +mod shared_handlers; + +/// 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(()) + } + + /// Checks whether the chain tip has changed via `getTip` and, if so, updates the mempool + /// mirror's prev_hash. + /// + /// This is called after `checkBlock` failures to reduce stale-tip classification races. + /// We avoid `createNewBlock` (which is expensive) because only the prev_hash is needed for + /// stale detection. + /// + /// 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_prev_hash( + &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: Result<(), BitcoinCoreSv2JDPError> = async { + let mut get_tip_request = self.mining_ipc_client.get_tip_request(); + + get_tip_request + .get() + .get_context() + .map_err(|e| { + error!("Failed to get getTip request context: {e}"); + e + })? + .set_thread(self.thread_ipc_client.clone()); + + let get_tip_response = get_tip_request.send().promise.await.map_err(|e| { + error!("Failed to send getTip request: {e}"); + e + })?; + + let tip_hash_bytes = get_tip_response + .get() + .map_err(|e| { + error!("Failed to read getTip response: {e}"); + e + })? + .get_result() + .map_err(|e| { + error!("Failed to get tip result from getTip: {e}"); + e + })? + .get_hash() + .map_err(|e| { + error!("Failed to get tip hash from getTip result: {e}"); + e + })?; + + let tip_hash = BlockHash::from_slice(tip_hash_bytes).map_err(|e| { + error!("Failed to parse tip hash from getTip: {e}"); + BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed(format!( + "Failed to parse tip hash: {e}" + ))) + })?; + + { + let mut mempool_mirror = self.mempool_mirror.borrow_mut(); + if mempool_mirror.get_current_prev_hash() != Some(tip_hash) { + debug!( + old_prev_hash = ?mempool_mirror.get_current_prev_hash(), + new_prev_hash = ?tip_hash, + "Chain tip changed; updating mempool mirror prev_hash" + ); + mempool_mirror.set_current_prev_hash(tip_hash); + } + } + + Ok(()) + } + .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_prev_hash (thread busy); retrying" + ); + last_error = Some(e); + tokio::time::sleep(std::time::Duration::from_millis(RETRY_BACKOFF_MS)).await; + } + Err(e) => return Err(e), + } + } + + // Normally one of the retry attempts returns early (Ok or Err). + // If execution reaches here, no terminal error was captured, so synthesize one. + Err(last_error.unwrap_or_else(|| { + BitcoinCoreSv2JDPError::CapnpError(capnp::Error::failed( + "force_update_mempool_mirror_prev_hash 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/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs new file mode 100644 index 000000000..48d5c43ab --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs @@ -0,0 +1,12 @@ +//! Bitcoin Core v32.x IPC implementation modules. +//! +//! This namespace contains the concrete v32.x runtime implementations used when +//! [`crate::runtime_api::BitcoinCoreVersion::V32X`] is selected. +//! +//! It is wired against `bitcoin_capnp_types_v32`, which re-exports the matching `capnp` +//! and `capnp-rpc` APIs. + +pub(crate) use bitcoin_capnp_types_v32 as bitcoin_capnp_types; + +pub mod job_declaration_protocol; +pub mod template_distribution_protocol; 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..a8fa16d67 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs @@ -0,0 +1,10 @@ +//! Module for interacting with Bitcoin Core v32.x via Sv2 Template Distribution Protocol via +//! capnp over UNIX socket. + +use super::bitcoin_capnp_types; + +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x/template_distribution_protocol/mod.rs"] +mod shared; + +pub use shared::*; diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/error.rs similarity index 83% rename from bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/error.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/error.rs index 972c28c82..d2f5bcc96 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/error.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/error.rs @@ -1,11 +1,11 @@ -//! Error types for Bitcoin Core v31.x Sv2 Job Declaration Protocol via capnp over UNIX socket. +//! Error types shared by Bitcoin Core v31.x and v32.x Sv2 Job Declaration Protocol runtimes. use std::path::PathBuf; use stratum_core::bitcoin::consensus; -use bitcoin_capnp_types_v31::capnp; +use super::bitcoin_capnp_types::capnp; -/// Errors from the [`crate::unix_capnp::v31x::job_declaration_protocol::BitcoinCoreSv2JDP`] layer. +/// Errors from the Sv2 Job Declaration Protocol IPC runtime layer. #[derive(Debug)] pub enum BitcoinCoreSv2JDPError { /// Cap'n Proto RPC error. diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/handlers.rs new file mode 100644 index 000000000..631d6ac9f --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/handlers.rs @@ -0,0 +1,292 @@ +//! Shared JDP handlers for Bitcoin Core v31.x and v32.x runtimes. + +use super::BitcoinCoreSv2JDP; +use crate::runtime_api::job_declaration_protocol::io::{JdResponse, ValidationContext}; +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}; + +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 (initial_validation_context, ntime, 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 ntime = mempool_mirror + .get_current_ntime() + .expect("current_ntime must be set"); + + let initial_validation_context = ValidationContext { prev_hash, nbits }; + + // 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: {:?}, ntime: {} from mempool mirror", + initial_validation_context.prev_hash, initial_validation_context.nbits, ntime, + ); + + (initial_validation_context, ntime, txdata) + }; // mempool_mirror dropped here, we don't want to hold it across await points + + let txdata_for_response = txdata.clone(); + + let mut check_block_reason_for_stale: Option = None; + + 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 template ntime as the block timestamp + // This ensures we meet Bitcoin Core's timestamp validation rules + let block_time = 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" + ); + check_block_reason_for_stale = + check_block_reason.ok().and_then(|r| r.to_string().ok()); + 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, update local prev_hash 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. + // Updating prev_hash here narrows this TOCTOU window and lets us correctly emit + // `stale-chain-tip` instead of generic `invalid-job` when context drift occurred. + // + // Additionally, a `bad-cb-height` rejection from checkBlock signals a job that was + // already stale at arrival (coinbase built from an old tip) and is mapped to + // stale-chain-tip even without prev_hash drift. + if let Err(e) = self.force_update_mempool_mirror_prev_hash().await { + debug!( + error = ?e, + "Failed to update prev_hash after checkBlock failure; continuing with current validation context" + ); + } + } + + let latest_validation_context = { + let mempool_mirror = self.mempool_mirror.borrow(); + 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"), + } + }; + + let response = if valid_job { + JdResponse::Success { + prev_hash: initial_validation_context.prev_hash, + nbits: initial_validation_context.nbits, + txdata: txdata_for_response, + } + } else { + let context_drifted = + initial_validation_context.prev_hash != latest_validation_context.prev_hash; + + let stale_at_arrival = matches!( + check_block_reason_for_stale.as_deref(), + Some("bad-cb-height") + ); + + let error_code = if context_drifted || stale_at_arrival { + debug!( + initial_prev_hash = ?initial_validation_context.prev_hash, + latest_prev_hash = ?latest_validation_context.prev_hash, + "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); + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/monitors.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/monitors.rs similarity index 95% rename from bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/monitors.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/monitors.rs index a63a83032..a1e79a1ea 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/monitors.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/job_declaration_protocol/monitors.rs @@ -1,15 +1,14 @@ -//! Background monitors for Bitcoin Core v31.x Sv2 Job Declaration Protocol via capnp over UNIX -//! socket. +//! Background monitors shared by Bitcoin Core v31.x and v32.x Sv2 Job Declaration Protocol +//! runtimes. -use crate::unix_capnp::v31x::job_declaration_protocol::BitcoinCoreSv2JDP; -use bitcoin_capnp_types_v31::capnp; +use super::{BitcoinCoreSv2JDP, bitcoin_capnp_types::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` whenever the template - /// changes. Returns the [`JoinHandle`] so the caller can await clean shutdown. + /// refreshes the local mempool mirror 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(); diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/mod.rs new file mode 100644 index 000000000..cdafffcbd --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/mod.rs @@ -0,0 +1,5 @@ +//! Shared implementation modules reused by Bitcoin Core v31.x and v32.x runtimes. +//! +//! Shared JDP/TDP modules in this namespace are reused via `#[path = "..."]` from +//! `unix_capnp::v31x` and `unix_capnp::v32x`, so they compile in each version-local `super::*` +//! context; they are not exported from this module tree. diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/error.rs similarity index 97% rename from bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/error.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/error.rs index 545e4ff20..91b7eff70 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/error.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/error.rs @@ -1,12 +1,11 @@ -//! Error types for Bitcoin Core v31.x Sv2 Template Distribution Protocol via capnp over UNIX -//! socket. +//! Error types shared by Bitcoin Core v31.x and v32.x Sv2 Template Distribution Protocol runtimes. use std::path::Path; use stratum_core::bitcoin::{ block::ValidationError, consensus, consensus::encode::Error as ConsensusEncodeError, }; -use bitcoin_capnp_types_v31::capnp; +use super::bitcoin_capnp_types::capnp; /// Error type for [`super::BitcoinCoreSv2TDP`] #[derive(Debug)] diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/handlers.rs similarity index 97% rename from bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/handlers.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/handlers.rs index ab08dfd86..2d676612e 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/handlers.rs @@ -1,8 +1,6 @@ -//! Handlers for Bitcoin Core v31.x Sv2 Template Distribution Protocol via capnp over UNIX socket. +//! Handlers shared by Bitcoin Core v31.x and v32.x Sv2 Template Distribution Protocol runtimes. -use crate::unix_capnp::v31x::template_distribution_protocol::{ - BitcoinCoreSv2TDP, error::BitcoinCoreSv2TDPError, -}; +use super::{BitcoinCoreSv2TDP, error::BitcoinCoreSv2TDPError}; use stratum_core::{ parsers_sv2::TemplateDistribution, template_distribution_sv2::{ diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/mod.rs new file mode 100644 index 000000000..3eb56afd6 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/mod.rs @@ -0,0 +1,824 @@ +//! Module for interacting with Bitcoin Core v31.x and v32.x via Sv2 Template Distribution Protocol +//! via capnp over UNIX socket. + +use self::template_data::TemplateData; +use super::bitcoin_capnp_types; +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 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}; + +#[allow(clippy::duplicate_mod)] +pub mod error; +#[allow(clippy::duplicate_mod)] +mod handlers; +#[allow(clippy::duplicate_mod)] +#[path = "../../v32x_v31x_v30x/template_distribution_protocol/monitors.rs"] +mod monitors; +#[allow(clippy::duplicate_mod)] +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/v31x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/template_data.rs similarity index 98% rename from bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/template_data.rs index 87a7bf74b..701ce3ef0 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x/template_distribution_protocol/template_data.rs @@ -1,13 +1,12 @@ -//! Template-data helpers for Bitcoin Core v31.x Sv2 Template Distribution Protocol via capnp over -//! UNIX socket. +//! Template-data helpers shared by Bitcoin Core v31.x and v32.x Sv2 Template Distribution Protocol +//! runtimes. -use crate::unix_capnp::v31x::template_distribution_protocol::error::TemplateDataError; +use super::{bitcoin_capnp_types, 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_v31 as bitcoin_capnp_types; use std::{fs::File, io::Write, path::Path}; use stratum_core::bitcoin::{ Target, Transaction, TxOut, @@ -246,7 +245,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_v30x/job_declaration_protocol/mempool.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/job_declaration_protocol/mempool.rs similarity index 54% rename from bitcoin-core-sv2/src/unix_capnp/v31x_v30x/job_declaration_protocol/mempool.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/job_declaration_protocol/mempool.rs index 9286cd7d9..476689d13 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/job_declaration_protocol/mempool.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/job_declaration_protocol/mempool.rs @@ -1,4 +1,5 @@ -//! Local mempool mirror shared by Bitcoin Core v30.x and v31.x Sv2 Job Declaration Protocol. +//! Local mempool mirror shared by Bitcoin Core v30.x, v31.x, and v32.x Sv2 Job Declaration +//! Protocol. use std::collections::HashMap; use stratum_core::bitcoin::{Block, BlockHash, CompactTarget, Transaction, Wtxid}; @@ -6,14 +7,13 @@ 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. +/// and 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, + current_ntime: Option, } impl MempoolMirror { @@ -32,17 +32,7 @@ impl MempoolMirror { } 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()) - }); + self.current_ntime = Some(block.header.time); // skip the coinbase transaction for tx in block.txdata.iter().skip(1) { @@ -83,43 +73,18 @@ impl MempoolMirror { self.current_prev_hash } + /// Sets the current template's prev_hash. + pub fn set_current_prev_hash(&mut self, prev_hash: BlockHash) { + self.current_prev_hash = Some(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 ntime. + pub fn get_current_ntime(&self) -> Option { + self.current_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/v31x_v30x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/job_declaration_protocol/mod.rs similarity index 78% rename from bitcoin-core-sv2/src/unix_capnp/v31x_v30x/job_declaration_protocol/mod.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/job_declaration_protocol/mod.rs index 5a0356a67..2d3e815eb 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/job_declaration_protocol/mod.rs @@ -1,3 +1,3 @@ -//! Shared Job Declaration Protocol modules for Bitcoin Core v30.x and v31.x. +//! Shared Job Declaration Protocol modules for Bitcoin Core v30.x, v31.x, and v32.x. pub mod mempool; diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/mod.rs new file mode 100644 index 000000000..c199cb5f7 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/mod.rs @@ -0,0 +1,6 @@ +//! Shared implementation modules reused by Bitcoin Core v30.x, v31.x, and v32.x runtimes. + +pub mod job_declaration_protocol; + +// TDP shared monitors are reused via `#[path = "..."]` from v30x/v31x/v32x modules so they +// compile in each version-local `super::*` context; they are not exported from this module tree. diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/template_distribution_protocol/monitors.rs b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/template_distribution_protocol/monitors.rs similarity index 99% rename from bitcoin-core-sv2/src/unix_capnp/v31x_v30x/template_distribution_protocol/monitors.rs rename to bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/template_distribution_protocol/monitors.rs index 86f73ac35..665f802dc 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x_v30x/template_distribution_protocol/monitors.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v32x_v31x_v30x/template_distribution_protocol/monitors.rs @@ -1,4 +1,4 @@ -// Shared monitor implementation included by v30.x and v31.x TDP modules. +// Shared monitor implementation included by v30.x, v31.x, and v32.x TDP modules. use super::{BitcoinCoreSv2TDP, bitcoin_capnp_types::capnp}; use stratum_core::parsers_sv2::TemplateDistribution; 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/integration-tests/Cargo.lock b/integration-tests/Cargo.lock index 4c860f33a..4ad729a50 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=6e4c99834df947df3a0f733d2a2dc6e12100e441#6e4c99834df947df3a0f733d2a2dc6e12100e441" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -1103,7 +1113,8 @@ version = "0.5.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=6e4c99834df947df3a0f733d2a2dc6e12100e441)", "stratum-core", "tokio", "tokio-util", diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index fc00a999a..72fafa4d5 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,15 +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_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) @@ -174,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![ diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index 5d391d84a..be7b04f6e 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -3,8 +3,10 @@ //! 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(stale-chain-tip)` when the declared BIP34 height is -//! intentionally mismatched. +//! - `DeclareMiningJob` returns `Error(stale-chain-tip)` when tip drift is detected during a +//! missing-transactions retry flow. +//! - `DeclareMiningJob` returns `Error(stale-chain-tip)` for stale-at-arrival coinbase jobs +//! rejected by `checkBlock` with `bad-cb-height`. //! //! File structure: //! - top: version-specific `#[tokio::test]` wrappers. @@ -12,7 +14,8 @@ use async_channel::Sender; use integration_tests_sv2::{ - start_bitcoin_core, start_tracing, template_provider::DifficultyLevel, + start_bitcoin_core, start_tracing, + template_provider::{BitcoinCore, DifficultyLevel}, }; use std::time::Duration; use stratum_apps::{ @@ -46,6 +49,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(); @@ -102,7 +110,8 @@ 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_stale_chain_tip_scenario(&incoming_sender, next_height).await; + assert_jdp_stale_chain_tip_scenario(&incoming_sender, &bitcoin_core, next_height).await; + assert_jdp_stale_chain_tip_at_arrival_scenario(&incoming_sender, &bitcoin_core).await; cancellation_token.cancel(); jdp_thread @@ -146,10 +155,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:?}"), @@ -158,14 +167,54 @@ async fn assert_jdp_success_scenario( async fn assert_jdp_stale_chain_tip_scenario( incoming_sender: &Sender, + bitcoin_core: &BitcoinCore, next_height: u32, ) { - let response = send_declare_mining_job_and_recv_response( + // This coinbase is valid for the tip height observed before the missing-txs round-trip. + let coinbase_tx = build_valid_coinbase_tx(next_height); + + let missing_tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(vec![0x01]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(0), + script_pubkey: ScriptBuf::new(), + }], + }; + let missing_wtxid = missing_tx.compute_wtxid(); + + // Round 1: declare unknown wtxid so JDP asks for missing transactions. + let first_response = send_declare_mining_job_and_recv_response( incoming_sender, - build_valid_coinbase_tx(next_height.saturating_add(10_000)), - vec![], + coinbase_tx.clone(), + vec![missing_wtxid], vec![], - "jdp/stale-chain-tip", + "jdp/stale-chain-tip/round1", + ) + .await; + match first_response { + JdResponse::MissingTransactions { missing_wtxids, .. } => { + assert_eq!(missing_wtxids, vec![missing_wtxid]); + } + response => panic!("expected MissingTransactions, got: {response:?}"), + } + + // Move chain tip between the MissingTransactions response and retry. + bitcoin_core.generate_blocks(1); + + // Round 2: provide the missing tx and expect stale-chain-tip due to tip drift. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + coinbase_tx, + vec![missing_wtxid], + vec![missing_tx], + "jdp/stale-chain-tip/round2", ) .await; @@ -173,13 +222,63 @@ async fn assert_jdp_stale_chain_tip_scenario( JdResponse::Error { error_code, .. } => { assert_eq!( error_code, ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, - "expected stale-chain-tip error for intentionally mismatched BIP34 height" + "expected stale-chain-tip after tip moved between missing-txs round-trip" ); } response => panic!("expected Error(stale-chain-tip), got: {response:?}"), } } +async fn assert_jdp_stale_chain_tip_at_arrival_scenario( + incoming_sender: &Sender, + bitcoin_core: &BitcoinCore, +) { + // Build a coinbase that is valid for the current tip, then advance the tip so the same + // declaration becomes stale-at-arrival. + let current_height = bitcoin_core + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks; + let current_height = u32::try_from(current_height).expect("current height should fit in u32"); + let coinbase_tx = + build_valid_coinbase_tx(current_height.checked_add(1).expect("height overflow")); + + bitcoin_core.generate_blocks(1); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + coinbase_tx.clone(), + vec![], + vec![], + "jdp/stale-chain-tip/at-arrival", + ) + .await; + + match response { + JdResponse::Error { error_code, .. } => { + assert_eq!( + error_code, ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + "expected stale-chain-tip for stale-at-arrival coinbase" + ); + return; + } + JdResponse::Success { .. } => { + if tokio::time::Instant::now() >= deadline { + panic!( + "timed out waiting for stale-at-arrival classification after tip update" + ); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + response => panic!( + "expected Error(stale-chain-tip) or transient Success while tip update propagates, got: {response:?}" + ), + } + } +} + async fn send_declare_mining_job_and_recv_response( incoming_sender: &Sender, coinbase_tx: Transaction, diff --git a/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs index cd9ec4c51..2a03e60af 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(); diff --git a/miner-apps/Cargo.lock b/miner-apps/Cargo.lock index b043632a8..6e5346118 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=6e4c99834df947df3a0f733d2a2dc6e12100e441#6e4c99834df947df3a0f733d2a2dc6e12100e441" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -917,7 +927,8 @@ version = "0.5.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=6e4c99834df947df3a0f733d2a2dc6e12100e441)", "stratum-core", "tokio", "tokio-util", 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/Cargo.lock b/pool-apps/Cargo.lock index 74b9938a0..c2705b909 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=6e4c99834df947df3a0f733d2a2dc6e12100e441#6e4c99834df947df3a0f733d2a2dc6e12100e441" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -351,7 +361,8 @@ version = "0.5.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=6e4c99834df947df3a0f733d2a2dc6e12100e441)", "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 6aaa09df9..d2df33c48 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, Txid, Wtxid, + Block, BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, }, job_declaration_sv2::{ DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution, @@ -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). /// @@ -68,55 +72,27 @@ 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, } +/// Latest `DeclaredCustomJob` accepted via `SetCustomMiningJob` for a downstream. +type ActiveCustomJob = DeclaredCustomJob; + #[derive(Clone, Copy)] struct AllocatedTokenEntry { request_id: RequestId, inserted_at: Instant, } -/// Per-downstream client state for declared custom jobs and their token entries. #[derive(Default)] struct DownstreamState { declared_custom_jobs: HashMap, allocated_token_entries: HashMap, + active_custom_job: Option, } impl DownstreamState { - /// Stores a DeclaredCustomJob and its corresponding allocated token. - fn insert_declared_custom_job( - &mut self, - request_id: RequestId, - allocated_token: JdToken, - declared_custom_job: DeclaredCustomJob, - ) { - self.declared_custom_jobs - .insert(request_id, declared_custom_job); - self.allocated_token_entries.insert( - allocated_token, - AllocatedTokenEntry { - request_id, - inserted_at: Instant::now(), - }, - ); - } - - /// Removes both the DeclaredCustomJob and its corresponding allocated token. - fn remove_declared_custom_job(&mut self, request_id: RequestId, allocated_token: JdToken) { - self.declared_custom_jobs.remove(&request_id); - self.allocated_token_entries.remove(&allocated_token); - } - - /// Atomically removes and returns a declared custom job by its token. - fn take_declared_custom_job(&mut self, allocated_token: JdToken) -> Option { - let entry = self.allocated_token_entries.remove(&allocated_token)?; - let job = self.declared_custom_jobs.remove(&entry.request_id)?; - Some(job) - } - /// Removes expired allocated tokens and their corresponding DeclaredCustomJob. /// Returns a list of expired `(token, request_id)` pairs for logging. fn prune_expired_allocations( @@ -136,13 +112,6 @@ impl DownstreamState { expired } - - /// Looks up the ValidationContext for a given RequestId. - fn validation_context_for_request(&self, request_id: RequestId) -> Option { - self.declared_custom_jobs - .get(&request_id) - .map(|job| job.validation_context) - } } #[cfg_attr(not(test), hotpath::measure_all)] @@ -162,14 +131,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 = @@ -202,10 +171,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 @@ -221,12 +204,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, ()> { @@ -234,17 +217,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() + .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 + 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 { @@ -294,7 +277,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( @@ -461,8 +444,6 @@ fn validation_context_drifted( 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)] @@ -519,13 +500,12 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { 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 }, - txid_list: None, // irrelevant for coinbase tx validation + txdata: None, // irrelevant for coinbase tx validation 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 @@ -577,17 +557,14 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { provide_missing_transactions_success.as_ref().and_then(|_| { self.downstream_states .with(&downstream_id, |state| { - state.validation_context_for_request(declare_mining_job.request_id) + state + .declared_custom_jobs + .get(&declare_mining_job.request_id) + .map(|job| job.validation_context) }) .flatten() }); - // Ensure downstream state exists before awaiting IPC response. - // Later writes must use `with_mut` only, so a disconnect cleanup that removes this - // state cannot be undone by recreating it after the response arrives. - self.downstream_states - .with_mut_or_default(downstream_id, |_| {}); - // Create oneshot channel for response let (response_tx, response_rx) = tokio::sync::oneshot::channel(); @@ -621,32 +598,27 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { JdResponse::Success { prev_hash, nbits, - min_ntime, - txid_list, + txdata, } => { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, - validation_context: ValidationContext { - prev_hash, - nbits, - min_ntime, - }, - txid_list: Some(txid_list), + validation_context: ValidationContext { prev_hash, nbits }, + txdata: Some(txdata), validated: true, }; - let updated = self.downstream_states.with_mut(&downstream_id, |state| { - state.insert_declared_custom_job( - declare_mining_job.request_id, - allocated_token, - declared_custom_job, - ); - }); - if updated.is_none() { - tracing::error!(downstream_id, "downstream state missing after IPC response"); - return DeclareMiningJobResult::Error( - ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - ); - } + self.downstream_states + .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 { @@ -655,7 +627,9 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } => { self.downstream_states.with_mut(&downstream_id, |state| { state - .remove_declared_custom_job(declare_mining_job.request_id, allocated_token); + .declared_custom_jobs + .remove(&declare_mining_job.request_id); + state.allocated_token_entries.remove(&allocated_token); }); let tip_drifted = previous_pending_validation_context @@ -684,10 +658,10 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // 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_states.with_mut(&downstream_id, |state| { - state.remove_declared_custom_job( - declare_mining_job.request_id, - allocated_token, - ); + 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) @@ -695,25 +669,22 @@ 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 }; - let updated = self.downstream_states.with_mut(&downstream_id, |state| { - state.insert_declared_custom_job( - declare_mining_job.request_id, - allocated_token, - declared_custom_job, - ); - }); - if updated.is_none() { - tracing::error!( - downstream_id, - "downstream state missing after IPC response" - ); - return DeclareMiningJobResult::Error( - ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - ); - } + self.downstream_states + .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) } @@ -726,13 +697,102 @@ 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(); + 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; - // Send request to BitcoinCoreSv2JDP (fire-and-forget) - let request = JdRequest::PushSolution { - push_solution: push_solution_static, + 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); @@ -756,10 +816,36 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { 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 + .downstream_states + .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!( + 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, + ); + } + }; + let declared_custom_job = match self .downstream_states .with_mut(&downstream_id, |state| { - state.take_declared_custom_job(allocated_token) + // 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() { @@ -768,7 +854,8 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { tracing::debug!( downstream_id, allocated_token, - "Provided token is not associated with any DeclareMiningJob request" + 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, @@ -841,7 +928,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( @@ -935,6 +1022,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 0fdf234be..2144db645 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, 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 diff --git a/stratum-apps/Cargo.lock b/stratum-apps/Cargo.lock index e52a10f5e..79f305fde 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=6e4c99834df947df3a0f733d2a2dc6e12100e441#6e4c99834df947df3a0f733d2a2dc6e12100e441" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -852,7 +862,8 @@ version = "0.5.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=6e4c99834df947df3a0f733d2a2dc6e12100e441)", "stratum-core", "tokio", "tokio-util", diff --git a/stratum-apps/src/tp_type.rs b/stratum-apps/src/tp_type.rs index b0330fcfe..e2d1b1b24 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()); } }