diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 68bb35be9..a196f4238 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -35,6 +35,38 @@ jobs: if: matrix.os == 'macos-latest' run: brew install capnp + # TEMPORARY: build Bitcoin Core from the bitcoin/bitcoin#35671 PR branch + # (TxCollection) until an official v32 release with TxCollection support is + # available. Remove these steps (and BITCOIN_CORE_V32_BINARY below) once v32 + # follows the standard release-binary download flow. + - name: Install Bitcoin Core build dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install -y build-essential cmake pkgconf python3 libevent-dev libboost-dev ccache + + - name: Install Bitcoin Core build dependencies (macOS) + if: matrix.os == 'macos-latest' + run: brew install cmake boost pkgconf ccache + + - name: ccache + uses: actions/cache@v4 + with: + path: ${{ matrix.os == 'macos-latest' && '~/Library/Caches/ccache' || '~/.cache/ccache' }} + key: ccache-bitcoin-${{ runner.os }}-${{ github.sha }} + restore-keys: ccache-bitcoin-${{ runner.os }}- + + - name: Checkout Bitcoin Core (bitcoin/bitcoin#35671) + run: | + git clone --depth 1 https://github.com/bitcoin/bitcoin.git + cd bitcoin + git fetch --depth 1 origin pull/35671/head:pr-35671 + git checkout pr-35671 + + - name: Build Bitcoin Core + run: | + cd bitcoin + cmake -B build -DENABLE_WALLET=ON -DBUILD_TESTS=OFF -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + cmake --build build -j $(nproc 2>/dev/null || sysctl -n hw.ncpu) + # Install cargo-binstall so tool installs below use prebuilt binaries. - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@v1.20.0 @@ -49,5 +81,7 @@ jobs: run: cargo binstall --no-confirm --disable-telemetry --disable-strategies quick-install --locked cargo-nextest@0.9.100 - name: Integration Tests + env: + BITCOIN_CORE_V32_BINARY: ${{ github.workspace }}/bitcoin/build/bin/bitcoin-node run: | RUST_BACKTRACE=1 RUST_LOG=debug cargo nextest run --manifest-path=integration-tests/Cargo.toml --nocapture diff --git a/bitcoin-core-sv2/Cargo.toml b/bitcoin-core-sv2/Cargo.toml index fc9c065d0..0b166bdba 100644 --- a/bitcoin-core-sv2/Cargo.toml +++ b/bitcoin-core-sv2/Cargo.toml @@ -23,6 +23,12 @@ 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 +# TEMPORARY: pinned to https://github.com/2140-dev/bitcoin-capnp-types/pull/29 (TxCollection) +# switch back to 2140-dev once that PR is merged and released +bitcoin_capnp_types_v32 = { package = "bitcoin-capnp-types", git = "https://github.com/Sjors/bitcoin-capnp-types.git", branch = "2026/07/tx-collection" } + # fetching from github enables synchronizing development workflows across sv2-apps and stratum repos # it MUST be changed before bitcoin-core-sv2 is published to crates.io # with the proper version of stratum-core being fetched from crates.io as well diff --git a/bitcoin-core-sv2/README.md b/bitcoin-core-sv2/README.md index fb6463959..16c620b29 100644 --- a/bitcoin-core-sv2/README.md +++ b/bitcoin-core-sv2/README.md @@ -8,11 +8,12 @@ A Rust library that integrates [Bitcoin Core](https://bitcoin.org/en/bitcoin-cor - building Sv2 applications that act as a Client under the Template Distribution Protocol (e.g.: Pool or JDC) while connecting directly to the Bitcoin Core node. - building a Sv2 Template Provider application that acts as a Template Distribution Protocol Server while creating templates from a Bitcoin Core node. -The crate exposes three module families: +The crate exposes four module families: - `bitcoin_core_sv2::common` - version-agnostic enum-dispatch runtime handles and protocol-specific `new(version, ...)` factories. - `bitcoin_core_sv2::unix_capnp::v30x` - Bitcoin Core v30.x IPC implementation. - `bitcoin_core_sv2::unix_capnp::v31x` - Bitcoin Core v31.x IPC implementation. +- `bitcoin_core_sv2::unix_capnp::v32x` - Bitcoin Core v32.x IPC implementation. ### Flavor naming rationale @@ -59,6 +60,7 @@ The `min_interval` parameter (in seconds) determines the minimum amount of time - `tdp_logger_v30x` - Template Distribution Protocol logger wired to `bitcoin_core_sv2::unix_capnp::v30x`. - `tdp_logger_v31x` - Template Distribution Protocol logger wired to `bitcoin_core_sv2::unix_capnp::v31x`. +- `tdp_logger_v32x` - Template Distribution Protocol logger wired to `bitcoin_core_sv2::unix_capnp::v32x`. ## License diff --git a/bitcoin-core-sv2/examples/tdp_logger_v32x.rs b/bitcoin-core-sv2/examples/tdp_logger_v32x.rs new file mode 100644 index 000000000..5097d1c1d --- /dev/null +++ b/bitcoin-core-sv2/examples/tdp_logger_v32x.rs @@ -0,0 +1,178 @@ +//! A simple example of how to use `BitcoinCoreSv2TDP`. +//! +//! This example demonstrates the pattern used in applications where `BitcoinCoreSv2TDP` is +//! spawned in a dedicated thread with its own Tokio runtime and `LocalSet`. This allows the +//! main application to run in a separate async context while `BitcoinCoreSv2TDP` runs in its +//! own isolated thread. +//! +//! We connect to the Bitcoin Core UNIX socket, and log the received Sv2 Template Distribution +//! Protocol messages. +//! +//! We send a `CoinbaseOutputConstraints` message to the `BitcoinCoreSv2TDP` instance once at +//! startup. +//! +//! `BitcoinCoreSv2TDP` will not start distributing new templates until it receives the first +//! `CoinbaseOutputConstraints` message. + +use bitcoin_core_sv2::unix_capnp::v32x::template_distribution_protocol::BitcoinCoreSv2TDP; +use std::path::Path; + +use async_channel::unbounded; +use stratum_core::{ + parsers_sv2::TemplateDistribution, + template_distribution_sv2::{CoinbaseOutputConstraints, RequestTransactionData}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + // the user must provide the path to the Bitcoin Core UNIX socket + let args: Vec = std::env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} /path/to/bitcoin/regtest/node.sock", args[0]); + std::process::exit(1); + } + + let bitcoin_core_unix_socket_path = Path::new(&args[1]); + + // `BitcoinCoreSv2TDP` uses this to cancel internally spawned tasks + let cancellation_token = CancellationToken::new(); + + // get new templates whenever the mempool has changed by more than 100 sats + let fee_threshold = 100; + + // the minimum interval between template updates in seconds + let min_interval = 5; + + // these messages are sent into the `BitcoinCoreSv2TDP` instance + let (msg_sender_into_bitcoin_core_sv2, msg_receiver_into_bitcoin_core_sv2) = unbounded(); + // these messages are received from the `BitcoinCoreSv2TDP` instance + let (msg_sender_from_bitcoin_core_sv2, msg_receiver_from_bitcoin_core_sv2) = unbounded(); + + // clone so we can move it into the thread + let cancellation_token_clone = cancellation_token.clone(); + let bitcoin_core_unix_socket_path_clone = bitcoin_core_unix_socket_path.to_path_buf(); + + // spawn a dedicated thread to run the BitcoinCoreSv2TDP instance + // because we're limited to tokio::task::LocalSet + // + // please note that it's important to keep a reference to the join handle so we can wait for it + // to finish shutdown this will no longer be a pre-requisite once https://github.com/bitcoin/bitcoin/pull/33676 lands in a release + // see https://github.com/stratum-mining/sv2-apps/issues/81 for more details + let join_handle = std::thread::spawn(move || { + // we need a dedicated runtime so we can spawn an async task inside the LocalSet + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + error!("Failed to create Tokio runtime: {:?}", e); + cancellation_token_clone.cancel(); + return; + } + }; + let tokio_local_set = tokio::task::LocalSet::new(); + + tokio_local_set.block_on(&rt, async move { + // create a new `BitcoinCoreSv2TDP` instance + let mut sv2_bitcoin_core = match BitcoinCoreSv2TDP::new( + &bitcoin_core_unix_socket_path_clone, + fee_threshold, + min_interval, + msg_receiver_into_bitcoin_core_sv2, + msg_sender_from_bitcoin_core_sv2, + cancellation_token_clone.clone(), + ) + .await + { + Ok(sv2_bitcoin_core) => sv2_bitcoin_core, + Err(e) => { + error!("Failed to create BitcoinCoreToSv2: {:?}", e); + cancellation_token_clone.cancel(); + return; + } + }; + + // run the `BitcoinCoreSv2TDP` instance, which will block until the cancellation token + // is activated + sv2_bitcoin_core.run().await; + }); + }); + + // clone so we can move it + let cancellation_token_clone = cancellation_token.clone(); + + // clone so we can move it + let msg_sender_into_bitcoin_core_sv2_clone = msg_sender_into_bitcoin_core_sv2.clone(); + + // a task to consume and log the received Sv2 Template Distribution Protocol messages + tokio::spawn(async move { + loop { + tokio::select! { + // monitor for Ctrl+C, activating the cancellation token and exiting the loop + _ = tokio::signal::ctrl_c() => { + info!("Ctrl+C received"); + cancellation_token_clone.cancel(); + return; + } + // monitor potential internal activations of the cancellation token for exiting the loop + _ = cancellation_token_clone.cancelled() => { + info!("Cancellation token activated"); + return; + } + // monitor for Sv2 Template Distribution Protocol messages + // coming from `BitcoinCoreSv2TDP` + Ok(template_distribution_message) = msg_receiver_from_bitcoin_core_sv2.recv() => { + // log the message + info!("Message received: {}", template_distribution_message); + + // send a RequestTransactionData every time a NewTemplate message is received + if let TemplateDistribution::NewTemplate(new_template) = template_distribution_message { + let template_id = new_template.template_id; + let request_transaction_data = TemplateDistribution::RequestTransactionData(RequestTransactionData { + template_id, + }); + + match msg_sender_into_bitcoin_core_sv2_clone.send(request_transaction_data).await { + Ok(_) => (), + Err(e) => { + error!("Failed to send request transaction data: {}", e); + cancellation_token_clone.cancel(); + return; + } + } + } + } + } + } + }); + + // send CoinbaseOutputConstraints once at startup + // + // `BitcoinCoreSv2TDP` will not start distributing new templates until it receives the first + // `CoinbaseOutputConstraints` message. + let new_coinbase_output_constraints = + TemplateDistribution::CoinbaseOutputConstraints(CoinbaseOutputConstraints { + coinbase_output_max_additional_size: 2, + coinbase_output_max_additional_sigops: 2, + }); + + if let Err(e) = msg_sender_into_bitcoin_core_sv2 + .send(new_coinbase_output_constraints) + .await + { + error!("Failed to send coinbase output constraints: {}", e); + cancellation_token.cancel(); + } + info!("Sent CoinbaseOutputConstraints"); + + // wait for the cancellation token to be activated + cancellation_token.cancelled().await; + info!("Shutting down..."); + + // wait for the dedicated thread to finish shutdown + join_handle.join().unwrap(); + info!("BitcoinCoreSv2TDP dedicated thread shutdown complete."); +} diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs index 8155a7dc8..8d27f4862 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/io.rs @@ -1,14 +1,20 @@ //! 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::{ + BlockHash, CompactTarget, Transaction, TxMerkleNode, Wtxid, block::Version, }; use tokio::sync::oneshot; -/// Snapshot of the template parameters used by the validator at decision time. +/// Identifies a downstream client of `jd-server`. +pub type DownstreamId = usize; + +/// Identifies a `DeclareMiningJob` request within a downstream connection. +pub type RequestId = u32; + +/// Snapshot of the template parameters used by the mirror-based validators (v30.x/v31.x) +/// at decision time. /// -/// This lets callers distinguish stale-tip races from other validation failures. +/// This lets those backends distinguish stale-tip races from other validation failures. /// /// Please check https://github.com/stratum-mining/sv2-apps/issues/364 /// for more details on the regression that motivated this field. @@ -22,41 +28,73 @@ pub struct ValidationContext { /// A request sent from `jd-server` to the [`BitcoinCoreSv2JDP`](super::BitcoinCoreSv2JDP) IPC /// thread. /// -/// Built from a `DeclareMiningJob` (plus an optional `ProvideMissingTransactionsSuccess`) -/// or a `PushSolution`. +/// `DeclareMiningJob` is built from a `DeclareMiningJob` message (plus an optional +/// `ProvideMissingTransactionsSuccess`), `PushSolution` from a `PushSolution` message. The +/// remaining variants let `jd-server` mirror its job lifecycle so backends that retain +/// per-job state (the v32.x `TxCollection` backend keeps a validated `BlockTemplate` per +/// declared job) can release it. pub enum JdRequest { - /// Validate a declared mining job via Bitcoin Core's `checkBlock`. + /// Validate a declared mining job. + /// + /// Invariants (enforced by `jd-server`): `wtxid_list` contains no duplicates and every + /// transaction in `missing_txs` is part of `wtxid_list`. The v32.x `TxCollection` + /// backend relies on these; Bitcoin Core rejects violations with RPC-level errors. DeclareMiningJob { + downstream_id: DownstreamId, + request_id: RequestId, version: Version, coinbase_tx: Transaction, wtxid_list: Vec, missing_txs: Vec, response_tx: oneshot::Sender, }, - /// Submit a mining solution to Bitcoin Core (fire-and-forget). + /// Submit a solution for a previously declared job to Bitcoin Core (fire-and-forget). + /// + /// `downstream_id`/`request_id` identify the `DeclareMiningJob` the solution belongs + /// to. `coinbase_tx` is the declared coinbase with the solution's extranonce applied. PushSolution { - push_solution: PushSolution<'static>, + downstream_id: DownstreamId, + request_id: RequestId, + version: u32, + ntime: u32, + nonce: u32, + coinbase_tx: Transaction, }, + /// Release any backend state retained for a declared job that will never be used + /// (consumed with an error, expired, or superseded). Fire-and-forget; harmless when the + /// backend retained nothing. + ReleaseDeclaredJob { + downstream_id: DownstreamId, + request_id: RequestId, + }, + /// Release all backend state retained for a disconnected downstream (fire-and-forget). + CleanupDownstream { downstream_id: DownstreamId }, } /// The result of trying to handle a DeclareMiningJob request. +/// +/// `Error` and `MissingTransactions` carry only the chain tip (`prev_hash`) the validator +/// operated against: stale-tip classification is restricted to `prev_hash` comparison +/// (see https://github.com/stratum-mining/sv2-apps/issues/597). #[derive(Debug, Clone)] pub enum JdResponse { Success { 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, + /// Coinbase merkle branch (sibling hashes from leaf to root at position 0) in the + /// txid merkle tree, used by `jd-server` to validate a `SetCustomMiningJob`'s + /// `merkle_path`. It does not depend on the coinbase transaction itself. + merkle_path: Vec, }, Error { error_code: &'static str, - validation_context: ValidationContext, + /// Chain tip at decision time; `None` when the failure happened before the validator + /// could establish one (e.g. internal IPC errors). + prev_hash: Option, }, MissingTransactions { missing_wtxids: Vec, - validation_context: ValidationContext, + prev_hash: BlockHash, }, } diff --git a/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs index d2b45671f..ce6d356e7 100644 --- a/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/common/job_declaration_protocol/mod.rs @@ -14,11 +14,12 @@ pub mod io; use crate::{ common::{BitcoinCoreSv2Error, BitcoinCoreSv2Protocol, BitcoinCoreVersion}, - unix_capnp::{v30x, v31x}, + unix_capnp::{v30x, v31x, v32x}, }; use async_channel::Receiver; use io::JdRequest; use std::path::Path; +use stratum_core::bitcoin::{TxMerkleNode, Txid, consensus::Encodable, hashes::Hash}; pub use tokio_util::sync::CancellationToken; /// Version-agnostic JDP runtime handle. @@ -28,6 +29,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 +37,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 +78,60 @@ 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) + }), + } +} + +/// Computes the coinbase merkle branch in the txid merkle tree for a block whose +/// non-coinbase transactions have the given txids (in block order). +/// +/// Returns the sibling hashes at each level from leaf to root, needed to reconstruct the +/// block header's merkle root from the coinbase position (index 0). The branch does not +/// depend on the coinbase transaction itself, so a placeholder leaf is used. +/// +/// Used to compare with a `SetCustomMiningJob.merkle_path`. +pub fn coinbase_merkle_branch(txids: &[Txid]) -> Vec { + let mut hashes: Vec = Vec::with_capacity(1 + txids.len()); + // placeholder for the coinbase txid; position-0 branches never include their own leaf + hashes.push(TxMerkleNode::all_zeros()); + for txid in txids { + hashes.push((*txid).into()); } + + if hashes.len() == 1 { + return Vec::new(); + } + + let mut branch = Vec::new(); + + while hashes.len() > 1 { + branch.push(hashes[1]); + + let half = hashes.len().div_ceil(2); + let mut next_level = Vec::with_capacity(half); + for idx in 0..half { + let left = hashes[2 * idx]; + let right = hashes[std::cmp::min(2 * idx + 1, hashes.len() - 1)]; + let mut engine = TxMerkleNode::engine(); + left.consensus_encode(&mut engine) + .expect("in-memory writers don't error"); + right + .consensus_encode(&mut engine) + .expect("in-memory writers don't error"); + next_level.push(TxMerkleNode::from_engine(engine)); + } + hashes = next_level; + } + + branch } diff --git a/bitcoin-core-sv2/src/common/mod.rs b/bitcoin-core-sv2/src/common/mod.rs index ab537dd03..5e51b334e 100644 --- a/bitcoin-core-sv2/src/common/mod.rs +++ b/bitcoin-core-sv2/src/common/mod.rs @@ -10,6 +10,7 @@ use std::fmt; pub enum BitcoinCoreVersion { V30X, V31X, + V32X, } impl BitcoinCoreVersion { @@ -17,6 +18,7 @@ impl BitcoinCoreVersion { match self { Self::V30X => 30, Self::V31X => 31, + Self::V32X => 32, } } } @@ -28,6 +30,7 @@ impl TryFrom for BitcoinCoreVersion { match value { 30 => Ok(Self::V30X), 31 => Ok(Self::V31X), + 32 => Ok(Self::V32X), _ => Err(value), } } diff --git a/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs index 78b8f9633..d5b69119c 100644 --- a/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs +++ b/bitcoin-core-sv2/src/common/template_distribution_protocol/mod.rs @@ -14,7 +14,7 @@ use crate::{ common::{BitcoinCoreSv2Error, BitcoinCoreSv2Protocol, BitcoinCoreVersion}, - unix_capnp::{v30x, v31x}, + unix_capnp::{v30x, v31x, v32x}, }; use async_channel::{Receiver, Sender}; use std::path::Path; @@ -28,6 +28,7 @@ pub use tokio_util::sync::CancellationToken; pub enum BitcoinCoreSv2TDP { V30X(v30x::template_distribution_protocol::BitcoinCoreSv2TDP), V31X(v31x::template_distribution_protocol::BitcoinCoreSv2TDP), + V32X(v32x::template_distribution_protocol::BitcoinCoreSv2TDP), } impl BitcoinCoreSv2TDP { @@ -35,6 +36,7 @@ impl BitcoinCoreSv2TDP { match self { Self::V30X(runtime) => runtime.run().await, Self::V31X(runtime) => runtime.run().await, + Self::V32X(runtime) => runtime.run().await, } } } @@ -82,5 +84,18 @@ where .map_err(|error| { BitcoinCoreSv2TDPError::from_debug(version, BitcoinCoreSv2Protocol::TDP, error) }), + BitcoinCoreVersion::V32X => v32x::template_distribution_protocol::BitcoinCoreSv2TDP::new( + bitcoin_core_unix_socket_path, + fee_threshold, + min_interval, + incoming_messages, + outgoing_messages, + global_cancellation_token, + ) + .await + .map(BitcoinCoreSv2TDP::V32X) + .map_err(|error| { + BitcoinCoreSv2TDPError::from_debug(version, BitcoinCoreSv2Protocol::TDP, error) + }), } } diff --git a/bitcoin-core-sv2/src/lib.rs b/bitcoin-core-sv2/src/lib.rs index a3842b310..49dc3ee09 100644 --- a/bitcoin-core-sv2/src/lib.rs +++ b/bitcoin-core-sv2/src/lib.rs @@ -19,6 +19,7 @@ //! factories with enum dispatch across backend versions. //! - [`unix_capnp::v30x`] contains the Bitcoin Core v30.x IPC implementation. //! - [`unix_capnp::v31x`] contains the Bitcoin Core v31.x IPC implementation. +//! - [`unix_capnp::v32x`] contains the Bitcoin Core v32.x IPC implementation. //! //! ## Flavor direction //! diff --git a/bitcoin-core-sv2/src/unix_capnp/mod.rs b/bitcoin-core-sv2/src/unix_capnp/mod.rs index 9ef418203..81c153365 100644 --- a/bitcoin-core-sv2/src/unix_capnp/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/mod.rs @@ -9,3 +9,4 @@ pub mod v30x; pub mod v31x; +pub mod v32x; diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs index 430d5257c..051b31c62 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/handlers.rs @@ -1,21 +1,24 @@ //! Handlers for Bitcoin Core v30.x Sv2 Job Declaration Protocol via capnp over UNIX socket. use crate::{ - common::job_declaration_protocol::io::{JdResponse, ValidationContext}, + common::job_declaration_protocol::{ + coinbase_merkle_branch, + io::{JdResponse, ValidationContext}, + }, unix_capnp::v30x::job_declaration_protocol::{ BitcoinCoreSv2JDP, mempool::decode_bip34_height_from_coinbase_script_sig, }, }; 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; @@ -93,7 +96,7 @@ impl BitcoinCoreSv2JDP { // we don't care if the receiver dropped the channel let _ = response_tx.send(JdResponse::MissingTransactions { missing_wtxids, - validation_context: initial_validation_context, + prev_hash: initial_validation_context.prev_hash, }); return; } @@ -111,7 +114,7 @@ impl BitcoinCoreSv2JDP { (initial_validation_context, initial_bip34_height, txdata) }; // mempool_mirror dropped here, we don't want to hold it across await points - let txid_list: Vec = txdata.iter().map(|tx| tx.compute_txid()).collect(); + let txdata_for_response = txdata.clone(); let valid_job = { let mut all_transactions = Vec::with_capacity(1 + txdata.len()); @@ -159,7 +162,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -177,7 +180,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -192,7 +195,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -269,7 +272,12 @@ impl BitcoinCoreSv2JDP { prev_hash: initial_validation_context.prev_hash, nbits: initial_validation_context.nbits, min_ntime: initial_validation_context.min_ntime, - txid_list, + merkle_path: coinbase_merkle_branch( + &txdata_for_response + .iter() + .map(|tx| tx.compute_txid()) + .collect::>(), + ), } } else { let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; @@ -301,7 +309,7 @@ impl BitcoinCoreSv2JDP { JdResponse::Error { error_code, - validation_context: latest_validation_context, + prev_hash: Some(latest_validation_context.prev_hash), } }; @@ -310,10 +318,10 @@ impl BitcoinCoreSv2JDP { let _ = response_tx.send(response); } - /// Submits a mining solution to Bitcoin Core. + /// Submits a solved block to Bitcoin Core. /// - /// Not yet implemented — deliberately left as a stub for future work. - pub(crate) async fn handle_push_solution(&self, _push_solution: PushSolution<'_>) { + /// Not yet implemented for v30.x IPC, which does not expose `submitBlock`. + pub(crate) async fn handle_push_solution(&self) { // todo } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs index e8adee931..04e60aa4c 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/job_declaration_protocol/mod.rs @@ -357,6 +357,7 @@ impl BitcoinCoreSv2JDP { wtxid_list, missing_txs, response_tx, + .. } => { self.handle_declare_mining_job( version, @@ -369,9 +370,12 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { push_solution } => { - self.handle_push_solution(push_solution).await; + JdRequest::PushSolution { .. } => { + self.handle_push_solution().await; } + + // This backend retains no per-job state, so there is nothing to release. + JdRequest::ReleaseDeclaredJob { .. } | JdRequest::CleanupDownstream { .. } => {} } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs index 2d46c0dd7..08ce374d2 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v30x/template_distribution_protocol/template_data.rs @@ -244,7 +244,7 @@ impl TemplateData { thread_map: ThreadMapIpcClient, path_dir: &Path, ) -> Result<(), TemplateDataError> { - let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.to_owned_bytes(); + let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.as_ref().to_vec(); let solution_coinbase_tx: Transaction = deserialize(&solution_coinbase_tx_bytes).map_err(|e| { diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs index 4d4e0a821..5fece988e 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/handlers.rs @@ -1,21 +1,24 @@ //! Handlers for Bitcoin Core v31.x Sv2 Job Declaration Protocol via capnp over UNIX socket. use crate::{ - common::job_declaration_protocol::io::{JdResponse, ValidationContext}, + common::job_declaration_protocol::{ + coinbase_merkle_branch, + io::{JdResponse, ValidationContext}, + }, unix_capnp::v31x::job_declaration_protocol::{ BitcoinCoreSv2JDP, mempool::decode_bip34_height_from_coinbase_script_sig, }, }; 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; @@ -93,7 +96,7 @@ impl BitcoinCoreSv2JDP { // we don't care if the receiver dropped the channel let _ = response_tx.send(JdResponse::MissingTransactions { missing_wtxids, - validation_context: initial_validation_context, + prev_hash: initial_validation_context.prev_hash, }); return; } @@ -111,7 +114,7 @@ impl BitcoinCoreSv2JDP { (initial_validation_context, initial_bip34_height, txdata) }; // mempool_mirror dropped here, we don't want to hold it across await points - let txid_list: Vec = txdata.iter().map(|tx| tx.compute_txid()).collect(); + let txdata_for_response = txdata.clone(); let valid_job = { let mut all_transactions = Vec::with_capacity(1 + txdata.len()); @@ -156,7 +159,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -174,7 +177,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -192,7 +195,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -207,7 +210,7 @@ impl BitcoinCoreSv2JDP { // deliberately ignore potential send errors let _ = response_tx.send(JdResponse::Error { error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, - validation_context: initial_validation_context, + prev_hash: Some(initial_validation_context.prev_hash), }); warn!("Terminating Sv2 Bitcoin Core IPC Connection"); self.cancellation_token.cancel(); @@ -284,7 +287,12 @@ impl BitcoinCoreSv2JDP { prev_hash: initial_validation_context.prev_hash, nbits: initial_validation_context.nbits, min_ntime: initial_validation_context.min_ntime, - txid_list, + merkle_path: coinbase_merkle_branch( + &txdata_for_response + .iter() + .map(|tx| tx.compute_txid()) + .collect::>(), + ), } } else { let stale_at_arrival_by_bip34 = declared_bip34_height != latest_bip34_height; @@ -316,7 +324,7 @@ impl BitcoinCoreSv2JDP { JdResponse::Error { error_code, - validation_context: latest_validation_context, + prev_hash: Some(latest_validation_context.prev_hash), } }; @@ -325,10 +333,10 @@ impl BitcoinCoreSv2JDP { let _ = response_tx.send(response); } - /// Submits a mining solution to Bitcoin Core. + /// Submits a solved block to Bitcoin Core. /// - /// Not yet implemented — deliberately left as a stub for future work. - pub(crate) async fn handle_push_solution(&self, _push_solution: PushSolution<'_>) { + /// Not yet implemented for v31.x IPC, which does not expose `submitBlock`. + pub(crate) async fn handle_push_solution(&self) { // todo } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs index 524dbfd12..251057650 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/job_declaration_protocol/mod.rs @@ -418,6 +418,7 @@ impl BitcoinCoreSv2JDP { wtxid_list, missing_txs, response_tx, + .. } => { self.handle_declare_mining_job( version, @@ -430,9 +431,12 @@ impl BitcoinCoreSv2JDP { } // Handle PushSolution requests (no response needed) - JdRequest::PushSolution { push_solution } => { - self.handle_push_solution(push_solution).await; + JdRequest::PushSolution { .. } => { + self.handle_push_solution().await; } + + // This backend retains no per-job state, so there is nothing to release. + JdRequest::ReleaseDeclaredJob { .. } | JdRequest::CleanupDownstream { .. } => {} } } diff --git a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs index 87a7bf74b..c19d7ad0d 100644 --- a/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs +++ b/bitcoin-core-sv2/src/unix_capnp/v31x/template_distribution_protocol/template_data.rs @@ -246,7 +246,7 @@ impl TemplateData { thread_map: ThreadMapIpcClient, path_dir: &Path, ) -> Result<(), TemplateDataError> { - let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.to_owned_bytes(); + let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.as_ref().to_vec(); let solution_coinbase_tx: Transaction = deserialize(&solution_coinbase_tx_bytes).map_err(|e| { diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs new file mode 100644 index 000000000..330a375be --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/error.rs @@ -0,0 +1,40 @@ +//! Error types for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX socket. + +use std::path::PathBuf; +use stratum_core::bitcoin::consensus; + +use bitcoin_capnp_types_v32::capnp; + +/// Errors from the [`crate::unix_capnp::v32x::job_declaration_protocol::BitcoinCoreSv2JDP`] layer. +#[derive(Debug)] +pub enum BitcoinCoreSv2JDPError { + /// Cap'n Proto RPC error. + CapnpError(capnp::Error), + /// Failed to create a dedicated thread IPC client, capturing the underlying context. + FailedToCreateThreadIpcClient(String), + /// Failed to connect to the Bitcoin Core Unix socket. + CannotConnectToUnixSocket(PathBuf, String), + /// Failed to deserialize a block from the IPC response. + FailedToDeserializeBlock(consensus::encode::Error), + /// Bitcoin Core reported no chain tip (or a malformed one) via `getTip`. + NoChainTip, + /// Readiness signal receiver was dropped before bootstrap completed. + ReadinessSignalFailed, +} + +impl BitcoinCoreSv2JDPError { + /// Returns true when the error indicates transient IPC contention in Bitcoin Core. + pub fn is_thread_busy(&self) -> bool { + matches!( + self, + BitcoinCoreSv2JDPError::CapnpError(capnp_error) + if capnp_error.to_string().contains("thread busy") + ) + } +} + +impl From for BitcoinCoreSv2JDPError { + fn from(error: capnp::Error) -> Self { + BitcoinCoreSv2JDPError::CapnpError(error) + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs new file mode 100644 index 000000000..89f7c811d --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/handlers.rs @@ -0,0 +1,659 @@ +//! Handlers for Bitcoin Core v32.x Sv2 Job Declaration Protocol via capnp over UNIX socket. + +use crate::{ + common::job_declaration_protocol::io::{DownstreamId, JdResponse, RequestId}, + unix_capnp::v32x::job_declaration_protocol::{ + BitcoinCoreSv2JDP, error::BitcoinCoreSv2JDPError, + }, +}; +use bitcoin_capnp_types::mining_capnp::{ + block_template::Client as BlockTemplateIpcClient, + tx_collection::Client as TxCollectionIpcClient, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use stratum_core::{ + bitcoin::{ + BlockHash, Transaction, TxMerkleNode, Wtxid, + block::{Header, Version}, + consensus::{deserialize, serialize}, + hashes::Hash, + }, + job_declaration_sv2::{ + ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + }, +}; +use tokio::sync::oneshot; +use tracing::{debug, error, info, warn}; + +const MAX_SUBMIT_SOLUTION_ATTEMPTS: usize = 3; +const SUBMIT_SOLUTION_RETRY_BACKOFF_MS: u64 = 15; + +/// `reason` returned by `TxCollection::makeTemplate` when the collection is still incomplete. +const MAKE_TEMPLATE_REASON_MISSING_TXS: &str = "missing-txs"; + +impl BitcoinCoreSv2JDP { + /// Validates a declared mining job via Bitcoin Core's `TxCollection` interface. + /// + /// The declared wtxids are collected with `collectTxs`, completed with `addMissingTxs` + /// (transactions from `ProvideMissingTransactions.Success`), and checked with + /// `unknownTxPos`. Once complete, `makeTemplate` reconstructs the block inside Bitcoin + /// Core and validates it, so no local mempool mirror is needed. Returns success with the + /// template parameters or an error if validation fails. + /// + /// The declared coinbase (with a zeroed extranonce, which no contextual check depends + /// on) is passed to `makeTemplate`, so it is fully validated at declaration time (BIP34 + /// height, output value, sigops, weight, witness commitment). + /// + /// On success the validated template is retained under `(downstream_id, request_id)` + /// so a later [`JdRequest::PushSolution`] can submit a solution against it. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn handle_declare_mining_job( + &self, + downstream_id: DownstreamId, + request_id: RequestId, + version: Version, + coinbase_tx: Transaction, + wtxid_list: Vec, + 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 response = match self + .validate_declare_mining_job( + downstream_id, + request_id, + &coinbase_tx, + &wtxid_list, + &missing_txs, + ) + .await + { + Ok(response) => response, + Err(e) => { + error!("DeclareMiningJob validation failed with IPC error: {e:?}"); + // deliberately ignore potential send errors + // we don't care if the receiver dropped the channel + let _ = response_tx.send(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, + prev_hash: None, + }); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + // deliberately ignore potential send errors + // we don't care if the receiver dropped the channel + let _ = response_tx.send(response); + } + + /// Runs the `TxCollection` validation flow and maps the outcome to a [`JdResponse`]. + /// + /// Returns `Err` only for IPC-level failures; validation failures are expressed as + /// [`JdResponse::Error`] or [`JdResponse::MissingTransactions`]. + async fn validate_declare_mining_job( + &self, + downstream_id: DownstreamId, + request_id: RequestId, + coinbase_tx: &Transaction, + wtxid_list: &[Wtxid], + missing_txs: &[Transaction], + ) -> Result { + let (tip_hash, tip_height) = self.get_tip().await?; + + // Remember client-provided transactions before completing this request's collection + // with them, so other downstreams (and retries) declaring the same transactions can + // be served from the cache instead of another ProvideMissingTransactions round. + if !missing_txs.is_empty() { + let mut provided_txs = self.provided_txs.borrow_mut(); + for tx in missing_txs { + provided_txs.insert(tx.clone()); + } + } + + let collection = self.collect_txs(wtxid_list).await?; + + // Complete the collection with transactions from ProvideMissingTransactions.Success + if !missing_txs.is_empty() { + self.add_missing_txs(&collection, missing_txs).await?; + } + + // Ask Bitcoin Core which declared transactions it still doesn't know about, and try + // to supply them from the provided-transaction cache before asking the client. + let missing_positions = self.unknown_tx_pos(&collection).await?; + if !missing_positions.is_empty() { + let missing_wtxids = Self::wtxids_at_positions(wtxid_list, &missing_positions); + let (cached_txs, missing_wtxids) = { + let mut provided_txs = self.provided_txs.borrow_mut(); + let mut cached_txs = Vec::new(); + let mut still_missing = Vec::new(); + for wtxid in missing_wtxids { + match provided_txs.get(&wtxid) { + Some(tx) => cached_txs.push(tx), + None => still_missing.push(wtxid), + } + } + (cached_txs, still_missing) + }; + + if !cached_txs.is_empty() { + debug!( + count = cached_txs.len(), + "Supplying missing transactions from the provided-transaction cache" + ); + self.add_missing_txs(&collection, &cached_txs).await?; + } + + if !missing_wtxids.is_empty() { + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::MissingTransactions { + missing_wtxids, + prev_hash: tip_hash, + }); + } + } + + // A BIP34 height mismatch would also be caught by makeTemplate (bad-cb-height), but + // checking it here lets us classify the error as a stale chain tip instead of a + // generically invalid job. + let declared_bip34_height = coinbase_tx + .input + .first() + .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 next_height = (tip_height + 1) as u32; + if declared_bip34_height != next_height { + debug!( + ?tip_hash, + tip_height, + declared_bip34_height, + "Declared BIP34 height does not match the next block height; classifying error as stale-chain-tip" + ); + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::Error { + error_code: ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + prev_hash: Some(tip_hash), + }); + } + + // Reconstruct and validate the block, including the declared coinbase, inside + // Bitcoin Core + let (reason, debug_msg, template) = self + .make_template(&collection, tip_hash, coinbase_tx) + .await?; + + let Some(template) = template else { + // Transactions can disappear from the mempool between unknownTxPos and + // makeTemplate (e.g. eviction, replacement, new block). Give the client a chance + // to provide them instead of failing the declaration outright. + if reason == MAKE_TEMPLATE_REASON_MISSING_TXS { + let missing_positions = self.unknown_tx_pos(&collection).await?; + if !missing_positions.is_empty() { + let missing_wtxids = Self::wtxids_at_positions(wtxid_list, &missing_positions); + self.destroy_tx_collection(&collection).await; + return Ok(JdResponse::MissingTransactions { + missing_wtxids, + prev_hash: tip_hash, + }); + } + } + + self.destroy_tx_collection(&collection).await; + + error!( + reason, + debug = debug_msg, + "Bitcoin Core rejected the declared job via TxCollection::makeTemplate" + ); + + // The declared BIP34 height matched the tip at arrival, so a makeTemplate + // failure is either a stale-tip race (tip moved while we were validating) or a + // genuinely invalid job. + let (latest_tip_hash, latest_tip_height) = self.get_tip().await?; + let error_code = if latest_tip_hash != tip_hash { + debug!( + ?tip_hash, + tip_height, + ?latest_tip_hash, + latest_tip_height, + "Detected stale chain tip during DeclareMiningJob validation; classifying error as stale-chain-tip" + ); + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP + } else { + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB + }; + + return Ok(JdResponse::Error { + error_code, + prev_hash: Some(latest_tip_hash), + }); + }; + + // The validated template provides the parameters (header) and the coinbase merkle + // branch that jd-server needs for SetCustomMiningJob validation. The block itself + // stays inside Bitcoin Core: the template is retained so a later PushSolution can + // be submitted against it via submitSolution. + let header = self.get_template_header(&template).await?; + let merkle_path = self.get_coinbase_merkle_path(&template).await?; + + self.destroy_tx_collection(&collection).await; + + debug!( + prev_hash = ?header.prev_blockhash, + nbits = ?header.bits, + min_ntime = header.time, + merkle_path_len = merkle_path.len(), + "TxCollection::makeTemplate validated the declared job; retaining template" + ); + + let replaced_template = self + .declared_templates + .borrow_mut() + .insert((downstream_id, request_id), template); + if let Some(replaced_template) = replaced_template { + // A re-declared request id supersedes the previous declaration. + self.destroy_template(&replaced_template).await; + } + + Ok(JdResponse::Success { + prev_hash: header.prev_blockhash, + nbits: header.bits, + min_ntime: header.time, + merkle_path, + }) + } + + /// Discards the retained template of a declared job, if any. + pub(crate) async fn release_declared_job( + &self, + downstream_id: DownstreamId, + request_id: RequestId, + ) { + let template = self + .declared_templates + .borrow_mut() + .remove(&(downstream_id, request_id)); + if let Some(template) = template { + debug!(downstream_id, request_id, "Releasing retained template"); + self.destroy_template(&template).await; + } + } + + /// Discards all retained templates of a disconnected downstream. + pub(crate) async fn cleanup_downstream(&self, downstream_id: DownstreamId) { + let templates: Vec = { + let mut declared_templates = self.declared_templates.borrow_mut(); + let keys: Vec<(DownstreamId, RequestId)> = declared_templates + .keys() + .filter(|(id, _)| *id == downstream_id) + .copied() + .collect(); + keys.iter() + .filter_map(|key| declared_templates.remove(key)) + .collect() + }; + if !templates.is_empty() { + debug!( + downstream_id, + count = templates.len(), + "Releasing retained templates for disconnected downstream" + ); + } + for template in &templates { + self.destroy_template(template).await; + } + } + + /// Maps `unknownTxPos` positions back to the declared wtxids. + fn wtxids_at_positions(wtxid_list: &[Wtxid], positions: &[u32]) -> Vec { + positions + .iter() + .filter_map(|&pos| wtxid_list.get(pos as usize).copied()) + .collect() + } + + /// Calls `Mining::collectTxs` with the declared wtxids. + async fn collect_txs( + &self, + wtxid_list: &[Wtxid], + ) -> Result { + let mut collect_txs_request = self.mining_ipc_client.collect_txs_request(); + collect_txs_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + { + let mut wtxids = collect_txs_request + .get() + .init_wtxids(wtxid_list.len() as u32); + for (pos, wtxid) in wtxid_list.iter().enumerate() { + wtxids.set(pos as u32, wtxid.as_byte_array()); + } + } + let collect_txs_response = collect_txs_request.send().promise.await?; + Ok(collect_txs_response.get()?.get_result()?) + } + + /// Calls `TxCollection::addMissingTxs` with client-provided transactions. + /// + /// The caller must only pass transactions whose wtxid is part of the collection + /// (see the [`JdRequest::DeclareMiningJob`] invariants); Bitcoin Core rejects the + /// whole call otherwise. + async fn add_missing_txs( + &self, + collection: &TxCollectionIpcClient, + missing_txs: &[Transaction], + ) -> Result<(), BitcoinCoreSv2JDPError> { + let mut add_missing_txs_request = collection.add_missing_txs_request(); + add_missing_txs_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + { + let mut txs = add_missing_txs_request + .get() + .init_txs(missing_txs.len() as u32); + for (pos, tx) in missing_txs.iter().enumerate() { + txs.set(pos as u32, &serialize(tx)); + } + } + add_missing_txs_request.send().promise.await?; + Ok(()) + } + + /// Calls `TxCollection::unknownTxPos`, returning the positions of transactions Bitcoin + /// Core does not know about. + async fn unknown_tx_pos( + &self, + collection: &TxCollectionIpcClient, + ) -> Result, BitcoinCoreSv2JDPError> { + let mut unknown_tx_pos_request = collection.unknown_tx_pos_request(); + unknown_tx_pos_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let unknown_tx_pos_response = unknown_tx_pos_request.send().promise.await?; + let positions = unknown_tx_pos_response.get()?.get_result()?; + Ok((0..positions.len()).map(|pos| positions.get(pos)).collect()) + } + + /// Calls `TxCollection::makeTemplate` with the declared coinbase, returning the BIP-22 + /// style `reason`/`debug` strings and, on success, the validated + /// [`BlockTemplateIpcClient`]. + async fn make_template( + &self, + collection: &TxCollectionIpcClient, + prev_hash: BlockHash, + coinbase_tx: &Transaction, + ) -> Result<(String, String, Option), BitcoinCoreSv2JDPError> { + let mut make_template_request = collection.make_template_request(); + make_template_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + make_template_request + .get() + .set_prevhash(prev_hash.as_byte_array()); + make_template_request + .get() + .set_coinbase(&serialize(coinbase_tx)); + let make_template_response = make_template_request.send().promise.await?; + let make_template_result = make_template_response.get()?; + let reason = make_template_result + .get_reason()? + .to_string() + .map_err(bitcoin_capnp_types::capnp::Error::from)?; + let debug_msg = make_template_result + .get_debug()? + .to_string() + .map_err(bitcoin_capnp_types::capnp::Error::from)?; + let template = if make_template_result.has_result() { + Some(make_template_result.get_result()?) + } else { + None + }; + Ok((reason, debug_msg, template)) + } + + /// Fetches the header of a validated template via `BlockTemplate::getBlockHeader`. + async fn get_template_header( + &self, + template: &BlockTemplateIpcClient, + ) -> Result { + let mut get_block_header_request = template.get_block_header_request(); + get_block_header_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let get_block_header_response = get_block_header_request.send().promise.await?; + let header_bytes = get_block_header_response.get()?.get_result()?; + deserialize(header_bytes).map_err(BitcoinCoreSv2JDPError::FailedToDeserializeBlock) + } + + /// Fetches the coinbase merkle branch of a validated template via + /// `BlockTemplate::getCoinbaseMerklePath`. + /// + /// The branch does not depend on the template's (dummy) coinbase, so it also applies to + /// the declared coinbase. + async fn get_coinbase_merkle_path( + &self, + template: &BlockTemplateIpcClient, + ) -> Result, BitcoinCoreSv2JDPError> { + let mut get_coinbase_merkle_path_request = template.get_coinbase_merkle_path_request(); + get_coinbase_merkle_path_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let get_coinbase_merkle_path_response = + get_coinbase_merkle_path_request.send().promise.await?; + let get_coinbase_merkle_path_result = get_coinbase_merkle_path_response.get()?; + let path = get_coinbase_merkle_path_result.get_result()?; + let mut merkle_path = Vec::with_capacity(path.len() as usize); + for pos in 0..path.len() { + let node_bytes: [u8; 32] = path.get(pos)?.try_into().map_err(|_| { + bitcoin_capnp_types::capnp::Error::failed( + "coinbase merkle path node is not 32 bytes".to_string(), + ) + })?; + merkle_path.push(TxMerkleNode::from_byte_array(node_bytes)); + } + Ok(merkle_path) + } + + /// Best-effort `TxCollection::destroy` so Bitcoin Core can free the collection early. + async fn destroy_tx_collection(&self, collection: &TxCollectionIpcClient) { + let mut destroy_request = collection.destroy_request(); + match destroy_request.get().get_context() { + Ok(mut context) => context.set_thread(self.thread_ipc_client.clone()), + Err(e) => { + debug!("Failed to set TxCollection destroy request thread context: {e}"); + return; + } + } + if let Err(e) = destroy_request.send().promise.await { + debug!("Failed to destroy TxCollection: {e}"); + } + } + + /// Best-effort `BlockTemplate::destroy` so Bitcoin Core can free the template early. + async fn destroy_template(&self, template: &BlockTemplateIpcClient) { + let mut destroy_request = template.destroy_request(); + match destroy_request.get().get_context() { + Ok(mut context) => context.set_thread(self.thread_ipc_client.clone()), + Err(e) => { + debug!("Failed to set BlockTemplate destroy request thread context: {e}"); + return; + } + } + if let Err(e) = destroy_request.send().promise.await { + debug!("Failed to destroy BlockTemplate: {e}"); + } + } + + /// Submits a solution for a previously declared job via the retained template's + /// `submitSolution` method. Bitcoin Core reconstructs the solved block from the + /// template's transactions plus the given header fields and coinbase, so the block + /// never crosses the IPC boundary. + pub(crate) async fn handle_push_solution( + &self, + downstream_id: DownstreamId, + request_id: RequestId, + version: u32, + ntime: u32, + nonce: u32, + coinbase_tx: Transaction, + ) { + let template = self + .declared_templates + .borrow_mut() + .remove(&(downstream_id, request_id)); + let Some(template) = template else { + error!( + downstream_id, + request_id, "No retained template for PushSolution; dropping solution" + ); + return; + }; + + let coinbase_bytes: Vec = serialize(&coinbase_tx); + debug!( + downstream_id, + request_id, + version, + ntime, + nonce, + coinbase_bytes_len = coinbase_bytes.len(), + "Submitting solution via BlockTemplate::submitSolution" + ); + + // a dedicated thread is used to submit solutions to Bitcoin Core + // therefore retries should be extremely rare + for attempt in 1..=MAX_SUBMIT_SOLUTION_ATTEMPTS { + let mut submit_solution_request = template.submit_solution_request(); + + match submit_solution_request.get().get_context() { + Ok(mut context) => context.set_thread(self.submit_block_thread_ipc_client.clone()), + Err(e) => { + error!("Failed to set submitSolution request thread context: {e}"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + } + + submit_solution_request.get().set_version(version); + submit_solution_request.get().set_timestamp(ntime); + submit_solution_request.get().set_nonce(nonce); + submit_solution_request.get().set_coinbase(&coinbase_bytes); + + let submit_solution_response = match submit_solution_request.send().promise.await { + Ok(response) => response, + Err(e) => { + let err: BitcoinCoreSv2JDPError = e.into(); + if err.is_thread_busy() && attempt < MAX_SUBMIT_SOLUTION_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_SUBMIT_SOLUTION_ATTEMPTS, + "Transient IPC contention during submitSolution (thread busy); retrying" + ); + tokio::time::sleep(std::time::Duration::from_millis( + SUBMIT_SOLUTION_RETRY_BACKOFF_MS, + )) + .await; + continue; + } + + error!("Failed to send submitSolution request: {err:?}"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + let submit_solution_result = match submit_solution_response.get() { + Ok(result) => result, + Err(e) => { + let err: BitcoinCoreSv2JDPError = e.into(); + if err.is_thread_busy() && attempt < MAX_SUBMIT_SOLUTION_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_SUBMIT_SOLUTION_ATTEMPTS, + "Transient IPC contention while reading submitSolution response (thread busy); retrying" + ); + tokio::time::sleep(std::time::Duration::from_millis( + SUBMIT_SOLUTION_RETRY_BACKOFF_MS, + )) + .await; + continue; + } + + error!("Failed to get submitSolution result: {err:?}"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.cancellation_token.cancel(); + return; + } + }; + + let accepted = submit_solution_result.get_result(); + + if accepted { + info!( + downstream_id, + request_id, "Bitcoin Core accepted solution via submitSolution" + ); + } else { + warn!( + downstream_id, + request_id, "Bitcoin Core rejected solution via submitSolution" + ); + } + + break; + } + + self.destroy_template(&template).await; + } +} + +/// Decodes BIP34 height from the first push in coinbase scriptSig. +/// Returns None if scriptSig does not start with a canonical small push. +pub(crate) fn decode_bip34_height_from_coinbase_script_sig(script_sig: &[u8]) -> Option { + let first = *script_sig.first()?; + + // Support small-integer opcodes (OP_0, OP_1..OP_16) used by some templates. + if first == 0x00 { + return Some(0); + } + if (0x51..=0x60).contains(&first) { + return Some((first - 0x50) as u32); + } + + // Canonical small push form: first byte is push length (1..=4). + let push_len = first as usize; + if push_len == 0 || push_len > 4 || script_sig.len() < 1 + push_len { + return None; + } + + let mut height_bytes = [0u8; 4]; + height_bytes[..push_len].copy_from_slice(&script_sig[1..1 + push_len]); + Some(u32::from_le_bytes(height_bytes)) +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs new file mode 100644 index 000000000..4c6a9d880 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/mod.rs @@ -0,0 +1,335 @@ +//! Module for interacting with Bitcoin Core v32.x via Sv2 Job Declaration Protocol via capnp over +//! UNIX socket. + +use crate::{ + common::job_declaration_protocol::io::{DownstreamId, JdRequest, RequestId}, + unix_capnp::v32x::job_declaration_protocol::{ + error::BitcoinCoreSv2JDPError, + provided_tx_cache::{DEFAULT_PROVIDED_TX_CACHE_MAX_BYTES, ProvidedTxCache}, + }, +}; +use async_channel::Receiver; +use bitcoin_capnp_types::{ + capnp_rpc::{RpcSystem, rpc_twoparty_capnp, twoparty}, + init_capnp::init::Client as InitIpcClient, + mining_capnp::{ + block_template::Client as BlockTemplateIpcClient, mining::Client as MiningIpcClient, + }, + proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use std::{cell::RefCell, collections::HashMap, path::Path, rc::Rc}; +use stratum_core::bitcoin::{BlockHash, hashes::Hash}; +use tokio::net::UnixStream; +use tokio_util::compat::*; +pub use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +pub mod error; +mod handlers; +mod provided_tx_cache; + +/// How often to poll `isInitialBlockDownload` while waiting for IBD to finish during startup. +const IBD_POLL_INTERVAL_SECS: u64 = 1; + +/// The main abstraction for interacting with Bitcoin Core via Sv2 Job Declaration Protocol. +/// +/// 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 +/// +/// Unlike the v30.x/v31.x backends, this implementation does not keep a local mempool mirror. +/// Incoming [`DeclareMiningJob`] requests are validated with Bitcoin Core's `TxCollection` +/// interface (https://github.com/bitcoin/bitcoin/pull/35671): +/// - `collectTxs` references the declared transactions directly in Bitcoin Core's mempool +/// - `unknownTxPos` reports which declared transactions Bitcoin Core does not know about +/// - `addMissingTxs` completes the collection with transactions provided by the client +/// - `makeTemplate` reconstructs and validates the block, returning a `BlockTemplate` +/// +/// If transactions are missing, a [`MissingTransactions`] response is sent. If validation +/// succeeds, a [`Success`] response with the template parameters is sent and the validated +/// `BlockTemplate` is retained, keyed by `(downstream_id, request_id)`. +/// +/// Client-provided transactions are additionally remembered in a bounded +/// [`ProvidedTxCache`], so later declarations of the same transactions — by any downstream — +/// complete without another `ProvideMissingTransactions` round. +/// +/// Incoming [`PushSolution`] requests submit mining solutions to Bitcoin Core via the +/// retained template's `submitSolution` method; the block itself never leaves the node. +/// [`ReleaseDeclaredJob`] and [`CleanupDownstream`] requests discard retained templates +/// that will no longer be used. +#[derive(Clone)] +pub struct BitcoinCoreSv2JDP { + thread_ipc_client: ThreadIpcClient, + submit_block_thread_ipc_client: ThreadIpcClient, + mining_ipc_client: MiningIpcClient, + cancellation_token: CancellationToken, + /// Validated templates of declared jobs, retained for `PushSolution`. + /// + /// Dropping a client releases the corresponding `BlockTemplate` inside Bitcoin Core, + /// but an explicit `destroy` is preferred for prompt cleanup. + declared_templates: Rc>>, + /// Client-provided transactions remembered across declarations, so other downstreams + /// (or retries) declaring the same transactions skip the `ProvideMissingTransactions` + /// round. + provided_txs: Rc>, + incoming_requests: Receiver, +} + +impl BitcoinCoreSv2JDP { + /// Creates a new [`BitcoinCoreSv2JDP`] instance. + /// + /// Waits for Bitcoin Core to leave IBD and signals readiness before returning. + pub async fn new

