1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
//! Sv2 Group Channel - Mining Client Abstraction.
//!
//! This module provides the [`GroupChannel`] struct, which acts as a mining client's
//! abstraction over the state of a Sv2 group channel. It tracks group-level job state
//! and associated standard and extended channels, but delegates share validation and job lifecycle
//! to the channels themselves.
extern crate alloc;
use super::{HashMap, HashSet, MAX_FUTURE_JOBS};
use crate::client::error::GroupChannelError;
use alloc::collections::VecDeque;
use mining_sv2::{NewExtendedMiningJobOwned, SetNewPrevHashOwned as SetNewPrevHashMp};
/// Mining Client abstraction over the state of an Sv2 Group Channel.
///
/// Tracks:
/// - the group channel's unique `group_channel_id`
/// - associated `channel_ids` (indexed by `channel_id`)
/// - future jobs (indexed by `job_id`, to be activated upon receipt of a
/// [`SetNewPrevHash`](SetNewPrevHashMp) message, capped at [`MAX_FUTURE_JOBS`])
/// - active job
///
/// Does **not** track:
/// - past or stale jobs
/// - share validation state (handled per-channel)
#[derive(Debug, Clone)]
pub struct GroupChannel {
/// Unique identifier for the group channel
group_channel_id: u32,
/// Set of channel IDs associated with this group channel
channel_ids: HashSet<u32>,
/// Future jobs, indexed by job_id, waiting to be activated
future_jobs: HashMap<u32, NewExtendedMiningJobOwned>,
/// Future job IDs ordered by receipt, oldest at the front and newest at the back.
/// Replaced IDs move to the back; overflow evicts from the front.
future_job_order: VecDeque<u32>,
/// Currently active mining job for the group channel
active_job: Option<NewExtendedMiningJobOwned>,
/// Full extranonce size for jobs associated with this group channel.
/// The constructor initializes this as None, but as new channels are added, we keep this updated.
/// At no point in time, two channels can belong to the same group while having different full extranonce sizes.
full_extranonce_size: Option<usize>,
}
impl GroupChannel {
/// Creates a new [`GroupChannel`] with the given group_channel_id.
pub fn new(group_channel_id: u32) -> Self {
Self {
group_channel_id,
channel_ids: HashSet::new(),
future_jobs: HashMap::new(),
future_job_order: VecDeque::new(),
active_job: None,
full_extranonce_size: None,
}
}
/// Adds a channel to the group by its `channel_id` with the specified `full_extranonce_size`.
/// For extended channels, the `full_extranonce_size` is the sum of its `extranonce_prefix` size and its `rollable_extranonce_size`.
/// For standard channels, the `full_extranonce_size` is the size of its `extranonce_prefix`.
///
/// If this is the first channel ever added to the group, sets the group's `full_extranonce_size`.
/// If other channels already exist, validates that the `full_extranonce_size` matches.
///
/// Returns an error if the provided `full_extranonce_size` doesn't match the existing value.
pub fn add_channel_id(
&mut self,
channel_id: u32,
full_extranonce_size: usize,
) -> Result<(), GroupChannelError> {
match self.full_extranonce_size {
// if the full extranonce size is already set, check if it matches the new full extranonce size
Some(existing_size) => {
if existing_size != full_extranonce_size {
return Err(GroupChannelError::FullExtranonceSizeMismatch);
}
}
// if the full extranonce size is not yet set, set it
None => {
self.full_extranonce_size = Some(full_extranonce_size);
}
}
self.channel_ids.insert(channel_id);
Ok(())
}
/// Removes a channel from the group channel
/// channel by its `channel_id`.
pub fn remove_channel_id(&mut self, channel_id: u32) {
self.channel_ids.remove(&channel_id);
}
/// Returns the group channel ID.
pub fn get_group_channel_id(&self) -> u32 {
self.group_channel_id
}
/// Returns an iterator over all channel IDs associated with this group channel.
pub fn get_channel_ids(&self) -> impl Iterator<Item = &u32> + '_ {
self.channel_ids.iter()
}
/// Returns the number of channel IDs associated with this group channel.
pub fn get_channel_ids_count(&self) -> usize {
self.channel_ids.len()
}
/// Returns `true` if this group channel has no channel IDs associated with it.
pub fn is_empty(&self) -> bool {
self.channel_ids.is_empty()
}
/// Returns `true` if this group channel contains `channel_id`.
pub fn has_channel_id(&self, channel_id: u32) -> bool {
self.channel_ids.contains(&channel_id)
}
/// Returns a reference to the current active job, if any.
pub fn get_active_job(&self) -> Option<&NewExtendedMiningJobOwned> {
self.active_job.as_ref()
}
/// Returns an iterator over all future jobs, keyed by `job_id`.
///
/// At most [`MAX_FUTURE_JOBS`] jobs are kept (oldest evicted first).
pub fn get_future_jobs(&self) -> impl Iterator<Item = (&u32, &NewExtendedMiningJobOwned)> + '_ {
self.future_jobs.iter()
}
/// Returns a reference to a future job by `job_id`, if present.
pub fn get_future_job(&self, job_id: u32) -> Option<&NewExtendedMiningJobOwned> {
self.future_jobs.get(&job_id)
}
/// Returns the number of future jobs.
pub fn get_future_jobs_count(&self) -> usize {
self.future_jobs.len()
}
/// Returns the full extranonce size for jobs associated with this group channel.
pub fn get_full_extranonce_size(&self) -> Option<usize> {
self.full_extranonce_size
}
/// Handles a newly received [`NewExtendedMiningJob`](mining_sv2::NewExtendedMiningJob) message from upstream.
///
/// - If `min_ntime` is present, sets this job as active.
/// - If `min_ntime` is empty, stores it as a future job. At most [`MAX_FUTURE_JOBS`] future
/// jobs are kept: storing a new one beyond that limit evicts the oldest.
pub fn on_new_extended_mining_job(
&mut self,
new_extended_mining_job: NewExtendedMiningJobOwned,
) {
match new_extended_mining_job.min_ntime.clone().into_inner() {
Some(_min_ntime) => {
self.active_job = Some(new_extended_mining_job);
}
None => {
let job_id = new_extended_mining_job.job_id;
self.future_jobs.insert(job_id, new_extended_mining_job);
// a replaced job_id moves to the back of the eviction order
self.future_job_order.retain(|id| *id != job_id);
self.future_job_order.push_back(job_id);
if self.future_jobs.len() > MAX_FUTURE_JOBS {
if let Some(evicted_job_id) = self.future_job_order.pop_front() {
self.future_jobs.remove(&evicted_job_id);
}
}
}
}
}
/// Handles an upstream [`SetNewPrevHash`](SetNewPrevHashMp) message.
///
/// Activates the future job matching `job_id` from the message, making it the active job.
/// The activated job carries the `min_ntime` from the message, so it is no longer a future job.
/// Clears all other future jobs.
///
/// Returns `Err(GroupChannelError::JobIdNotFound)` if no matching job found.
pub fn on_set_new_prev_hash(
&mut self,
set_new_prev_hash: SetNewPrevHashMp,
) -> Result<(), GroupChannelError> {
match self.future_jobs.remove(&set_new_prev_hash.job_id) {
Some(mut job) => {
// the activated job is no longer a future job, so it must carry a min_ntime,
// otherwise consumers dispatching on it would misclassify the active job
job.set_no_future(set_new_prev_hash.min_ntime);
self.active_job = Some(job);
}
None => return Err(GroupChannelError::JobIdNotFound),
}
// all other future jobs are now useless
self.future_jobs.clear();
self.future_job_order.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use binary_sv2::Sv2OptionOwned as Sv2Option;
#[test]
fn test_future_jobs_are_bounded() {
let mut group_channel = GroupChannel::new(1);
let future_job = NewExtendedMiningJobOwned {
channel_id: 1,
job_id: 0,
min_ntime: Sv2Option::new(None),
version: 536870912,
version_rolling_allowed: true,
coinbase_tx_prefix: vec![
2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 34, 82, 0,
]
.try_into()
.unwrap(),
coinbase_tx_suffix: vec![
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252, 0, 0, 0,
0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209, 222,
253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180, 139,
235, 216, 54, 151, 78, 140, 249, 0, 0, 0, 0,
]
.try_into()
.unwrap(),
merkle_path: vec![].try_into().unwrap(),
};
let flood_size = 10_000u32;
for job_id in 0..flood_size {
let mut job = future_job.clone();
job.job_id = job_id;
group_channel.on_new_extended_mining_job(job);
}
assert_eq!(group_channel.get_future_jobs_count(), MAX_FUTURE_JOBS);
for job_id in 0..flood_size - MAX_FUTURE_JOBS as u32 {
assert!(group_channel.get_future_job(job_id).is_none());
}
for job_id in flood_size - MAX_FUTURE_JOBS as u32..flood_size {
assert!(group_channel.get_future_job(job_id).is_some());
}
}
#[test]
fn test_replaced_future_job_moves_to_back_of_eviction_order() {
let mut group_channel = GroupChannel::new(1);
let future_job = NewExtendedMiningJobOwned {
channel_id: 1,
job_id: 0,
min_ntime: Sv2Option::new(None),
version: 536870912,
version_rolling_allowed: true,
coinbase_tx_prefix: vec![
2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 34, 82, 0,
]
.try_into()
.unwrap(),
coinbase_tx_suffix: vec![
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252, 0, 0, 0,
0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209, 222,
253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180, 139,
235, 216, 54, 151, 78, 140, 249, 0, 0, 0, 0,
]
.try_into()
.unwrap(),
merkle_path: vec![].try_into().unwrap(),
};
// fill the store with MAX_FUTURE_JOBS distinct job_ids
for job_id in 0..MAX_FUTURE_JOBS as u32 {
let mut job = future_job.clone();
job.job_id = job_id;
group_channel.on_new_extended_mining_job(job);
}
// re-send job_id 0: it should move to the back of the eviction order
group_channel.on_new_extended_mining_job(future_job.clone());
// one more distinct job_id: job_id 1 is now the oldest and gets evicted
let mut job = future_job.clone();
job.job_id = MAX_FUTURE_JOBS as u32;
group_channel.on_new_extended_mining_job(job);
assert_eq!(group_channel.get_future_jobs_count(), MAX_FUTURE_JOBS);
assert!(group_channel.get_future_job(1).is_none());
assert!(group_channel.get_future_job(0).is_some());
// the replaced job_id can still be activated
let set_new_prev_hash = SetNewPrevHashMp {
channel_id: 1,
job_id: 0,
prev_hash: [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into(),
nbits: 503543726,
min_ntime: 1746839905,
};
group_channel
.on_set_new_prev_hash(set_new_prev_hash)
.unwrap();
}
#[test]
fn test_add_channel_id() {
let mut group_channel = GroupChannel::new(1);
group_channel.add_channel_id(1, 10).unwrap();
assert_eq!(group_channel.get_full_extranonce_size(), Some(10));
// add a second channel with the same full extranonce size
group_channel.add_channel_id(2, 10).unwrap();
assert_eq!(group_channel.get_full_extranonce_size(), Some(10));
// add a third channel with a different full extranonce size
// this should return an error
assert!(group_channel.add_channel_id(3, 12).is_err());
assert_eq!(group_channel.get_channel_ids_count(), 2);
assert!(!group_channel.has_channel_id(3));
assert_eq!(group_channel.get_full_extranonce_size(), Some(10));
}
#[test]
fn test_future_job_activation_propagates_min_ntime() {
// Regression test: the future job used to be promoted as-is, keeping min_ntime as
// None, so is_future() still returned true on the active job.
let channel_id = 1;
let min_ntime = 1745596970;
let mut group_channel = GroupChannel::new(channel_id);
group_channel.add_channel_id(1, 32).unwrap();
let future_job = NewExtendedMiningJobOwned {
channel_id,
job_id: 1,
min_ntime: Sv2Option::new(None),
version: 536870912,
version_rolling_allowed: true,
coinbase_tx_prefix: vec![
2, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 34, 82, 0,
]
.try_into()
.unwrap(),
coinbase_tx_suffix: vec![
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252,
]
.try_into()
.unwrap(),
merkle_path: vec![].try_into().unwrap(),
};
group_channel.on_new_extended_mining_job(future_job);
assert_eq!(group_channel.get_future_jobs_count(), 1);
assert!(group_channel.get_active_job().is_none());
let set_new_prev_hash = SetNewPrevHashMp {
channel_id,
job_id: 1,
prev_hash: [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into(),
min_ntime,
nbits: 545259519,
};
group_channel
.on_set_new_prev_hash(set_new_prev_hash)
.unwrap();
let active_job = group_channel.get_active_job().unwrap();
assert!(!active_job.is_future());
assert_eq!(active_job.min_ntime.clone().into_inner(), Some(min_ntime));
}
}