Skip to content

Commit 4b37344

Browse files
authored
Merge pull request #628 from plebhash/2026-07-18-fix-jdc-race
fix JDC `RequestTransactionData` race
2 parents 6d2c59f + a7953c6 commit 4b37344

4 files changed

Lines changed: 367 additions & 24 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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+
}

miner-apps/jd-client/src/lib/channel_manager/mod.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ use stratum_apps::{
3737
},
3838
mining_sv2::{OpenExtendedMiningChannel, SetCustomMiningJob, SetTarget, UpdateChannel},
3939
parsers_sv2::{AnyMessage, JobDeclaration, Mining, TemplateDistribution, Tlv},
40-
template_distribution_sv2::{NewTemplate, SetNewPrevHash as SetNewPrevHashTdp},
40+
template_distribution_sv2::{
41+
NewTemplate, RequestTransactionData, SetNewPrevHash as SetNewPrevHashTdp,
42+
},
4143
},
4244
sync::{SharedLock, SharedMap},
4345
task_manager::TaskManager,
@@ -1196,6 +1198,25 @@ impl ChannelManager {
11961198
Ok(())
11971199
}
11981200

1201+
/// Sends a transaction-data request for the template.
1202+
///
1203+
/// Only call this when the response can be consumed (i.e., `UpstreamState::Connected`
1204+
/// in full-template mode). Templates received before the upstream channel opens are
1205+
/// covered by the re-request in `handle_open_extended_mining_channel_success`.
1206+
async fn request_transaction_data(
1207+
&self,
1208+
template_id: TemplateId,
1209+
) -> JDCResult<(), error::ChannelManager> {
1210+
let message =
1211+
TemplateDistribution::RequestTransactionData(RequestTransactionData { template_id });
1212+
self.channel_manager_io
1213+
.tp_sender
1214+
.send(message)
1215+
.await
1216+
.map_err(|_e| JDCError::shutdown(JDCErrorKind::ChannelErrorSender))?;
1217+
Ok(())
1218+
}
1219+
11991220
/// Utility method to request for more token to JDS.
12001221
pub async fn allocate_tokens(
12011222
&self,

0 commit comments

Comments
 (0)