( + bitcoin_core_unix_socket_path: P, + incoming_requests: Receiver, + 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 self_ = Self { + thread_ipc_client, + submit_block_thread_ipc_client, + mining_ipc_client, + cancellation_token: cancellation_token.clone(), + declared_templates: Rc::new(RefCell::new(HashMap::new())), + provided_txs: Rc::new(RefCell::new(ProvidedTxCache::new( + DEFAULT_PROVIDED_TX_CACHE_MAX_BYTES, + ))), + incoming_requests, + }; + + // Wait for IBD to finish before signaling readiness, mirroring the behavior of the + // mirror-based backends (whose initial createNewBlock blocks during IBD). + loop { + if self_.is_initial_block_download().await? { + debug!("Bitcoin Core is in IBD; waiting before accepting JDP requests"); + tokio::select! { + _ = cancellation_token.cancelled() => { + return Err(BitcoinCoreSv2JDPError::ReadinessSignalFailed); + } + _ = tokio::time::sleep(std::time::Duration::from_secs(IBD_POLL_INTERVAL_SECS)) => {} + } + } else { + break; + } + } + + info!("IPC JDP client successfully created."); + + // 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_) + } + + /// Returns whether Bitcoin Core is still in Initial Block Download. + async fn is_initial_block_download(&self) -> Result { + let mut ibd_request = self.mining_ipc_client.is_initial_block_download_request(); + ibd_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let ibd_response = ibd_request.send().promise.await?; + Ok(ibd_response.get()?.get_result()) + } + + /// Returns the current chain tip as `(prev_hash, height)`. + pub(crate) async fn get_tip(&self) -> Result<(BlockHash, i32), BitcoinCoreSv2JDPError> { + let mut get_tip_request = self.mining_ipc_client.get_tip_request(); + get_tip_request + .get() + .get_context()? + .set_thread(self.thread_ipc_client.clone()); + let get_tip_response = get_tip_request.send().promise.await?; + let get_tip_result = get_tip_response.get()?; + if !get_tip_result.get_has_result() { + return Err(BitcoinCoreSv2JDPError::NoChainTip); + } + let block_ref = get_tip_result.get_result()?; + let hash_bytes: [u8; 32] = block_ref + .get_hash()? + .try_into() + .map_err(|_| BitcoinCoreSv2JDPError::NoChainTip)?; + Ok(( + BlockHash::from_byte_array(hash_bytes), + block_ref.get_height(), + )) + } + + /// Main event loop - runs in a LocalSet on dedicated thread. + /// + /// Processes incoming job declaration requests until shutdown. + pub async fn run(&self) { + 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; + } + } + } + } + } + warn!("Exiting BitcoinCoreSv2JDP request loop"); + } + + /// 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 { + downstream_id, + request_id, + version, + coinbase_tx, + wtxid_list, + missing_txs, + response_tx, + } => { + self.handle_declare_mining_job( + downstream_id, + request_id, + version, + coinbase_tx, + wtxid_list, + missing_txs, + response_tx, + ) + .await; + } + + // Handle PushSolution requests (no response needed) + JdRequest::PushSolution { + downstream_id, + request_id, + version, + ntime, + nonce, + coinbase_tx, + } => { + self.handle_push_solution( + downstream_id, + request_id, + version, + ntime, + nonce, + coinbase_tx, + ) + .await; + } + + // Discard retained templates that will no longer be used + JdRequest::ReleaseDeclaredJob { + downstream_id, + request_id, + } => { + self.release_declared_job(downstream_id, request_id).await; + } + JdRequest::CleanupDownstream { downstream_id } => { + self.cleanup_downstream(downstream_id).await; + } + } + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs new file mode 100644 index 000000000..e8447519f --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/job_declaration_protocol/provided_tx_cache.rs @@ -0,0 +1,215 @@ +//! Cache of client-provided transactions for the Bitcoin Core v32.x Sv2 Job Declaration +//! Protocol. +//! +//! Transactions supplied through `ProvideMissingTransactions.Success` are typically ones the +//! JDS node will never learn over P2P (prioritized or out-of-band transactions that don't +//! meet its mempool policy). Without a cache, every downstream declaring a template with such +//! a transaction — and every retry — pays its own `ProvideMissingTransactions` round trip. +//! +//! Unlike the mempool mirror of the v30.x/v31.x backends, this cache involves no background +//! synchronization and is never the source of truth: Bitcoin Core's `unknownTxPos` decides +//! what is missing, and the cache is only consulted to supply those transactions without +//! asking the client again. Entries are keyed by wtxid, which commits to the full serialized +//! transaction, so an entry inserted by one downstream cannot misrepresent a transaction +//! requested by another. +//! +//! The cache is bounded by serialized size with least-recently-used eviction. Confirmed +//! transactions stop appearing in declared templates, stop being looked up, and age out on +//! their own. A cache miss is never an error; it merely costs the round trip that would have +//! happened anyway. +//! +//! This cache becomes unnecessary if Bitcoin Core grows a node-side store for +//! `TxCollection`-provided transactions (see the discussion in +//! https://github.com/bitcoin/bitcoin/pull/35671). + +use std::collections::HashMap; +use stratum_core::bitcoin::{Transaction, Wtxid, consensus::serialize}; +use tracing::{debug, warn}; + +/// Default cache budget. Generous compared to the worst case of one full block (~4 MB) of +/// exotic transactions, while remaining far below what the retired mempool mirror held. +pub const DEFAULT_PROVIDED_TX_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; + +struct CacheEntry { + tx: Transaction, + size: usize, + last_used: u64, +} + +/// Bounded, least-recently-used cache of client-provided transactions keyed by wtxid. +pub struct ProvidedTxCache { + max_bytes: usize, + total_bytes: usize, + tick: u64, + entries: HashMap, +} + +impl ProvidedTxCache { + /// Creates an empty cache holding at most `max_bytes` of serialized transactions. + pub fn new(max_bytes: usize) -> Self { + Self { + max_bytes, + total_bytes: 0, + tick: 0, + entries: HashMap::new(), + } + } + + fn next_tick(&mut self) -> u64 { + self.tick += 1; + self.tick + } + + /// Inserts a transaction, evicting least-recently-used entries if the budget is + /// exceeded. A transaction larger than the whole budget is ignored. + pub fn insert(&mut self, tx: Transaction) { + let wtxid = tx.compute_wtxid(); + let tick = self.next_tick(); + if let Some(entry) = self.entries.get_mut(&wtxid) { + entry.last_used = tick; + return; + } + + let size = serialize(&tx).len(); + if size > self.max_bytes { + warn!( + %wtxid, + size, + max_bytes = self.max_bytes, + "Ignoring provided transaction larger than the whole cache budget" + ); + return; + } + + self.total_bytes += size; + self.entries.insert( + wtxid, + CacheEntry { + tx, + size, + last_used: tick, + }, + ); + self.evict_to_fit(); + } + + /// Returns a copy of the transaction with the given wtxid, marking it recently used. + pub fn get(&mut self, wtxid: &Wtxid) -> Option { + let tick = self.next_tick(); + let entry = self.entries.get_mut(wtxid)?; + entry.last_used = tick; + Some(entry.tx.clone()) + } + + fn evict_to_fit(&mut self) { + while self.total_bytes > self.max_bytes { + let Some(oldest_wtxid) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(wtxid, _)| *wtxid) + else { + return; + }; + if let Some(entry) = self.entries.remove(&oldest_wtxid) { + self.total_bytes -= entry.size; + debug!(wtxid = %oldest_wtxid, size = entry.size, "Evicted provided transaction from cache"); + } + } + } + + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + fn contains(&self, wtxid: &Wtxid) -> bool { + self.entries.contains_key(wtxid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stratum_core::bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Witness, absolute::LockTime, + transaction::Version, + }; + + /// Builds a distinct dummy transaction; `script_len` pads the output script to control + /// the serialized size. + fn dummy_tx(marker: u32, script_len: usize) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(marker), + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(0), + script_pubkey: ScriptBuf::from_bytes(vec![0x6a; script_len]), + }], + } + } + + #[test] + fn insert_and_get_round_trip() { + let mut cache = ProvidedTxCache::new(1024); + let tx = dummy_tx(1, 10); + let wtxid = tx.compute_wtxid(); + + assert!(cache.get(&wtxid).is_none()); + cache.insert(tx.clone()); + assert_eq!(cache.get(&wtxid), Some(tx)); + } + + #[test] + fn duplicate_insert_does_not_grow_cache() { + let mut cache = ProvidedTxCache::new(1024); + let tx = dummy_tx(1, 10); + cache.insert(tx.clone()); + let bytes_after_first = cache.total_bytes; + cache.insert(tx); + assert_eq!(cache.len(), 1); + assert_eq!(cache.total_bytes, bytes_after_first); + } + + #[test] + fn evicts_least_recently_used_first() { + let tx_size = serialize(&dummy_tx(0, 100)).len(); + // Room for exactly two transactions. + let mut cache = ProvidedTxCache::new(2 * tx_size); + + let tx_a = dummy_tx(1, 100); + let tx_b = dummy_tx(2, 100); + let tx_c = dummy_tx(3, 100); + let wtxid_a = tx_a.compute_wtxid(); + let wtxid_b = tx_b.compute_wtxid(); + let wtxid_c = tx_c.compute_wtxid(); + + cache.insert(tx_a); + cache.insert(tx_b); + // Touch A so B becomes the least recently used entry. + assert!(cache.get(&wtxid_a).is_some()); + cache.insert(tx_c); + + assert!(cache.contains(&wtxid_a)); + assert!(!cache.contains(&wtxid_b)); + assert!(cache.contains(&wtxid_c)); + assert!(cache.total_bytes <= 2 * tx_size); + } + + #[test] + fn ignores_transaction_larger_than_budget() { + let mut cache = ProvidedTxCache::new(64); + let tx = dummy_tx(1, 1000); + let wtxid = tx.compute_wtxid(); + cache.insert(tx); + assert!(!cache.contains(&wtxid)); + assert_eq!(cache.total_bytes, 0); + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs new file mode 100644 index 000000000..a0c0309c2 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/mod.rs @@ -0,0 +1,10 @@ +//! Bitcoin Core v32.x IPC implementation modules. +//! +//! This namespace contains the concrete v32.x runtime implementations used when +//! [`crate::common::BitcoinCoreVersion::V32X`] is selected. +//! +//! It is wired against `bitcoin_capnp_types_v32`, which re-exports the matching `capnp` +//! and `capnp-rpc` APIs. + +pub mod job_declaration_protocol; +pub mod template_distribution_protocol; diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs new file mode 100644 index 000000000..b101784d5 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/error.rs @@ -0,0 +1,130 @@ +//! Error types for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over UNIX +//! socket. + +use std::path::Path; +use stratum_core::bitcoin::{ + block::ValidationError, consensus, consensus::encode::Error as ConsensusEncodeError, +}; + +use bitcoin_capnp_types_v32::capnp; + +/// Error type for [`crate::BitcoinCoreSv2TDP`] +#[derive(Debug)] +pub enum BitcoinCoreSv2TDPError { + CapnpError(capnp::Error), + CannotConnectToUnixSocket(Box, String), + InvalidTemplateHeader(consensus::encode::Error), + InvalidTemplateHeaderLength, + FailedToSerializeCoinbasePrefix, + FailedToSerializeCoinbaseOutputs, + TemplateNotFound, + TemplateIpcClientNotFound, + FailedToSendNewTemplateMessage, + FailedToSendSetNewPrevHashMessage, + FailedToFetchTemplateTxData, + FailedToSendRequestTransactionDataResponseMessage, + FailedToRecvTemplateDistributionMessage, + FailedToSendTemplateDistributionMessage, + FailedToSubmitSolution, + FailedToSetThread, + FailedToGetWaitNextRequestOptions, + CreateNewBlockRequestInterrupted, + FailedToSendInterruptCreateNewBlockRequest, + FailedToSendInterruptWaitRequest, + FailedToWaitForMonitorIpcTemplatesTask, + FailedToCreateSolutionDir, + InvalidBlockRewardRemaining(i64), +} + +impl From for BitcoinCoreSv2TDPError { + fn from(error: capnp::Error) -> Self { + BitcoinCoreSv2TDPError::CapnpError(error) + } +} + +impl From for BitcoinCoreSv2TDPError { + fn from(error: consensus::encode::Error) -> Self { + BitcoinCoreSv2TDPError::InvalidTemplateHeader(error) + } +} + +#[derive(Debug)] +pub enum TemplateDataError { + InvalidCoinbaseTx(ConsensusEncodeError), + InvalidSolution, + InvalidSolutionPoW(ValidationError), + InvalidMerkleRoot, + InvalidBlockVersion, + InvalidCoinbaseTxVersion, + InvalidCoinbaseScriptSig, + FailedToSumCoinbaseOutputs, + CapnpError(capnp::Error), + FailedIpcSubmitSolution, + FailedToSerializeEmptyCoinbaseOutputs, + FailedToSerializeCoinbaseOutputs, + FailedToConvertMerklePathHashToU256, + FailedToCreateMerklePathSeq, + BitcoinCoreSv2TDPError(BitcoinCoreSv2TDPError), +} + +impl From for TemplateDataError { + fn from(error: BitcoinCoreSv2TDPError) -> Self { + TemplateDataError::BitcoinCoreSv2TDPError(error) + } +} + +impl From for TemplateDataError { + fn from(error: ConsensusEncodeError) -> Self { + TemplateDataError::InvalidCoinbaseTx(error) + } +} + +impl From for TemplateDataError { + fn from(error: capnp::Error) -> Self { + TemplateDataError::CapnpError(error) + } +} + +impl std::fmt::Display for TemplateDataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TemplateDataError::InvalidCoinbaseTx(e) => { + write!(f, "Invalid coinbase transaction: {e}") + } + TemplateDataError::InvalidSolution => write!(f, "Invalid solution"), + TemplateDataError::InvalidSolutionPoW(e) => write!(f, "Invalid solution: {e}"), + TemplateDataError::InvalidMerkleRoot => write!(f, "Invalid merkle root"), + TemplateDataError::InvalidBlockVersion => write!(f, "Invalid block version"), + TemplateDataError::InvalidCoinbaseTxVersion => { + write!(f, "Invalid coinbase transaction version") + } + TemplateDataError::InvalidCoinbaseScriptSig => { + write!(f, "Invalid coinbase script signature") + } + TemplateDataError::FailedToSerializeEmptyCoinbaseOutputs => { + write!(f, "Failed to serialize empty coinbase outputs") + } + TemplateDataError::FailedToSerializeCoinbaseOutputs => { + write!(f, "Failed to serialize coinbase outputs") + } + TemplateDataError::FailedToSumCoinbaseOutputs => { + write!(f, "Failed to sum coinbase outputs") + } + TemplateDataError::CapnpError(e) => write!(f, "Cap'n Proto error: {e}"), + TemplateDataError::FailedIpcSubmitSolution => { + write!(f, "Failed to submit solution via IPC") + } + TemplateDataError::FailedToConvertMerklePathHashToU256 => { + write!(f, "Failed to convert merkle path hash to U256") + } + TemplateDataError::FailedToCreateMerklePathSeq => { + write!(f, "Failed to create merkle path sequence") + } + TemplateDataError::BitcoinCoreSv2TDPError(error) => { + write!(f, "Bitcoin Core Sv2 error: {error:?}") + } + } + } +} + +impl std::error::Error for TemplateDataError {} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs new file mode 100644 index 000000000..3ddda4863 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/handlers.rs @@ -0,0 +1,234 @@ +//! Handlers for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::{ + BitcoinCoreSv2TDP, error::BitcoinCoreSv2TDPError, +}; +use stratum_core::{ + parsers_sv2::TemplateDistribution, + template_distribution_sv2::{ + CoinbaseOutputConstraints, ERROR_CODE_REQUEST_TRANSACTION_DATA_STALE_TEMPLATE_ID, + ERROR_CODE_REQUEST_TRANSACTION_DATA_TEMPLATE_ID_NOT_FOUND, RequestTransactionData, + RequestTransactionDataError, SubmitSolution, + }, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error}; + +impl BitcoinCoreSv2TDP { + pub(crate) async fn handle_coinbase_output_constraints( + &mut self, + coinbase_output_constraints: CoinbaseOutputConstraints, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!("handle_coinbase_output_constraints() called"); + + // Break the loop in monitor_ipc_templates() and spawn a new one after bootstrapping the + // new template IPC client. We no longer care about templates created under previous + // constraints for future template monitoring. + debug!("Cancelling template_ipc_client_cancellation_token"); + self.template_ipc_client_cancellation_token.cancel(); + + // Wait for the old monitor_ipc_templates task to finish before bootstrapping a new + // template IPC client. + // + // This keeps template monitoring scoped to one coinbase-output constraint set at a time: + // the old monitor interrupts its own in-flight waitNext request and exits before the + // replacement client is published and monitored. + debug!("Waiting for current monitor_ipc_templates() task to finish"); + let handle = self.monitor_ipc_templates_handle.borrow_mut().take(); + #[allow(clippy::collapsible_if)] + if let Some(handle) = handle { + if let Err(e) = handle.await { + error!("monitor_ipc_templates task panicked: {:?}", e); + return Err(BitcoinCoreSv2TDPError::FailedToWaitForMonitorIpcTemplatesTask); + } + } + + self.template_ipc_client_cancellation_token = CancellationToken::new(); + debug!("Created new template_ipc_client_cancellation_token"); + + debug!("Bootstrapping new template IPC client with new constraints"); + self.bootstrap_template_ipc_client_from_coinbase_output_constraints( + coinbase_output_constraints, + ) + .await + .map_err(|e| { + error!("Failed to bootstrap new template IPC client: {:?}", e); + e + })?; + + debug!("Spawning new monitor_ipc_templates() task"); + self.monitor_ipc_templates(); + + Ok(()) + } + + pub(crate) async fn handle_request_transaction_data( + &self, + request_transaction_data: RequestTransactionData, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!( + "handle_request_transaction_data() called for template_id: {}", + request_transaction_data.template_id + ); + + let is_stale = { + let stale_template_ids_guard = self.stale_template_ids.read().map_err(|e| { + error!("Failed to acquire read lock on stale_template_ids: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage + })?; + stale_template_ids_guard.contains(&request_transaction_data.template_id) + }; + if is_stale { + debug!( + "Template {} is stale, sending error response", + request_transaction_data.template_id + ); + let request_transaction_data_error = RequestTransactionDataError { + template_id: request_transaction_data.template_id, + error_code: ERROR_CODE_REQUEST_TRANSACTION_DATA_STALE_TEMPLATE_ID + .to_string() + .try_into() + .expect("error code must be valid string"), + }; + + if let Err(e) = self + .outgoing_messages + .send(TemplateDistribution::RequestTransactionDataError( + request_transaction_data_error.clone(), + )) + .await + { + error!( + "Failed to send RequestTransactionDataError message: {:?}", + e + ); + return Err( + BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage, + ); + } + + return Ok(()); + } + + let template_data = { + let template_data_guard = self.template_data.read().map_err(|e| { + error!("Failed to acquire read lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage + })?; + + // clone so we can drop the read lock and avoid holding it across the await + template_data_guard + .get(&request_transaction_data.template_id) + .cloned() + }; + + let response_message = { + match template_data { + Some(template_data) => { + debug!( + "Template {} found, sending success response", + request_transaction_data.template_id + ); + + let request_transaction_data_success = match template_data + .get_request_transaction_data_success_message(self.thread_map.clone()) + .await + { + Ok(request_transaction_data_success) => request_transaction_data_success, + Err(e) => { + error!("Failed to fetch template tx data: {:?}", e); + return Err(BitcoinCoreSv2TDPError::FailedToFetchTemplateTxData); + } + }; + TemplateDistribution::RequestTransactionDataSuccess( + request_transaction_data_success, + ) + } + None => { + debug!( + "Template {} not found, sending error response", + request_transaction_data.template_id + ); + TemplateDistribution::RequestTransactionDataError(RequestTransactionDataError { + template_id: request_transaction_data.template_id, + error_code: ERROR_CODE_REQUEST_TRANSACTION_DATA_TEMPLATE_ID_NOT_FOUND + .to_string() + .try_into() + .expect("error code must be valid string"), + }) + } + } + }; + + if let Err(e) = self.outgoing_messages.send(response_message.clone()).await { + error!("Failed to send message: {:?}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSendRequestTransactionDataResponseMessage); + } + + Ok(()) + } + + pub(crate) async fn handle_submit_solution( + &self, + submit_solution: SubmitSolution<'static>, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!( + "handle_submit_solution() called for template_id: {}", + submit_solution.template_id + ); + let template_data = { + let template_data_guard = self.template_data.read().map_err(|e| { + error!("Failed to acquire read lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::TemplateNotFound + })?; + + let Some(template_data) = template_data_guard.get(&submit_solution.template_id) else { + error!( + "Template data not found for template id: {}", + submit_solution.template_id + ); + debug!( + "Available template IDs: {:?}", + template_data_guard.keys().collect::>() + ); + return Err(BitcoinCoreSv2TDPError::TemplateNotFound); + }; + template_data.clone() + }; + debug!("Found template data for solution submission"); + + let solution_block_dir = self + .unix_socket_path + .parent() + .expect("unix_socket_path must have a parent"); + + let solutions_dir = solution_block_dir.join("solutions"); + + if !solutions_dir.exists() { + std::fs::create_dir_all(&solutions_dir).map_err(|e| { + error!("Failed to create solutions directory: {:?}", e); + BitcoinCoreSv2TDPError::FailedToCreateSolutionDir + })?; + } + + debug!("Submitting solution to Bitcoin Core"); + match template_data + .submit_solution( + submit_solution, + self.thread_ipc_client.clone(), + self.thread_map.clone(), + &solutions_dir, + ) + .await + { + Ok(_) => { + debug!("Solution submitted successfully"); + Ok(()) + } + Err(e) => { + error!("Failed to submit solution: {:?}", e); + Err(BitcoinCoreSv2TDPError::FailedToSubmitSolution) + } + } + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs new file mode 100644 index 000000000..69c52467a --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/mod.rs @@ -0,0 +1,819 @@ +//! Module for interacting with Bitcoin Core v32.x via Sv2 Template Distribution Protocol via +//! capnp over UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::template_data::TemplateData; +use async_channel::{Receiver, Sender}; +use bitcoin_capnp_types::{ + capnp, + capnp_rpc::{RpcSystem, rpc_twoparty_capnp, twoparty}, + init_capnp::init::Client as InitIpcClient, + mining_capnp::{ + block_template::{ + Client as BlockTemplateIpcClient, wait_next_params::Owned as WaitNextParams, + wait_next_results::Owned as WaitNextResults, + }, + coinbase_tx, + mining::Client as MiningIpcClient, + }, + proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use capnp::capability::Request; +use error::BitcoinCoreSv2TDPError; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + rc::Rc, + sync::atomic::{AtomicU64, Ordering}, + time::Instant, +}; +use stratum_core::{ + binary_sv2::U256, + bitcoin::{ + OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, + absolute::LockTime, + block::Header, + consensus::{Decodable, deserialize}, + transaction::Version as TransactionVersion, + }, + parsers_sv2::TemplateDistribution, + template_distribution_sv2::CoinbaseOutputConstraints, +}; + +use std::sync::RwLock; +use tokio::{net::UnixStream, task::JoinHandle}; +use tokio_util::compat::*; +pub use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +pub mod error; +mod handlers; +mod monitors; +mod template_data; + +const WEIGHT_FACTOR: u32 = 4; +const MIN_BLOCK_RESERVED_WEIGHT: u64 = 2000; + +/// The main abstraction for interacting with Bitcoin Core via Sv2 Template Distribution Protocol. +/// +/// It is instantiated with: +/// - A `&`[`std::path::Path`] to the Bitcoin Core UNIX socket +/// - A `u64` for the fee delta threshold in satoshis +/// - A `u8` for the minimum interval in seconds between template updates +/// - A [`async_channel::Receiver`] for incoming [`TemplateDistribution`] messages (handles +/// [`CoinbaseOutputConstraints`], [`RequestTransactionData`], and [`SubmitSolution`]) +/// - A [`async_channel::Sender`] for outgoing [`TemplateDistribution`] messages +/// - A [`tokio_util::sync::CancellationToken`] to stop the internally spawned tasks +/// +/// The instance waits for the first [`CoinbaseOutputConstraints`] message to be received via the +/// incoming channel before initializing the template IPC client. Upon receiving this message and +/// successfully initializing, the [`BitcoinCoreSv2TDP`] instance sends a `NewTemplate` followed by +/// a corresponding `SetNewPrevHash` message over the outgoing channel. +/// +/// As configured via `fee_threshold`, the [`BitcoinCoreSv2TDP`] instance will monitor the mempool +/// for changes and send a `NewTemplate` message if the fee delta is greater than the configured +/// threshold. +/// +/// When there's a new Chain Tip, the [`BitcoinCoreSv2TDP`] instance will send a `NewTemplate` +/// followed by a corresponding `SetNewPrevHash` message over the outgoing channel. +/// +/// Incoming [`RequestTransactionData`] messages are used to request transactions relative to a +/// specific template, for which a corresponding `RequestTransactionDataSuccess` or +/// `RequestTransactionDataError` message is sent over the outgoing channel. +/// +/// Incoming [`SubmitSolution`] messages are used to submit solutions to a specific template. +#[derive(Clone)] +pub struct BitcoinCoreSv2TDP { + fee_threshold: u64, + min_interval: u8, + thread_map: ThreadMapIpcClient, + thread_ipc_client: ThreadIpcClient, + mining_ipc_client: MiningIpcClient, + monitor_ipc_templates_handle: Rc>>>, + current_template_ipc_client: Rc>>, + current_prev_hash: Rc>>>, + template_data: Rc>>, + stale_template_ids: Rc>>, + template_id_factory: Rc, + incoming_messages: Receiver>, + outgoing_messages: Sender>, + global_cancellation_token: CancellationToken, + template_ipc_client_cancellation_token: CancellationToken, + last_sent_template_instant: Option, + unix_socket_path: PathBuf, +} + +impl BitcoinCoreSv2TDP { + /// Creates a new [`BitcoinCoreSv2TDP`] instance. + #[allow(clippy::too_many_arguments)] + pub async fn new

( + bitcoin_core_unix_socket_path: P, + fee_threshold: u64, + min_interval: u8, + incoming_messages: Receiver>, + outgoing_messages: Sender>, + global_cancellation_token: CancellationToken, + ) -> Result + where + P: AsRef, + { + let bitcoin_core_unix_socket_path = bitcoin_core_unix_socket_path.as_ref(); + info!( + "Creating new BitcoinCoreSv2TDP via IPC over UNIX socket: {}", + bitcoin_core_unix_socket_path.display() + ); + + let stream = UnixStream::connect(bitcoin_core_unix_socket_path) + .await + .map_err(|e| { + BitcoinCoreSv2TDPError::CannotConnectToUnixSocket( + bitcoin_core_unix_socket_path.into(), + e.to_string(), + ) + })?; + let (reader, writer) = stream.into_split(); + let reader_compat = reader.compat(); + let writer_compat = writer.compat_write(); + + let rpc_network = Box::new(twoparty::VatNetwork::new( + reader_compat, + writer_compat, + rpc_twoparty_capnp::Side::Client, + Default::default(), + )); + + let mut rpc_system = RpcSystem::new(rpc_network, None); + let bootstrap_client: InitIpcClient = + rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server); + + tokio::task::spawn_local(rpc_system); + + let construct_response = bootstrap_client.construct_request().send().promise.await?; + + let thread_map: ThreadMapIpcClient = construct_response.get()?.get_thread_map()?; + let thread_request = thread_map.make_thread_request(); + let thread_response = thread_request.send().promise.await?; + + let thread_ipc_client: ThreadIpcClient = thread_response.get()?.get_result()?; + + info!("IPC execution thread client successfully created."); + + let mut mining_client_request = bootstrap_client.make_mining_request(); + mining_client_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + let mining_client_response = mining_client_request.send().promise.await?; + let mining_ipc_client: MiningIpcClient = mining_client_response.get()?.get_result()?; + + info!("IPC mining client successfully created."); + + let template_ipc_client_cancellation_token = CancellationToken::new(); + + Ok(Self { + fee_threshold, + min_interval, + thread_map, + thread_ipc_client, + mining_ipc_client, + monitor_ipc_templates_handle: Rc::new(RefCell::new(None)), + template_id_factory: Rc::new(AtomicU64::new(0)), + current_template_ipc_client: Rc::new(RefCell::new(None)), + current_prev_hash: Rc::new(RefCell::new(None)), + template_data: Rc::new(RwLock::new(HashMap::new())), + stale_template_ids: Rc::new(RwLock::new(HashSet::new())), + global_cancellation_token, + incoming_messages, + outgoing_messages, + template_ipc_client_cancellation_token, + last_sent_template_instant: None, + unix_socket_path: bitcoin_core_unix_socket_path.to_path_buf(), + }) + } + + /// Runs the [`BitcoinCoreSv2TDP`] instance, monitoring for: + /// - Chain Tip changes, for which it will send a `NewTemplate` message, followed by a + /// `SetNewPrevHash` message + /// - incoming [`RequestTransactionData`] messages, for which it will send a + /// `RequestTransactionDataSuccess` or `RequestTransactionDataError` message as a response + /// - incoming [`SubmitSolution`] messages, for which it will submit the solution to the Bitcoin + /// Core IPC client + /// - incoming [`CoinbaseOutputConstraints`] messages, for which it will update the coinbase + /// output constraints + /// + /// Blocks until the cancellation token is activated. + pub async fn run(&mut self) { + // wait for first CoinbaseOutputConstraints message + info!("Waiting for first CoinbaseOutputConstraints message"); + debug!("run() started, waiting for initial CoinbaseOutputConstraints"); + loop { + tokio::select! { + _ = self.global_cancellation_token.cancelled() => { + warn!("Exiting run"); + debug!("run() early exit - global cancellation token activated before first CoinbaseOutputConstraints"); + return; + } + Ok(message) = self.incoming_messages.recv() => { + debug!("run() received message during initial loop: {:?}", message); + match message { + TemplateDistribution::CoinbaseOutputConstraints(coinbase_output_constraints) => { + info!("Received: {:?}", coinbase_output_constraints); + debug!("First CoinbaseOutputConstraints received - max_additional_size: {}, max_additional_sigops: {}", + coinbase_output_constraints.coinbase_output_max_additional_size, + coinbase_output_constraints.coinbase_output_max_additional_sigops); + + match self + .bootstrap_template_ipc_client_from_coinbase_output_constraints( + coinbase_output_constraints, + ) + .await + { + Ok(()) => { + debug!( + "Successfully bootstrapped initial template IPC client" + ); + break; + } + Err(BitcoinCoreSv2TDPError::CreateNewBlockRequestInterrupted) => { + debug!( + "Initial createNewBlock request interrupted during shutdown" + ); + return; + } + Err(e) => { + error!( + "Failed to bootstrap initial template IPC client: {:?}", + e + ); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self.global_cancellation_token.cancel(); + return; + } + } + } + _ => { + warn!("Received unexpected message: {:?}", message); + warn!("Ignoring..."); + continue; + } + } + } + } + } + + // spawn the monitoring tasks + debug!("Spawning monitoring tasks..."); + self.monitor_ipc_templates(); + debug!("monitor_ipc_templates() spawned"); + self.monitor_incoming_messages(); + debug!("monitor_incoming_messages() spawned"); + + // block until the global cancellation token is activated + debug!("run() entering main blocking wait for global_cancellation_token"); + self.global_cancellation_token.cancelled().await; + debug!("global_cancellation_token cancelled - beginning shutdown sequence"); + + // Wait for the monitor_ipc_templates task to finish gracefully + debug!("Waiting for monitor_ipc_templates() task to finish"); + let handle = self.monitor_ipc_templates_handle.borrow_mut().take(); + if let Some(handle) = handle { + match handle.await { + Ok(()) => { + debug!("monitor_ipc_templates() task finished successfully"); + } + Err(e) => { + error!( + "error waiting for monitor_ipc_templates task to finish: {:?}", + e + ); + } + } + } + + debug!("run() exiting"); + } + + async fn fetch_template_data( + &self, + template_ipc_client: BlockTemplateIpcClient, + thread_ipc_client: ThreadIpcClient, + ) -> Result { + debug!("Fetching template data over IPC"); + let template_id = self.template_id_factory.fetch_add(1, Ordering::Relaxed); + debug!( + "fetch_template_data() - assigned template_id: {}", + template_id + ); + + let mut template_header_request = template_ipc_client.get_block_header_request(); + template_header_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let template_header_bytes = template_header_request + .send() + .promise + .await? + .get()? + .get_result()? + .to_vec(); + + // Deserialize the template header from Bitcoin Core's serialization format + debug!( + "Deserializing template header ({} bytes)", + template_header_bytes.len() + ); + let header: Header = deserialize(&template_header_bytes)?; + debug!( + "Template header deserialized - prev_hash: {:?}", + header.prev_blockhash + ); + + let mut coinbase_tx_request = template_ipc_client.get_coinbase_tx_request(); + coinbase_tx_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let coinbase_tx_response = coinbase_tx_request.send().promise.await?; + let coinbase_tx_result = coinbase_tx_response.get()?; + let coinbase_tx_reader = coinbase_tx_result.get_result()?; + let (coinbase_tx, block_reward_remaining) = coinbase_tx_from_ipc(coinbase_tx_reader)?; + debug!( + "Coinbase tx built from getCoinbaseTx result: {:?}", + coinbase_tx + ); + + let mut merkle_path_request = template_ipc_client.get_coinbase_merkle_path_request(); + merkle_path_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let merkle_path: Vec> = merkle_path_request + .send() + .promise + .await? + .get()? + .get_result()? + .iter() + .map(|x| x.map(|slice| slice.to_vec())) + .collect::, _>>()?; + + // Create the template data structure + let template_data = TemplateData::new( + template_id, + header, + coinbase_tx, + block_reward_remaining, + merkle_path, + template_ipc_client, + ); + debug!("TemplateData created successfully"); + + Ok(template_data) + } + + async fn new_thread_ipc_client(&self) -> Result { + debug!("Creating new thread IPC client"); + let thread_ipc_client_request = self.thread_map.make_thread_request(); + let thread_ipc_client_response = thread_ipc_client_request.send().promise.await?; + let thread_ipc_client = thread_ipc_client_response.get()?.get_result()?; + + Ok(thread_ipc_client) + } + + fn set_current_template_ipc_client(&self, template_ipc_client: BlockTemplateIpcClient) { + let mut current_template_ipc_client_guard = self.current_template_ipc_client.borrow_mut(); + *current_template_ipc_client_guard = Some(template_ipc_client); + debug!("Updated current_template_ipc_client"); + } + + fn current_template_ipc_client( + &self, + ) -> Result { + match self.current_template_ipc_client.borrow().clone() { + Some(template_ipc_client) => Ok(template_ipc_client), + None => { + error!("Template IPC client not found"); + Err(BitcoinCoreSv2TDPError::TemplateIpcClientNotFound) + } + } + } + + fn store_template_data( + &self, + template_data: &TemplateData, + ) -> Result<(), BitcoinCoreSv2TDPError> { + let mut template_data_guard = self.template_data.write().map_err(|e| { + error!("Failed to acquire write lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + + template_data_guard.insert(template_data.get_template_id(), template_data.clone()); + debug!( + "Saved template data with template_id: {}", + template_data.get_template_id() + ); + + Ok(()) + } + + fn current_template_ids(&self) -> Result, BitcoinCoreSv2TDPError> { + let template_data_guard = self.template_data.read().map_err(|e| { + error!("Failed to acquire read lock on template_data: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + + Ok(template_data_guard.keys().copied().collect()) + } + + async fn publish_template( + &mut self, + template_data: TemplateData, + future_template: bool, + send_set_new_prev_hash: bool, + update_last_sent_template_instant: bool, + ) -> Result<(), BitcoinCoreSv2TDPError> { + let new_template = template_data + .get_new_template_message(future_template) + .map_err(|e| { + error!("Failed to get NewTemplate message: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + let set_new_prev_hash = if send_set_new_prev_hash { + Some(template_data.get_set_new_prev_hash_message()) + } else { + None + }; + + self.store_template_data(&template_data)?; + + if send_set_new_prev_hash { + self.current_prev_hash + .replace(Some(template_data.get_prev_hash())); + debug!( + "Set current_prev_hash to: {}", + template_data.get_prev_hash() + ); + } + + debug!( + "Sending NewTemplate (future={}) with template_id: {}", + future_template, + template_data.get_template_id() + ); + self.outgoing_messages + .send(TemplateDistribution::NewTemplate(new_template)) + .await + .map_err(|e| { + error!("Failed to send NewTemplate message: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendNewTemplateMessage + })?; + debug!("Successfully sent NewTemplate message"); + + if let Some(set_new_prev_hash) = set_new_prev_hash { + debug!( + "Sending SetNewPrevHash with prev_hash: {}", + template_data.get_prev_hash() + ); + self.outgoing_messages + .send(TemplateDistribution::SetNewPrevHash(set_new_prev_hash)) + .await + .map_err(|e| { + error!("Failed to send SetNewPrevHash message: {:?}", e); + BitcoinCoreSv2TDPError::FailedToSendSetNewPrevHashMessage + })?; + debug!("Successfully sent SetNewPrevHash message"); + } + + if update_last_sent_template_instant { + self.last_sent_template_instant = Some(Instant::now()); + } + + Ok(()) + } + + /// Creates a fresh Bitcoin Core Template IPC client from the given + /// [`CoinbaseOutputConstraints`] and immediately sends a `NewTemplate` + `SetNewPrevHash`. + /// + /// This method intentionally couples these operations because every constraints update should + /// make a newly constrained template visible to the Sv2 side right away. On success, it: + /// + /// - creates a new `BlockTemplateIpcClient` configured with the provided constraints + /// - fetches the corresponding `TemplateData` + /// - stores the fetched `TemplateData` + /// - sends `NewTemplate(future_template = true)` + /// - sends the matching `SetNewPrevHash` + /// - updates `current_prev_hash` and `last_sent_template_instant` + /// - stores the client as `current_template_ipc_client` + async fn bootstrap_template_ipc_client_from_coinbase_output_constraints( + &mut self, + coinbase_output_constraints: CoinbaseOutputConstraints, + ) -> Result<(), BitcoinCoreSv2TDPError> { + debug!( + "bootstrap_template_ipc_client_from_coinbase_output_constraints() called - max_size: {}, max_sigops: {}", + coinbase_output_constraints.coinbase_output_max_additional_size, + coinbase_output_constraints.coinbase_output_max_additional_sigops + ); + + let mut template_ipc_client_request = self.mining_ipc_client.create_new_block_request(); + + template_ipc_client_request + .get() + .get_context() + .map_err(|e| { + error!("Failed to get template IPC client request context: {e}"); + e + })? + .set_thread(self.thread_ipc_client.clone()); + + let mut template_ipc_client_request_options = template_ipc_client_request + .get() + .get_options() + .map_err(|e| { + error!("Failed to get template IPC client request options: {e}"); + e + })?; + + let coinbase_weight = (coinbase_output_constraints.coinbase_output_max_additional_size + * WEIGHT_FACTOR) as u64; + let block_reserved_weight = coinbase_weight.max(MIN_BLOCK_RESERVED_WEIGHT); // 2000 is the minimum block reserved weight + debug!("Setting block_reserved_weight: {block_reserved_weight}"); + template_ipc_client_request_options.set_block_reserved_weight(block_reserved_weight); + template_ipc_client_request_options.set_coinbase_output_max_additional_sigops( + coinbase_output_constraints.coinbase_output_max_additional_sigops as u64, + ); + template_ipc_client_request_options.set_use_mempool(true); + + debug!("Sending createNewBlock request to Bitcoin Core"); + let create_new_block_promise = template_ipc_client_request.send().promise; + let template_ipc_client_response = tokio::select! { + template_ipc_client_response = create_new_block_promise => { + template_ipc_client_response.map_err(|e| { + error!("Failed to send template IPC client request: {}", e); + e + })? + } + _ = self.global_cancellation_token.cancelled() => { + debug!("Interrupting createNewBlock request"); + self.interrupt_create_new_block_request().await?; + return Err(BitcoinCoreSv2TDPError::CreateNewBlockRequestInterrupted); + } + }; + + let template_ipc_client_result = template_ipc_client_response.get().map_err(|e| { + error!("Failed to get template IPC client result: {}", e); + e + })?; + + let template_ipc_client = template_ipc_client_result.get_result().map_err(|e| { + error!("Failed to get template IPC client result: {}", e); + e + })?; + + debug!("Fetching template data from bootstrapped template IPC client"); + let template_data = self + .fetch_template_data(template_ipc_client.clone(), self.thread_ipc_client.clone()) + .await + .map_err(|e| { + error!("Failed to fetch template data: {:?}", e); + e + })?; + + self.publish_template(template_data, true, true, true) + .await?; + self.set_current_template_ipc_client(template_ipc_client); + + Ok(()) + } + + async fn interrupt_wait_request( + &self, + template_ipc_client: &BlockTemplateIpcClient, + ) -> Result<(), BitcoinCoreSv2TDPError> { + let interrupt_wait_request = template_ipc_client.interrupt_wait_request(); + if let Err(e) = interrupt_wait_request.send().promise.await { + error!("Failed to send interrupt wait request: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSendInterruptWaitRequest); + } + + Ok(()) + } + + async fn interrupt_create_new_block_request(&self) -> Result<(), BitcoinCoreSv2TDPError> { + let interrupt_request = self.mining_ipc_client.interrupt_request(); + if let Err(e) = interrupt_request.send().promise.await { + error!("Failed to send interrupt createNewBlock request: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSendInterruptCreateNewBlockRequest); + } + + Ok(()) + } + + async fn new_wait_next_request( + &self, + template_ipc_client: &BlockTemplateIpcClient, + thread_ipc_client: ThreadIpcClient, + ) -> Result, BitcoinCoreSv2TDPError> { + let mut wait_next_request = template_ipc_client.wait_next_request(); + + match wait_next_request.get().get_context() { + Ok(mut context) => context.set_thread(thread_ipc_client.clone()), + Err(e) => { + error!("Failed to set thread: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToSetThread); + } + } + + let mut wait_next_request_options = match wait_next_request.get().get_options() { + Ok(options) => options, + Err(e) => { + error!("Failed to get waitNext request options: {}", e); + return Err(BitcoinCoreSv2TDPError::FailedToGetWaitNextRequestOptions); + } + }; + + wait_next_request_options.set_fee_threshold(self.fee_threshold as i64); + + // 10 seconds timeout for waitNext requests + // please note that this is NOT how often we expect to get new templates + // it's just the max time we'll wait for the current waitNext request to complete + wait_next_request_options.set_timeout(10_000.0); + + Ok(wait_next_request) + } + + // spawns a task that processes the stale template data after 10s + // we wait 10s in case there's any incoming RequestTransactionData referring to stale templates + // immediately after the chain tip change + async fn process_stale_template_data(&self, stale_template_ids: HashSet) { + let self_clone = self.clone(); + tokio::task::spawn_local(async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + + // update the stale template ids + { + let mut stale_template_ids_guard = match self_clone.stale_template_ids.write() { + Ok(guard) => guard, + Err(e) => { + error!( + "Failed to acquire write lock on stale_template_ids: {:?}", + e + ); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + *stale_template_ids_guard = stale_template_ids.clone(); + + debug!( + "Marked {} templates as stale: {:?}", + stale_template_ids.len(), + stale_template_ids + ); + } + + // remove the stale template data from the template_data HashMap + let removed_template_data = { + let mut template_data_guard = match self_clone.template_data.write() { + Ok(guard) => guard, + Err(e) => { + error!("Failed to acquire write lock on template_data: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + let mut removed_template_data: Vec = Vec::new(); + + for stale_template_id in &stale_template_ids { + if let Some(template_data) = template_data_guard.remove(stale_template_id) { + removed_template_data.push(template_data); + } + } + + removed_template_data + }; + + debug!("Creating a dedicated thread IPC client for destroy_ipc_client"); + let thread_ipc_client = match self_clone.new_thread_ipc_client().await { + Ok(thread_ipc_client) => thread_ipc_client, + Err(e) => { + error!("Failed to create thread IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + for template_data in removed_template_data { + match template_data + .destroy_ipc_client(thread_ipc_client.clone()) + .await + { + Ok(()) => (), + Err(e) => { + error!("Failed to destroy template IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + } + } + }); + } +} + +fn coinbase_tx_from_ipc( + coinbase_tx: coinbase_tx::Reader<'_>, +) -> Result<(Transaction, u64), BitcoinCoreSv2TDPError> { + let block_reward_remaining: i64 = coinbase_tx.get_block_reward_remaining(); + let block_reward_remaining: u64 = block_reward_remaining + .try_into() + .map_err(|_| BitcoinCoreSv2TDPError::InvalidBlockRewardRemaining(block_reward_remaining))?; + + let witness = { + let witness_bytes = coinbase_tx.get_witness()?; + let mut witness = Witness::new(); + if !witness_bytes.is_empty() { + witness.push(witness_bytes); + } + witness + }; + + let mut required_outputs = Vec::new(); + for output_bytes in coinbase_tx.get_required_outputs()?.iter() { + let output_bytes = output_bytes?; + required_outputs.push(TxOut::consensus_decode(&mut &output_bytes[..])?); + } + + let transaction = Transaction { + version: TransactionVersion::non_standard(coinbase_tx.get_version() as i32), + lock_time: LockTime::from_consensus(coinbase_tx.get_lock_time()), + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::from_bytes(coinbase_tx.get_script_sig_prefix()?.to_vec()), + sequence: Sequence::from_consensus(coinbase_tx.get_sequence()), + witness, + }], + output: required_outputs, + }; + + Ok((transaction, block_reward_remaining)) +} + +#[cfg(test)] +mod tests { + use super::*; + use stratum_core::bitcoin::{Amount, consensus::serialize}; + + #[test] + fn coinbase_tx_from_ipc_builds_transaction_from_struct_fields() { + let required_output = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(vec![0x6a, 0x24]), + }; + let required_output_bytes = serialize(&required_output); + + let mut message = capnp::message::Builder::new_default(); + let mut coinbase_tx_builder: coinbase_tx::Builder<'_> = message.init_root(); + coinbase_tx_builder.set_version(2); + coinbase_tx_builder.set_sequence(0xffff_fffe); + coinbase_tx_builder.set_script_sig_prefix(&[0x03, 0xaa, 0xbb, 0xcc]); + coinbase_tx_builder.set_witness(&[0x42; 32]); + coinbase_tx_builder.set_block_reward_remaining(5_000_000_000); + coinbase_tx_builder.set_lock_time(840_000); + { + let mut required_outputs = coinbase_tx_builder.reborrow().init_required_outputs(1); + required_outputs.set(0, &required_output_bytes); + } + + let coinbase_tx_reader = coinbase_tx_builder.into_reader(); + let (coinbase_tx, value_remaining) = + coinbase_tx_from_ipc(coinbase_tx_reader).expect("coinbase tx should convert"); + + println!("coinbase_tx: {:?}", coinbase_tx); + + assert_eq!(value_remaining, 5_000_000_000); + assert_eq!(coinbase_tx.version, TransactionVersion::TWO); + assert_eq!(coinbase_tx.lock_time.to_consensus_u32(), 840_000); + assert_eq!(coinbase_tx.input.len(), 1); + assert_eq!(coinbase_tx.input[0].previous_output, OutPoint::null()); + assert_eq!( + coinbase_tx.input[0].sequence, + Sequence::from_consensus(0xffff_fffe) + ); + assert_eq!( + coinbase_tx.input[0].script_sig.as_bytes(), + &[0x03, 0xaa, 0xbb, 0xcc] + ); + assert_eq!(coinbase_tx.input[0].witness.len(), 1); + assert_eq!(&coinbase_tx.input[0].witness[0], &[0x42; 32]); + assert_eq!(coinbase_tx.output, vec![required_output]); + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs new file mode 100644 index 000000000..bf75ae50f --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/monitors.rs @@ -0,0 +1,291 @@ +//! Background monitors for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over +//! UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::BitcoinCoreSv2TDP; + +use bitcoin_capnp_types_v32::capnp; +use stratum_core::parsers_sv2::TemplateDistribution; +use tracing::{debug, error, info, warn}; + +impl BitcoinCoreSv2TDP { + /// Spawns a new task to monitor the IPC templates + /// + /// This task is responsible for: + /// - Creating a dedicated blocking_thread_ipc_client for waitNext requests + /// - Entering a loop to handle waitNext requests + /// - Handling the response from the waitNext request + /// - Updating the current template data + /// - Sending the NewTemplate message + pub fn monitor_ipc_templates(&self) { + let mut self_clone = self.clone(); + + let handle = tokio::task::spawn_local(async move { + debug!("monitor_ipc_templates() task started"); + // a dedicated thread_ipc_client is used for waitNext requests + // this is because waitNext requests are blocking, and we don't want to block the main + // thread where other requests are handled + // + // as soon as this task is cancelled, the blocking_thread_ipc_client is dropped, + // which cleans up the thread on the Bitcoin Core side + debug!("Creating dedicated blocking_thread_ipc_client for waitNext requests"); + let blocking_thread_ipc_client = match self_clone.new_thread_ipc_client().await { + Ok(blocking_thread_ipc_client) => blocking_thread_ipc_client, + Err(e) => { + error!("Failed to create blocking thread IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + let mut template_ipc_client = match self_clone.current_template_ipc_client() { + Ok(template_ipc_client) => template_ipc_client, + Err(e) => { + error!("Failed to get current template IPC client: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + debug!("monitor_ipc_templates() entering main loop"); + loop { + debug!("monitor_ipc_templates() loop iteration start"); + + // Create a new request for each iteration + let wait_next_request = match self_clone + .new_wait_next_request(&template_ipc_client, blocking_thread_ipc_client.clone()) + .await + { + Ok(wait_next_request) => wait_next_request, + Err(e) => { + error!("Failed to create waitNext request: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + return; + } + }; + + tokio::select! { + _ = self_clone.global_cancellation_token.cancelled() => { + debug!("Interrupting waitNext request"); + if let Err(e) = self_clone.interrupt_wait_request(&template_ipc_client).await { + error!("Failed to interrupt waitNext request during shutdown: {:?}", e); + } + warn!("Exiting mempool change monitoring loop"); + break; + } + _ = self_clone.template_ipc_client_cancellation_token.cancelled() => { + debug!("Interrupting waitNext request"); + if let Err(e) = self_clone.interrupt_wait_request(&template_ipc_client).await { + error!("Failed to interrupt waitNext request: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + warn!("Exiting mempool change monitoring loop"); + break; + } + wait_next_request_response = wait_next_request.send().promise => { + match wait_next_request_response { + Ok(response) => { + let result = match response.get() { + Ok(result) => result, + Err(e) => { + error!("Failed to get response: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + let new_template_ipc_client = match result.get_result() { + Ok(new_template_ipc_client) => { + debug!("waitNext returned new template IPC client"); + new_template_ipc_client + }, + Err(e) => { + match e.kind { + capnp::ErrorKind::MessageContainsNullCapabilityPointer => { + debug!("waitNext timed out (no mempool changes)"); + debug!("Continuing to next waitNext iteration"); + continue; // Go back to the start of the loop + } + _ => { + error!("Failed to get new template IPC client: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + } + }; + + debug!("Fetching new template data..."); + let new_template_data = match self_clone.fetch_template_data( + new_template_ipc_client.clone(), + blocking_thread_ipc_client.clone(), + ).await { + Ok(new_template_data) => new_template_data, + Err(e) => { + error!("Failed to fetch template data: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + let new_prev_hash = new_template_data.get_prev_hash(); + let current_prev_hash = match self_clone.current_prev_hash.borrow().clone() { + Some(prev_hash) => prev_hash, + None => { + error!("current_prev_hash is not set"); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + if new_prev_hash != current_prev_hash { + info!("⛓️ Chain Tip changed! New prev_hash: {}", new_prev_hash); + debug!("CHAIN TIP CHANGE DETECTED - old: {}, new: {}", current_prev_hash, new_prev_hash); + + let stale_template_ids = match self_clone.current_template_ids() { + Ok(stale_template_ids) => stale_template_ids, + Err(e) => { + error!("Failed to collect stale template ids: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + }; + + match self_clone.publish_template(new_template_data, true, true, false).await { + Ok(()) => { + self_clone.set_current_template_ipc_client(new_template_ipc_client.clone()); + template_ipc_client = new_template_ipc_client; + } + Err(e) => { + error!("Failed to publish chain-tip template: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + + // process the stale template data after 10s + self_clone.process_stale_template_data(stale_template_ids).await; + } else { + // check if the minimum interval has been reached + if let Some(last_sent_template_instant) = self_clone.last_sent_template_instant { + let elapsed = last_sent_template_instant.elapsed().as_millis(); + let min_interval_millis = self_clone.min_interval as u128 * 1_000; + + // if the minimum interval has not been reached, sleep for the remaining time + if elapsed < min_interval_millis { + let sleep_duration = min_interval_millis - elapsed; + // Safe cast: min_interval is u8 (max 255), so sleep_duration is at most 255,000 ms, + // which fits comfortably in u64 (max: 18,446,744,073,709,551,615) + debug!("Sleeping for {} milliseconds to reach the minimum interval", sleep_duration); + tokio::time::sleep(std::time::Duration::from_millis(sleep_duration as u64)).await; + } + } + + info!("💹 Mempool fees increased! Sending NewTemplate message."); + debug!("MEMPOOL FEE CHANGE DETECTED - sending non-future template"); + + match self_clone.publish_template(new_template_data, false, false, true).await { + Ok(()) => { + self_clone.set_current_template_ipc_client(new_template_ipc_client.clone()); + template_ipc_client = new_template_ipc_client; + } + Err(e) => { + error!("Failed to publish fee-update template: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + } + } + Err(e) => { + debug!("waitNext request failed with error: {}", e); + error!("Failed to get response: {}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + } + } + } + + debug!("monitor_ipc_templates() task exiting"); + }); + + // Store the handle so we can wait for this task to finish before spawning a new one + // when handle_coinbase_output_constraints is called + *self.monitor_ipc_templates_handle.borrow_mut() = Some(handle); + } + + /// Spawns a new task to monitor the incoming messages + /// + /// This task is responsible for: + /// - Entering a loop to listen for incoming messages + /// - Routing incoming messages to the appropriate handler + pub fn monitor_incoming_messages(&self) { + let mut self_clone = self.clone(); + + tokio::task::spawn_local(async move { + debug!("monitor_incoming_messages() task started"); + loop { + tokio::select! { + _ = self_clone.global_cancellation_token.cancelled() => { + warn!("Exiting incoming messages loop"); + debug!("monitor_incoming_messages() exiting due to cancellation"); + break; + } + Ok(incoming_message) = self_clone.incoming_messages.recv() => { + info!("Received: {}", incoming_message); + debug!("monitor_incoming_messages() processing message"); + + match incoming_message { + TemplateDistribution::CoinbaseOutputConstraints(coinbase_output_constraints) => { + debug!("Received CoinbaseOutputConstraints - max_additional_size: {}, max_additional_sigops: {}", + coinbase_output_constraints.coinbase_output_max_additional_size, + coinbase_output_constraints.coinbase_output_max_additional_sigops); + if let Err(e) = self_clone.handle_coinbase_output_constraints(coinbase_output_constraints).await { + error!("Failed to handle coinbase output constraints: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + TemplateDistribution::RequestTransactionData(request_transaction_data) => { + debug!("Received RequestTransactionData for template_id: {}", request_transaction_data.template_id); + if let Err(e) = self_clone.handle_request_transaction_data(request_transaction_data).await { + error!("Failed to handle request transaction data: {:?}", e); + warn!("Terminating Sv2 Bitcoin Core IPC Connection"); + self_clone.global_cancellation_token.cancel(); + break; + } + } + TemplateDistribution::SubmitSolution(submit_solution) => { + debug!("Received SubmitSolution for template_id: {}", submit_solution.template_id); + if let Err(e) = self_clone.handle_submit_solution(submit_solution).await { + error!("Failed to handle submit solution: {:?}", e); + // no need to activate the global cancellation token here + } + } + _ => { + error!("Received unexpected message: {}", incoming_message); + warn!("Ignoring message"); + continue; + } + } + } + } + } + }); + } +} diff --git a/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs new file mode 100644 index 000000000..5800e6871 --- /dev/null +++ b/bitcoin-core-sv2/src/unix_capnp/v32x/template_distribution_protocol/template_data.rs @@ -0,0 +1,410 @@ +//! Template-data helpers for Bitcoin Core v32.x Sv2 Template Distribution Protocol via capnp over +//! UNIX socket. + +use crate::unix_capnp::v32x::template_distribution_protocol::error::TemplateDataError; + +use bitcoin_capnp_types::{ + mining_capnp::block_template::Client as BlockTemplateIpcClient, + proxy_capnp::{thread::Client as ThreadIpcClient, thread_map::Client as ThreadMapIpcClient}, +}; +use bitcoin_capnp_types_v32 as bitcoin_capnp_types; +use std::{fs::File, io::Write, path::Path}; +use stratum_core::bitcoin::{ + Target, Transaction, TxOut, + block::{Block, Header, Version}, + consensus::{deserialize, serialize}, + hashes::{Hash, HashEngine, sha256d}, +}; + +use stratum_core::{ + binary_sv2::{B016M, B064K, B0255, Seq064K, Seq0255, U256}, + template_distribution_sv2::{ + NewTemplate, RequestTransactionDataSuccess, SetNewPrevHash, SubmitSolution, + }, +}; +use tracing::{debug, error, info}; + +#[derive(Clone)] +pub struct TemplateData { + template_id: u64, + header: Header, + coinbase_tx: Transaction, + block_reward_remaining: u64, + merkle_path: Vec>, + template_ipc_client: BlockTemplateIpcClient, +} + +// impl block for public methods +impl TemplateData { + pub fn new( + template_id: u64, + header: Header, + coinbase_tx: Transaction, + block_reward_remaining: u64, + merkle_path: Vec>, + template_ipc_client: BlockTemplateIpcClient, + ) -> Self { + Self { + template_id, + header, + coinbase_tx, + block_reward_remaining, + merkle_path, + template_ipc_client, + } + } + + /// Destroys the template IPC client, cleaning up the resources on the Bitcoin Core side + pub async fn destroy_ipc_client( + &self, + thread_ipc_client: ThreadIpcClient, + ) -> Result<(), TemplateDataError> { + debug!("Destroying template IPC client: {}", self.template_id); + let mut destroy_ipc_client_request = self.template_ipc_client.destroy_request(); + let destroy_ipc_client_request_params = destroy_ipc_client_request.get(); + + destroy_ipc_client_request_params + .get_context()? + .set_thread(thread_ipc_client); + + destroy_ipc_client_request.send().promise.await?; + + Ok(()) + } + + pub fn get_template_id(&self) -> u64 { + self.template_id + } + + pub fn get_new_template_message( + &self, + future_template: bool, + ) -> Result, TemplateDataError> { + let new_template = NewTemplate { + template_id: self.template_id, + future_template, + version: self.get_version()?, + coinbase_tx_version: self.get_coinbase_tx_version()?, + coinbase_prefix: self.get_coinbase_script_sig()?, + coinbase_tx_input_sequence: self.get_coinbase_input_sequence(), + coinbase_tx_value_remaining: self.block_reward_remaining, + coinbase_tx_outputs_count: self.get_required_coinbase_outputs().len() as u32, + coinbase_tx_outputs: self.get_serialized_required_coinbase_outputs()?, + coinbase_tx_locktime: self.get_coinbase_tx_lock_time(), + merkle_path: self.get_merkle_path()?, + }; + Ok(new_template.into_static()) + } + + // please note that `SetNewPrevHash.target` is consensus and not weak-block + // so it's essentially redundant with `SetNewPrevHash.n_bits` + pub fn get_set_new_prev_hash_message(&self) -> SetNewPrevHash<'static> { + let set_new_prev_hash = SetNewPrevHash { + template_id: self.template_id, + prev_hash: self.get_prev_hash(), + header_timestamp: self.get_ntime(), + n_bits: self.get_nbits(), + target: self.get_target(), + }; + set_new_prev_hash.into_static() + } + + pub async fn get_request_transaction_data_success_message( + &self, + thread_map: ThreadMapIpcClient, + ) -> Result, TemplateDataError> { + let request_transaction_data_success = RequestTransactionDataSuccess { + template_id: self.template_id, + transaction_list: self.get_tx_data(thread_map).await?, + excess_data: vec![] + .try_into() + .expect("empty vec should always be valid for B064K"), + }; + Ok(request_transaction_data_success.into_static()) + } + + pub fn get_prev_hash(&self) -> U256<'static> { + self.header.prev_blockhash.to_byte_array().into() + } + + async fn dump_solution_to_disk( + &self, + thread_map: ThreadMapIpcClient, + solution_coinbase_tx: Transaction, + solution_header_version: u32, + solution_header_timestamp: u32, + solution_header_nonce: u32, + path_dir: &Path, + ) { + let self_clone = self.clone(); + let path_dir = path_dir.to_path_buf(); + tokio::task::spawn_local(async move { + debug!("Creating a dedicated thread IPC client for getBlock request"); + + // validate the solution + let solution_header = { + if solution_coinbase_tx.version != self_clone.coinbase_tx.version + || solution_coinbase_tx.lock_time != self_clone.coinbase_tx.lock_time + || solution_coinbase_tx.input.len() != 1 + || solution_coinbase_tx.input[0].sequence + != self_clone.coinbase_tx.input[0].sequence + || solution_coinbase_tx.input[0].witness + != self_clone.coinbase_tx.input[0].witness + || solution_coinbase_tx.input[0].previous_output + != self_clone.coinbase_tx.input[0].previous_output + { + error!("Solution coinbase tx is not congruent with original coinbase tx"); + return; + } + + // Compute merkle root from coinbase transaction and merkle path + let coinbase_txid = solution_coinbase_tx.compute_txid(); + let mut current_hash = *coinbase_txid.as_byte_array(); + + // Combine with each sibling hash in the merkle path + for sibling_hash_bytes in &self_clone.merkle_path { + // Combine current hash with sibling hash and double SHA256 + let mut hasher = sha256d::Hash::engine(); + HashEngine::input(&mut hasher, ¤t_hash); + HashEngine::input(&mut hasher, sibling_hash_bytes); + current_hash = *sha256d::Hash::from_engine(hasher).as_byte_array(); + } + + let solution_header = Header { + version: Version::from_consensus(solution_header_version as i32), + prev_blockhash: self_clone.header.prev_blockhash, + merkle_root: sha256d::Hash::from_byte_array(current_hash).into(), + time: solution_header_timestamp, + nonce: solution_header_nonce, + bits: self_clone.header.bits, + }; + + if let Err(e) = solution_header.validate_pow(solution_header.target()) { + error!("Solution header is not valid: {}", e); + return; + } + + solution_header + }; + + let thread_ipc_client = thread_map + .make_thread_request() + .send() + .promise + .await + .expect("Failed to send thread IPC client request") + .get() + .expect("Failed to get thread IPC client reader") + .get_result() + .expect("Failed to get thread IPC client result"); + + let mut template_block_request = self_clone.template_ipc_client.get_block_request(); + let mut template_block_request_context = template_block_request + .get() + .get_context() + .expect("Failed to get template block request context"); + template_block_request_context.set_thread(thread_ipc_client.clone()); + + let template_block_response = template_block_request + .send() + .promise + .await + .expect("Failed to send template block request"); + let template_block_reader = template_block_response + .get() + .expect("Failed to get template block response"); + let template_block_bytes = template_block_reader + .get_result() + .expect("Failed to get template block result"); + + // Deserialize the complete block template from Bitcoin Core's serialization format + let mut solution_block: Block = + deserialize(template_block_bytes).expect("Failed to deserialize block template"); + + solution_block.txdata[0] = solution_coinbase_tx; + solution_block.header = solution_header; + + let solution_block_bytes = serialize(&solution_block); + let solution_block_hash = solution_block.block_hash().to_string(); + let solution_block_path = path_dir.join(format!("{solution_block_hash}.dat")); + + let mut file = + File::create(&solution_block_path).expect("Failed to create solution block file"); + file.write_all(&solution_block_bytes) + .expect("Failed to write solution block to file"); + info!( + "Solution block dumped to: {}", + solution_block_path.display() + ); + }); + } + + pub async fn submit_solution( + &self, + submit_solution: SubmitSolution<'static>, + thread_ipc_client: ThreadIpcClient, + thread_map: ThreadMapIpcClient, + path_dir: &Path, + ) -> Result<(), TemplateDataError> { + let solution_coinbase_tx_bytes = submit_solution.coinbase_tx.as_ref().to_vec(); + + let solution_coinbase_tx: Transaction = + deserialize(&solution_coinbase_tx_bytes).map_err(|e| { + error!("SubmitSolution.coinbase_tx is invalid: {}", e); + TemplateDataError::InvalidCoinbaseTx(e) + })?; + + // spawn a task to dump the solution to disk + self.dump_solution_to_disk( + thread_map.clone(), + solution_coinbase_tx, + submit_solution.version, + submit_solution.header_timestamp, + submit_solution.header_nonce, + path_dir, + ) + .await; + + let mut submit_solution_request = self.template_ipc_client.submit_solution_request(); + let mut submit_solution_request_params = submit_solution_request.get(); + + submit_solution_request_params.set_version(submit_solution.version); + submit_solution_request_params.set_timestamp(submit_solution.header_timestamp); + submit_solution_request_params.set_nonce(submit_solution.header_nonce); + submit_solution_request_params.set_coinbase(&solution_coinbase_tx_bytes); + + submit_solution_request_params + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let submit_solution_response = submit_solution_request.send().promise.await?; + + if !submit_solution_response.get()?.get_result() { + return Err(TemplateDataError::FailedIpcSubmitSolution); + } + + Ok(()) + } +} + +// impl block for private methods +impl TemplateData { + fn get_nbits(&self) -> u32 { + self.header.bits.to_consensus() + } + + fn get_target(&self) -> U256<'_> { + let target = Target::from(self.header.bits); + let target_bytes: [u8; 32] = target.to_le_bytes(); + U256::from(target_bytes) + } + + fn get_ntime(&self) -> u32 { + self.header.time + } + + fn get_version(&self) -> Result { + self.header + .version + .to_consensus() + .try_into() + .map_err(|_| TemplateDataError::InvalidBlockVersion) + } + + fn get_coinbase_tx_version(&self) -> Result { + self.coinbase_tx + .version + .0 + .try_into() + .map_err(|_| TemplateDataError::InvalidCoinbaseTxVersion) + } + + fn get_coinbase_script_sig(&self) -> Result, TemplateDataError> { + let coinbase_script_sig: B0255 = self.coinbase_tx.input[0] + .script_sig + .to_bytes() + .try_into() + .map_err(|_| TemplateDataError::InvalidCoinbaseScriptSig)?; + Ok(coinbase_script_sig) + } + + fn get_coinbase_input_sequence(&self) -> u32 { + self.coinbase_tx.input[0].sequence.to_consensus_u32() + } + + fn get_required_coinbase_outputs(&self) -> &[TxOut] { + &self.coinbase_tx.output + } + + fn get_serialized_required_coinbase_outputs(&self) -> Result, TemplateDataError> { + let mut serialized_required_coinbase_outputs = Vec::new(); + for output in self.get_required_coinbase_outputs() { + serialized_required_coinbase_outputs.extend_from_slice(&serialize(output)); + } + let serialized_required_coinbase_outputs: B064K = serialized_required_coinbase_outputs + .try_into() + .map_err(|_| TemplateDataError::FailedToSerializeCoinbaseOutputs)?; + Ok(serialized_required_coinbase_outputs) + } + + fn get_coinbase_tx_lock_time(&self) -> u32 { + self.coinbase_tx.lock_time.to_consensus_u32() + } + + async fn get_tx_data( + &self, + thread_map: ThreadMapIpcClient, + ) -> Result>, TemplateDataError> { + debug!("Creating a dedicated thread IPC client for get_tx_data"); + let thread_ipc_client_request = thread_map.make_thread_request(); + let thread_ipc_client_response = thread_ipc_client_request.send().promise.await?; + let thread_ipc_client = thread_ipc_client_response.get()?.get_result()?; + + let mut template_block_request = self.template_ipc_client.get_block_request(); + template_block_request + .get() + .get_context()? + .set_thread(thread_ipc_client.clone()); + + let template_block_response = template_block_request.send().promise.await?; + let template_block_bytes = template_block_response.get()?.get_result()?; + + // Deserialize the complete block template from Bitcoin Core's serialization format + debug!( + "Deserializing block template ({} bytes)", + template_block_bytes.len() + ); + let block: Block = deserialize(template_block_bytes)?; + debug!( + "Block deserialized - prev_hash from header: {:?}", + block.header.prev_blockhash + ); + + let tx_data: Vec> = block + .txdata + .iter() + .skip(1) // skip coinbase tx + .map(|tx| { + serialize(tx) + .try_into() + .expect("tx data should always be valid for B016M") + }) + .collect(); + Ok(Seq064K::new(tx_data).expect("tx data should always be valid for Seq064K")) + } + + fn get_merkle_path(&self) -> Result>, TemplateDataError> { + // Convert each Vec in the merkle path to U256 + let merkle_path_u256: Vec> = self + .merkle_path + .iter() + .map(|hash_bytes| { + // Convert Vec to U256 + U256::try_from(hash_bytes.clone()) + .map_err(|_| TemplateDataError::FailedToConvertMerklePathHashToU256) + }) + .collect::, _>>()?; + + Seq0255::new(merkle_path_u256).map_err(|_| TemplateDataError::FailedToCreateMerklePathSeq) + } +} diff --git a/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 d72747484..57773ca3e 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/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -1103,7 +1113,8 @@ version = "0.4.0" dependencies = [ "async-channel 1.9.0", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", diff --git a/integration-tests/lib/template_provider.rs b/integration-tests/lib/template_provider.rs index 9fa14b820..13f3ddbb3 100644 --- a/integration-tests/lib/template_provider.rs +++ b/integration-tests/lib/template_provider.rs @@ -16,6 +16,11 @@ 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 only while v32 tests depend on a Bitcoin Core +// build of https://github.com/bitcoin/bitcoin/pull/35671 (TxCollection). Remove once v32 is +// released and follows the standard binary resolution flow used by other versions. +const BITCOIN_CORE_V32_BINARY_ENV: &str = "BITCOIN_CORE_V32_BINARY"; /// Allow static signet fixtures to leave IBD without freezing Bitcoin Core's /// clock, so mined blocks still use wall-clock timestamps. /// @@ -58,15 +63,45 @@ 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(|_| { + panic!( + "Bitcoin Core v32 is not released yet. Build \ + https://github.com/bitcoin/bitcoin/pull/35671 from source and point \ + {BITCOIN_CORE_V32_BINARY_ENV} at the resulting bitcoin-node binary \ + (e.g. /build/bin/bitcoin-node)." + ) + }); + + if configured_path.file_name().and_then(|name| name.to_str()) == Some("bitcoin") { + if let Some(parent) = configured_path.parent() { + 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 +209,74 @@ 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 locally built bitcoin/bitcoin#35671 binary + // (via BITCOIN_CORE_V32_BINARY) until official Bitcoin Core v32 release artifacts + // are available. let os = env::consts::OS; - let 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 binary not found at {} (from {}).", + 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![ @@ -324,6 +375,78 @@ impl BitcoinCore { Ok(()) } + /// Fund the node's wallet with spendable *legacy* (pre-segwit) outputs by mining 101 + /// blocks to a legacy address, which is returned for later use. + /// + /// Useful for building witness-free transactions, which can be included in a block + /// whose coinbase carries no witness commitment. + pub fn fund_wallet_legacy(&self) -> Result { + let client = &self.bitcoind.client; + let address: String = client.call("getnewaddress", &["".into(), "legacy".into()])?; + // Mine in batches: a single 101-block generatetoaddress can exceed the RPC + // client's 15s timeout when several test nodes mine concurrently. + let mut remaining = 101u32; + while remaining > 0 { + let batch = remaining.min(20); + let _: serde_json::Value = + client.call("generatetoaddress", &[batch.into(), address.clone().into()])?; + remaining -= batch; + } + Ok(address) + } + + /// Create and sign a legacy (witness-free) self-transfer WITHOUT broadcasting it, and + /// return its consensus-serialized bytes. + /// + /// Explicitly spends a mature coinbase output of `funded_address` (from + /// [`BitcoinCore::fund_wallet_legacy`]) so no segwit input sneaks in through wallet + /// coin selection, keeping the transaction free of witness data. + pub fn create_unbroadcast_legacy_transaction( + &self, + funded_address: &str, + ) -> Result, corepc_node::Error> { + let client = &self.bitcoind.client; + let unspent: serde_json::Value = client.call( + "listunspent", + &[ + 100.into(), + 9_999_999.into(), + serde_json::json!([funded_address]), + ], + )?; + let utxo = unspent + .as_array() + .and_then(|utxos| utxos.first()) + .unwrap_or_else(|| { + panic!("expected a mature UTXO on the legacy funding address {funded_address}") + }) + .clone(); + let txid = utxo["txid"].as_str().expect("listunspent entry has txid"); + let vout = utxo["vout"].as_u64().expect("listunspent entry has vout"); + let amount = utxo["amount"] + .as_f64() + .expect("listunspent entry has amount"); + + let destination: String = client.call("getnewaddress", &["".into(), "legacy".into()])?; + let raw: String = client.call( + "createrawtransaction", + &[ + serde_json::json!([{ "txid": txid, "vout": vout }]), + serde_json::json!({ destination: amount - 0.0001 }), + ], + )?; + let signed: serde_json::Value = + client.call("signrawtransactionwithwallet", &[raw.into()])?; + assert!( + signed["complete"].as_bool().unwrap_or(false), + "wallet should fully sign the self-transfer: {signed}" + ); + let signed_hex = signed["hex"] + .as_str() + .expect("signrawtransactionwithwallet should return hex"); + Ok(hex::decode(signed_hex).expect("signed transaction should be valid hex")) + } + /// Return the hash of the most recent block. pub fn get_best_block_hash(&self) -> Result { let client = &self.bitcoind.client; @@ -331,6 +454,64 @@ impl BitcoinCore { Ok(block_hash) } + /// Copy all blocks from `source` to this node via RPC. + /// + /// The integration test nodes are not connected over P2P, so this is the way to give + /// two nodes the same chain while keeping their mempools independent. The node reorgs + /// to the source chain, so the source must have more work than any leftover local + /// chain (test datadirs persist across runs). + pub fn sync_chain_from(&self, source: &BitcoinCore) -> Result<(), corepc_node::Error> { + let source_client = &source.bitcoind.client; + let target_client = &self.bitcoind.client; + let source_height: u64 = source_client.call("getblockcount", &[])?; + for height in 1..=source_height { + let hash: String = source_client.call("getblockhash", &[height.into()])?; + let block_hex: String = source_client.call("getblock", &[hash.into(), 0.into()])?; + let result: serde_json::Value = + target_client.call("submitblock", &[block_hex.into()])?; + // Blocks the node already knows about are reported as duplicates, and blocks + // that do not (yet) beat a leftover local chain as inconclusive. + assert!( + result.is_null() + || result + .as_str() + .is_some_and(|r| r.starts_with("duplicate") || r == "inconclusive"), + "submitblock rejected block at height {height}: {result}" + ); + } + assert_eq!( + self.get_best_block_hash()?, + source.get_best_block_hash()?, + "chain sync should leave both nodes on the same tip" + ); + Ok(()) + } + + /// Return the hash of the block at the given height. + pub fn get_block_hash(&self, height: u64) -> Result { + Ok(self + .bitcoind + .client + .call("getblockhash", &[height.into()])?) + } + + /// Return the txids of the block with the given hash. + pub fn get_block_txids(&self, block_hash: &str) -> Result, corepc_node::Error> { + let block: serde_json::Value = self + .bitcoind + .client + .call("getblock", &[block_hash.into(), 1.into()])?; + Ok(block["tx"] + .as_array() + .map(|txids| { + txids + .iter() + .filter_map(|txid| txid.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default()) + } + /// Return the IPC socket path for connecting to this node. pub fn ipc_socket_path(&self) -> PathBuf { let network_dir = if self.is_signet { "signet" } else { "regtest" }; diff --git a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs index dc5eb573d..2e7999f1d 100644 --- a/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_jdp_io.rs @@ -3,8 +3,13 @@ //! Flow covered per Bitcoin Core Sv2 runtime behavior and Sv2 JDP expectations: //! - `DeclareMiningJob` returns `MissingTransactions` when unknown wtxids are declared. //! - `DeclareMiningJob` returns `Success` for a minimal valid declaration. +//! - `DeclareMiningJob` returns `Error(invalid-job)` when the declared coinbase pays more +//! than the block subsidy. //! - `DeclareMiningJob` returns `Error(stale-chain-tip)` when the declared BIP34 height is //! intentionally mismatched. +//! - A transaction provided once via missing-transactions is remembered, so a later +//! declaration of the same transaction (by another downstream) succeeds without a +//! missing-transactions round. //! //! File structure: //! - top: version-specific `#[tokio::test]` wrappers. @@ -30,7 +35,10 @@ use stratum_apps::{ transaction::Version as TxVersion, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, Wtxid, }, - job_declaration_sv2::ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + job_declaration_sv2::{ + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, + ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, + }, }, }; @@ -44,6 +52,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(); @@ -100,7 +113,9 @@ async fn assert_jdp_io_integration_for_version(version: BitcoinCoreVersion) { assert_jdp_missing_transactions_scenario(&incoming_sender, coinbase_tx.clone(), missing_wtxid) .await; assert_jdp_success_scenario(&incoming_sender, coinbase_tx).await; + assert_jdp_overpaying_coinbase_scenario(&incoming_sender, next_height).await; assert_jdp_stale_chain_tip_scenario(&incoming_sender, next_height).await; + assert_jdp_provided_tx_remembered_scenario(&incoming_sender, &bitcoin_core).await; cancellation_token.cancel(); jdp_thread @@ -115,6 +130,8 @@ async fn assert_jdp_missing_transactions_scenario( ) { let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, coinbase_tx, vec![missing_wtxid], vec![], @@ -136,6 +153,8 @@ async fn assert_jdp_success_scenario( ) { let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, coinbase_tx, vec![], vec![], @@ -144,22 +163,56 @@ async fn assert_jdp_success_scenario( .await; match response { - JdResponse::Success { txid_list, .. } => { + JdResponse::Success { merkle_path, .. } => { assert!( - txid_list.is_empty(), - "txid_list should be empty when no non-coinbase txs were declared" + merkle_path.is_empty(), + "merkle path should be empty when no non-coinbase txs were declared" ); } response => panic!("expected Success, got: {response:?}"), } } +async fn assert_jdp_overpaying_coinbase_scenario( + incoming_sender: &Sender, + next_height: u32, +) { + // Regtest initial block subsidy is 50 BTC; with an empty transaction list there are no + // fees, so paying one extra satoshi must fail contextual coinbase validation + // (bad-cb-amount). + let mut coinbase_tx = build_valid_coinbase_tx(next_height); + coinbase_tx.output[0].value = Amount::from_sat(50 * 100_000_000 + 1); + + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 0, + coinbase_tx, + vec![], + vec![], + "jdp/overpaying-coinbase", + ) + .await; + + match response { + JdResponse::Error { error_code, .. } => { + assert_eq!( + error_code, ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, + "expected invalid-job error for overpaying coinbase" + ); + } + response => panic!("expected Error(invalid-job), got: {response:?}"), + } +} + async fn assert_jdp_stale_chain_tip_scenario( incoming_sender: &Sender, next_height: u32, ) { let response = send_declare_mining_job_and_recv_response( incoming_sender, + 0, + 0, build_valid_coinbase_tx(next_height.saturating_add(10_000)), vec![], vec![], @@ -178,8 +231,117 @@ async fn assert_jdp_stale_chain_tip_scenario( } } +/// A transaction provided via the missing-transactions flow must be remembered, so a later +/// declaration of the same transaction by a *different* downstream succeeds without another +/// missing-transactions round (served by the provided-transaction cache on v32.x and by the +/// mempool mirror on v30.x/v31.x). +async fn assert_jdp_provided_tx_remembered_scenario( + incoming_sender: &Sender, + bitcoin_core: &integration_tests_sv2::template_provider::BitcoinCore, +) { + // Fund with legacy outputs and build a witness-free transaction that is NOT in the + // node's mempool, so the block needs no witness commitment in its minimal coinbase. + let funded_address = bitcoin_core + .fund_wallet_legacy() + .unwrap_or_else(|e| panic!("failed to fund wallet with legacy outputs: {e}")); + let tx_bytes = bitcoin_core + .create_unbroadcast_legacy_transaction(&funded_address) + .unwrap_or_else(|e| panic!("failed to create unbroadcast transaction: {e}")); + let tx: Transaction = stratum_apps::stratum_core::bitcoin::consensus::deserialize(&tx_bytes) + .expect("failed to decode unbroadcast transaction"); + let wtxid = tx.compute_wtxid(); + + let next_height = u32::try_from( + bitcoin_core + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + + 1, + ) + .expect("next height should fit in u32"); + + // Wait until the backend's chain context reflects the newly mined blocks (the + // mirror-based backends refresh asynchronously). + let mut caught_up = false; + for _ in 0..30 { + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 1, + build_valid_coinbase_tx(next_height), + vec![], + vec![], + "jdp/provided-tx-catchup", + ) + .await; + if matches!(response, JdResponse::Success { .. }) { + caught_up = true; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + assert!( + caught_up, + "backend did not catch up to the funded chain tip" + ); + + // Round 1: the transaction is unknown to the node, so it must be requested. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 2, + build_valid_coinbase_tx(next_height), + vec![wtxid], + vec![], + "jdp/provided-tx-round-1", + ) + .await; + match response { + JdResponse::MissingTransactions { missing_wtxids, .. } => { + assert_eq!(missing_wtxids, vec![wtxid]); + } + response => panic!("expected MissingTransactions, got: {response:?}"), + } + + // Round 2: provide the transaction; the declaration must now validate. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 0, + 2, + build_valid_coinbase_tx(next_height), + vec![wtxid], + vec![tx], + "jdp/provided-tx-round-2", + ) + .await; + assert!( + matches!(response, JdResponse::Success { .. }), + "expected Success after providing the missing transaction, got: {response:?}" + ); + + // Round 3: a different downstream declares the same transaction WITHOUT providing it; + // the remembered copy must be used instead of another missing-transactions round. + let response = send_declare_mining_job_and_recv_response( + incoming_sender, + 1, + 3, + build_valid_coinbase_tx(next_height), + vec![wtxid], + vec![], + "jdp/provided-tx-round-3", + ) + .await; + assert!( + matches!(response, JdResponse::Success { .. }), + "expected Success from the remembered transaction, got: {response:?}" + ); +} + +#[allow(clippy::too_many_arguments)] async fn send_declare_mining_job_and_recv_response( incoming_sender: &Sender, + downstream_id: usize, + request_id: u32, coinbase_tx: Transaction, wtxid_list: Vec, missing_txs: Vec, @@ -188,6 +350,8 @@ async fn send_declare_mining_job_and_recv_response( let (response_tx, response_rx) = tokio::sync::oneshot::channel(); incoming_sender .send(JdRequest::DeclareMiningJob { + downstream_id, + request_id, // Use a fixed, valid block version across scenarios so assertions focus on IO paths. version: BlockVersion::from_consensus(0x2000_0000), coinbase_tx, diff --git a/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs b/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs index 7c2bfe0f7..509b4d707 100644 --- a/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs +++ b/integration-tests/tests/bitcoin_core_ipc_tdp_io.rs @@ -41,6 +41,11 @@ async fn tdp_io_integration_v31x() { assert_tdp_io_integration(BitcoinCoreVersion::V31X).await; } +#[tokio::test] +async fn tdp_io_integration_v32x() { + assert_tdp_io_integration(BitcoinCoreVersion::V32X).await; +} + async fn assert_tdp_io_integration(version: BitcoinCoreVersion) { start_tracing(); diff --git a/integration-tests/tests/jds_block_propagation.rs b/integration-tests/tests/jds_block_propagation.rs index c2fb4bee7..b2386be19 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(); @@ -50,3 +48,105 @@ async fn propagated_from_jds_to_tp() { assert_ne!(current_block_hash, new_block_hash); shutdown_all!(translator, jdc, pool); } + +// Block containing a transaction the JDS node only learned about through +// ProvideMissingTransactions is propagated from JDS to its node. +// +// The JDC and JDS use separate nodes sharing the same chain, but only the JDC's node has +// the transaction in its mempool. The declared job therefore requires a +// ProvideMissingTransactions round before it validates, and the solved block reaches the +// JDS node purely through the JDS (the JDC -> TP SubmitSolution path is blocked). +#[tokio::test] +async fn propagated_from_jds_to_tp_with_missing_transactions() { + start_tracing(); + let (tp_1, _tp_addr_1) = start_template_provider(None, DifficultyLevel::Low); // JDS node + let (tp_2, tp_addr_2) = start_template_provider(None, DifficultyLevel::Low); // JDC node + + // Give both nodes the same chain, then add a transaction only to the JDC node's + // mempool. + assert!(tp_2.fund_wallet().is_ok()); + tp_1.bitcoin_core() + .sync_chain_from(tp_2.bitcoin_core()) + .unwrap(); + let (_address, txid) = tp_2.create_mempool_transaction().unwrap(); + + let current_block_hash = tp_1.get_best_block_hash().unwrap(); + let current_height = tp_1.get_blockchain_info().unwrap().blocks as u64; + assert_eq!(current_block_hash, tp_2.get_best_block_hash().unwrap()); + + let (pool, pool_addr, jds_addr, _) = + start_pool_with_jds(tp_1.bitcoin_core(), vec![], vec![], false).await; + let (jdc_jds_sniffer, jdc_jds_sniffer_addr) = start_sniffer("0", jds_addr, false, vec![], None); + let ignore_submit_solution = + IgnoreMessage::new(MessageDirection::ToUpstream, MESSAGE_TYPE_SUBMIT_SOLUTION); + let (_jdc_tp_sniffer, jdc_tp_sniffer_addr) = start_sniffer( + "1", + tp_addr_2, + false, + vec![ignore_submit_solution.into()], + None, + ); + let (jdc, jdc_addr, _) = start_jdc( + &[(pool_addr, jdc_jds_sniffer_addr)], + sv2_tp_config(jdc_tp_sniffer_addr), + vec![], + vec![], + false, + None, + ); + let (translator, tproxy_addr, _) = + start_sv2_translator(&[jdc_addr], false, vec![], vec![], None, false).await; + let (_minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + + // The declaration must complete a ProvideMissingTransactions round before it succeeds. + jdc_jds_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_PROVIDE_MISSING_TRANSACTIONS, + ) + .await; + jdc_jds_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_PROVIDE_MISSING_TRANSACTIONS_SUCCESS, + ) + .await; + jdc_jds_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_DECLARE_MINING_JOB_SUCCESS, + ) + .await; + jdc_jds_sniffer + .wait_for_message_type(MessageDirection::ToUpstream, MESSAGE_TYPE_PUSH_SOLUTION) + .await; + + // PushSolution is fire-and-forget, so poll the JDS node for the new tip. + let mut new_block_hash = tp_1.get_best_block_hash().unwrap(); + for _ in 0..100 { + if new_block_hash != current_block_hash { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + new_block_hash = tp_1.get_best_block_hash().unwrap(); + } + assert_ne!( + current_block_hash, new_block_hash, + "JDS node should have accepted the solved block" + ); + + // Check the first block after the old tip in case more than one was found. + let solved_block_hash = tp_1 + .bitcoin_core() + .get_block_hash(current_height + 1) + .unwrap(); + let txids = tp_1 + .bitcoin_core() + .get_block_txids(&solved_block_hash) + .unwrap(); + assert!( + txids.contains(&txid.to_string()), + "solved block should contain the transaction provided via ProvideMissingTransactions; got {txids:?}" + ); + shutdown_all!(translator, jdc, pool); +} diff --git a/integration-tests/tests/jds_downstream_state_isolation.rs b/integration-tests/tests/jds_downstream_state_isolation.rs new file mode 100644 index 000000000..04266bb55 --- /dev/null +++ b/integration-tests/tests/jds_downstream_state_isolation.rs @@ -0,0 +1,167 @@ +use integration_tests_sv2::{ + interceptor::MessageDirection, + mock_roles::{MockDownstream, WithSetup}, + sniffer::Sniffer, + template_provider::DifficultyLevel, + *, +}; +use stratum_apps::stratum_core::{ + common_messages_sv2::Protocol, + job_declaration_sv2::*, + mining_sv2::*, + parsers_sv2::{AnyMessage, JobDeclaration, Mining}, +}; + +#[tokio::test] +// Regression coverage for https://github.com/stratum-mining/sv2-apps/issues/590 +// +// Rationale: +// - DeclareMiningJob.request_id is scoped to a single downstream connection. +// - The prior implementation in JDS stored declaration state in a global map keyed only by +// request_id, so two different downstreams reusing the same request_id could overwrite each +// other. +// - That cross-downstream state collision could surface as SetCustomMiningJobError in one flow, +// even though both downstreams followed valid DeclareMiningJob/SetCustomMiningJob sequences. +// +// This test intentionally drives two independent downstreams to collide on request_id and then +// asserts both flows complete without any SetCustomMiningJobError, proving JDS state isolation by +// downstream. +async fn jds_isolates_state_for_colliding_request_ids_across_downstreams() { + start_tracing(); + + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + let (pool, pool_addr, jds_addr, _) = + start_pool_with_jds(tp.bitcoin_core(), vec![], vec![], false).await; + + let (jdc1_jds_sniffer, jdc1_jds_sniffer_addr) = + start_sniffer("jdc1-jds", jds_addr, false, vec![], None); + let (jdc2_jds_sniffer, jdc2_jds_sniffer_addr) = + start_sniffer("jdc2-jds", jds_addr, false, vec![], None); + let (jdc1_pool_sniffer, jdc1_pool_sniffer_addr) = + start_sniffer("jdc1-pool", pool_addr, false, vec![], None); + let (jdc2_pool_sniffer, jdc2_pool_sniffer_addr) = + start_sniffer("jdc2-pool", pool_addr, false, vec![], None); + + let (jdc1, jdc1_addr, _) = start_jdc( + &[(jdc1_pool_sniffer_addr, jdc1_jds_sniffer_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + false, + None, + ); + let (jdc2, jdc2_addr, _) = start_jdc( + &[(jdc2_pool_sniffer_addr, jdc2_jds_sniffer_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + false, + None, + ); + + // Attach one mock mining downstream per JDC so both JDCs open mining channels and + // independently trigger the DeclareMiningJob/SetCustomMiningJob flow against the same JDS. + let send_to_jdc1 = MockDownstream::new( + jdc1_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + let send_to_jdc2 = MockDownstream::new( + jdc2_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + + // Trigger both JDCs to start job declaration for their mining channel. + send_to_jdc1 + .send(AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 1, + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 0, + }, + ))) + .await + .unwrap(); + send_to_jdc2 + .send(AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 1, + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 0, + }, + ))) + .await + .unwrap(); + + // Wait until both DeclareMiningJob messages are observed, then assert request_id collision. + jdc1_jds_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_DECLARE_MINING_JOB, + ) + .await; + jdc2_jds_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_DECLARE_MINING_JOB, + ) + .await; + + let jdc1_request_id = next_declare_mining_job_request_id(&jdc1_jds_sniffer); + let jdc2_request_id = next_declare_mining_job_request_id(&jdc2_jds_sniffer); + assert_eq!( + jdc1_request_id, jdc2_request_id, + "the regression test expects both downstreams to collide on request_id" + ); + + // Both downstream flows must complete without token-crossing failures. + jdc1_pool_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_SUCCESS, + ) + .await; + jdc2_pool_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_SUCCESS, + ) + .await; + + assert_no_set_custom_mining_job_error(&jdc1_pool_sniffer); + assert_no_set_custom_mining_job_error(&jdc2_pool_sniffer); + + jdc1.shutdown().await; + jdc2.shutdown().await; + pool.shutdown().await; +} + +// Returns the first DeclareMiningJob request_id observed from a given sniffer. +fn next_declare_mining_job_request_id(sniffer: &Sniffer<'_>) -> u32 { + loop { + if let Some((_, AnyMessage::JobDeclaration(JobDeclaration::DeclareMiningJob(msg)))) = + sniffer.next_message_from_downstream() + { + return msg.request_id; + } + } +} + +// Scans captured downstream responses and ensures no SetCustomMiningJobError is emitted. +fn assert_no_set_custom_mining_job_error(sniffer: &Sniffer<'_>) { + while let Some((_, message)) = sniffer.next_message_from_upstream() { + if let AnyMessage::Mining(Mining::SetCustomMiningJobError(msg)) = message { + panic!( + "unexpected SetCustomMiningJobError while validating colliding request_id flow: {}", + msg.error_code.as_utf8_or_hex() + ); + } + } +} diff --git a/miner-apps/Cargo.lock b/miner-apps/Cargo.lock index be79dc9c6..f84969f1c 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/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -917,7 +927,8 @@ version = "0.4.0" dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "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 902f8d41c..ad392e288 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/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -351,7 +361,8 @@ version = "0.4.0" dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", diff --git a/pool-apps/jd-server/README.md b/pool-apps/jd-server/README.md index 7e7044b50..40904be06 100644 --- a/pool-apps/jd-server/README.md +++ b/pool-apps/jd-server/README.md @@ -40,4 +40,4 @@ Active tokens are single-use. During `SetCustomMiningJob` handling, the `JobDecl Malformed, unknown, expired, wrong-owner, or already consumed tokens are rejected with `invalid-mining-job-token`. -Allocated and active tokens are periodically removed by a janitor task after their configured timeouts. When a downstream disconnects, its allocated tokens are removed immediately because they only authorize future `DeclareMiningJob` messages from that JDS connection. Active tokens are retained until they are consumed or expire, because they authorize a later `SetCustomMiningJob` sent through the Pool's Mining Protocol path, not through the JDS connection. +Allocated and active tokens are periodically removed by a janitor task after their configured timeouts. When a downstream disconnects, both allocated and active tokens owned by that downstream are removed immediately. diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs b/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs index 19a6bf1cf..2d35b1963 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_declaration_message_handler.rs @@ -168,7 +168,7 @@ impl HandleJobDeclarationMessagesFromClientAsync for JobDeclarator { // validate job let response = match self .job_validator - .handle_declare_mining_job(msg.clone(), None) + .handle_declare_mining_job(client_id, msg.clone(), None) .await { // if job is valid, activate token and return DeclareMiningJobSuccess @@ -299,7 +299,11 @@ impl HandleJobDeclarationMessagesFromClientAsync for JobDeclarator { let response = match self .job_validator - .handle_declare_mining_job(pending_declare_mining_job.clone(), Some(msg.clone())) + .handle_declare_mining_job( + client_id, + pending_declare_mining_job.clone(), + Some(msg.clone()), + ) .await { // if job is valid, activate token and return DeclareMiningJobSuccess @@ -368,13 +372,19 @@ impl HandleJobDeclarationMessagesFromClientAsync for JobDeclarator { async fn handle_push_solution( &mut self, - _client_id: Option, + client_id: Option, msg: PushSolution<'_>, _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { info!("Received: {}", msg); - self.job_validator.handle_push_solution(msg).await; + // Shutdown: client_id is always Some; None indicates a bug. + let client_id = + client_id.ok_or_else(|| JDSError::shutdown(error::JDSErrorKind::ClientNotFound(0)))?; + + self.job_validator + .handle_push_solution(client_id, msg) + .await; Ok(()) } diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs index 307285a5a..0f6201bd6 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/bitcoin_core_ipc.rs @@ -7,8 +7,8 @@ use crate::{ ALLOCATED_TOKEN_TIMEOUT_SECS, JANITOR_INTERVAL_SECS, }, }; -use dashmap::DashMap; use std::{ + collections::{HashMap, HashSet}, path::PathBuf, sync::{Arc, Mutex}, thread::JoinHandle, @@ -18,24 +18,22 @@ use stratum_apps::{ bitcoin_core_sv2::common::{ job_declaration_protocol::{ self, - io::{JdRequest, JdResponse, ValidationContext}, + io::{JdRequest, JdResponse}, CancellationToken, }, BitcoinCoreVersion, }, stratum_core::{ bitcoin::{ - self, - block::Version, - consensus::{Decodable, Encodable}, - hashes::Hash, - BlockHash, CompactTarget, Transaction, TxMerkleNode, Txid, Wtxid, + self, block::Version, consensus::Decodable, hashes::Hash, BlockHash, CompactTarget, + Transaction, TxMerkleNode, Wtxid, }, job_declaration_sv2::{ DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution, ERROR_CODE_DECLARE_MINING_JOB_INTERNAL_ERROR, ERROR_CODE_DECLARE_MINING_JOB_INVALID_COINBASE_TX, ERROR_CODE_DECLARE_MINING_JOB_INVALID_COINBASE_TX_INPUT, + ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB, ERROR_CODE_DECLARE_MINING_JOB_INVALID_MINING_JOB_TOKEN, ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP, }, @@ -54,10 +52,15 @@ use stratum_apps::{ ERROR_CODE_SET_CUSTOM_MINING_JOB_STALE_CHAIN_TIP, }, }, + sync::SharedMap, tp_type::BitcoinNetwork, - utils::types::{JdToken, RequestId}, + 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). /// @@ -66,17 +69,39 @@ use stratum_apps::{ #[derive(Clone)] 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 - validated: bool, + /// Chain tip the declaration was evaluated against; used for stale-tip classification + /// (see https://github.com/stratum-mining/sv2-apps/issues/597). + prev_hash: BlockHash, + /// Populated once the job passes Bitcoin Core validation ([`JdResponse::Success`]); + /// `None` while waiting for missing transactions. + validated: Option, +} + +/// Template parameters of a fully validated declared job. +#[derive(Clone)] +struct ValidatedJobData { + nbits: CompactTarget, + /// Coinbase merkle branch reported by the validator, used to validate a + /// `SetCustomMiningJob.merkle_path`. + merkle_path: Vec, } +/// Latest `DeclaredCustomJob` accepted via `SetCustomMiningJob` for a downstream. +type ActiveCustomJob = DeclaredCustomJob; + #[derive(Clone, Copy)] struct AllocatedTokenEntry { request_id: RequestId, inserted_at: Instant, } +#[derive(Default)] +struct DownstreamState { + declared_custom_jobs: HashMap, + allocated_token_entries: HashMap, + active_custom_job: Option, +} + #[cfg_attr(not(test), hotpath::measure_all)] impl DeclaredCustomJob { /// Returns the block version from the original `DeclareMiningJob`. @@ -84,24 +109,14 @@ impl DeclaredCustomJob { self.declare_mining_job.version } - /// Returns `nbits` (difficulty target). - fn get_nbits(&self) -> u32 { - self.validation_context.nbits.to_consensus() - } - - /// Returns `prev_hash`. - fn get_prev_hash(&self) -> BlockHash { - self.validation_context.prev_hash - } - - /// Reconstructs the declared coinbase transaction by concatenating prefix, extranonce (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 = @@ -134,10 +149,24 @@ impl DeclaredCustomJob { // The full extranonce fills the remaining space in scriptSig let full_extranonce_size: usize = script_sig_size - script_sig_bytes_in_prefix; - // Concatenate prefix + full extranonce (zeros) + suffix to form the complete transaction - // bytes + let extranonce_bytes = match extranonce { + Some(bytes) => { + if bytes.len() != full_extranonce_size { + tracing::error!( + "PushSolution extranonce size mismatch: expected {}, got {}", + full_extranonce_size, + bytes.len() + ); + return Err(()); + } + bytes.to_vec() + } + None => vec![0; full_extranonce_size], + }; + + // Concatenate prefix + extranonce + suffix to form the complete transaction bytes. let mut declared_coinbase_tx = declared_coinbase_tx_prefix; - declared_coinbase_tx.extend_from_slice(&vec![0; full_extranonce_size]); + declared_coinbase_tx.extend_from_slice(&extranonce_bytes); declared_coinbase_tx.extend_from_slice(&declared_coinbase_tx_suffix); // Deserialize the transaction @@ -147,65 +176,6 @@ impl DeclaredCustomJob { }, ) } - - /// Computes the coinbase merkle branch in the txid merkle tree. - /// - /// Returns the sibling hashes at each level from leaf to root, needed to - /// reconstruct the block header's merkle root from the coinbase position (index 0). - /// - /// Requires `txid_list` 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 - /// so error_code = "declared-job-not-yet-validated" - /// therefore () error type is sufficient. - fn get_merkle_path(&self) -> Result, ()> { - if !self.validated { - return Err(()); - } - - let txid_list = self.txid_list.as_ref().ok_or(())?; - - let coinbase_tx = self - .get_coinbase_tx() - .expect("coinbase tx already validated"); - let coinbase_txid: TxMerkleNode = coinbase_tx.compute_txid().into(); - - let mut hashes: Vec = Vec::with_capacity(1 + txid_list.len()); - hashes.push(coinbase_txid); - for txid in txid_list { - hashes.push((*txid).into()); - } - - if hashes.len() == 1 { - return Ok(Vec::new()); - } - - let mut branch = Vec::new(); - - while hashes.len() > 1 { - branch.push(hashes[1]); - - let half = hashes.len().div_ceil(2); - let mut next_level = Vec::with_capacity(half); - for idx in 0..half { - let left = hashes[2 * idx]; - let right = hashes[std::cmp::min(2 * idx + 1, hashes.len() - 1)]; - let mut engine = TxMerkleNode::engine(); - left.consensus_encode(&mut engine) - .expect("in-memory writers don't error"); - right - .consensus_encode(&mut engine) - .expect("in-memory writers don't error"); - next_level.push(TxMerkleNode::from_engine(engine)); - } - hashes = next_level; - } - - Ok(branch) - } } /// Engine for validating and propagating solutions for Custom Jobs using Bitcoin Core over IPC. @@ -214,8 +184,7 @@ impl DeclaredCustomJob { #[derive(Clone)] pub struct BitcoinCoreIPCEngine { request_sender: async_channel::Sender, - allocated_token_entries: Arc>, - declared_custom_jobs: Arc>, + downstream_states: SharedMap, cancellation_token: CancellationToken, jdp_thread_handle: Arc>>>, } @@ -227,9 +196,10 @@ 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. + /// Blocks until the backend is ready to process requests (mempool mirror bootstrapped for + /// v30.x/v31.x, IBD finished for v32.x). pub async fn new( version: BitcoinCoreVersion, network: BitcoinNetwork, @@ -313,8 +283,9 @@ impl BitcoinCoreIPCEngine { }); }); - // Wait for BitcoinCoreSv2JDP to complete mempool bootstrap, mirroring the - // pool's Template Provider startup behavior during IBD. + // Wait for BitcoinCoreSv2JDP to become ready (mempool bootstrap or IBD wait, + // depending on the backend), mirroring the pool's Template Provider startup + // behavior during IBD. // Until `new()` succeeds, this function is still the only owner of the spawned JDP // thread handle, so cancellation/bootstrap failure must join here rather than detach it. let mut ready_rx = ready_rx; @@ -329,18 +300,18 @@ impl BitcoinCoreIPCEngine { } return Err(JDSErrorKind::BitcoinCoreIPC( - "Mempool bootstrap did not complete".to_string(), + "Bitcoin Core JDP backend did not become ready".to_string(), )); } } } _ = cancellation_token.cancelled() => { - tracing::info!("BitcoinCoreIPCEngine stopped before mempool bootstrap completed"); + tracing::info!("BitcoinCoreIPCEngine stopped before the JDP backend became ready"); if let Err(e) = jdp_thread_handle.join() { tracing::warn!("BitcoinCoreSv2JDP thread join failed during startup cancellation: {e:?}"); } return Err(JDSErrorKind::BitcoinCoreIPC( - "Mempool bootstrap did not complete".to_string(), + "Bitcoin Core JDP backend did not become ready".to_string(), )); } _ = tokio::time::sleep(Duration::from_secs(1)) => { @@ -350,15 +321,13 @@ impl BitcoinCoreIPCEngine { } } - let allocated_token_to_request_id = - Arc::new(DashMap::::new()); - let declared_custom_jobs = Arc::new(DashMap::::new()); + let downstream_states = SharedMap::::new(); // Spawn janitor task to clean up stale declared jobs that were never // consumed by SetCustomMiningJob. - let janitor_allocated_token_to_request_id = Arc::clone(&allocated_token_to_request_id); - let janitor_declared_custom_jobs = Arc::clone(&declared_custom_jobs); + let janitor_downstream_states = downstream_states.clone(); let janitor_cancellation = cancellation_token.clone(); + let janitor_request_sender = request_sender.clone(); tokio::spawn(async move { let janitor_interval = Duration::from_secs(JANITOR_INTERVAL_SECS); let token_timeout = Duration::from_secs(ALLOCATED_TOKEN_TIMEOUT_SECS); @@ -367,23 +336,40 @@ impl BitcoinCoreIPCEngine { _ = janitor_cancellation.cancelled() => break, _ = tokio::time::sleep(janitor_interval) => { let now = Instant::now(); - let expired_tokens: Vec = janitor_allocated_token_to_request_id - .iter() - .filter_map(|entry| { - let inserted_at = entry.value().inserted_at; - if now.saturating_duration_since(inserted_at) > token_timeout { - Some(*entry.key()) - } else { - None + janitor_downstream_states.for_each_mut(|downstream_id, state| { + let expired_tokens: Vec = state + .allocated_token_entries + .iter() + .filter_map(|(token, entry)| { + if now.saturating_duration_since(entry.inserted_at) + > token_timeout + { + Some(*token) + } else { + None + } + }) + .collect(); + + for token in expired_tokens { + if let Some(entry) = state.allocated_token_entries.remove(&token) { + state.declared_custom_jobs.remove(&entry.request_id); + // let the validator release any state retained for the job + let _ = janitor_request_sender.try_send( + JdRequest::ReleaseDeclaredJob { + downstream_id, + request_id: entry.request_id, + }, + ); + tracing::debug!( + downstream_id, + token, + request_id = entry.request_id, + "Removed expired declared custom job state" + ); } - }) - .collect(); - - for token in expired_tokens { - if let Some((_, entry)) = janitor_allocated_token_to_request_id.remove(&token) { - janitor_declared_custom_jobs.remove(&entry.request_id); } - } + }); } } } @@ -391,21 +377,149 @@ impl BitcoinCoreIPCEngine { Ok(Self { request_sender, - allocated_token_entries: allocated_token_to_request_id, - declared_custom_jobs, + downstream_states, cancellation_token, jdp_thread_handle: Arc::new(Mutex::new(Some(jdp_thread_handle))), }) } -} -fn validation_context_drifted( - previous_ctx: ValidationContext, - current_ctx: ValidationContext, -) -> bool { - previous_ctx.prev_hash != current_ctx.prev_hash - || previous_ctx.nbits != current_ctx.nbits - || previous_ctx.min_ntime != current_ctx.min_ntime + /// Tells the validator to release any state retained for a declared job that will + /// never receive a solution (fire-and-forget). + fn release_declared_job(&self, downstream_id: DownstreamId, request_id: RequestId) { + let _ = self.request_sender.try_send(JdRequest::ReleaseDeclaredJob { + downstream_id, + request_id, + }); + } + + /// Cross-checks a `SetCustomMiningJob` against the stored declaration, returning the + /// `SetCustomMiningJobError` error code on mismatch. + fn validate_set_custom_mining_job( + declared_custom_job: &DeclaredCustomJob, + set_custom_mining_job: &SetCustomMiningJob<'_>, + ) -> Result<(), &'static str> { + // Job may be pending retry after missing txs and not fully validated yet. + let Some(validated) = declared_custom_job.validated.as_ref() else { + tracing::error!("Job not yet validated"); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED); + }; + + // Get declared values from stored job + let declared_prev_hash = declared_custom_job.prev_hash; + let declared_nbits = validated.nbits.to_consensus(); + let declared_version: u32 = declared_custom_job.get_version(); + + // Extract values from SetCustomMiningJob message + let custom_job_prev_hash = { + let bytes = set_custom_mining_job.prev_hash.to_array(); + BlockHash::from_byte_array(bytes) + }; + let custom_job_nbits: u32 = set_custom_mining_job.nbits; + let custom_job_version: u32 = set_custom_mining_job.version; + + // Validate prev_hash + if custom_job_prev_hash != declared_prev_hash { + tracing::debug!( + "prev_hash mismatch: custom={:?}, declared={:?}", + custom_job_prev_hash, + declared_prev_hash + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_STALE_CHAIN_TIP); + } + + // Validate nbits + if custom_job_nbits != declared_nbits { + tracing::debug!( + "nbits mismatch: custom={}, declared={}", + custom_job_nbits, + declared_nbits + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_NBITS); + } + + // Validate version + if custom_job_version != declared_version { + tracing::debug!( + "version mismatch: custom={}, declared={}", + custom_job_version, + declared_version + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_VERSION); + } + + // validate coinbase tx + { + let declared_coinbase_tx = match declared_custom_job.get_coinbase_tx(None) { + Ok(tx) => tx, + Err(_) => return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX), + }; + + if declared_coinbase_tx.version.0 != set_custom_mining_job.coinbase_tx_version as i32 { + tracing::debug!( + "coinbase version mismatch: custom={}, declared={}", + set_custom_mining_job.coinbase_tx_version, + declared_coinbase_tx.version.0 + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_VERSION); + } + + let script_sig = declared_coinbase_tx.input[0].script_sig.as_bytes(); + let coinbase_prefix = set_custom_mining_job.coinbase_prefix.as_bytes(); + if !script_sig.starts_with(coinbase_prefix) { + tracing::debug!("coinbase prefix mismatch"); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_PREFIX); + } + + if declared_coinbase_tx.input[0].sequence.0 + != set_custom_mining_job.coinbase_tx_input_n_sequence + { + tracing::debug!( + "coinbase input sequence mismatch: custom={}, declared={}", + set_custom_mining_job.coinbase_tx_input_n_sequence, + declared_coinbase_tx.input[0].sequence.0 + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_INPUT_N_SEQUENCE); + } + + let declared_outputs_bytes = + bitcoin::consensus::serialize(&declared_coinbase_tx.output); + if declared_outputs_bytes != set_custom_mining_job.coinbase_tx_outputs.as_bytes() { + tracing::debug!("coinbase outputs mismatch"); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_OUTPUTS); + } + + if declared_coinbase_tx.lock_time.to_consensus_u32() + != set_custom_mining_job.coinbase_tx_locktime + { + tracing::debug!( + "coinbase locktime mismatch: custom={}, declared={}", + set_custom_mining_job.coinbase_tx_locktime, + declared_coinbase_tx.lock_time.to_consensus_u32() + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_LOCKTIME); + } + } + + // validate merkle path + { + let custom_merkle_path: Vec = set_custom_mining_job + .merkle_path + .iter() + .map(|u256| TxMerkleNode::from_byte_array(u256.to_array())) + .collect(); + + if validated.merkle_path != custom_merkle_path { + tracing::debug!( + "merkle path mismatch: custom={:?}, declared={:?}", + custom_merkle_path, + validated.merkle_path + ); + return Err(ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MERKLE_PATH); + } + } + + Ok(()) + } } #[cfg_attr(not(test), hotpath::measure_all)] @@ -422,6 +536,14 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } + fn cleanup_downstream(&self, downstream_id: DownstreamId) { + self.downstream_states.remove(&downstream_id); + // let the validator release any state retained for the downstream's jobs + let _ = self + .request_sender + .try_send(JdRequest::CleanupDownstream { downstream_id }); + } + /// Validates a `DeclareMiningJob` by forwarding it to Bitcoin Core over IPC. /// /// Steps: @@ -432,6 +554,7 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { /// `DeclaredCustomJob` for later `SetCustomMiningJob` validation. async fn handle_declare_mining_job( &self, + downstream_id: DownstreamId, declare_mining_job: DeclareMiningJob<'_>, provide_missing_transactions_success: Option>, ) -> DeclareMiningJobResult { @@ -453,17 +576,11 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { let declared_coinbase_tx = { let temp_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static.clone(), - validation_context: ValidationContext { - prev_hash: BlockHash::all_zeros(), // irrelevant for coinbase tx validation - nbits: CompactTarget::from_consensus(0), /* irrelevant for coinbase tx - * validation */ - min_ntime: 0, // irrelevant for coinbase tx validation - }, - txid_list: None, // irrelevant for coinbase tx validation - validated: false, // irrelevant for coinbase tx validation + prev_hash: BlockHash::all_zeros(), // irrelevant for coinbase tx validation + validated: None, // irrelevant for coinbase tx validation }; - match temp_job.get_coinbase_tx() { + match temp_job.get_coinbase_tx(None) { Ok(tx) => { tracing::debug!("Declared coinbase transaction validated successfully"); tx @@ -492,10 +609,25 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { .map(|u256| Wtxid::from_byte_array(u256.to_array())) .collect(); - // Parse missing transactions from ProvideMissingTransactionsSuccess - let missing_txs: Vec = - if let Some(ref pmts) = provide_missing_transactions_success { - pmts.transaction_list + // A declared job must not list the same transaction twice. + let declared_wtxids: HashSet = wtxid_list.iter().copied().collect(); + if declared_wtxids.len() != wtxid_list.len() { + tracing::debug!( + downstream_id, + request_id = declare_mining_job.request_id, + "DeclareMiningJob wtxid list contains duplicates" + ); + return DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_INVALID_JOB); + } + + // Parse missing transactions from ProvideMissingTransactionsSuccess, ignoring any + // transaction that is not part of the declared job. Anything the validator still + // considers missing afterwards is reported through another + // ProvideMissingTransactions round. + let missing_txs: Vec = if let Some(ref pmts) = + provide_missing_transactions_success + { + pmts.transaction_list .iter_bytes() .filter_map(|tx_bytes| { match bitcoin::consensus::Decodable::consensus_decode(&mut &tx_bytes[..]) { @@ -506,16 +638,32 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } }) + .filter(|tx: &Transaction| { + let declared = declared_wtxids.contains(&tx.compute_wtxid()); + if !declared { + tracing::warn!( + downstream_id, + request_id = declare_mining_job.request_id, + "Ignoring provided missing transaction that is not part of the declared job" + ); + } + declared + }) .collect() - } else { - Vec::new() - }; + } else { + Vec::new() + }; - let previous_pending_validation_context = + let previous_pending_prev_hash = provide_missing_transactions_success.as_ref().and_then(|_| { - self.declared_custom_jobs - .get(&declare_mining_job.request_id) - .map(|job| job.validation_context) + self.downstream_states + .with(&downstream_id, |state| { + state + .declared_custom_jobs + .get(&declare_mining_job.request_id) + .map(|job| job.prev_hash) + }) + .flatten() }); // Create oneshot channel for response @@ -523,6 +671,8 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // Send request to BitcoinCoreSv2JDP (clone wtxid_list since we need it for error handling) let request = JdRequest::DeclareMiningJob { + downstream_id, + request_id: declare_mining_job.request_id, version: Version::from_consensus(declare_mining_job.version as i32), coinbase_tx: declared_coinbase_tx, wtxid_list: wtxid_list.clone(), @@ -551,43 +701,47 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { JdResponse::Success { prev_hash, nbits, - min_ntime, - txid_list, + min_ntime: _, + merkle_path, } => { 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), - validated: true, + prev_hash, + validated: Some(ValidatedJobData { nbits, merkle_path }), }; - self.declared_custom_jobs - .insert(declare_mining_job.request_id, declared_custom_job); - self.allocated_token_entries.insert( - allocated_token, - AllocatedTokenEntry { - request_id: declare_mining_job.request_id, - inserted_at: Instant::now(), - }, - ); + self.downstream_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 { error_code, - validation_context, + prev_hash, } => { - self.declared_custom_jobs - .remove(&declare_mining_job.request_id); - self.allocated_token_entries.remove(&allocated_token); + self.downstream_states.with_mut(&downstream_id, |state| { + state + .declared_custom_jobs + .remove(&declare_mining_job.request_id); + state.allocated_token_entries.remove(&allocated_token); + }); + self.release_declared_job(downstream_id, declare_mining_job.request_id); - let tip_drifted = previous_pending_validation_context - .map(|previous_ctx| { - validation_context_drifted(previous_ctx, validation_context) - }) - .unwrap_or(false); + let tip_drifted = match (previous_pending_prev_hash, prev_hash) { + (Some(previous_prev_hash), Some(current_prev_hash)) => { + previous_prev_hash != current_prev_hash + } + _ => false, + }; if tip_drifted { DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP) @@ -597,38 +751,43 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } JdResponse::MissingTransactions { missing_wtxids, - validation_context, + prev_hash, } => { - let tip_drifted = previous_pending_validation_context - .map(|previous_ctx| { - validation_context_drifted(previous_ctx, validation_context) - }) + let tip_drifted = previous_pending_prev_hash + .map(|previous_prev_hash| previous_prev_hash != prev_hash) .unwrap_or(false); // If this is a retry after ProvideMissingTransactionsSuccess and context drifted, // classify as stale-chain-tip instead of asking for yet another missing-txs round. if provide_missing_transactions_success.is_some() && tip_drifted { - self.declared_custom_jobs - .remove(&declare_mining_job.request_id); - self.allocated_token_entries.remove(&allocated_token); + self.downstream_states.with_mut(&downstream_id, |state| { + state + .declared_custom_jobs + .remove(&declare_mining_job.request_id); + state.allocated_token_entries.remove(&allocated_token); + }); + self.release_declared_job(downstream_id, declare_mining_job.request_id); DeclareMiningJobResult::Error(ERROR_CODE_DECLARE_MINING_JOB_STALE_CHAIN_TIP) } else { let declared_custom_job = DeclaredCustomJob { declare_mining_job: declare_mining_job_static, - validation_context, - txid_list: None, - validated: false, // this is only set to true on JdResponse::Success + prev_hash, + validated: None, // this is only populated on JdResponse::Success }; - self.declared_custom_jobs - .insert(declare_mining_job.request_id, declared_custom_job); - self.allocated_token_entries.insert( - allocated_token, - AllocatedTokenEntry { - request_id: declare_mining_job.request_id, - inserted_at: Instant::now(), - }, - ); + self.downstream_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) } @@ -636,19 +795,103 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } } - async fn handle_push_solution(&self, push_solution: PushSolution<'_>) { - // Convert to static lifetime for channel transfer - let push_solution_static = push_solution.into_static(); + async fn handle_push_solution( + &self, + downstream_id: DownstreamId, + push_solution: PushSolution<'_>, + ) { + 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) => match active_job.validated.as_ref() { + Some(validated) => ( + active_job.prev_hash, + validated.nbits.to_consensus(), + active_job.get_version(), + ), + None => { + tracing::error!( + "Active custom job on downstream {} was never validated", + downstream_id, + ); + return None; + } + }, + None => { + tracing::error!( + "No active custom job found for PushSolution on downstream {}", + downstream_id, + ); + return None; + } + }; + + let declared_fixed_version_bits = + declared_version & !PUSH_SOLUTION_VERSION_ROLLING_MASK; + let solved_fixed_version_bits = + push_solution.version & !PUSH_SOLUTION_VERSION_ROLLING_MASK; + + if prev_hash != declared_prev_hash + || push_solution.nbits != declared_nbits + || solved_fixed_version_bits != declared_fixed_version_bits + { + tracing::error!( + "Ignoring PushSolution that does not match latest declared custom job on downstream {}: expected prev_hash={:?}, nbits={}, version={}, got prev_hash={:?}, nbits={}, version={} (mask=0x{:08x}, expected_fixed_version_bits=0x{:08x}, got_fixed_version_bits=0x{:08x})", + downstream_id, + declared_prev_hash, + declared_nbits, + declared_version, + prev_hash, + push_solution.nbits, + push_solution.version, + PUSH_SOLUTION_VERSION_ROLLING_MASK, + declared_fixed_version_bits, + solved_fixed_version_bits + ); + return None; + } + + state.active_custom_job.take() + }) + .flatten(); + + let Some(active_job) = active_job else { + return; + }; + + let request_id = active_job.declare_mining_job.request_id; + + // Reconstruct the declared coinbase with the solution's extranonce; the validator + // reconstructs and submits the full block from its retained template. + let coinbase_tx = match active_job.get_coinbase_tx(Some(push_solution.extranonce.as_ref())) + { + Ok(coinbase_tx) => coinbase_tx, + Err(_) => { + tracing::error!("Failed to reconstruct solved coinbase transaction"); + self.release_declared_job(downstream_id, request_id); + return; + } + }; - // Send request to BitcoinCoreSv2JDP (fire-and-forget) let request = JdRequest::PushSolution { - push_solution: push_solution_static, + downstream_id, + request_id, + version: push_solution.version, + ntime: push_solution.ntime, + nonce: push_solution.nonce, + coinbase_tx, }; if let Err(e) = self.request_sender.send(request).await { - tracing::error!("Failed to send PushSolution request: {}", e); + tracing::error!(downstream_id, "Failed to send PushSolution request: {}", e); } else { - tracing::debug!("PushSolution request sent successfully"); + tracing::debug!(downstream_id, "PushSolution request sent successfully"); } } @@ -663,16 +906,27 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { // DeclareMiningJob token. async fn handle_set_custom_mining_job( &self, + downstream_id: DownstreamId, set_custom_mining_job: SetCustomMiningJob<'_>, allocated_token: JdToken, // Note: This is the corresponding DeclareMiningJob token ) -> SetCustomMiningJobResult { // Look up request_id using the allocated token - let request_id = match self.allocated_token_entries.get(&allocated_token) { - Some(entry) => entry.request_id, + let request_id = match self + .downstream_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!( - "Provided token {} is not associated with any DeclareMiningJob request", - allocated_token + downstream_id, + allocated_token, + "Provided token is not associated with any DeclareMiningJob request" ); return SetCustomMiningJobResult::Error( ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, @@ -680,177 +934,55 @@ impl JobValidationEngine for BitcoinCoreIPCEngine { } }; - // Clean up immediately - the job is being consumed regardless of validation result - self.allocated_token_entries.remove(&allocated_token); - - let declared_custom_job = { - match self.declared_custom_jobs.remove(&request_id) { - Some((_request_id, declared_custom_job)) => declared_custom_job, - None => { - tracing::debug!("DeclaredCustomJob associated with allocated token {} and request id {} not found", allocated_token, request_id); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, - ); - } - } - }; - - // Job may be pending retry after missing txs and not fully validated yet. - if !declared_custom_job.validated { - tracing::error!("Job not yet validated"); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED, - ); - } - - // Get declared values from stored job - let declared_prev_hash = declared_custom_job.get_prev_hash(); - let declared_nbits = declared_custom_job.get_nbits(); - let declared_version: u32 = declared_custom_job.get_version(); - - // Extract values from SetCustomMiningJob message - let custom_job_prev_hash = { - let bytes = set_custom_mining_job.prev_hash.to_array(); - BlockHash::from_byte_array(bytes) - }; - let custom_job_nbits: u32 = set_custom_mining_job.nbits; - let custom_job_version: u32 = set_custom_mining_job.version; - - // Validate prev_hash - { - if custom_job_prev_hash != declared_prev_hash { - tracing::debug!( - "prev_hash mismatch: custom={:?}, declared={:?}", - custom_job_prev_hash, - declared_prev_hash - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_STALE_CHAIN_TIP, - ); - } - } - - // Validate nbits + let declared_custom_job = match self + .downstream_states + .with_mut(&downstream_id, |state| { + // Clean up immediately - the job is being consumed regardless of validation result. + state.allocated_token_entries.remove(&allocated_token); + state.declared_custom_jobs.remove(&request_id) + }) + .flatten() { - if custom_job_nbits != declared_nbits { + Some(declared_custom_job) => declared_custom_job, + None => { tracing::debug!( - "nbits mismatch: custom={}, declared={}", - custom_job_nbits, - declared_nbits + downstream_id, + allocated_token, + request_id, + "DeclaredCustomJob associated with allocated token and request id not found" ); return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_NBITS, + ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, ); } - } + }; - // Validate version + // The job is consumed at this point, so a validation failure must also release any + // validator state retained for it (e.g. the v32.x backend's BlockTemplate). + if let Err(error_code) = + Self::validate_set_custom_mining_job(&declared_custom_job, &set_custom_mining_job) { - if custom_job_version != declared_version { - tracing::debug!( - "version mismatch: custom={}, declared={}", - custom_job_version, - declared_version - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_VERSION, - ); - } + self.release_declared_job(downstream_id, request_id); + return SetCustomMiningJobResult::Error(error_code); } - // validate coinbase tx - { - let declared_coinbase_tx = match declared_custom_job.get_coinbase_tx() { - Ok(tx) => tx, - Err(_) => { - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX, - ) - } - }; - - if declared_coinbase_tx.version.0 != set_custom_mining_job.coinbase_tx_version as i32 { - tracing::debug!( - "coinbase version mismatch: custom={}, declared={}", - set_custom_mining_job.coinbase_tx_version, - declared_coinbase_tx.version.0 - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_VERSION, - ); - } - - let script_sig = declared_coinbase_tx.input[0].script_sig.as_bytes(); - let coinbase_prefix = set_custom_mining_job.coinbase_prefix.as_bytes(); - if !script_sig.starts_with(coinbase_prefix) { - tracing::debug!("coinbase prefix mismatch"); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_PREFIX, - ); - } - - if declared_coinbase_tx.input[0].sequence.0 - != set_custom_mining_job.coinbase_tx_input_n_sequence - { - tracing::debug!( - "coinbase input sequence mismatch: custom={}, declared={}", - set_custom_mining_job.coinbase_tx_input_n_sequence, - declared_coinbase_tx.input[0].sequence.0 - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_INPUT_N_SEQUENCE, - ); - } - - let declared_outputs_bytes = - bitcoin::consensus::serialize(&declared_coinbase_tx.output); - if declared_outputs_bytes != set_custom_mining_job.coinbase_tx_outputs.as_bytes() { - tracing::debug!("coinbase outputs mismatch"); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_OUTPUTS, - ); - } - - if declared_coinbase_tx.lock_time.to_consensus_u32() - != set_custom_mining_job.coinbase_tx_locktime - { - tracing::debug!( - "coinbase locktime mismatch: custom={}, declared={}", - set_custom_mining_job.coinbase_tx_locktime, - declared_coinbase_tx.lock_time.to_consensus_u32() - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_COINBASE_TX_LOCKTIME, - ); - } - } - - // validate merkle path - { - let declared_merkle_path = match declared_custom_job.get_merkle_path() { - Ok(path) => path, - Err(_) => { - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_JOB_NOT_YET_VALIDATED, - ) - } - }; - - let custom_merkle_path: Vec = set_custom_mining_job - .merkle_path - .iter() - .map(|u256| TxMerkleNode::from_byte_array(u256.to_array())) - .collect(); - - if declared_merkle_path != custom_merkle_path { - tracing::debug!( - "merkle path mismatch: custom={:?}, declared={:?}", - custom_merkle_path, - declared_merkle_path - ); - return SetCustomMiningJobResult::Error( - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MERKLE_PATH, - ); + let replaced_request_id = + self.downstream_states + .with_mut_or_default(downstream_id, |state| { + state + .active_custom_job + .replace(declared_custom_job) + .map(|replaced_job| replaced_job.declare_mining_job.request_id) + }); + if let Some(replaced_request_id) = replaced_request_id { + tracing::debug!( + "Replaced previous active custom job for downstream {} with newer SetCustomMiningJob", + downstream_id, + ); + // The replaced job can no longer receive a solution; release its retained state + // (unless the same request id was just re-activated). + if replaced_request_id != request_id { + self.release_declared_job(downstream_id, replaced_request_id); } } diff --git a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs index b95410d32..ee4ba326c 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/job_validation/mod.rs @@ -6,7 +6,7 @@ use stratum_apps::{ job_declaration_sv2::{DeclareMiningJob, ProvideMissingTransactionsSuccess, PushSolution}, mining_sv2::SetCustomMiningJob, }, - utils::types::JdToken, + utils::types::{DownstreamId, JdToken}, }; pub mod bitcoin_core_ipc; @@ -30,21 +30,35 @@ pub trait JobValidationEngine: Send + Sync { /// Handles a declare mining job request. async fn handle_declare_mining_job( &self, + downstream_id: DownstreamId, declare_mining_job: DeclareMiningJob<'_>, provide_missing_transactions_success: Option>, ) -> DeclareMiningJobResult; /// Submits a mining solution to the backend. - async fn handle_push_solution(&self, push_solution: PushSolution<'_>); + /// + /// 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, + push_solution: PushSolution<'_>, + ); /// Validates a `SetCustomMiningJob` (Mining Protocol) against the previously declared job /// identified by `allocated_token`. async fn handle_set_custom_mining_job( &self, + downstream_id: DownstreamId, set_custom_mining_job: SetCustomMiningJob<'_>, allocated_token: JdToken, ) -> SetCustomMiningJobResult; + /// Removes validation state associated with a downstream connection. + /// + /// Called by [`crate::job_declarator::JobDeclarator`] when a downstream disconnects. + fn cleanup_downstream(&self, _downstream_id: DownstreamId) {} + /// Performs backend-specific shutdown work. /// /// Default implementation is a no-op so non-threaded engines do not need to diff --git a/pool-apps/jd-server/src/lib/job_declarator/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/mod.rs index fdd8f4596..5f8b1f9ca 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/mod.rs @@ -361,6 +361,7 @@ impl JobDeclarator { .remove(&downstream_id) .is_some(); + self.job_validator.cleanup_downstream(downstream_id); self.token_manager.remove_downstream(downstream_id); debug!( @@ -438,38 +439,40 @@ impl JobDeclarator { }; // this allows JobValidationEngine to lookup the corresponding DeclareMiningJob - let allocated_token = match self.token_manager.allocated_from_active(active_token) { - Some(token) => { - debug!( - request_id, - channel_id, - active_token, - allocated_token = token, - "SetCustomMiningJob: active token mapped to allocated token" - ); - token - } - None => { - debug!( - request_id, - channel_id, - active_token, - "SetCustomMiningJob: active token not found in TokenManager" - ); - return Ok(SetCustomMiningJobResponse::error( - request_id, - channel_id, - ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, - )); - } - }; + let (allocated_token, downstream_id) = + match self.token_manager.allocated_from_active(active_token) { + Some((token, downstream_id)) => { + debug!( + request_id, + channel_id, + active_token, + allocated_token = token, + downstream_id, + "SetCustomMiningJob: active token mapped to allocated token" + ); + (token, downstream_id) + } + None => { + debug!( + request_id, + channel_id, + active_token, + "SetCustomMiningJob: active token not found in TokenManager" + ); + return Ok(SetCustomMiningJobResponse::error( + request_id, + channel_id, + ERROR_CODE_SET_CUSTOM_MINING_JOB_INVALID_MINING_JOB_TOKEN, + )); + } + }; // Clean up TokenManager self.token_manager.deactivate(active_token); match self .job_validator - .handle_set_custom_mining_job(set_custom_mining_job, allocated_token) + .handle_set_custom_mining_job(downstream_id, set_custom_mining_job, allocated_token) .await { SetCustomMiningJobResult::Success => { diff --git a/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs b/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs index 9933ebf31..756a18089 100644 --- a/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs +++ b/pool-apps/jd-server/src/lib/job_declarator/token_management/mod.rs @@ -134,13 +134,17 @@ impl TokenManager { ); } - /// Returns the allocated token that corresponds to an active token. + /// Returns the allocated token and owning downstream that correspond to an active token. /// Returns `None` if the active token is not found. - pub fn allocated_from_active(&self, active_token: JdToken) -> Option { - let mapped = self.active_tokens.get(&active_token).map(|entry| entry.0); + pub fn allocated_from_active(&self, active_token: JdToken) -> Option<(JdToken, DownstreamId)> { + let mapped = self + .active_tokens + .get(&active_token) + .map(|entry| (entry.0, entry.2)); debug!( active_token, - mapped_allocated_token = mapped, + mapped_allocated_token = mapped.map(|(allocated, _)| allocated), + mapped_downstream_id = mapped.map(|(_, downstream_id)| downstream_id), found = mapped.is_some(), active_tokens_len = self.active_tokens.len(), allocated_tokens_len = self.allocated_tokens.len(), @@ -156,15 +160,15 @@ impl TokenManager { } /// Removes allocated tokens belonging to a given downstream. - /// - /// Active tokens are intentionally retained here and can still be consumed by - /// `SetCustomMiningJob` or evicted later by the janitor timeout. + /// Also removes active tokens that were activated by the same downstream. pub fn remove_downstream(&self, downstream_id: DownstreamId) { let allocated_tokens_before = self.allocated_tokens.len(); let active_tokens_before = self.active_tokens.len(); self.allocated_tokens .retain(|_, (_, owner)| *owner != downstream_id); + self.active_tokens + .retain(|_, (_, _, owner)| *owner != downstream_id); let allocated_tokens_after = self.allocated_tokens.len(); let active_tokens_after = self.active_tokens.len(); @@ -174,11 +178,12 @@ impl TokenManager { downstream_id, removed_allocated_tokens = allocated_tokens_before.saturating_sub(allocated_tokens_after), + removed_active_tokens = active_tokens_before.saturating_sub(active_tokens_after), allocated_tokens_before, allocated_tokens_after, active_tokens_before, active_tokens_after, - "TokenManager: removed downstream-allocated tokens and retained active tokens" + "TokenManager: removed downstream tokens" ); } 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 cbc7f9c38..8724bbebc 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/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection#26d1aad13ebc322dcecb73d084d972d1a24d5845" +dependencies = [ + "capnp", + "capnp-rpc", + "capnpc", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -852,7 +862,8 @@ version = "0.4.0" dependencies = [ "async-channel", "bitcoin-capnp-types 0.1.2", - "bitcoin-capnp-types 0.2.1", + "bitcoin-capnp-types 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-capnp-types 0.2.1 (git+https://github.com/Sjors/bitcoin-capnp-types.git?branch=2026%2F07%2Ftx-collection)", "stratum-core", "tokio", "tokio-util", diff --git a/stratum-apps/src/tp_type.rs b/stratum-apps/src/tp_type.rs index 03ca1bac7..9d88dfb3a 100644 --- a/stratum-apps/src/tp_type.rs +++ b/stratum-apps/src/tp_type.rs @@ -25,7 +25,7 @@ where let major = ::deserialize(deserializer)?; BitcoinCoreVersion::try_from(major).map_err(|unsupported| { serde::de::Error::custom(format!( - "unsupported Bitcoin Core IPC version: {unsupported}. expected 30 or 31" + "unsupported Bitcoin Core IPC version: {unsupported}. expected 30, 31, or 32" )) }) } @@ -142,7 +142,7 @@ mod tests { #[cfg(feature = "bitcoin-core-sv2")] #[test] - fn bitcoin_core_version_accepts_30_and_31() { + fn bitcoin_core_version_accepts_30_31_and_32() { assert!(matches!( BitcoinCoreVersion::try_from(30), Ok(BitcoinCoreVersion::V30X) @@ -151,12 +151,16 @@ mod tests { BitcoinCoreVersion::try_from(31), Ok(BitcoinCoreVersion::V31X) )); + assert!(matches!( + BitcoinCoreVersion::try_from(32), + Ok(BitcoinCoreVersion::V32X) + )); } #[cfg(feature = "bitcoin-core-sv2")] #[test] fn bitcoin_core_version_rejects_unsupported_values() { assert!(BitcoinCoreVersion::try_from(29).is_err()); - assert!(BitcoinCoreVersion::try_from(32).is_err()); + assert!(BitcoinCoreVersion::try_from(33).is_err()); } }