diff --git a/sv2/channels-sv2/src/client/extended.rs b/sv2/channels-sv2/src/client/extended.rs index 4c55717338..6cee1fe749 100644 --- a/sv2/channels-sv2/src/client/extended.rs +++ b/sv2/channels-sv2/src/client/extended.rs @@ -4,7 +4,7 @@ //! **Extended Channel** within a mining client. extern crate alloc; -use super::{HashMap, MAX_FUTURE_JOBS}; +use super::{HashMap, MAX_FUTURE_JOBS, MAX_PAST_JOBS}; use crate::{ bip141::try_strip_bip141, chain_tip::ChainTip, @@ -60,7 +60,8 @@ pub type ExtendedJob = (NewExtendedMiningJobOwned, Vec, Target); /// - Future jobs (indexed by `job_id`, capped at [`MAX_FUTURE_JOBS`]) to be activated by a /// [`SetNewPrevHash`](SetNewPrevHashMp) message. /// - The currently active job. -/// - Past jobs (previously active under the current chain tip, indexed by `job_id`). +/// - Past jobs (previously active under the current chain tip, indexed by `job_id`, capped at +/// [`MAX_PAST_JOBS`]). /// - Stale jobs (previously active and past jobs under the previous chain tip, indexed by /// `job_id`). /// - Share accounting for the channel (as tracked by the client). @@ -82,6 +83,9 @@ pub struct ExtendedChannel { active_job: Option, // past jobs are indexed with job_id (u32) past_jobs: HashMap, + // Past job IDs ordered by retirement, oldest at the front and newest at the back. + // Replaced IDs move to the back; overflow evicts from the front. + past_job_order: VecDeque, // stale jobs are indexed with job_id (u32) stale_jobs: HashMap, share_accounting: ShareAccounting, @@ -111,6 +115,7 @@ impl ExtendedChannel { future_job_order: VecDeque::new(), active_job: None, past_jobs: HashMap::new(), + past_job_order: VecDeque::new(), stale_jobs: HashMap::new(), share_accounting: ShareAccounting::new(), chain_tip: None, @@ -242,6 +247,8 @@ impl ExtendedChannel { } /// Returns an iterator over all past jobs for this channel. + /// + /// At most [`MAX_PAST_JOBS`] jobs are kept (oldest evicted first). pub fn get_past_jobs(&self) -> impl Iterator + '_ { self.past_jobs.iter() } @@ -252,6 +259,8 @@ impl ExtendedChannel { } /// Returns the number of past jobs tracked by this channel. + /// + /// At most [`MAX_PAST_JOBS`] jobs are kept (oldest evicted first). pub fn get_past_jobs_count(&self) -> usize { self.past_jobs.len() } @@ -302,6 +311,8 @@ impl ExtendedChannel { /// At most [`MAX_FUTURE_JOBS`] future jobs are kept: storing a new one beyond that limit /// evicts the oldest. /// - Otherwise, the job is activated and previous active job moves to the past jobs list. + /// At most [`MAX_PAST_JOBS`] past jobs are kept: retiring one beyond that limit evicts the + /// oldest. pub fn on_new_extended_mining_job( &mut self, new_extended_mining_job: NewExtendedMiningJobOwned, @@ -329,8 +340,8 @@ impl ExtendedChannel { match new_extended_mining_job.min_ntime.clone().into_inner() { Some(_min_ntime) => { - if let Some(active_job) = self.active_job.clone() { - self.past_jobs.insert(active_job.0.job_id, active_job); + if let Some(active_job) = self.active_job.take() { + self.retire_job_to_past(active_job); } self.active_job = Some(( new_extended_mining_job, @@ -367,6 +378,9 @@ impl ExtendedChannel { /// Handles a `SetCustomMiningJobSuccess` message from upstream. /// Requires the corresponding `SetCustomMiningJob`. /// + /// The previous active job (if any) moves to the past jobs list. At most [`MAX_PAST_JOBS`] + /// past jobs are kept: retiring one beyond that limit evicts the oldest. + /// /// To be used by a Sv2 Job Declarator Client pub fn on_set_custom_mining_job_success( &mut self, @@ -461,8 +475,8 @@ impl ExtendedChannel { merkle_path: set_custom_mining_job.merkle_path, }; - if let Some(active_job) = self.active_job.clone() { - self.past_jobs.insert(active_job.0.job_id, active_job); + if let Some(active_job) = self.active_job.take() { + self.retire_job_to_past(active_job); } self.active_job = Some(( new_extended_mining_job, @@ -473,6 +487,25 @@ impl ExtendedChannel { Ok(()) } + // Moves a displaced job into past jobs, evicting the oldest past job beyond + // [`MAX_PAST_JOBS`]. A share against an evicted job is rejected as `InvalidJobId` even + // though it would otherwise have been accepted and propagated: a bounded loss of + // creditable work, the price of bounding memory under a hostile upstream. + fn retire_job_to_past(&mut self, job: ExtendedJob) { + let job_id = job.0.job_id; + self.past_jobs.insert(job_id, job); + + // a replaced job_id moves to the back of the eviction order + self.past_job_order.retain(|id| *id != job_id); + self.past_job_order.push_back(job_id); + + if self.past_jobs.len() > MAX_PAST_JOBS { + if let Some(evicted_job_id) = self.past_job_order.pop_front() { + self.past_jobs.remove(&evicted_job_id); + } + } + } + /// Handles a [`ChainTip`] update. /// /// To be used by a Sv2 Job Declarator Client, which should never receive a @@ -494,19 +527,22 @@ impl ExtendedChannel { self.future_jobs.clear(); self.future_job_order.clear(); - // the previously active job belongs to the old chain tip, so demote it to past before - // the past -> stale rotation below. without this, a share arriving before the next - // SetCustomMiningJobSuccess would still pass the is_active_job check in validate_share - // and be re-hashed against the new prev_hash with the old job's coinbase/merkle path. - if let Some(active_job) = self.active_job.take() { - self.past_jobs.insert(active_job.0.job_id, active_job); - } - // mark all past jobs as stale, so that shares are not propagated self.stale_jobs = self.past_jobs.clone(); + // the previously active job belongs to the old chain tip, so it goes stale with them. + // without this, a share arriving before the next SetCustomMiningJobSuccess would still + // pass the is_active_job check in validate_share and be re-hashed against the new + // prev_hash with the old job's coinbase/merkle path. it bypasses the MAX_PAST_JOBS + // cap: retiring it through the capped past path would push the oldest past job out of + // the stale set, misclassifying its late shares as InvalidJobId instead of Stale. + if let Some(active_job) = self.active_job.take() { + self.stale_jobs.insert(active_job.0.job_id, active_job); + } + // clear past jobs, as we're no longer going to propagate shares for them self.past_jobs.clear(); + self.past_job_order.clear(); // clear seen shares, as shares for past chain tip will be rejected as stale self.share_accounting.flush_seen_shares(); @@ -540,14 +576,6 @@ impl ExtendedChannel { } }; - // the job that was active under the previous chain tip must be retired to stale rather - // than silently dropped, otherwise a late share for it would be rejected as - // InvalidJobId instead of Stale - if let Some(previously_active_job) = previously_active_job { - self.past_jobs - .insert(previously_active_job.0.job_id, previously_active_job); - } - // all other future jobs are now useless self.future_jobs.clear(); self.future_job_order.clear(); @@ -555,8 +583,18 @@ impl ExtendedChannel { // mark all past jobs as stale, so that shares are not propagated self.stale_jobs = self.past_jobs.clone(); + // the job that was active under the previous chain tip goes stale with them rather + // than being silently dropped, bypassing the MAX_PAST_JOBS cap: retiring it through + // the capped past path would push the oldest past job out of the stale set, and a + // late share for either job would be rejected as InvalidJobId instead of Stale + if let Some(previously_active_job) = previously_active_job { + self.stale_jobs + .insert(previously_active_job.0.job_id, previously_active_job); + } + // clear past jobs, as we're no longer going to propagate shares for them self.past_jobs.clear(); + self.past_job_order.clear(); // clear seen shares, as shares for past chain tip will be rejected as stale self.share_accounting.flush_seen_shares(); @@ -757,7 +795,7 @@ mod tests { error::ExtendedChannelError, extended::ExtendedChannel, share_accounting::{ShareValidationError, ShareValidationResult}, - MAX_FUTURE_JOBS, + MAX_FUTURE_JOBS, MAX_PAST_JOBS, }, extranonce_manager::ExtranoncePrefix, }; @@ -995,6 +1033,67 @@ mod tests { channel.on_set_new_prev_hash(set_new_prev_hash).unwrap(); } + #[test] + fn test_past_jobs_are_bounded() { + let channel_id = 1; + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + + let mut channel = ExtendedChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1.0, + true, + 4u16, + ); + + let active_job = NewExtendedMiningJob { + channel_id, + job_id: 0, + min_ntime: Sv2Option::new(Some(1746839905)), + 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 = active_job.clone(); + job.job_id = job_id; + channel.on_new_extended_mining_job(job).unwrap(); + } + + assert_eq!(channel.get_past_jobs_count(), MAX_PAST_JOBS); + + // the last job is active; of the retired ones, only the newest MAX_PAST_JOBS survive + for job_id in 0..flood_size - 1 - MAX_PAST_JOBS as u32 { + assert!(channel.get_past_job(job_id).is_none()); + } + for job_id in flood_size - 1 - MAX_PAST_JOBS as u32..flood_size - 1 { + assert!(channel.get_past_job(job_id).is_some()); + } + } + #[test] fn test_past_jobs_flow() { let channel_id = 1; @@ -2118,4 +2217,123 @@ mod tests { assert!(channel.get_stale_job(1).is_some()); assert_eq!(channel.get_past_jobs_count(), 0); } + + // Builds an extended channel whose past jobs sit at the MAX_PAST_JOBS cap, with job + // MAX_PAST_JOBS as the active job. Returns the channel and the job used as template. + fn extended_channel_with_past_jobs_at_cap() -> (ExtendedChannel, NewExtendedMiningJob) { + let channel_id = 1; + let extranonce_prefix = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + + let mut channel = ExtendedChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1.0, + true, + 8u16, + ); + + let job_template = NewExtendedMiningJob { + channel_id, + job_id: 0, + min_ntime: Sv2Option::new(Some(1745596970)), + 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, 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, 1, 32, 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, 0, 0, 0, 0, + ] + .try_into() + .unwrap(), + merkle_path: vec![].try_into().unwrap(), + }; + + // jobs 0..=MAX_PAST_JOBS are immediately active, each retiring its predecessor, so + // past jobs end up exactly at the cap + for job_id in 0..=MAX_PAST_JOBS as u32 { + let mut job = job_template.clone(); + job.job_id = job_id; + channel.on_new_extended_mining_job(job).unwrap(); + } + assert_eq!(channel.get_past_jobs_count(), MAX_PAST_JOBS); + + (channel, job_template) + } + + #[test] + fn test_set_new_prev_hash_keeps_all_past_jobs_in_stale_set() { + // Regression test: with past jobs at the MAX_PAST_JOBS cap, retiring the displaced + // active job through the capped past path evicted the oldest past job right before + // past drained into stale, so its late share was rejected as InvalidJobId instead of + // Stale. The displaced job must go stale with the whole past set (bounded at + // MAX_PAST_JOBS + 1). + let (mut channel, job_template) = extended_channel_with_past_jobs_at_cap(); + let channel_id = job_template.channel_id; + + // a future job to activate on the tip transition + let future_job_id = 100; + let mut future_job = job_template; + future_job.job_id = future_job_id; + future_job.min_ntime = Sv2Option::new(None); + channel.on_new_extended_mining_job(future_job).unwrap(); + + let prev_hash: [u8; 32] = [ + 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, + ]; + channel + .on_set_new_prev_hash(SetNewPrevHashMp { + channel_id, + job_id: future_job_id, + prev_hash: prev_hash.into(), + nbits: 545259519, + min_ntime: 1745596980, + }) + .unwrap(); + + // the displaced active job and every retained past job are stale — none dropped + assert_eq!(channel.get_stale_jobs_count(), MAX_PAST_JOBS + 1); + for job_id in 0..=MAX_PAST_JOBS as u32 { + assert!(channel.get_stale_job(job_id).is_some()); + } + assert_eq!(channel.get_past_jobs_count(), 0); + assert_eq!(channel.get_active_job().unwrap().0.job_id, future_job_id); + } + + #[test] + fn test_chain_tip_update_keeps_all_past_jobs_in_stale_set() { + // Same regression as test_set_new_prev_hash_keeps_all_past_jobs_in_stale_set, for the + // on_chain_tip_update path used by Job Declarator Clients. + let (mut channel, _job_template) = extended_channel_with_past_jobs_at_cap(); + + let 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, + ]; + channel + .on_chain_tip_update(ChainTip::new(prev_hash.into(), 545259519, 1745596980)) + .unwrap(); + + // the displaced active job and every retained past job are stale — none dropped + assert_eq!(channel.get_stale_jobs_count(), MAX_PAST_JOBS + 1); + for job_id in 0..=MAX_PAST_JOBS as u32 { + assert!(channel.get_stale_job(job_id).is_some()); + } + assert_eq!(channel.get_past_jobs_count(), 0); + assert!(channel.get_active_job().is_none()); + } } diff --git a/sv2/channels-sv2/src/client/mod.rs b/sv2/channels-sv2/src/client/mod.rs index 7ed09bac99..d7b78910f4 100644 --- a/sv2/channels-sv2/src/client/mod.rs +++ b/sv2/channels-sv2/src/client/mod.rs @@ -20,6 +20,18 @@ pub mod standard; /// overflow, the oldest future job is evicted. pub const MAX_FUTURE_JOBS: usize = 16; +/// Maximum number of past jobs a client channel retains under the current chain tip. +/// +/// Upstream servers control the job stream, so a malicious or buggy server can force one retained +/// past job per immediately-active job message. Bounding this map prevents unbounded memory +/// growth. Past jobs exist for late-share validation, so the cap must stay nonzero. On overflow, +/// the oldest past job is evicted: a share against it is rejected as +/// [`InvalidJobId`](crate::client::share_accounting::ShareValidationError::InvalidJobId) even +/// though it would otherwise have been accepted and propagated — a bounded loss of creditable +/// work, the price of bounding memory under a hostile upstream. Size the cap with that trade-off +/// in mind. +pub const MAX_PAST_JOBS: usize = 16; + // Type aliases that switch between `std::collections` and `hashbrown` // depending on whether the `no_std` feature is enabled. #[cfg(not(feature = "no_std"))] diff --git a/sv2/channels-sv2/src/client/standard.rs b/sv2/channels-sv2/src/client/standard.rs index a99128d07b..8f262ec7c1 100644 --- a/sv2/channels-sv2/src/client/standard.rs +++ b/sv2/channels-sv2/src/client/standard.rs @@ -5,7 +5,7 @@ //! and chain tip state, enabling share validation and mining job lifecycle management. extern crate alloc; -use super::{HashMap, MAX_FUTURE_JOBS}; +use super::{HashMap, MAX_FUTURE_JOBS, MAX_PAST_JOBS}; use crate::{ chain_tip::ChainTip, client::{ @@ -46,7 +46,8 @@ pub type StandardJob = (NewMiningJobOwned, Target); /// - nominal hashrate in h/s /// - future mining jobs (indexed by job_id, activated upon [`NewMiningJob`](mining_sv2::NewMiningJob) receipt, capped at [`MAX_FUTURE_JOBS`]) /// - active mining job -/// - past jobs (active jobs under current chain tip, indexed by job_id) +/// - past jobs (active jobs under current chain tip, indexed by job_id, capped at +/// [`MAX_PAST_JOBS`]) /// - stale jobs (jobs from previous chain tip, indexed by job_id) /// - share accounting state /// - chain tip state @@ -63,6 +64,9 @@ pub struct StandardChannel { future_job_order: VecDeque, active_job: Option, past_jobs: HashMap, + // Past job IDs ordered by retirement, oldest at the front and newest at the back. + // Replaced IDs move to the back; overflow evicts from the front. + past_job_order: VecDeque, stale_jobs: HashMap, share_accounting: ShareAccounting, chain_tip: Option, @@ -87,6 +91,7 @@ impl StandardChannel { future_job_order: VecDeque::new(), active_job: None, past_jobs: HashMap::new(), + past_job_order: VecDeque::new(), stale_jobs: HashMap::new(), share_accounting: ShareAccounting::new(), chain_tip: None, @@ -196,6 +201,8 @@ impl StandardChannel { } /// Returns an iterator over all past jobs for the channel (active jobs under current chain tip). + /// + /// At most [`MAX_PAST_JOBS`] jobs are kept (oldest evicted first). pub fn get_past_jobs(&self) -> impl Iterator + '_ { self.past_jobs.iter() } @@ -206,6 +213,8 @@ impl StandardChannel { } /// Returns the number of past jobs tracked by this channel. + /// + /// At most [`MAX_PAST_JOBS`] jobs are kept (oldest evicted first). pub fn get_past_jobs_count(&self) -> usize { self.past_jobs.len() } @@ -286,17 +295,36 @@ impl StandardChannel { /// - If `min_ntime` is present, the job is activated and replaces the current active job. /// - If `min_ntime` is empty, the job is added to future jobs. At most [`MAX_FUTURE_JOBS`] /// future jobs are kept: storing a new one beyond that limit evicts the oldest. - /// - If an active job exists, it is moved to past jobs on activation. + /// - If an active job exists, it is moved to past jobs on activation. At most + /// [`MAX_PAST_JOBS`] past jobs are kept: retiring one beyond that limit evicts the oldest. pub fn on_new_mining_job(&mut self, new_mining_job: NewMiningJobOwned) { self.store_new_mining_job(new_mining_job); } + // Moves a displaced job into past jobs, evicting the oldest past job beyond + // [`MAX_PAST_JOBS`]. A share against an evicted job is rejected as `InvalidJobId` even + // though it would otherwise have been accepted and propagated: a bounded loss of + // creditable work, the price of bounding memory under a hostile upstream. + fn retire_job_to_past(&mut self, job: StandardJob) { + let job_id = job.0.job_id; + self.past_jobs.insert(job_id, job); + + // a replaced job_id moves to the back of the eviction order + self.past_job_order.retain(|id| *id != job_id); + self.past_job_order.push_back(job_id); + + if self.past_jobs.len() > MAX_PAST_JOBS { + if let Some(evicted_job_id) = self.past_job_order.pop_front() { + self.past_jobs.remove(&evicted_job_id); + } + } + } + fn store_new_mining_job(&mut self, new_mining_job: NewMiningJobOwned) { match new_mining_job.min_ntime.clone().into_inner() { Some(_min_ntime) => { - if let Some(active_job) = self.active_job.as_ref() { - self.past_jobs - .insert(active_job.0.job_id, active_job.clone()); + if let Some(active_job) = self.active_job.take() { + self.retire_job_to_past(active_job); } self.active_job = Some((new_mining_job, self.target)); } @@ -342,14 +370,6 @@ impl StandardChannel { None => return Err(StandardChannelError::JobIdNotFound), }; - // the job that was active under the previous chain tip must be retired to stale rather - // than silently dropped, otherwise a late share for it would be rejected as - // InvalidJobId instead of Stale - if let Some(previously_active_job) = previously_active_job { - self.past_jobs - .insert(previously_active_job.0.job_id, previously_active_job); - } - // all other future jobs are now useless self.future_jobs.clear(); self.future_job_order.clear(); @@ -357,8 +377,18 @@ impl StandardChannel { // mark all past jobs as stale, so that shares are not propagated self.stale_jobs = self.past_jobs.clone(); + // the job that was active under the previous chain tip goes stale with them rather + // than being silently dropped, bypassing the MAX_PAST_JOBS cap: retiring it through + // the capped past path would push the oldest past job out of the stale set, and a + // late share for either job would be rejected as InvalidJobId instead of Stale + if let Some(previously_active_job) = previously_active_job { + self.stale_jobs + .insert(previously_active_job.0.job_id, previously_active_job); + } + // clear past jobs, as we're no longer going to propagate shares for them self.past_jobs.clear(); + self.past_job_order.clear(); // clear seen shares, as shares for past chain tip will be rejected as stale self.share_accounting.flush_seen_shares(); @@ -521,7 +551,7 @@ mod tests { error::StandardChannelError, share_accounting::{ShareValidationError, ShareValidationResult}, standard::StandardChannel, - MAX_FUTURE_JOBS, + MAX_FUTURE_JOBS, MAX_PAST_JOBS, }, extranonce_manager::ExtranoncePrefix, }; @@ -705,6 +735,53 @@ mod tests { channel.on_set_new_prev_hash(set_new_prev_hash).unwrap(); } + #[test] + fn test_past_jobs_are_bounded() { + let channel_id = 1; + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + + let mut channel = StandardChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1.0, + ); + + let active_job = NewMiningJob { + channel_id, + job_id: 0, + merkle_root: [ + 189, 200, 25, 246, 119, 73, 34, 42, 209, 112, 237, 50, 169, 71, 163, 192, 24, 84, + 56, 86, 147, 71, 243, 44, 18, 107, 167, 169, 169, 66, 186, 98, + ] + .into(), + version: 536870912, + min_ntime: Sv2Option::new(Some(1746839905)), + }; + + let flood_size = 10_000u32; + for job_id in 0..flood_size { + let mut job = active_job.clone(); + job.job_id = job_id; + channel.on_new_mining_job(job); + } + + assert_eq!(channel.get_past_jobs_count(), MAX_PAST_JOBS); + + // the last job is active; of the retired ones, only the newest MAX_PAST_JOBS survive + for job_id in 0..flood_size - 1 - MAX_PAST_JOBS as u32 { + assert!(channel.get_past_job(job_id).is_none()); + } + for job_id in flood_size - 1 - MAX_PAST_JOBS as u32..flood_size - 1 { + assert!(channel.get_past_job(job_id).is_some()); + } + } + #[test] fn test_past_jobs_flow() { let channel_id = 1; @@ -1375,6 +1452,78 @@ mod tests { assert_eq!(channel.get_past_jobs_count(), 0); } + #[test] + fn test_set_new_prev_hash_keeps_all_past_jobs_in_stale_set() { + // Regression test: with past jobs at the MAX_PAST_JOBS cap, retiring the displaced + // active job through the capped past path evicted the oldest past job right before + // past drained into stale, so its late share was rejected as InvalidJobId instead of + // Stale. The displaced job must go stale with the whole past set (bounded at + // MAX_PAST_JOBS + 1). + let channel_id = 1; + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + + let mut channel = StandardChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1.0, + ); + + let merkle_root = [ + 189, 200, 25, 246, 119, 73, 34, 42, 209, 112, 237, 50, 169, 71, 163, 192, 24, 84, 56, + 86, 147, 71, 243, 44, 18, 107, 167, 169, 169, 66, 186, 98, + ]; + + // fill past jobs to the cap: jobs 0..=MAX_PAST_JOBS are immediately active, each + // retiring its predecessor + for job_id in 0..=MAX_PAST_JOBS as u32 { + channel.on_new_mining_job(NewMiningJob { + channel_id, + job_id, + merkle_root: merkle_root.into(), + version: 536870912, + min_ntime: Sv2Option::new(Some(1746839900)), + }); + } + + // a future job to activate on the tip transition + let future_job_id = 100; + channel.on_new_mining_job(NewMiningJob { + channel_id, + job_id: future_job_id, + merkle_root: merkle_root.into(), + version: 536870912, + min_ntime: Sv2Option::new(None), + }); + + let prev_hash: [u8; 32] = [ + 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, + ]; + channel + .on_set_new_prev_hash(SetNewPrevHashMp { + channel_id, + job_id: future_job_id, + prev_hash: prev_hash.into(), + nbits: 503543726, + min_ntime: 1746839905, + }) + .unwrap(); + + // the displaced active job and every retained past job are stale — none dropped + assert_eq!(channel.get_stale_jobs_count(), MAX_PAST_JOBS + 1); + for job_id in 0..=MAX_PAST_JOBS as u32 { + assert!(channel.get_stale_job(job_id).is_some()); + } + assert_eq!(channel.get_past_jobs_count(), 0); + assert_eq!(channel.get_active_job().unwrap().0.job_id, future_job_id); + } + #[test] fn test_on_new_group_channel_job_invalid_coinbase() { // Regression test for a malicious/malformed upstream coinbase: empty prefix and suffix diff --git a/sv2/channels-sv2/src/server/extended.rs b/sv2/channels-sv2/src/server/extended.rs index e3edaba9df..3dcd361af9 100644 --- a/sv2/channels-sv2/src/server/extended.rs +++ b/sv2/channels-sv2/src/server/extended.rs @@ -462,6 +462,9 @@ impl ExtendedChannel { } /// Returns a reference to a past job from its job ID, if any. + /// + /// At most `MAX_PAST_JOBS` (16) past jobs are kept under the current chain tip (oldest + /// evicted first). pub fn get_past_job(&self, job_id: u32) -> Option<&ExtendedJob> { self.job_store.get_past_job(job_id) } @@ -472,8 +475,12 @@ impl ExtendedChannel { /// Updates the channel state with a new template. /// - /// If the template is a future template, the chain tip is not used. - /// If the template is not a future template, the chain tip must be set. + /// If the template is a future template, the chain tip is not used. At most + /// `MAX_FUTURE_JOBS` (16) future jobs are kept: storing a new one beyond that limit evicts + /// the oldest. + /// If the template is not a future template, the chain tip must be set, and the previous + /// active job (if any) moves to past jobs, of which at most `MAX_PAST_JOBS` (16) are kept + /// (oldest evicted first). /// /// Only meant for usage on a Sv2 Pool Server or a Sv2 Job Declaration Client, /// but not on mining clients such as Mining Devices or Proxies. @@ -528,8 +535,11 @@ impl ExtendedChannel { self.job_id_to_target .insert(new_job.get_job_id(), self.target); - // add the new active job to the job store - self.job_store.add_active_job(new_job); + // add the new active job to the job store, dropping the evicted past + // job's target mapping (its shares degrade to InvalidJobId) + if let Some(evicted_job_id) = self.job_store.add_active_job(new_job) { + self.job_id_to_target.remove(&evicted_job_id); + } } } } @@ -566,7 +576,11 @@ impl ExtendedChannel { false => { self.job_id_to_target .insert(extended_job.get_job_id(), self.target); - self.job_store.add_active_job(extended_job); + // dropping the evicted past job's target mapping (its shares degrade to + // InvalidJobId) + if let Some(evicted_job_id) = self.job_store.add_active_job(extended_job) { + self.job_id_to_target.remove(&evicted_job_id); + } } } @@ -635,8 +649,10 @@ impl ExtendedChannel { /// Updates the channel state with a new custom mining job. /// - /// If there is an active job, it is moved to the past jobs. - /// The new custom mining job is then set as the active job. + /// Under the same chain tip, the previously active job is moved to the past jobs; at most + /// `MAX_PAST_JOBS` (16) past jobs are kept, retiring one beyond that limit evicts the + /// oldest. On a chain tip change, the previously active job and all past jobs go stale + /// instead. The new custom mining job is then set as the active job. /// /// Assumes SetCustomMiningJob.{prev_hash, nbits, min_ntime} have already been validated. /// Updates the channel's `ChainTip``. @@ -671,14 +687,22 @@ impl ExtendedChannel { let job_id = new_job.get_job_id(); - self.job_store.add_active_job(new_job); - if is_new_chain_tip { + // tip transition: the displaced active job goes stale together with the past set, + // bypassing the MAX_PAST_JOBS cap — retiring it through the capped path would push + // the oldest past job out of the stale set, misclassifying its late shares as + // InvalidJobId instead of Stale + self.job_store.deactivate_job(); self.job_store.mark_past_jobs_as_stale(); self.share_accounting.flush_seen_shares(); self.job_id_to_target.clear(); } + // dropping the evicted past job's target mapping (its shares degrade to InvalidJobId) + if let Some(evicted_job_id) = self.job_store.add_active_job(new_job) { + self.job_id_to_target.remove(&evicted_job_id); + } + // update the chain tip self.chain_tip = Some(new_chain_tip); @@ -942,6 +966,7 @@ mod tests { jobs::{ extended::ExtendedJob, factory::{MAX_COINBASE_PREFIX_SIZE, MAX_SCRIPT_SIG_SIZE}, + job_store::{MAX_FUTURE_JOBS, MAX_PAST_JOBS}, }, share_accounting::{ShareValidationError, ShareValidationResult}, }, @@ -2202,6 +2227,60 @@ mod tests { assert!(allocator.allocate_extended(8).is_ok()); } + #[test] + fn test_retired_extranonce_prefix_released_after_job_eviction() { + // Eviction counterpart of the test above: when the last job created under a + // rotated-out prefix is evicted from past jobs, the prefix's slot must be released + // right away — a peer withholding the next chain transition must not be able to pin + // allocator slots. + let (mut allocator, mut channel, _prefix_1_bytes, job_id) = + extended_channel_with_rotated_extranonce_prefix(); + + let pubkey_hash = [ + 235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, + 8, 252, + ]; + let mut script_bytes = vec![0]; + script_bytes.push(20); + script_bytes.extend_from_slice(&pubkey_hash); + let coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: ScriptBuf::from(script_bytes), + }]; + + // flood enough non-future templates to push the pre-rotation job out of past jobs + for template_id in 2..2 + MAX_PAST_JOBS as u64 + 2 { + let template = NewTemplate { + template_id, + future_template: false, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![82, 0].try_into().unwrap(), + coinbase_tx_input_sequence: 4294967295, + coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: vec![ + 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, + ] + .try_into() + .unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + channel + .on_new_template(template, coinbase_reward_outputs.clone()) + .unwrap(); + } + assert!(channel.get_past_job(job_id).is_none()); + + // the evicted job was the last reference to the rotated-out prefix, so its slot is + // free again + assert_eq!(allocator.allocated_count(), 1); + assert!(allocator.allocate_extended(8).is_ok()); + } + #[test] fn test_on_group_channel_job_assigns_extranonce_prefix_to_future_job() { // Test that on_group_channel_job assigns the channel's extranonce prefix @@ -2711,6 +2790,184 @@ mod tests { assert!(channel.job_store.get_stale_job(first_job_id).is_none()); } + #[test] + fn test_set_custom_mining_job_chain_tip_change_keeps_all_past_jobs_in_stale_set() { + // At a tip transition the displaced active job and every retained past job must land + // in the stale set: with past jobs at the MAX_PAST_JOBS cap, a capped retirement of + // the displaced job would push the oldest past job out of the stale set, + // misclassifying its late shares as InvalidJobId instead of Stale. + let channel_id = 1; + + let mut channel = ExtendedChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(vec![1, 2, 3, 4]).unwrap(), + Target::from_le_bytes([0xff; 32]), + 100.0, + true, + 8u16, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let first_prev_hash = [ + 154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0, + 162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0, + ]; + let second_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, + ]; + + // fill past jobs to the cap under the first tip, plus the active job + let mut same_tip_job_ids = Vec::new(); + for request_id in 0..MAX_PAST_JOBS as u32 + 2 { + let job_id = channel + .on_set_custom_mining_job(custom_mining_job( + channel_id, + request_id, + first_prev_hash, + 1745596910, + )) + .unwrap(); + same_tip_job_ids.push(job_id); + } + + // tip transition + channel + .on_set_custom_mining_job(custom_mining_job( + channel_id, + MAX_PAST_JOBS as u32 + 2, + second_prev_hash, + 1745596970, + )) + .unwrap(); + + // the newest MAX_PAST_JOBS past jobs and the displaced active job are all stale + for job_id in same_tip_job_ids.iter().rev().take(MAX_PAST_JOBS + 1) { + assert!(channel.job_store.get_stale_job(*job_id).is_some()); + } + } + + #[test] + fn test_retired_extranonce_prefix_kept_through_future_job_activation() { + // Activating a future job created under a rotated-out prefix must not release that + // prefix's allocator slot, even when the activation's retirement of the displaced + // active job coincides with past jobs sitting at the MAX_PAST_JOBS cap. The activated + // job keeps validating shares under the old prefix bytes, so releasing the slot could + // hand the same extranonce space to a second live channel. + let total_extranonce_len = 32; + let max_channels = 2; + let min_rollable_size = 8; + + let mut allocator = + ExtranonceAllocator::new(vec![], total_extranonce_len, max_channels).unwrap(); + + let prefix_1 = allocator.allocate_extended(min_rollable_size).unwrap(); + let prefix_2 = allocator.allocate_extended(min_rollable_size).unwrap(); + let prefix_1_bytes = prefix_1.as_bytes().to_vec(); + let rollable_extranonce_size = + (total_extranonce_len as usize - prefix_1_bytes.len()) as u16; + + let mut channel = ExtendedChannel::new_for_pool( + 1, + "user_identity".to_string(), + prefix_1, + Target::from_le_bytes([0xff; 32]), + 100.0, + true, + rollable_extranonce_size, + 100, + 1.0, + String::new(), + ) + .unwrap(); + + let pubkey_hash = [ + 235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, + 8, 252, + ]; + let mut script_bytes = vec![0]; + script_bytes.push(20); + script_bytes.extend_from_slice(&pubkey_hash); + let coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: ScriptBuf::from(script_bytes), + }]; + + let template = |template_id: u64, future_template: bool| NewTemplate { + template_id, + future_template, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![82, 0].try_into().unwrap(), + coinbase_tx_input_sequence: 4294967295, + coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: vec![ + 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, + ] + .try_into() + .unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + + // a future job created under the first prefix, which is then rotated out + channel + .on_new_template(template(100, true), coinbase_reward_outputs.clone()) + .unwrap(); + channel.set_extranonce_prefix(prefix_2).unwrap(); + + // fill past jobs to the cap with non-future jobs under the second prefix + let ntime = 1745596910; + let prev_hash = [ + 154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0, + 162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0, + ] + .into(); + channel.set_chain_tip(ChainTip::new(prev_hash, 453040064, ntime)); + for template_id in 0..MAX_PAST_JOBS as u64 + 2 { + channel + .on_new_template( + template(template_id, false), + coinbase_reward_outputs.clone(), + ) + .unwrap(); + } + + // activate the future job created under the rotated-out prefix + let new_prev_hash = SetNewPrevHash { + template_id: 100, + 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(), + header_timestamp: ntime + 600, + n_bits: 453040064, + target: [0xff; 32].into(), + }; + channel.on_set_new_prev_hash(new_prev_hash).unwrap(); + + // the active job validates shares under the rotated-out prefix bytes, so its + // allocator slot must still be reserved + assert_eq!( + channel.get_active_job().unwrap().get_extranonce_prefix(), + prefix_1_bytes.as_slice() + ); + assert_eq!(allocator.allocated_count(), 2); + assert!(matches!( + allocator.allocate_extended(min_rollable_size), + Err(ExtranonceAllocatorError::CapacityExhausted) + )); + } + #[test] fn test_share_validation_version_rolling_not_allowed() { // when version rolling is not allowed on the channel, @@ -3069,4 +3326,165 @@ mod tests { merkle_path: vec![].try_into().unwrap(), } } + + #[test] + fn test_future_template_storage_is_bounded() { + let channel_id = 1; + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + let mut channel = ExtendedChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1.0, + true, + 4u16, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let pubkey_hash = [ + 235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, + 8, 252, + ]; + let mut script_bytes = vec![0]; // SegWit version 0 + script_bytes.push(20); // Push 20 bytes (length of pubkey hash) + script_bytes.extend_from_slice(&pubkey_hash); + let script = ScriptBuf::from(script_bytes); + let coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: script, + }]; + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + let template = NewTemplate { + template_id, + future_template: true, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![82, 0].try_into().unwrap(), + coinbase_tx_input_sequence: 4294967295, + coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: vec![ + 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, + ] + .try_into() + .unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + + channel + .on_new_template(template, coinbase_reward_outputs.clone()) + .unwrap(); + } + + // only the newest MAX_FUTURE_JOBS templates survive; the oldest were evicted + for template_id in 0..flood_size - MAX_FUTURE_JOBS as u64 { + assert!(channel + .get_future_job_id_from_template_id(template_id) + .is_none()); + } + for template_id in flood_size - MAX_FUTURE_JOBS as u64..flood_size { + assert!(channel + .get_future_job_id_from_template_id(template_id) + .is_some()); + } + } + + #[test] + fn test_past_job_storage_is_bounded() { + let channel_id = 1; + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + let mut channel = ExtendedChannel::new( + channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1.0, + true, + 4u16, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let ntime = 1747092633; + let 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(); + let nbits = 503543726; + channel.set_chain_tip(ChainTip::new(prev_hash, nbits, ntime)); + + let pubkey_hash = [ + 235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, + 8, 252, + ]; + let mut script_bytes = vec![0]; // SegWit version 0 + script_bytes.push(20); // Push 20 bytes (length of pubkey hash) + script_bytes.extend_from_slice(&pubkey_hash); + let script = ScriptBuf::from(script_bytes); + let coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: script, + }]; + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + let template = NewTemplate { + template_id, + future_template: false, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![82, 0].try_into().unwrap(), + coinbase_tx_input_sequence: 4294967295, + coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: vec![ + 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, + ] + .try_into() + .unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + + channel + .on_new_template(template, coinbase_reward_outputs.clone()) + .unwrap(); + } + + // each non-future template retires the previous active job; only the newest + // MAX_PAST_JOBS retired jobs survive + let retained = (0..=flood_size as u32) + .filter(|job_id| channel.get_past_job(*job_id).is_some()) + .count(); + assert_eq!(retained, MAX_PAST_JOBS); + assert!(channel.get_active_job().is_some()); + + // target metadata must not outlive the jobs it belongs to: one entry for the active + // job plus one per retained past job + assert_eq!(channel.job_id_to_target.len(), MAX_PAST_JOBS + 1); + } } diff --git a/sv2/channels-sv2/src/server/group.rs b/sv2/channels-sv2/src/server/group.rs index f7e7d4c693..72d5db1190 100644 --- a/sv2/channels-sv2/src/server/group.rs +++ b/sv2/channels-sv2/src/server/group.rs @@ -254,8 +254,12 @@ impl GroupChannel { /// Updates the group channel state with a new template. /// - /// If the template is a future template, the chain tip is not used. - /// If the template is not a future template, the chain tip must be set. + /// If the template is a future template, the chain tip is not used. At most + /// `MAX_FUTURE_JOBS` (16) future jobs are kept: storing a new one beyond that limit evicts + /// the oldest. + /// If the template is not a future template, the chain tip must be set, and the new job + /// replaces the active job. The replaced job is dropped: group channels never validate + /// shares, so no past-job history is kept. /// Returns an error if a non-future job cannot be created due to missing chain tip. /// /// Returns [`GroupChannelError::JobFactoryError`] wrapping @@ -302,7 +306,9 @@ impl GroupChannel { self.full_extranonce_size, ) .map_err(GroupChannelError::JobFactoryError)?; - self.job_store.add_active_job(new_job); + // group channels never validate shares, so the replaced active job is + // dropped instead of being retained as a past job + self.job_store.replace_active_job(new_job); } } } @@ -314,7 +320,8 @@ impl GroupChannel { /// (Template Distribution Protocol variant). /// /// If there is a future job matching the `template_id` specified in `SetNewPrevHash`, - /// this future job is "activated" and set as the active job. + /// this future job is "activated" and set as the active job. The previously active job is + /// dropped: group channels never validate shares, so no past or stale job history is kept. /// /// Updates the chain tip for the group channel. /// Returns an error if no matching future job is found, leaving the chain tip untouched. @@ -329,7 +336,9 @@ impl GroupChannel { true => { // activation is a no-op when no future job matches the template id, so the // chain tip must only advance once we know a job was actually activated. - if !self.job_store.activate_future_job( + // group channels never validate shares, so the displaced active job is + // dropped instead of being retired into past/stale history + if !self.job_store.activate_future_job_replacing_active( set_new_prev_hash.template_id, set_new_prev_hash.header_timestamp, ) { @@ -355,6 +364,7 @@ mod tests { jobs::{ error::JobFactoryError, factory::{MAX_COINBASE_PREFIX_SIZE, MAX_SCRIPT_SIG_SIZE}, + job_store::MAX_FUTURE_JOBS, }, }, }; @@ -908,4 +918,117 @@ mod tests { GroupChannelError::JobFactoryError(JobFactoryError::ScriptSigSizeTooLarge) )); } + + #[test] + fn test_future_template_storage_is_bounded() { + let mut group_channel = GroupChannel::new(1, 32, None, None).unwrap(); + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + let template = NewTemplate { + template_id, + future_template: true, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![].try_into().unwrap(), + coinbase_tx_input_sequence: u32::MAX, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs: vec![].try_into().unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + + group_channel.on_new_template(template, vec![]).unwrap(); + } + + // only the newest MAX_FUTURE_JOBS templates survive; the oldest were evicted + for template_id in 0..flood_size - MAX_FUTURE_JOBS as u64 { + assert!(group_channel + .get_future_job_id_from_template_id(template_id) + .is_none()); + } + for template_id in flood_size - MAX_FUTURE_JOBS as u64..flood_size { + assert!(group_channel + .get_future_job_id_from_template_id(template_id) + .is_some()); + } + } + + #[test] + fn test_replaced_active_job_is_dropped() { + let mut group_channel = GroupChannel::new(1, 32, None, None).unwrap(); + group_channel.set_chain_tip(ChainTip::new([0; 32].into(), 0x1d00ffff, 1)); + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + let template = NewTemplate { + template_id, + future_template: false, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![].try_into().unwrap(), + coinbase_tx_input_sequence: u32::MAX, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs: vec![].try_into().unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + + group_channel.on_new_template(template, vec![]).unwrap(); + } + + // group channels never validate shares, so replaced active jobs must be dropped + // instead of retained as past jobs + for job_id in 0..=flood_size as u32 { + assert!(group_channel.job_store.get_past_job(job_id).is_none()); + assert!(group_channel.job_store.get_stale_job(job_id).is_none()); + } + assert!(group_channel.get_active_job().is_some()); + + // future job activation must also drop the displaced active job, rather than retiring + // it into past/stale history + let future_template = NewTemplate { + template_id: flood_size, + future_template: true, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![].try_into().unwrap(), + coinbase_tx_input_sequence: u32::MAX, + coinbase_tx_value_remaining: 0, + coinbase_tx_outputs_count: 0, + coinbase_tx_outputs: vec![].try_into().unwrap(), + coinbase_tx_locktime: 0, + merkle_path: vec![].try_into().unwrap(), + }; + group_channel + .on_new_template(future_template, vec![]) + .unwrap(); + + let set_new_prev_hash = SetNewPrevHash { + template_id: flood_size, + 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(), + header_timestamp: 1746839905, + n_bits: 503543726, + target: [ + 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, + 174, 119, 3, 0, 0, + ] + .into(), + }; + group_channel + .on_set_new_prev_hash(set_new_prev_hash) + .unwrap(); + + for job_id in 0..=flood_size as u32 + 1 { + assert!(group_channel.job_store.get_past_job(job_id).is_none()); + assert!(group_channel.job_store.get_stale_job(job_id).is_none()); + } + assert!(group_channel.get_active_job().is_some()); + } } diff --git a/sv2/channels-sv2/src/server/jobs/job_store.rs b/sv2/channels-sv2/src/server/jobs/job_store.rs index 34425a7d8d..5c9c3c0d35 100644 --- a/sv2/channels-sv2/src/server/jobs/job_store.rs +++ b/sv2/channels-sv2/src/server/jobs/job_store.rs @@ -10,11 +10,29 @@ //! - **Retired Extranonce Prefixes**: Holds on to extranonce prefixes that were rotated out of the //! channel while jobs created under them can still accept shares, so that their allocator slots //! are not handed to another channel too early. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use super::Job; use crate::extranonce_manager::ExtranoncePrefix; +/// Maximum number of future jobs a server channel retains while waiting for a +/// template-distribution `SetNewPrevHash`. +/// +/// Template Distribution peers control `template_id`, so future jobs are stored under a +/// peer-controlled key. Bounding the map prevents a malicious or buggy peer from exhausting +/// server memory by streaming future templates while withholding `SetNewPrevHash`. On overflow, +/// the oldest future job is evicted. +pub(crate) const MAX_FUTURE_JOBS: usize = 16; + +/// Maximum number of past jobs a server channel retains under the current chain tip. +/// +/// Past jobs exist for late-share validation, so the cap must stay nonzero. A share against an +/// evicted job is rejected as `InvalidJobId` even though it would otherwise have been accepted +/// and credited — a bounded loss of creditable work, the price of bounding memory against a +/// malicious template-distribution peer streaming non-future templates while withholding +/// `SetNewPrevHash`. Size the cap with that trade-off in mind. +pub(crate) const MAX_PAST_JOBS: usize = 16; + /// Internal implementation for tracking mining job states in SV2 server channels. /// /// Maintains collections for future, active, past, and stale jobs, and tracks template-to-job ID @@ -22,11 +40,17 @@ use crate::extranonce_manager::ExtranoncePrefix; #[derive(Debug)] pub(crate) struct JobStore { future_template_to_job_id: HashMap, + // Future template 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_template_order: VecDeque, // Future jobs are indexed with job_id (u32) future_jobs: HashMap, active_job: Option, // Past jobs are indexed with job_id (u32) past_jobs: HashMap, + // Past job IDs ordered by retirement, oldest at the front and newest at the back. + // Replaced IDs move to the back; overflow evicts from the front. + past_job_order: VecDeque, // Stale jobs are indexed with job_id (u32) stale_jobs: HashMap, // Extranonce prefixes rotated out of the channel that are still referenced by at least one job @@ -40,9 +64,11 @@ impl JobStore { pub fn new() -> Self { Self { future_template_to_job_id: HashMap::new(), + future_template_order: VecDeque::new(), future_jobs: HashMap::new(), active_job: None, past_jobs: HashMap::new(), + past_job_order: VecDeque::new(), stale_jobs: HashMap::new(), retired_extranonce_prefixes: Vec::new(), } @@ -61,27 +87,135 @@ impl JobStore { /// If the template ID was already mapped to a future job, that job is dropped, since it could /// never be activated again (activation resolves jobs through this mapping). /// + /// At most `MAX_FUTURE_JOBS` future jobs are kept: storing a new one beyond that limit evicts + /// the oldest, since template IDs are peer-controlled and must not grow memory unboundedly. + /// /// Returns the new job's ID. pub fn add_future_job(&mut self, template_id: u64, new_job: T) -> u32 { + let mut dropped_job = false; + let new_job_id = new_job.get_job_id(); if let Some(old_job_id) = self .future_template_to_job_id .insert(template_id, new_job_id) { self.future_jobs.remove(&old_job_id); + dropped_job = true; } self.future_jobs.insert(new_job_id, new_job); + + // a replaced template_id moves to the back of the eviction order + self.future_template_order.retain(|id| *id != template_id); + self.future_template_order.push_back(template_id); + + if self.future_jobs.len() > MAX_FUTURE_JOBS { + if let Some(evicted_template_id) = self.future_template_order.pop_front() { + if let Some(evicted_job_id) = + self.future_template_to_job_id.remove(&evicted_template_id) + { + self.future_jobs.remove(&evicted_job_id); + dropped_job = true; + } + } + } + + // a dropped job (replaced template ID or evicted-oldest) may have been the last one + // holding a retired extranonce prefix alive; release such slots now rather than at the + // next chain transition, which a peer can withhold + if dropped_job { + self.prune_retired_extranonce_prefixes(); + } + new_job_id } - /// Adds an active job, moving the previous active job (if any) to past jobs. - pub fn add_active_job(&mut self, job: T) { - // Move currently active job to past jobs (so it can be marked as stale) + /// Moves the active job (if any) into past jobs, evicting the oldest past job beyond + /// `MAX_PAST_JOBS`. A share against an evicted job is rejected as `InvalidJobId` even though + /// it would otherwise have been accepted and credited: a bounded loss of creditable work, + /// the price of bounding memory under a hostile upstream. + /// + /// Returns the evicted job's ID, if any, so callers can drop metadata they key by job ID. + fn retire_active_to_past(&mut self) -> Option { + let active_job = self.active_job.take()?; + let job_id = active_job.get_job_id(); + self.past_jobs.insert(job_id, active_job); + + // job IDs are minted by the job factory, strictly monotonic per channel, so a retiring + // ID can never already be in the eviction order + self.past_job_order.push_back(job_id); + + if self.past_jobs.len() > MAX_PAST_JOBS { + if let Some(evicted_job_id) = self.past_job_order.pop_front() { + self.past_jobs.remove(&evicted_job_id); + // the evicted job may have been the last one holding a retired extranonce + // prefix alive; release such slots now rather than at the next chain + // transition, which a peer can withhold + self.prune_retired_extranonce_prefixes(); + return Some(evicted_job_id); + } + } + None + } + + /// Moves the active job (if any) into past jobs without the `MAX_PAST_JOBS` cap and without + /// pruning retired extranonce prefixes. + /// + /// Only for tip transitions, where past jobs immediately drain into stale jobs: `stale_jobs` + /// stays bounded at `MAX_PAST_JOBS + 1`, no job is dropped from the stale set (which would + /// misclassify its late shares as `InvalidJobId` instead of `Stale`), and no prune runs + /// while an in-flight future job is outside every collection (which would release its + /// retired extranonce prefix while the job goes on to accept shares under it). + fn retire_active_to_past_uncapped(&mut self) { if let Some(active_job) = self.active_job.take() { - self.past_jobs.insert(active_job.get_job_id(), active_job); + let job_id = active_job.get_job_id(); + self.past_jobs.insert(job_id, active_job); + + // job IDs are minted by the job factory, strictly monotonic per channel, so a + // retiring ID can never already be in the eviction order + self.past_job_order.push_back(job_id); } + } + + /// Adds an active job, moving the previous active job (if any) to past jobs. + /// + /// At most `MAX_PAST_JOBS` past jobs are kept: retiring one beyond that limit evicts the + /// oldest (giving up shares that would still have been creditable), and its ID is returned + /// so callers can drop metadata they key by job ID (e.g. the per-job target mapping of + /// standard and extended channels). + pub fn add_active_job(&mut self, job: T) -> Option { + // Move currently active job to past jobs (so it can be marked as stale) + let evicted_job_id = self.retire_active_to_past(); // Set the new active job self.active_job = Some(job); + evicted_job_id + } + + /// Replaces the active job, dropping the previous active job (if any). + /// + /// For channels that never validate shares (group channels), where retaining the replaced + /// job would be pure memory growth under peer-controlled message streams. + pub fn replace_active_job(&mut self, job: T) { + self.active_job = Some(job); + } + + /// Activates a future job given by template ID and header timestamp, dropping the previously + /// active job (if any) instead of keeping it as stale. + /// Returns `true` if successful, `false` if not found. + /// + /// For channels that never validate shares (group channels), which keep no past or stale + /// job history. A failed activation leaves channel state untouched. + pub fn activate_future_job_replacing_active( + &mut self, + template_id: u64, + prev_hash_header_timestamp: u32, + ) -> bool { + let activated = self.activate_future_job(template_id, prev_hash_header_timestamp); + if activated { + // group channels keep no job history: the job displaced by this activation went + // stale above and is dropped here + self.stale_jobs.clear(); + } + activated } /// Activates a future job given by template ID and header timestamp. @@ -102,33 +236,38 @@ impl JobStore { return false; }; - // Move currently active job to past jobs (so it can be marked as stale) - if let Some(active_job) = self.active_job.take() { - self.past_jobs.insert(active_job.get_job_id(), active_job); - } + // Move currently active job to past jobs (so it can be marked as stale). The + // retirement is uncapped: past jobs drain into stale jobs below, so the displaced job + // must not push another one out of the stale set, and no prune may run while the + // in-flight future job is outside every collection. + self.retire_active_to_past_uncapped(); // Activate the future job future_job.activate(prev_hash_header_timestamp); self.active_job = Some(future_job); self.future_jobs.clear(); self.future_template_to_job_id.clear(); + self.future_template_order.clear(); self.mark_past_jobs_as_stale(); true } - /// Moves the active job (if any) into past jobs. + /// Moves the active job (if any) into past jobs, without the `MAX_PAST_JOBS` cap. + /// + /// Only for tip transitions, right before [`Self::mark_past_jobs_as_stale`] drains past + /// jobs into stale jobs: the displaced job must go stale with the rest, not push another + /// job out of the stale set. pub fn deactivate_job(&mut self) { - if let Some(active_job) = self.active_job.take() { - self.past_jobs.insert(active_job.get_job_id(), active_job); - } + self.retire_active_to_past_uncapped(); } /// Marks all past jobs as stale so shares can be rejected with the proper error code. pub fn mark_past_jobs_as_stale(&mut self) { // Transfer past jobs to stale jobs collection and reset past jobs to empty self.stale_jobs = std::mem::take(&mut self.past_jobs); + self.past_job_order.clear(); // jobs that just went stale can no longer accept shares, so any retired extranonce prefix // they were the last reference to is now releasable self.prune_retired_extranonce_prefixes(); @@ -229,6 +368,173 @@ mod tests { fn activate(&mut self, _prev_hash_header_timestamp: u32) {} } + #[test] + fn future_jobs_are_bounded() { + let mut store = JobStore::new(); + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + store.add_future_job( + template_id, + DummyJob { + job_id: template_id as u32, + }, + ); + } + + // only the newest MAX_FUTURE_JOBS survive; the oldest were evicted + for template_id in 0..flood_size - MAX_FUTURE_JOBS as u64 { + assert!(store + .get_future_job_id_from_template_id(template_id) + .is_none()); + assert!(store.get_future_job(template_id as u32).is_none()); + } + for template_id in flood_size - MAX_FUTURE_JOBS as u64..flood_size { + assert!(store + .get_future_job_id_from_template_id(template_id) + .is_some()); + assert!(store.get_future_job(template_id as u32).is_some()); + } + } + + #[test] + fn past_jobs_are_bounded() { + let mut store = JobStore::new(); + + let flood_size = 10_000u32; + for job_id in 0..flood_size { + let evicted_job_id = store.add_active_job(DummyJob { job_id }); + // the first eviction happens once MAX_PAST_JOBS past jobs already exist; from + // then on each retirement evicts the oldest and reports its ID + if job_id as usize > MAX_PAST_JOBS { + assert_eq!(evicted_job_id, Some(job_id - MAX_PAST_JOBS as u32 - 1)); + } else { + assert_eq!(evicted_job_id, None); + } + } + + // the last job is active; of the retired ones, only the newest MAX_PAST_JOBS survive + for job_id in 0..flood_size - 1 - MAX_PAST_JOBS as u32 { + assert!(store.get_past_job(job_id).is_none()); + } + for job_id in flood_size - 1 - MAX_PAST_JOBS as u32..flood_size - 1 { + assert!(store.get_past_job(job_id).is_some()); + } + assert_eq!( + store.get_active_job().map(|job| job.get_job_id()), + Some(flood_size - 1) + ); + } + + struct PrefixedJob { + job_id: u32, + prefix: Vec, + } + + impl Job for PrefixedJob { + fn get_job_id(&self) -> u32 { + self.job_id + } + + fn get_extranonce_prefix(&self) -> &[u8] { + &self.prefix + } + + fn activate(&mut self, _prev_hash_header_timestamp: u32) {} + } + + #[test] + fn tip_transition_moves_displaced_and_all_past_jobs_to_stale() { + let mut store = JobStore::new(); + + // fill past jobs to the cap, plus an active job + for job_id in 0..=MAX_PAST_JOBS as u32 { + store.add_active_job(DummyJob { job_id }); + } + + let future_job_id = 100; + store.add_future_job( + 1, + DummyJob { + job_id: future_job_id, + }, + ); + assert!(store.activate_future_job(1, 0)); + + // the displaced active job and every past job must land in the stale set: dropping + // any of them would misclassify its late shares as InvalidJobId instead of Stale + for job_id in 0..=MAX_PAST_JOBS as u32 { + assert!(store.get_stale_job(job_id).is_some()); + } + assert_eq!(store.stale_jobs.len(), MAX_PAST_JOBS + 1); + assert!(store.past_jobs.is_empty()); + assert_eq!( + store.get_active_job().map(|job| job.get_job_id()), + Some(future_job_id) + ); + } + + #[test] + fn activation_keeps_retired_prefix_of_activated_job() { + let mut store = JobStore::new(); + let old_prefix = vec![1u8]; + + // a future job created under the old prefix, which is then rotated out + store.add_future_job( + 1, + PrefixedJob { + job_id: 100, + prefix: old_prefix.clone(), + }, + ); + store.retire_extranonce_prefix(ExtranoncePrefix::from_wire(old_prefix).unwrap()); + assert_eq!(store.retired_extranonce_prefixes.len(), 1); + + // fill past jobs to the cap with jobs under the new prefix, so that a capped + // retirement during activation would evict (and prune) mid-flight + for job_id in 0..=MAX_PAST_JOBS as u32 { + store.add_active_job(PrefixedJob { + job_id, + prefix: vec![2u8], + }); + } + + assert!(store.activate_future_job(1, 0)); + + // the activated job is the only remaining reference to the retired prefix; its slot + // must stay reserved while the job keeps accepting shares under those bytes + assert_eq!(store.retired_extranonce_prefixes.len(), 1); + } + + #[test] + fn dropped_jobs_release_retired_extranonce_prefixes() { + let mut store = JobStore::new(); + let old_prefix = vec![1u8]; + + // a future job created under the old prefix keeps the retired prefix alive + store.add_future_job( + 1, + PrefixedJob { + job_id: 1, + prefix: old_prefix.clone(), + }, + ); + store.retire_extranonce_prefix(ExtranoncePrefix::from_wire(old_prefix).unwrap()); + assert_eq!(store.retired_extranonce_prefixes.len(), 1); + + // replacing the future job under the same template ID drops the last job referencing + // the retired prefix, so its slot must be released without waiting for a chain + // transition + store.add_future_job( + 1, + PrefixedJob { + job_id: 2, + prefix: vec![2u8], + }, + ); + assert!(store.retired_extranonce_prefixes.is_empty()); + } + #[test] fn reused_template_id_evicts_superseded_future_job() { let mut store = JobStore::new(); diff --git a/sv2/channels-sv2/src/server/standard.rs b/sv2/channels-sv2/src/server/standard.rs index 89d6cee8bf..951373887a 100644 --- a/sv2/channels-sv2/src/server/standard.rs +++ b/sv2/channels-sv2/src/server/standard.rs @@ -415,6 +415,9 @@ impl StandardChannel { } /// Returns a reference to a past job from its job ID, if any. + /// + /// At most `MAX_PAST_JOBS` (16) past jobs are kept under the current chain tip (oldest + /// evicted first). pub fn get_past_job(&self, job_id: u32) -> Option<&StandardJob> { self.job_store.get_past_job(job_id) } @@ -447,8 +450,12 @@ impl StandardChannel { /// Updates the channel state with a new job. /// - /// If the template is a future template, the chain tip is not used. - /// If the template is not a future template, the chain tip must be set. + /// If the template is a future template, the chain tip is not used. At most + /// `MAX_FUTURE_JOBS` (16) future jobs are kept: storing a new one beyond that limit evicts + /// the oldest. + /// If the template is not a future template, the chain tip must be set, and the previous + /// active job (if any) moves to past jobs, of which at most `MAX_PAST_JOBS` (16) are kept + /// (oldest evicted first). /// /// Only meant for usage on a Sv2 Pool Server or a Sv2 Job Declaration Client, /// but not on mining clients such as Mining Devices or Proxies. @@ -502,8 +509,11 @@ impl StandardChannel { self.job_id_to_target .insert(new_job.get_job_id(), self.target); - // add the new active job to the job store - self.job_store.add_active_job(new_job); + // add the new active job to the job store, dropping the evicted past + // job's target mapping (its shares degrade to InvalidJobId) + if let Some(evicted_job_id) = self.job_store.add_active_job(new_job) { + self.job_id_to_target.remove(&evicted_job_id); + } } } } @@ -535,8 +545,11 @@ impl StandardChannel { self.job_id_to_target .insert(standard_job.get_job_id(), self.target); - // add the new active job to the job store - self.job_store.add_active_job(standard_job); + // add the new active job to the job store, dropping the evicted past job's + // target mapping (its shares degrade to InvalidJobId) + if let Some(evicted_job_id) = self.job_store.add_active_job(standard_job) { + self.job_id_to_target.remove(&evicted_job_id); + } } } @@ -811,7 +824,10 @@ mod tests { }, server::{ error::StandardChannelError, - jobs::factory::{MAX_COINBASE_PREFIX_SIZE, MAX_SCRIPT_SIG_SIZE}, + jobs::{ + factory::{MAX_COINBASE_PREFIX_SIZE, MAX_SCRIPT_SIG_SIZE}, + job_store::{MAX_FUTURE_JOBS, MAX_PAST_JOBS}, + }, share_accounting::{ShareValidationError, ShareValidationResult}, standard::StandardChannel, }, @@ -2318,4 +2334,163 @@ mod tests { prefix_1_bytes.as_slice() ); } + + #[test] + fn test_future_template_storage_is_bounded() { + let standard_channel_id = 1; + let user_identity = "user_identity".to_string(); + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + let mut standard_channel = StandardChannel::new( + standard_channel_id, + user_identity, + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 10.0, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let pubkey_hash = [ + 235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, + 8, 252, + ]; + let mut script_bytes = vec![0]; // SegWit version 0 + script_bytes.push(20); // Push 20 bytes (length of pubkey hash) + script_bytes.extend_from_slice(&pubkey_hash); + let script = ScriptBuf::from(script_bytes); + let coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: script, + }]; + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + let template = NewTemplate { + template_id, + future_template: true, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![2, 159, 0, 0].try_into().unwrap(), + coinbase_tx_input_sequence: 4294967294, + coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: vec![ + 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, + ] + .try_into() + .unwrap(), + coinbase_tx_locktime: 158, + merkle_path: vec![].try_into().unwrap(), + }; + + standard_channel + .on_new_template(template, coinbase_reward_outputs.clone()) + .unwrap(); + } + + // only the newest MAX_FUTURE_JOBS templates survive; the oldest were evicted + for template_id in 0..flood_size - MAX_FUTURE_JOBS as u64 { + assert!(standard_channel + .get_future_job_id_from_template_id(template_id) + .is_none()); + } + for template_id in flood_size - MAX_FUTURE_JOBS as u64..flood_size { + assert!(standard_channel + .get_future_job_id_from_template_id(template_id) + .is_some()); + } + } + + #[test] + fn test_past_job_storage_is_bounded() { + let standard_channel_id = 1; + let user_identity = "user_identity".to_string(); + let extranonce_prefix = [ + 83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + let mut standard_channel = StandardChannel::new( + standard_channel_id, + user_identity, + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 10.0, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let ntime = 1747092633; + let 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(); + let nbits = 503543726; + standard_channel.set_chain_tip(ChainTip::new(prev_hash, nbits, ntime)); + + let pubkey_hash = [ + 235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, + 8, 252, + ]; + let mut script_bytes = vec![0]; // SegWit version 0 + script_bytes.push(20); // Push 20 bytes (length of pubkey hash) + script_bytes.extend_from_slice(&pubkey_hash); + let script = ScriptBuf::from(script_bytes); + let coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: script, + }]; + + let flood_size = 10_000u64; + for template_id in 0..flood_size { + let template = NewTemplate { + template_id, + future_template: false, + version: 536870912, + coinbase_tx_version: 2, + coinbase_prefix: vec![2, 159, 0, 0].try_into().unwrap(), + coinbase_tx_input_sequence: 4294967294, + coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: vec![ + 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, + ] + .try_into() + .unwrap(), + coinbase_tx_locktime: 158, + merkle_path: vec![].try_into().unwrap(), + }; + + standard_channel + .on_new_template(template, coinbase_reward_outputs.clone()) + .unwrap(); + } + + // each non-future template retires the previous active job; only the newest + // MAX_PAST_JOBS retired jobs survive + let retained = (0..=flood_size as u32) + .filter(|job_id| standard_channel.get_past_job(*job_id).is_some()) + .count(); + assert_eq!(retained, MAX_PAST_JOBS); + assert!(standard_channel.get_active_job().is_some()); + + // target metadata must not outlive the jobs it belongs to: one entry for the active + // job plus one per retained past job + assert_eq!(standard_channel.job_id_to_target.len(), MAX_PAST_JOBS + 1); + } }