|
| 1 | +use integration_tests_sv2::{ |
| 2 | + interceptor::MessageDirection, |
| 3 | + mock_roles::{MockDownstream, MockUpstream, WithSetup}, |
| 4 | + utils::get_available_address, |
| 5 | + POOL_COINBASE_REWARD_ADDRESS, |
| 6 | +}; |
| 7 | +use jd_client_sv2::config::ConfigJDCMode; |
| 8 | +use std::time::Duration; |
| 9 | +use stratum_apps::{ |
| 10 | + config_helpers::CoinbaseRewardScript, |
| 11 | + stratum_core::{ |
| 12 | + bitcoin::{consensus::serialize, Amount, TxOut}, |
| 13 | + common_messages_sv2::Protocol, |
| 14 | + job_declaration_sv2::AllocateMiningJobTokenSuccess, |
| 15 | + mining_sv2::{OpenExtendedMiningChannel, OpenExtendedMiningChannelSuccess}, |
| 16 | + parsers_sv2::{AnyMessage, JobDeclaration, Mining, TemplateDistribution}, |
| 17 | + template_distribution_sv2::{ |
| 18 | + NewTemplate, RequestTransactionDataSuccess, SetNewPrevHash, |
| 19 | + MESSAGE_TYPE_REQUEST_TRANSACTION_DATA, |
| 20 | + }, |
| 21 | + }, |
| 22 | +}; |
| 23 | + |
| 24 | +// How long the test waits for a `RequestTransactionData` that must never be sent while the |
| 25 | +// upstream channel is closed. |
| 26 | +const PREMATURE_REQUEST_WINDOW: Duration = Duration::from_millis(2000); |
| 27 | +const TEMPLATE_ID: u64 = 1; |
| 28 | + |
| 29 | +/// Regression coverage for the JDC full-template transaction-data race (issue #626). |
| 30 | +/// |
| 31 | +/// In full-template mode, JDC needs template transaction data to build a `DeclareMiningJob` |
| 32 | +/// for the JDS. Building that job requires the upstream extended channel (and job factory), |
| 33 | +/// which only exist once a downstream has triggered the upstream channel open. JDC must |
| 34 | +/// therefore NOT request transaction data for templates that arrive before the upstream |
| 35 | +/// channel is open: the response could not be consumed, and consuming the template state |
| 36 | +/// anyway is what caused the original `TemplateNotFound` race. |
| 37 | +/// |
| 38 | +/// This test deliberately creates that ordering: |
| 39 | +/// |
| 40 | +/// 1. `NewTemplate` and `SetNewPrevHash` arrive before the upstream channel exists. |
| 41 | +/// 2. JDC must not send `RequestTransactionData` while the channel is closed. |
| 42 | +/// 3. When `OpenExtendedMiningChannelSuccess` arrives, JDC requests transaction data for the last |
| 43 | +/// future template. |
| 44 | +/// 4. That single response must then produce a `DeclareMiningJob`. |
| 45 | +#[tokio::test] |
| 46 | +async fn jdc_requests_tx_data_only_after_upstream_channel_opens() { |
| 47 | + integration_tests_sv2::start_tracing(); |
| 48 | + |
| 49 | + // Use mock roles for every upstream peer. This keeps the test deterministic and avoids |
| 50 | + // depending on Bitcoin Core timing: each protocol peer only sends the message needed to move |
| 51 | + // JDC to the next step of the scenario. |
| 52 | + // |
| 53 | + // A sniffer is placed between JDC and each mock role. The sniffers let the test observe JDC's |
| 54 | + // outbound messages without changing the protocol flow. |
| 55 | + let mock_tp_addr = get_available_address(); |
| 56 | + let mock_tp_sender = MockUpstream::new( |
| 57 | + mock_tp_addr, |
| 58 | + WithSetup::yes_with_defaults(Protocol::TemplateDistributionProtocol, 0), |
| 59 | + ) |
| 60 | + .start() |
| 61 | + .await; |
| 62 | + let (tp_sniffer, tp_sniffer_addr) = |
| 63 | + integration_tests_sv2::start_sniffer("jdc-tp", mock_tp_addr, false, vec![], None); |
| 64 | + |
| 65 | + let mock_pool_addr = get_available_address(); |
| 66 | + let mock_pool_sender = MockUpstream::new( |
| 67 | + mock_pool_addr, |
| 68 | + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), |
| 69 | + ) |
| 70 | + .start() |
| 71 | + .await; |
| 72 | + let (pool_sniffer, pool_sniffer_addr) = |
| 73 | + integration_tests_sv2::start_sniffer("jdc-pool", mock_pool_addr, false, vec![], None); |
| 74 | + |
| 75 | + let mock_jds_addr = get_available_address(); |
| 76 | + let mock_jds_sender = MockUpstream::new( |
| 77 | + mock_jds_addr, |
| 78 | + WithSetup::yes_with_defaults(Protocol::JobDeclarationProtocol, 0), |
| 79 | + ) |
| 80 | + .start() |
| 81 | + .await; |
| 82 | + let (jds_sniffer, jds_sniffer_addr) = |
| 83 | + integration_tests_sv2::start_sniffer("jdc-jds", mock_jds_addr, false, vec![], None); |
| 84 | + |
| 85 | + // Start JDC in full-template mode. This mode is required because coinbase-only mode does not |
| 86 | + // send `RequestTransactionData` to the Template Provider. |
| 87 | + let (jdc, jdc_addr, _) = integration_tests_sv2::start_jdc( |
| 88 | + &[(pool_sniffer_addr, jds_sniffer_addr)], |
| 89 | + integration_tests_sv2::sv2_tp_config(tp_sniffer_addr), |
| 90 | + vec![], |
| 91 | + vec![], |
| 92 | + false, |
| 93 | + Some(ConfigJDCMode::FullTemplate), |
| 94 | + ); |
| 95 | + |
| 96 | + // Wait for the two initial token allocations that JDC requests after completing the JDS |
| 97 | + // handshake. Tokens are required later to build a `DeclareMiningJob`, so providing them up |
| 98 | + // front isolates this scenario from token-allocation races unrelated to the template-state |
| 99 | + // lifecycle invariant under test. |
| 100 | + let first_token_request = loop { |
| 101 | + match jds_sniffer.next_message_from_downstream() { |
| 102 | + Some(( |
| 103 | + _, |
| 104 | + AnyMessage::JobDeclaration(JobDeclaration::AllocateMiningJobToken(message)), |
| 105 | + )) => break message, |
| 106 | + _ => tokio::time::sleep(Duration::from_secs(1)).await, |
| 107 | + } |
| 108 | + }; |
| 109 | + |
| 110 | + let second_token_request = loop { |
| 111 | + match jds_sniffer.next_message_from_downstream() { |
| 112 | + Some(( |
| 113 | + _, |
| 114 | + AnyMessage::JobDeclaration(JobDeclaration::AllocateMiningJobToken(message)), |
| 115 | + )) => break message, |
| 116 | + _ => tokio::time::sleep(Duration::from_secs(1)).await, |
| 117 | + } |
| 118 | + }; |
| 119 | + |
| 120 | + // Build the coinbase outputs returned by the mock JDS. The script intentionally differs from |
| 121 | + // JDC's startup reward script so the first token response updates JDC's active coinbase |
| 122 | + // outputs. The output value itself is not important here: JDC replaces it with the template's |
| 123 | + // `coinbase_tx_value_remaining` before constructing the declared job. |
| 124 | + let coinbase_script_pubkey = |
| 125 | + CoinbaseRewardScript::from_descriptor(&format!("addr({POOL_COINBASE_REWARD_ADDRESS})")) |
| 126 | + .expect("pool reward descriptor must be valid") |
| 127 | + .script_pubkey(); |
| 128 | + let coinbase_outputs = serialize(&vec![TxOut { |
| 129 | + value: Amount::from_sat(0), |
| 130 | + script_pubkey: coinbase_script_pubkey, |
| 131 | + }]); |
| 132 | + |
| 133 | + // Return both allocated tokens. After this step, JDC has every JDS-side prerequisite needed |
| 134 | + // to declare a job; only the upstream mining channel is intentionally still missing. |
| 135 | + mock_jds_sender |
| 136 | + .send(AnyMessage::JobDeclaration( |
| 137 | + JobDeclaration::AllocateMiningJobTokenSuccess(AllocateMiningJobTokenSuccess { |
| 138 | + request_id: first_token_request.request_id, |
| 139 | + mining_job_token: 0_u64 |
| 140 | + .to_le_bytes() |
| 141 | + .try_into() |
| 142 | + .expect("u64 token must fit into B0255"), |
| 143 | + coinbase_outputs: coinbase_outputs |
| 144 | + .clone() |
| 145 | + .try_into() |
| 146 | + .expect("serialized coinbase outputs must fit into B064K"), |
| 147 | + }), |
| 148 | + )) |
| 149 | + .await |
| 150 | + .expect("mock JDS should send the first token"); |
| 151 | + mock_jds_sender |
| 152 | + .send(AnyMessage::JobDeclaration( |
| 153 | + JobDeclaration::AllocateMiningJobTokenSuccess(AllocateMiningJobTokenSuccess { |
| 154 | + request_id: second_token_request.request_id, |
| 155 | + mining_job_token: 1_u64 |
| 156 | + .to_le_bytes() |
| 157 | + .try_into() |
| 158 | + .expect("u64 token must fit into B0255"), |
| 159 | + coinbase_outputs: coinbase_outputs |
| 160 | + .try_into() |
| 161 | + .expect("serialized coinbase outputs must fit into B064K"), |
| 162 | + }), |
| 163 | + )) |
| 164 | + .await |
| 165 | + .expect("mock JDS should send the second token"); |
| 166 | + |
| 167 | + // Send a valid future template and activate it with a matching `SetNewPrevHash`. |
| 168 | + // |
| 169 | + // The template fields are a known-good test vector also used by the channel job-factory tests. |
| 170 | + // Keeping the transaction list empty makes the scenario focused on JDC's template-state |
| 171 | + // lifecycle rather than on transaction validation. |
| 172 | + mock_tp_sender |
| 173 | + .send(AnyMessage::TemplateDistribution( |
| 174 | + TemplateDistribution::NewTemplate(NewTemplate { |
| 175 | + template_id: TEMPLATE_ID, |
| 176 | + future_template: true, |
| 177 | + version: 536_870_912, |
| 178 | + coinbase_tx_version: 2, |
| 179 | + coinbase_prefix: vec![82, 0] |
| 180 | + .try_into() |
| 181 | + .expect("coinbase prefix must fit into B0255"), |
| 182 | + coinbase_tx_input_sequence: u32::MAX, |
| 183 | + coinbase_tx_value_remaining: 5_000_000_000, |
| 184 | + coinbase_tx_outputs_count: 1, |
| 185 | + coinbase_tx_outputs: vec![ |
| 186 | + 0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, |
| 187 | + 209, 222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, |
| 188 | + 98, 180, 139, 235, 216, 54, 151, 78, 140, 249, |
| 189 | + ] |
| 190 | + .try_into() |
| 191 | + .expect("coinbase outputs must fit into B064K"), |
| 192 | + coinbase_tx_locktime: 0, |
| 193 | + merkle_path: vec![].try_into().expect("empty merkle path must be valid"), |
| 194 | + }), |
| 195 | + )) |
| 196 | + .await |
| 197 | + .expect("mock TP should send NewTemplate"); |
| 198 | + mock_tp_sender |
| 199 | + .send(AnyMessage::TemplateDistribution( |
| 200 | + TemplateDistribution::SetNewPrevHash(SetNewPrevHash { |
| 201 | + template_id: TEMPLATE_ID, |
| 202 | + prev_hash: [0x11; 32].into(), |
| 203 | + header_timestamp: 1_700_000_000, |
| 204 | + n_bits: 0x1d00ffff, |
| 205 | + target: [0xff; 32].into(), |
| 206 | + }), |
| 207 | + )) |
| 208 | + .await |
| 209 | + .expect("mock TP should send SetNewPrevHash"); |
| 210 | + |
| 211 | + // No downstream has asked for a mining channel, so the upstream extended channel is |
| 212 | + // guaranteed not to exist yet. While the upstream channel is not open, JDC must NOT |
| 213 | + // request transaction data: the response could not be turned into a `DeclareMiningJob` |
| 214 | + // and would be discarded. Any `RequestTransactionData` observed here means the |
| 215 | + // premature-consumption race from issue #626 is possible again. |
| 216 | + assert!( |
| 217 | + tp_sniffer |
| 218 | + .assert_message_not_present( |
| 219 | + MessageDirection::ToUpstream, |
| 220 | + MESSAGE_TYPE_REQUEST_TRANSACTION_DATA, |
| 221 | + PREMATURE_REQUEST_WINDOW, |
| 222 | + ) |
| 223 | + .await, |
| 224 | + "JDC must not request transaction data before the upstream channel is open" |
| 225 | + ); |
| 226 | + |
| 227 | + // Connect a downstream mining client and ask JDC for an extended channel. This is what causes |
| 228 | + // JDC to open its single extended channel with the upstream pool. |
| 229 | + let downstream_sender = MockDownstream::new( |
| 230 | + jdc_addr, |
| 231 | + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), |
| 232 | + ) |
| 233 | + .start() |
| 234 | + .await; |
| 235 | + downstream_sender |
| 236 | + .send(AnyMessage::Mining(Mining::OpenExtendedMiningChannel( |
| 237 | + OpenExtendedMiningChannel { |
| 238 | + request_id: 1, |
| 239 | + user_identity: "tx-data-race".try_into().unwrap(), |
| 240 | + nominal_hash_rate: 1_000.0, |
| 241 | + max_target: [0xff; 32].into(), |
| 242 | + min_extranonce_size: 0, |
| 243 | + }, |
| 244 | + ))) |
| 245 | + .await |
| 246 | + .expect("mock downstream should open an extended channel"); |
| 247 | + |
| 248 | + // Wait for JDC to forward the channel-open request to the mock pool, then complete the |
| 249 | + // upstream channel handshake. Only after this response does JDC have the extranonce and job |
| 250 | + // factory needed to declare the previously received template. |
| 251 | + let open_channel_request = loop { |
| 252 | + match pool_sniffer.next_message_from_downstream() { |
| 253 | + Some((_, AnyMessage::Mining(Mining::OpenExtendedMiningChannel(message)))) => { |
| 254 | + break message; |
| 255 | + } |
| 256 | + _ => tokio::time::sleep(Duration::from_secs(1)).await, |
| 257 | + } |
| 258 | + }; |
| 259 | + mock_pool_sender |
| 260 | + .send(AnyMessage::Mining( |
| 261 | + Mining::OpenExtendedMiningChannelSuccess(OpenExtendedMiningChannelSuccess { |
| 262 | + request_id: open_channel_request.request_id, |
| 263 | + channel_id: 9, |
| 264 | + target: [0xff; 32].into(), |
| 265 | + extranonce_size: open_channel_request.min_extranonce_size, |
| 266 | + extranonce_prefix: vec![0; 4] |
| 267 | + .try_into() |
| 268 | + .expect("extranonce prefix must fit into B032"), |
| 269 | + group_channel_id: 1, |
| 270 | + }), |
| 271 | + )) |
| 272 | + .await |
| 273 | + .expect("mock pool should open the upstream extended channel"); |
| 274 | + |
| 275 | + // Once the upstream channel is open, JDC must request transaction data for the last future |
| 276 | + // template it stored while the channel was closed. |
| 277 | + let tx_data_request = loop { |
| 278 | + match tp_sniffer.next_message_from_downstream() { |
| 279 | + Some(( |
| 280 | + _, |
| 281 | + AnyMessage::TemplateDistribution(TemplateDistribution::RequestTransactionData( |
| 282 | + message, |
| 283 | + )), |
| 284 | + )) => break message, |
| 285 | + _ => tokio::time::sleep(Duration::from_secs(1)).await, |
| 286 | + } |
| 287 | + }; |
| 288 | + assert_eq!(tx_data_request.template_id, TEMPLATE_ID); |
| 289 | + |
| 290 | + // The response to this request must be enough to declare the job: every prerequisite |
| 291 | + // (upstream channel, job factory, token, prev hash) is now in place. |
| 292 | + mock_tp_sender |
| 293 | + .send(AnyMessage::TemplateDistribution( |
| 294 | + TemplateDistribution::RequestTransactionDataSuccess(RequestTransactionDataSuccess { |
| 295 | + template_id: TEMPLATE_ID, |
| 296 | + excess_data: vec![] |
| 297 | + .try_into() |
| 298 | + .expect("empty excess data must fit into B064K"), |
| 299 | + transaction_list: vec![] |
| 300 | + .try_into() |
| 301 | + .expect("empty transaction list must be valid"), |
| 302 | + }), |
| 303 | + )) |
| 304 | + .await |
| 305 | + .expect("mock TP should send RequestTransactionDataSuccess"); |
| 306 | + |
| 307 | + // Once the upstream channel is available, JDC must declare the job built from the |
| 308 | + // previously received template. Premature consumption of that template state prevents the |
| 309 | + // declaration. |
| 310 | + let declare_mining_job = loop { |
| 311 | + match jds_sniffer.next_message_from_downstream() { |
| 312 | + Some((_, AnyMessage::JobDeclaration(JobDeclaration::DeclareMiningJob(message)))) => { |
| 313 | + break message |
| 314 | + } |
| 315 | + _ => tokio::time::sleep(Duration::from_secs(1)).await, |
| 316 | + } |
| 317 | + }; |
| 318 | + |
| 319 | + jdc.shutdown().await; |
| 320 | + |
| 321 | + assert_eq!(declare_mining_job.version, 536_870_912); |
| 322 | +} |
0 commit comments