Skip to content

Commit 646eb72

Browse files
authored
Merge pull request #2266 from plebhash/2026-08-03-fix-unbounded-future-jobs
`channels_sv2`: bound future-job storage on client channels
2 parents 416a723 + 76db56a commit 646eb72

4 files changed

Lines changed: 444 additions & 16 deletions

File tree

sv2/channels-sv2/src/client/extended.rs

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! **Extended Channel** within a mining client.
55
66
extern crate alloc;
7-
use super::HashMap;
7+
use super::{HashMap, MAX_FUTURE_JOBS};
88
use crate::{
99
bip141::try_strip_bip141,
1010
chain_tip::ChainTip,
@@ -17,7 +17,7 @@ use crate::{
1717
target::{bytes_to_hex, u256_to_block_hash},
1818
MAX_EXTRANONCE_LEN, VERSION_ROLLING_MASK,
1919
};
20-
use alloc::{format, string::String, vec, vec::Vec};
20+
use alloc::{collections::VecDeque, format, string::String, vec, vec::Vec};
2121
use binary_sv2::Sv2OptionOwned;
2222
use bitcoin::{
2323
absolute::LockTime,
@@ -57,8 +57,8 @@ pub type ExtendedJob = (NewExtendedMiningJobOwned, Vec<u8>, Target);
5757
/// - The channel's current target.
5858
/// - The channel's nominal hashrate.
5959
/// - Whether version rolling is supported (see [BIP 323](https://github.com/bitcoin/bips/blob/master/bip-0323.mediawiki)).
60-
/// - Future jobs (indexed by `job_id`) to be activated by a [`SetNewPrevHash`](SetNewPrevHashMp)
61-
/// message.
60+
/// - Future jobs (indexed by `job_id`, capped at [`MAX_FUTURE_JOBS`]) to be activated by a
61+
/// [`SetNewPrevHash`](SetNewPrevHashMp) message.
6262
/// - The currently active job.
6363
/// - Past jobs (previously active under the current chain tip, indexed by `job_id`).
6464
/// - Stale jobs (previously active and past jobs under the previous chain tip, indexed by
@@ -76,6 +76,9 @@ pub struct ExtendedChannel {
7676
version_rolling: bool,
7777
// future jobs are indexed with job_id (u32)
7878
future_jobs: HashMap<u32, ExtendedJob>,
79+
// Future job IDs ordered by receipt, oldest at the front and newest at the back.
80+
// Replaced IDs move to the back; overflow evicts from the front.
81+
future_job_order: VecDeque<u32>,
7982
active_job: Option<ExtendedJob>,
8083
// past jobs are indexed with job_id (u32)
8184
past_jobs: HashMap<u32, ExtendedJob>,
@@ -105,6 +108,7 @@ impl ExtendedChannel {
105108
nominal_hashrate,
106109
version_rolling,
107110
future_jobs: HashMap::new(),
111+
future_job_order: VecDeque::new(),
108112
active_job: None,
109113
past_jobs: HashMap::new(),
110114
stale_jobs: HashMap::new(),
@@ -221,6 +225,8 @@ impl ExtendedChannel {
221225
}
222226

223227
/// Returns an iterator over all future jobs for this channel.
228+
///
229+
/// At most [`MAX_FUTURE_JOBS`] jobs are kept (oldest evicted first).
224230
pub fn get_future_jobs(&self) -> impl Iterator<Item = (&u32, &ExtendedJob)> + '_ {
225231
self.future_jobs.iter()
226232
}
@@ -293,6 +299,8 @@ impl ExtendedChannel {
293299
///
294300
/// - If [`NewExtendedMiningJob::min_ntime`](mining_sv2::NewExtendedMiningJob::min_ntime) is empty, the job is considered a future job and
295301
/// added to the future jobs list (see [`get_future_jobs`](ExtendedChannel::get_future_jobs)).
302+
/// At most [`MAX_FUTURE_JOBS`] future jobs are kept: storing a new one beyond that limit
303+
/// evicts the oldest.
296304
/// - Otherwise, the job is activated and previous active job moves to the past jobs list.
297305
pub fn on_new_extended_mining_job(
298306
&mut self,
@@ -331,14 +339,25 @@ impl ExtendedChannel {
331339
));
332340
}
333341
None => {
342+
let job_id = new_extended_mining_job.job_id;
334343
self.future_jobs.insert(
335-
new_extended_mining_job.job_id,
344+
job_id,
336345
(
337346
new_extended_mining_job,
338347
self.extranonce_prefix.as_bytes().to_vec(),
339348
self.target,
340349
),
341350
);
351+
352+
// a replaced job_id moves to the back of the eviction order
353+
self.future_job_order.retain(|id| *id != job_id);
354+
self.future_job_order.push_back(job_id);
355+
356+
if self.future_jobs.len() > MAX_FUTURE_JOBS {
357+
if let Some(evicted_job_id) = self.future_job_order.pop_front() {
358+
self.future_jobs.remove(&evicted_job_id);
359+
}
360+
}
342361
}
343362
}
344363

@@ -467,6 +486,7 @@ impl ExtendedChannel {
467486

468487
// all other future jobs are now useless
469488
self.future_jobs.clear();
489+
self.future_job_order.clear();
470490

471491
// mark all past jobs as stale, so that shares are not propagated
472492
self.stale_jobs = self.past_jobs.clone();
@@ -505,6 +525,7 @@ impl ExtendedChannel {
505525

506526
// all other future jobs are now useless
507527
self.future_jobs.clear();
528+
self.future_job_order.clear();
508529

509530
// mark all past jobs as stale, so that shares are not propagated
510531
self.stale_jobs = self.past_jobs.clone();
@@ -709,6 +730,7 @@ mod tests {
709730
client::{
710731
extended::ExtendedChannel,
711732
share_accounting::{ShareValidationError, ShareValidationResult},
733+
MAX_FUTURE_JOBS,
712734
},
713735
extranonce_manager::ExtranoncePrefix,
714736
};
@@ -807,6 +829,145 @@ mod tests {
807829
);
808830
}
809831

832+
#[test]
833+
fn test_future_jobs_are_bounded() {
834+
let channel_id = 1;
835+
let extranonce_prefix = [
836+
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
837+
0, 0, 0, 0, 0, 0, 1,
838+
]
839+
.to_vec();
840+
841+
let mut channel = ExtendedChannel::new(
842+
channel_id,
843+
"user_identity".to_string(),
844+
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
845+
Target::from_le_bytes([0xff; 32]),
846+
1.0,
847+
true,
848+
4u16,
849+
);
850+
851+
let future_job = NewExtendedMiningJob {
852+
channel_id,
853+
job_id: 0,
854+
min_ntime: Sv2Option::new(None),
855+
version: 536870912,
856+
version_rolling_allowed: true,
857+
coinbase_tx_prefix: vec![
858+
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,
859+
0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 34, 82, 0,
860+
]
861+
.try_into()
862+
.unwrap(),
863+
coinbase_tx_suffix: vec![
864+
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
865+
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252, 0, 0, 0,
866+
0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209, 222,
867+
253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180, 139,
868+
235, 216, 54, 151, 78, 140, 249, 0, 0, 0, 0,
869+
]
870+
.try_into()
871+
.unwrap(),
872+
merkle_path: vec![].try_into().unwrap(),
873+
};
874+
875+
let flood_size = 10_000u32;
876+
for job_id in 0..flood_size {
877+
let mut job = future_job.clone();
878+
job.job_id = job_id;
879+
channel.on_new_extended_mining_job(job).unwrap();
880+
}
881+
882+
assert_eq!(channel.get_future_jobs_count(), MAX_FUTURE_JOBS);
883+
884+
for job_id in 0..flood_size - MAX_FUTURE_JOBS as u32 {
885+
assert!(channel.get_future_job(job_id).is_none());
886+
}
887+
for job_id in flood_size - MAX_FUTURE_JOBS as u32..flood_size {
888+
assert!(channel.get_future_job(job_id).is_some());
889+
}
890+
}
891+
892+
#[test]
893+
fn test_replaced_future_job_moves_to_back_of_eviction_order() {
894+
let channel_id = 1;
895+
let extranonce_prefix = [
896+
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
897+
0, 0, 0, 0, 0, 0, 1,
898+
]
899+
.to_vec();
900+
901+
let mut channel = ExtendedChannel::new(
902+
channel_id,
903+
"user_identity".to_string(),
904+
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
905+
Target::from_le_bytes([0xff; 32]),
906+
1.0,
907+
true,
908+
4u16,
909+
);
910+
911+
let future_job = NewExtendedMiningJob {
912+
channel_id,
913+
job_id: 0,
914+
min_ntime: Sv2Option::new(None),
915+
version: 536870912,
916+
version_rolling_allowed: true,
917+
coinbase_tx_prefix: vec![
918+
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,
919+
0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 34, 82, 0,
920+
]
921+
.try_into()
922+
.unwrap(),
923+
coinbase_tx_suffix: vec![
924+
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
925+
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252, 0, 0, 0,
926+
0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209, 222,
927+
253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180, 139,
928+
235, 216, 54, 151, 78, 140, 249, 0, 0, 0, 0,
929+
]
930+
.try_into()
931+
.unwrap(),
932+
merkle_path: vec![].try_into().unwrap(),
933+
};
934+
935+
// fill the store with MAX_FUTURE_JOBS distinct job_ids
936+
for job_id in 0..MAX_FUTURE_JOBS as u32 {
937+
let mut job = future_job.clone();
938+
job.job_id = job_id;
939+
channel.on_new_extended_mining_job(job).unwrap();
940+
}
941+
942+
// re-send job_id 0: it should move to the back of the eviction order
943+
channel
944+
.on_new_extended_mining_job(future_job.clone())
945+
.unwrap();
946+
947+
// one more distinct job_id: job_id 1 is now the oldest and gets evicted
948+
let mut job = future_job.clone();
949+
job.job_id = MAX_FUTURE_JOBS as u32;
950+
channel.on_new_extended_mining_job(job).unwrap();
951+
952+
assert_eq!(channel.get_future_jobs_count(), MAX_FUTURE_JOBS);
953+
assert!(channel.get_future_job(1).is_none());
954+
assert!(channel.get_future_job(0).is_some());
955+
956+
// the replaced job_id can still be activated
957+
let set_new_prev_hash = SetNewPrevHashMp {
958+
channel_id,
959+
job_id: 0,
960+
prev_hash: [
961+
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
962+
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
963+
]
964+
.into(),
965+
nbits: 503543726,
966+
min_ntime: 1746839905,
967+
};
968+
channel.on_set_new_prev_hash(set_new_prev_hash).unwrap();
969+
}
970+
810971
#[test]
811972
fn test_past_jobs_flow() {
812973
let channel_id = 1;

0 commit comments

Comments
 (0)