diff --git a/sv2/channels-sv2/src/client/extended.rs b/sv2/channels-sv2/src/client/extended.rs index 6cee1fe749..37dfd6d15f 100644 --- a/sv2/channels-sv2/src/client/extended.rs +++ b/sv2/channels-sv2/src/client/extended.rs @@ -15,7 +15,7 @@ use crate::{ extranonce_manager::ExtranoncePrefix, merkle_root::merkle_root_from_path, target::{bytes_to_hex, u256_to_block_hash}, - MAX_EXTRANONCE_LEN, VERSION_ROLLING_MASK, + MAX_EXTRANONCE_LEN, MAX_FUTURE_BLOCK_TIME, VERSION_ROLLING_MASK, }; use alloc::{collections::VecDeque, format, string::String, vec, vec::Vec}; use binary_sv2::Sv2OptionOwned; @@ -608,7 +608,12 @@ impl ExtendedChannel { /// /// Updates channel state with the share validation result: /// - Prevents propagation of stale, duplicate, low-difficulty, or low-ntime shares - /// (shares whose `ntime` is below the chain tip's `min_ntime`). + /// (shares whose `ntime` is below the chain tip's `min_ntime` or the job's own + /// `min_ntime` — an immediately-active job may carry a `min_ntime` later than the chain + /// tip's minimum, so the effective lower bound is the larger of the two — as well as + /// shares whose `ntime` exceeds the chain tip's `min_ntime + MAX_FUTURE_BLOCK_TIME`, see + /// [`MAX_FUTURE_BLOCK_TIME`] for how this clockless upper bound relates to the spec's + /// elapsed-time window). /// - Indicates whether a block was found from the share. /// - Maintains local share accounting for later reconciliation with upstream acknowledgements. pub fn validate_share( @@ -685,6 +690,25 @@ impl ExtendedChannel { )); } + // consensus caps block timestamps at ~2h in the future; the allowance is anchored at + // chain-tip receipt, since this crate has no clock (see MAX_FUTURE_BLOCK_TIME) + if share.ntime > chain_tip.min_ntime().saturating_add(MAX_FUTURE_BLOCK_TIME) { + return Err(ShareValidationError::Invalid( + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, + )); + } + + // an immediately-active job carries its own min_ntime, which may be later than the + // chain tip's minimum; jobs activated from the future queue have it overwritten with + // the SetNewPrevHash timestamp, making this check redundant there (and harmless) + if let Some(job_min_ntime) = job.0.min_ntime.clone().into_inner() { + if share.ntime < job_min_ntime { + return Err(ShareValidationError::Invalid( + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, + )); + } + } + // Only BIP323 general-purpose bits may differ from the job's advertised version. // When version rolling is not allowed, the share version must match the job version exactly. let version_rolling_mask = if job.0.version_rolling_allowed { @@ -1316,7 +1340,7 @@ mod tests { 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, 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(), @@ -2336,4 +2360,184 @@ mod tests { assert_eq!(channel.get_past_jobs_count(), 0); assert!(channel.get_active_job().is_none()); } + + #[test] + fn test_share_validation_ntime_below_job_min_ntime() { + // Regression test: an immediately-active job carries its own min_ntime, which may be + // later than the chain tip's minimum. A share in the gap + // (chain_tip.min_ntime <= ntime < job.min_ntime) must be rejected. + 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 = |job_id: u32, min_ntime: Option| NewExtendedMiningJob { + channel_id, + job_id, + min_ntime: Sv2Option::new(min_ntime), + 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(), + }; + + let share = |sequence_number: u32, job_id: u32, ntime: u32| SubmitSharesExtended { + channel_id, + sequence_number, + job_id, + nonce: 741057, + ntime, + version: 536870912, + extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(), + }; + + // activate a chain tip at nTime t via a future job + let tip_ntime: u32 = 1745596930; + channel.on_new_extended_mining_job(job(1, None)).unwrap(); + // network target: 000000000000d7c0... (hard, so no accidental BlockFound) + channel + .on_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(), + nbits: 453040064, + min_ntime: tip_ntime, + }) + .unwrap(); + + // re-confirm the chain-tip lower bound still holds + let res = channel.validate_share(share(0, 1, tip_ntime - 1)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // install an immediately-active job whose own min_ntime is later than the tip's + let job_min_ntime = tip_ntime + 3; + channel + .on_new_extended_mining_job(job(2, Some(job_min_ntime))) + .unwrap(); + + // a share in the gap passes the chain-tip bound but not the job's own bound + let res = channel.validate_share(share(1, 2, job_min_ntime - 1)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + let res = channel.validate_share(share(2, 2, job_min_ntime)); + assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); + } + + #[test] + fn test_share_validation_ntime_above_max_future_block_time() { + // Regression test: a share ntime beyond the chain tip's min_ntime + + // MAX_FUTURE_BLOCK_TIME would put a consensus-invalid timestamp in the block header, + // so it must be rejected; ntime exactly on the bound is still accepted. + 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, + ); + + channel + .on_new_extended_mining_job(NewExtendedMiningJob { + 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, + 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(), + }) + .unwrap(); + + let tip_ntime: u32 = 1745596930; + // network target: 000000000000d7c0... (hard, so no accidental BlockFound) + channel + .on_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(), + nbits: 453040064, + min_ntime: tip_ntime, + }) + .unwrap(); + + let share = |sequence_number: u32, ntime: u32| SubmitSharesExtended { + channel_id, + sequence_number, + job_id: 1, + nonce: 741057, + ntime, + version: 536870912, + extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(), + }; + + // one second above the bound: rejected before any PoW evaluation + let res = channel.validate_share(share(0, tip_ntime + crate::MAX_FUTURE_BLOCK_TIME + 1)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // u32::MAX is likewise rejected (the bound saturates instead of wrapping) + let res = channel.validate_share(share(1, u32::MAX)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // exactly on the bound the share is accepted (channel target is permissive) + let res = channel.validate_share(share(2, tip_ntime + crate::MAX_FUTURE_BLOCK_TIME)); + assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); + } } diff --git a/sv2/channels-sv2/src/client/mod.rs b/sv2/channels-sv2/src/client/mod.rs index 2935739cbc..a84e0d78af 100644 --- a/sv2/channels-sv2/src/client/mod.rs +++ b/sv2/channels-sv2/src/client/mod.rs @@ -35,6 +35,20 @@ pub const MAX_FUTURE_JOBS: usize = 16; /// small measured memory cost — see the load-test data in PR #2290. pub const MAX_PAST_JOBS: usize = 50; +/// Maximum number of accepted-share hashes a client channel retains for duplicate detection. +/// +/// 4 096 hashes is one 128 KB allocation, which keeps the `no_std`/embedded use case viable: +/// the bound has to be affordable on the smallest supported device, since an adversarial +/// upstream advertising a trivial target can drive the cache to it at message speed. +/// +/// A client cache does not need to hold a whole chain tip's worth of shares. It exists to catch +/// a share source re-submitting work it already sent — a retransmit or a buggy loop, which +/// arrives within seconds — not to reconcile a tip. 4 096 covers ~11 hours of history for a +/// typical 6 shares/min channel and ~7 minutes for a very busy 600 shares/min proxy channel, +/// far beyond any realistic duplicate window in both cases. Overflow evicts oldest-first, and +/// an evicted-then-replayed hash costs one double-counted local statistic. +pub const MAX_SEEN_SHARES: usize = 4_096; + // 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/share_accounting.rs b/sv2/channels-sv2/src/client/share_accounting.rs index bfe2d37e87..84c81ecb95 100644 --- a/sv2/channels-sv2/src/client/share_accounting.rs +++ b/sv2/channels-sv2/src/client/share_accounting.rs @@ -5,8 +5,8 @@ //! are intended for use in Mining Clients. extern crate alloc; -use super::{HashMap, HashSet}; -use alloc::string::String; +use super::{HashMap, MAX_SEEN_SHARES}; +use alloc::{collections::VecDeque, string::String}; use bitcoin::hashes::sha256d::Hash; use mining_sv2::{ ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE, ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW, @@ -109,7 +109,11 @@ pub struct ShareAccounting { validated_shares: u32, validated_work_sum: f64, rejected_shares: HashMap, // - seen_shares: HashSet, + // Accepted share hashes, oldest at the front; bounded by `MAX_SEEN_SHARES`. + // A flat `VecDeque` rather than a set plus a companion order queue: storing each hash once + // halves the footprint that matters on embedded targets, and scanning 4 096 contiguous + // hashes per share is negligible at client share rates. + seen_shares: VecDeque, best_diff: f64, blocks_found: u32, } @@ -131,7 +135,7 @@ impl ShareAccounting { validated_work_sum: 0.0, rejected_shares: HashMap::new(), - seen_shares: HashSet::new(), + seen_shares: VecDeque::new(), best_diff: 0.0, blocks_found: 0, } @@ -190,6 +194,17 @@ impl ShareAccounting { /// called when the upstream server confirms via [`SubmitSharesSuccess`](mining_sv2::SubmitSharesSuccess). /// /// `validated_shares` saturates at `u32::MAX`. + /// + /// At most [`MAX_SEEN_SHARES`] hashes are retained for duplicate detection; beyond that the + /// oldest hash is evicted. + /// + /// Unlike the server side, overflow evicts rather than failing: a replay of an evicted hash + /// only double-counts one local statistic — nothing is paid out, and nothing is forwarded as + /// newly-validated that the upstream won't independently dedup. That is also why the bound + /// is a flat constant here instead of being derived from the channel's target and hashrate: + /// the target is upstream-controlled, so a derived bound could not constrain a hostile + /// upstream anyway, and it would have to be clamped to something affordable on the smallest + /// supported device regardless — which is exactly what [`MAX_SEEN_SHARES`] already is. pub fn track_validated_share( &mut self, share_sequence_number: u32, @@ -199,13 +214,21 @@ impl ShareAccounting { self.last_share_sequence_number = share_sequence_number; self.validated_shares = self.validated_shares.saturating_add(1); self.validated_work_sum += share_work; - self.seen_shares.insert(share_hash); + if !self.seen_shares.contains(&share_hash) { + // evict before inserting, so the queue never exceeds the bound even transiently and + // its backing allocation settles at exactly `MAX_SEEN_SHARES` entries + if self.seen_shares.len() == MAX_SEEN_SHARES { + self.seen_shares.pop_front(); + } + self.seen_shares.push_back(share_hash); + } } /// Clears the set of seen share hashes. /// - /// Should be called on every chain tip update - /// to prevent unbounded memory growth. + /// Should be called on every chain tip update to allow new shares for the new tip. This is + /// also what makes the seen-shares cap per-tip: the set only ever holds one chain tip's + /// worth of validated shares. pub fn flush_seen_shares(&mut self) { self.seen_shares.clear(); } @@ -266,6 +289,9 @@ impl ShareAccounting { } /// Checks if the given share hash has already been seen (duplicate detection). + /// + /// The underlying queue holds at most [`MAX_SEEN_SHARES`] hashes (oldest evicted first) and + /// is flushed on every chain-tip transition. pub fn is_share_seen(&self, share_hash: Hash) -> bool { self.seen_shares.contains(&share_hash) } @@ -297,7 +323,7 @@ impl ShareAccounting { #[cfg(test)] mod tests { - use super::{alloc::format, ShareAccounting, UNKNOWN_ERROR_CODE}; + use super::{alloc::format, ShareAccounting, MAX_SEEN_SHARES, UNKNOWN_ERROR_CODE}; use bitcoin::hashes::Hash as _; #[test] @@ -371,4 +397,36 @@ mod tests { ); assert_eq!(accounting.get_rejected_shares_count(), 10_002); } + + #[test] + fn seen_shares_are_bounded_by_fifo_eviction() { + fn hash(i: u32) -> bitcoin::hashes::sha256d::Hash { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&i.to_le_bytes()); + ::from_slice(&bytes).unwrap() + } + + let cap = MAX_SEEN_SHARES as u32; + let overflow = 100; + let mut accounting = ShareAccounting::new(); + + // flood past the bound with unique hashes, as an adversarial upstream advertising a + // trivial target would + for i in 0..cap + overflow { + accounting.track_validated_share(i, hash(i), 1.0); + } + + // retention is bounded, and it is the oldest hashes that were dropped + assert_eq!(accounting.seen_shares.len(), cap as usize); + for i in 0..overflow { + assert!(!accounting.is_share_seen(hash(i))); + } + for i in overflow..cap + overflow { + assert!(accounting.is_share_seen(hash(i))); + } + + // a chain-tip transition clears both the set and the eviction order + accounting.flush_seen_shares(); + assert_eq!(accounting.seen_shares.len(), 0); + } } diff --git a/sv2/channels-sv2/src/client/standard.rs b/sv2/channels-sv2/src/client/standard.rs index 8f262ec7c1..88db773f3b 100644 --- a/sv2/channels-sv2/src/client/standard.rs +++ b/sv2/channels-sv2/src/client/standard.rs @@ -15,7 +15,7 @@ use crate::{ extranonce_manager::ExtranoncePrefix, merkle_root::merkle_root_from_path, target::{bytes_to_hex, u256_to_block_hash}, - MAX_EXTRANONCE_LEN, VERSION_ROLLING_MASK, + MAX_EXTRANONCE_LEN, MAX_FUTURE_BLOCK_TIME, VERSION_ROLLING_MASK, }; use alloc::{collections::VecDeque, format, string::String}; use binary_sv2::Sv2OptionOwned; @@ -402,7 +402,11 @@ impl StandardChannel { /// /// - Checks if the share refers to an active or past job; rejects stale jobs. /// - Verifies the share meets the channel target, is not a duplicate, is not stale, and has - /// `ntime` >= the chain tip's `min_ntime`. + /// `ntime` >= the chain tip's `min_ntime` as well as the job's own `min_ntime` (an + /// immediately-active job may carry a `min_ntime` later than the chain tip's minimum, so + /// the effective lower bound is the larger of the two). Also rejects `ntime` above the + /// chain tip's `min_ntime + MAX_FUTURE_BLOCK_TIME` (see [`MAX_FUTURE_BLOCK_TIME`] for how + /// this clockless upper bound relates to the spec's elapsed-time window). /// - Updates share accounting state based on validation result. /// - Returns whether the share is valid or resulted in a block being found. /// - Returns error describing why share is not valid. @@ -456,6 +460,25 @@ impl StandardChannel { )); } + // consensus caps block timestamps at ~2h in the future; the allowance is anchored at + // chain-tip receipt, since this crate has no clock (see MAX_FUTURE_BLOCK_TIME) + if share.ntime > chain_tip.min_ntime().saturating_add(MAX_FUTURE_BLOCK_TIME) { + return Err(ShareValidationError::Invalid( + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, + )); + } + + // an immediately-active job carries its own min_ntime, which may be later than the + // chain tip's minimum; jobs activated from the future queue have it overwritten with + // the SetNewPrevHash timestamp, making this check redundant there (and harmless) + if let Some(job_min_ntime) = job.0.min_ntime.clone().into_inner() { + if share.ntime < job_min_ntime { + return Err(ShareValidationError::Invalid( + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, + )); + } + } + // Only the non-rollable version bits are compared: `!VERSION_ROLLING_MASK` zeroes // the BIP323 general-purpose bits the miner may change, so any remaining difference // from the job's advertised version means an unauthorized change. Standard channels @@ -1524,6 +1547,89 @@ mod tests { assert_eq!(channel.get_active_job().unwrap().0.job_id, future_job_id); } + #[test] + fn test_share_validation_ntime_below_job_min_ntime() { + // Regression test: an immediately-active job carries its own min_ntime, which may be + // later than the chain tip's minimum. A share in the gap + // (chain_tip.min_ntime <= ntime < job.min_ntime) must be rejected. + 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, + ]; + + // activate a chain tip at nTime t via a future job + let tip_ntime: u32 = 1745596930; + channel.on_new_mining_job(NewMiningJob { + channel_id, + job_id: 1, + merkle_root: merkle_root.into(), + version: 536870912, + min_ntime: Sv2Option::new(None), + }); + // network target: 000000000000d7c0... (hard, so no accidental BlockFound) + channel + .on_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(), + nbits: 453040064, + min_ntime: tip_ntime, + }) + .unwrap(); + + // install an immediately-active job whose own min_ntime is later than the tip's + let job_min_ntime = tip_ntime + 3; + channel.on_new_mining_job(NewMiningJob { + channel_id, + job_id: 2, + merkle_root: merkle_root.into(), + version: 536870912, + min_ntime: Sv2Option::new(Some(job_min_ntime)), + }); + + // a share in the gap passes the chain-tip bound but not the job's own bound + let share_in_gap = SubmitSharesStandardOwned { + channel_id, + sequence_number: 0, + job_id: 2, + nonce: 3, + ntime: job_min_ntime - 1, + version: 536870912, + }; + let res = channel.validate_share(share_in_gap); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // at the job's min_ntime the share is accepted (channel target is permissive) + let share_at_job_min_ntime = SubmitSharesStandardOwned { + channel_id, + sequence_number: 1, + job_id: 2, + nonce: 3, + ntime: job_min_ntime, + version: 536870912, + }; + let res = channel.validate_share(share_at_job_min_ntime); + assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); + } #[test] fn test_on_new_group_channel_job_invalid_coinbase() { // Regression test for a malicious/malformed upstream coinbase: empty prefix and suffix @@ -1567,4 +1673,74 @@ mod tests { assert_eq!(channel.get_future_jobs_count(), 0); assert_eq!(channel.get_active_job(), None); } + + #[test] + fn test_share_validation_ntime_above_max_future_block_time() { + // Regression test: a share ntime beyond the chain tip's min_ntime + + // MAX_FUTURE_BLOCK_TIME would put a consensus-invalid timestamp in the block header, + // so it must be rejected; ntime exactly on the bound is still accepted. + 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, + ); + + channel.on_new_mining_job(NewMiningJob { + channel_id, + job_id: 1, + 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(None), + }); + + let tip_ntime: u32 = 1745596930; + // network target: 000000000000d7c0... (hard, so no accidental BlockFound) + channel + .on_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(), + nbits: 453040064, + min_ntime: tip_ntime, + }) + .unwrap(); + + let share = |sequence_number: u32, ntime: u32| SubmitSharesStandardOwned { + channel_id, + sequence_number, + job_id: 1, + nonce: 3, + ntime, + version: 536870912, + }; + + // one second above the bound: rejected before any PoW evaluation + let res = channel.validate_share(share(0, tip_ntime + crate::MAX_FUTURE_BLOCK_TIME + 1)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // u32::MAX is likewise rejected (the bound saturates instead of wrapping) + let res = channel.validate_share(share(1, u32::MAX)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // exactly on the bound the share is accepted (channel target is permissive) + let res = channel.validate_share(share(2, tip_ntime + crate::MAX_FUTURE_BLOCK_TIME)); + assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); + } } diff --git a/sv2/channels-sv2/src/lib.rs b/sv2/channels-sv2/src/lib.rs index f5acaf9da2..0dd79f6393 100644 --- a/sv2/channels-sv2/src/lib.rs +++ b/sv2/channels-sv2/src/lib.rs @@ -23,6 +23,83 @@ pub use extranonce_manager::MAX_EXTRANONCE_LEN; /// version. All other bits of a share's version must match the job's version exactly. pub const VERSION_ROLLING_MASK: u32 = 0x1fffffe0; +/// Mirrors Bitcoin Core's `MAX_FUTURE_BLOCK_TIME` (`src/chain.h`): a block timestamp more than +/// 2 hours in the future is consensus-invalid. +/// +/// Share validation enforces `share.ntime <= chain_tip.min_ntime() + MAX_FUTURE_BLOCK_TIME`, +/// anchoring the consensus allowance at chain-tip receipt (`min_ntime` ≈ wall time when the tip +/// arrived, since this crate is `no_std`-compatible and has no clock). This is deliberately +/// looser than the Sv2 spec's elapsed-time window (`ntime <= SetNewPrevHash timestamp + seconds +/// elapsed since receipt`, which is stricter than consensus): embedding applications that have a +/// time source can additionally enforce the spec-exact window. The bound equals the consensus +/// limit at tip receipt and becomes conservative as the tip ages; a false rejection would require +/// a >2h-old chain tip *and* a miner stamping wall time instead of rolling from the job's +/// `min_ntime` — a known, negligible edge. +pub const MAX_FUTURE_BLOCK_TIME: u32 = 2 * 60 * 60; + +/// Worst-case chain-tip lifetime, in minutes, assumed when bounding the accepted-share dedup +/// cache (`seen_shares`). +/// +/// `seen_shares` only needs to hold shares for the lifetime of one chain tip (it is flushed on +/// every tip transition), and 10 hours is far beyond any realistic block interval, so the +/// resulting cap is unreachable by a well-behaved channel. +pub const WORST_CASE_TIP_MINUTES: u64 = 600; + +/// Safety margin applied on top of the expected share rate when bounding the accepted-share +/// dedup cache (`seen_shares`). +/// +/// Poisson variance over thousands of expected shares is ~√N, so a 2× factor is already +/// generous; its real job is covering the window before vardiff converges. +pub const SEEN_SHARES_MARGIN: u64 = 2; + +/// Lower clamp applied to the seen-shares budget derived by [`seen_shares_budget`]. +/// +/// Keeps duplicate detection meaningful when the configured expected rate is absurdly low: +/// 4 096 hashes ≈ 0.3 MB measured (the backing `HashSet` rounds up to 8 192 slots). Without +/// it, a tiny derived budget would close a legitimate server channel after a handful of +/// accepted shares. +pub const MIN_SEEN_SHARES_CAP: usize = 4_096; + +/// Upper clamp on the seen-shares budget derived by [`seen_shares_budget`], in hashes. +/// +/// Backstops absurd expected-rate configuration, so that no single server channel's dedup cache +/// can be sized past a few tens of MB. Client channels do not use this: they bound their cache +/// at the far smaller [`client::MAX_SEEN_SHARES`], see there for why. +pub const MAX_SEEN_SHARES_CAP: usize = 1 << 20; + +/// Returns the maximum number of accepted-share hashes retained for duplicate detection, given +/// the channel's expected share rate: +/// +/// ```text +/// budget = clamp(expected_shares_per_minute × WORST_CASE_TIP_MINUTES × SEEN_SHARES_MARGIN, +/// MIN_SEEN_SHARES_CAP, MAX_SEEN_SHARES_CAP) +/// ``` +/// +/// Sustaining more than [`SEEN_SHARES_MARGIN`]× the expected rate for +/// [`WORST_CASE_TIP_MINUTES`] straight means the share rate has far outrun the channel's +/// configured expectation with no tip transition in between — not a state to accommodate. +/// This assumes mainnet-like block cadence: on networks where a tip can stay pinned past ~20 +/// hours (regtest, an idle CI chain), an honest channel can walk into the budget, so such +/// setups should raise the configured rate or treat the resulting channel closure as expected. +/// +/// Worst-case per-channel memory (measured, not payload-only: the backing `HashSet` rounds its +/// capacity to a power of two at ~7/8 load and transiently doubles while rehashing): at the +/// default-ish 6 shares/min the budget is 7 200 hashes ≈ 0.5 MB per channel; at 100 shares/min, +/// 120 000 hashes ≈ 9 MB steady and ~15 MB peak through the last rehash. The result is clamped +/// into [[`MIN_SEEN_SHARES_CAP`], [`MAX_SEEN_SHARES_CAP`]] so that a degenerate expected rate +/// can neither disable dedup nor unbound memory. +/// +/// Only server channels derive a budget: they have a pool-configured expected rate, and the +/// budget is load-bearing there (validation fails once it is hit, signalling the embedding +/// application to close the channel), so it must be unreachable for any honest rate. Client +/// channels bound `seen_shares` at the flat [`client::MAX_SEEN_SHARES`] and evict oldest-first; +/// see [`client::share_accounting::ShareAccounting`] for why they need no derivation. +pub fn seen_shares_budget(expected_shares_per_minute: f64) -> usize { + // `as usize` saturates on overflow/NaN, and the margin factor makes truncation irrelevant + ((expected_shares_per_minute * (WORST_CASE_TIP_MINUTES * SEEN_SHARES_MARGIN) as f64) as usize) + .clamp(MIN_SEEN_SHARES_CAP, MAX_SEEN_SHARES_CAP) +} + #[cfg(not(feature = "no_std"))] pub mod server; diff --git a/sv2/channels-sv2/src/merkle_root.rs b/sv2/channels-sv2/src/merkle_root.rs index 32d536c92b..36aaf5bfd9 100644 --- a/sv2/channels-sv2/src/merkle_root.rs +++ b/sv2/channels-sv2/src/merkle_root.rs @@ -11,6 +11,8 @@ use tracing::error; /// Computes the Merkle root from coinbase transaction components and a path of transaction hashes. /// /// Validates and deserializes a coinbase transaction before building the 32-byte Merkle root. +/// The assembled bytes must not only deserialize as a transaction, but also satisfy the coinbase +/// invariant (exactly one input spending the null outpoint, see [`Transaction::is_coinbase`]). /// Returns [`None`] if the arguments are invalid. /// /// ## Components @@ -40,6 +42,11 @@ pub fn merkle_root_from_path>( } }; + if !coinbase.is_coinbase() { + error!("ERROR: not a coinbase transaction"); + return None; + } + let coinbase_id: [u8; 32] = *coinbase.compute_txid().as_ref(); Some(merkle_root_from_path_(coinbase_id, path)) @@ -63,3 +70,46 @@ pub fn merkle_root_from_path_>(coinbase_id: [u8; 32], path: &[T]) } root } + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{ + absolute::LockTime, + blockdata::witness::Witness, + transaction::{OutPoint, TxIn, TxOut, Version}, + Amount, ScriptBuf, Sequence, Txid, + }; + + fn tx_with_previous_output(previous_output: OutPoint) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(0), + input: vec![TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence(0xffffffff), + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: ScriptBuf::new(), + }], + } + } + + #[test] + fn test_rejects_semantically_non_coinbase_transaction() { + // a validly encoded transaction whose sole input spends a non-null outpoint is not a + // coinbase, and must be rejected rather than yield a merkle root + let non_coinbase = + tx_with_previous_output(OutPoint::new(Txid::from_byte_array([0xab; 32]), 0)); + let serialized = consensus::serialize(&non_coinbase); + assert!(merkle_root_from_path::<&[u8]>(&serialized, &[], &[], &[]).is_none()); + + // the same transaction with a null outpoint is a coinbase and must be accepted + let coinbase = tx_with_previous_output(OutPoint::null()); + let serialized = consensus::serialize(&coinbase); + assert!(merkle_root_from_path::<&[u8]>(&serialized, &[], &[], &[]).is_some()); + } +} diff --git a/sv2/channels-sv2/src/server/extended.rs b/sv2/channels-sv2/src/server/extended.rs index c8ccac4537..881dbcb23b 100644 --- a/sv2/channels-sv2/src/server/extended.rs +++ b/sv2/channels-sv2/src/server/extended.rs @@ -50,7 +50,7 @@ use crate::{ share_accounting::{ShareAccounting, ShareValidationError, ShareValidationResult}, }, target::{bytes_to_hex, hash_rate_to_target, u256_to_block_hash}, - MAX_EXTRANONCE_LEN, VERSION_ROLLING_MASK, + MAX_EXTRANONCE_LEN, MAX_FUTURE_BLOCK_TIME, VERSION_ROLLING_MASK, }; use bitcoin::{ blockdata::block::{Header, Version}, @@ -249,7 +249,10 @@ impl ExtendedChannel { stable_hashrate: false, job_store: JobStore::new(), job_factory, - share_accounting: ShareAccounting::new(share_batch_size), + share_accounting: ShareAccounting::new( + share_batch_size, + crate::seen_shares_budget(expected_share_per_minute as f64), + ), expected_share_per_minute, chain_tip: None, }) @@ -715,11 +718,20 @@ impl ExtendedChannel { /// Validates a share. /// /// Updates the channel state with the result of the share validation. - /// Rejects shares with `ntime` below the chain tip's `min_ntime`. + /// Rejects shares with `ntime` below the chain tip's `min_ntime`, or above + /// `min_ntime + MAX_FUTURE_BLOCK_TIME` (see [`MAX_FUTURE_BLOCK_TIME`] for how this clockless + /// bound relates to the spec's elapsed-time window). pub fn validate_share( &mut self, share: SubmitSharesExtendedOwned, ) -> Result { + // the accepted-share dedup cache is a hard budget on servers: forgetting a + // still-valid hash would re-enable duplicate-share replay, so once the budget is hit + // the channel must be closed by the embedding application + if self.share_accounting.is_seen_shares_budget_exhausted() { + return Err(ShareValidationError::SeenSharesBudgetExhausted); + } + let job_id = share.job_id; // check if job_id is active job @@ -809,6 +821,10 @@ impl ExtendedChannel { let prev_hash = chain_tip.prev_hash(); let nbits = CompactTarget::from_consensus(chain_tip.nbits()); + // no per-job min_ntime check is needed here, unlike client channels: template jobs + // take min_ntime from the chain tip at creation, and on_set_custom_mining_job syncs + // the tip to each custom job's min_ntime and stales all history whenever it changes, + // so no non-stale job can carry a min_ntime above the current tip's if share.ntime < chain_tip.min_ntime() { self.share_accounting .increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE); @@ -817,6 +833,16 @@ impl ExtendedChannel { )); } + // consensus caps block timestamps at ~2h in the future; the allowance is anchored at + // chain-tip receipt, since this crate has no clock (see MAX_FUTURE_BLOCK_TIME) + if share.ntime > chain_tip.min_ntime().saturating_add(MAX_FUTURE_BLOCK_TIME) { + self.share_accounting + .increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE); + return Err(ShareValidationError::Invalid( + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, + )); + } + // Only BIP323 general-purpose bits may differ from the job's advertised version. // When version rolling is not allowed, the share version must match the job version exactly. let version_rolling_mask = if job.version_rolling_allowed() { @@ -3487,4 +3513,100 @@ mod tests { // job plus one per retained past job assert_eq!(channel.job_id_to_target.len(), MAX_PAST_JOBS + 1); } + + #[test] + fn test_share_validation_ntime_above_max_future_block_time() { + // Regression test: a share ntime beyond the chain tip's min_ntime + + // MAX_FUTURE_BLOCK_TIME would put a consensus-invalid timestamp in the block header, + // so it must be rejected; ntime exactly on the bound is still accepted. + 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_000.0, // bigger hashrate to get higher difficulty + true, + 8u16, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let template = NewTemplate { + template_id: 1, + 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(), + }; + + 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 coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: ScriptBuf::from(script_bytes), + }]; + + // anchor the tip so the pre-mined share (ntime 1745611105, nonce 16647, from + // test_share_validation_valid_share) sits exactly on the upper bound + let share_ntime: u32 = 1745611105; + let ntime = share_ntime - crate::MAX_FUTURE_BLOCK_TIME; + let n_bits = 453040064; + let prev_hash = [ + 23, 205, 72, 134, 153, 86, 220, 153, 224, 28, 216, 146, 228, 120, 227, 157, 213, 99, + 160, 163, 128, 59, 139, 190, 158, 62, 0, 0, 0, 0, 0, 0, + ] + .into(); + channel.set_chain_tip(ChainTip::new(prev_hash, n_bits, ntime)); + channel + .on_new_template(template, coinbase_reward_outputs) + .unwrap(); + + let share = |sequence_number: u32, ntime: u32| SubmitSharesExtended { + channel_id, + sequence_number, + job_id: 1, + nonce: 16647, + ntime, + version: 536870912, + extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(), + }; + + // one second above the bound: rejected before any PoW evaluation + let res = channel.validate_share(share(0, share_ntime + 1)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // u32::MAX is likewise rejected (the bound saturates instead of wrapping) + let res = channel.validate_share(share(1, u32::MAX)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // exactly on the bound: the pre-mined share is accepted + let res = channel.validate_share(share(2, share_ntime)); + assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); + } } diff --git a/sv2/channels-sv2/src/server/group.rs b/sv2/channels-sv2/src/server/group.rs index 72d5db1190..a4a40c3335 100644 --- a/sv2/channels-sv2/src/server/group.rs +++ b/sv2/channels-sv2/src/server/group.rs @@ -40,6 +40,7 @@ use crate::{ use bitcoin::transaction::TxOut; use std::collections::HashSet; use template_distribution_sv2::{NewTemplateOwned, SetNewPrevHashOwned as SetNewPrevHashTdp}; +use tracing::warn; /// Abstraction of a Group Channel. /// @@ -323,15 +324,31 @@ impl GroupChannel { /// 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. /// + /// If no future jobs are queued, the peer is not conforming to the Template Distribution + /// Protocol, which requires at least one future `NewTemplate` before every `SetNewPrevHash`. + /// The message is still applied rather than rejected: its chain-tip fields are self-contained + /// and remain usable, so discarding them would only leave this channel unable to recover. The + /// active job (if any) is dropped, since it commits to the previous chain tip. + /// /// Updates the chain tip for the group channel. - /// Returns an error if no matching future job is found, leaving the chain tip untouched. + /// Returns an error if future jobs are queued but none matches the `template_id`, leaving + /// the chain tip untouched. pub fn on_set_new_prev_hash( &mut self, set_new_prev_hash: SetNewPrevHashTdp, ) -> Result<(), GroupChannelError> { match self.job_store.has_future_jobs() { false => { - return Err(GroupChannelError::TemplateIdNotFound); + // a chain-tip update with no queued future template means the peer broke + // the protocol, but the tip itself is still usable, so recover instead of + // wedging the channel. the active job committed to the previous chain tip, + // and group channels never validate shares against stored jobs, so it is + // dropped outright rather than retired to stale. + warn!( + "SetNewPrevHash with no queued future template: non-conforming Template \ + Distribution peer, recovering the chain tip" + ); + self.job_store.clear_active_job(); } true => { // activation is a no-op when no future job matches the template id, so the @@ -783,6 +800,46 @@ mod tests { assert!(group_channel.get_active_job().is_none()); } + #[test] + fn test_set_new_prev_hash_without_future_jobs_updates_chain_tip() { + // Regression test: a SetNewPrevHash with no queued future job means the peer broke the + // Template Distribution Protocol, which requires at least one future NewTemplate + // beforehand. The channel must still recover from it — record the new chain tip rather + // than reject the message and wedge the group-job pipeline in a persistent error path, + // since the tip carried by the message is self-contained and usable. + let mut group_channel = GroupChannel::new(1, 32, None, None).unwrap(); + assert!(!group_channel.job_store.has_future_jobs()); + assert!(group_channel.get_chain_tip().is_none()); + + let prev_hash: binary_sv2::U256Owned = [ + 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 set_new_prev_hash = SetNewPrevHash { + template_id: 0, + prev_hash: prev_hash.clone(), + 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(); + + let chain_tip = group_channel.get_chain_tip().unwrap(); + assert_eq!(chain_tip.prev_hash(), prev_hash); + assert_eq!(chain_tip.min_ntime(), 1746839905); + assert_eq!(chain_tip.nbits(), 503543726); + // no job could have been activated + assert!(group_channel.get_active_job().is_none()); + } + // a 52 char pool tag places the worst-case scriptSig exactly on the budget: // 8 (MAX_COINBASE_PREFIX_SIZE) + 1 + 3 ("Sv2") + 3 + 52 (tag) + 1 + 32 (extranonce) = 100 const POOL_TAG_AT_SCRIPT_SIG_BUDGET: usize = 52; diff --git a/sv2/channels-sv2/src/server/jobs/factory.rs b/sv2/channels-sv2/src/server/jobs/factory.rs index 76841637ab..61ab3015cd 100644 --- a/sv2/channels-sv2/src/server/jobs/factory.rs +++ b/sv2/channels-sv2/src/server/jobs/factory.rs @@ -65,15 +65,23 @@ impl JobIdFactory { } /// Increments then returns the internal state on a new ID. + /// + /// Explicitly wraps to `0` after `u32::MAX`, restarting the sequence. This makes the + /// overflow behavior identical across build profiles (unchecked `+= 1` would panic with + /// overflow checks enabled and wrap silently without them). Reuse of an ID is safe because + /// job stores only retain recent jobs — future jobs are consumed on activation and past/stale + /// jobs are flushed on every chain-tip transition — so by the time an ID comes around again + /// (2³² allocations later), no store still tracks its previous holder. fn next(&mut self) -> u32 { - self.state += 1; + self.state = self.state.wrapping_add(1); self.state } } /// A Factory for creating Extended or Standard Jobs. /// -/// Ensures unique job ids. +/// Ensures unique job ids within any window of 2³² allocations: IDs are sequential and +/// explicitly wrap to `0` after `u32::MAX` (see `JobIdFactory::next` for why reuse is safe). /// /// Enables creation of new Extended Jobs from NewTemplate and SetCustomMiningJob messages. /// @@ -1121,6 +1129,34 @@ mod tests { )); } + #[test] + fn test_job_id_wraps_to_zero_after_u32_max() { + let mut job_factory = JobFactory::new(true, None, None); + + let new_job = |job_factory: &mut JobFactory| { + job_factory + .new_extended_job( + 1, + None, + vec![0; 32], + template_with_coinbase_prefix(vec![82, 0]), + coinbase_reward_outputs(), + 32, + ) + .unwrap() + }; + + // place the private counter one below the last u32 ID + job_factory.job_id_factory.state = u32::MAX - 1; + assert_eq!(new_job(&mut job_factory).get_job_message().job_id, u32::MAX); + + // the next allocation must wrap to 0 and restart the sequence, identically across + // build profiles (this test panics on overflow-checked builds without the explicit + // wrapping arithmetic) + assert_eq!(new_job(&mut job_factory).get_job_message().job_id, 0); + assert_eq!(new_job(&mut job_factory).get_job_message().job_id, 1); + } + #[test] fn test_custom_job_rejects_oversized_script_sig() { // a custom job's coinbase_prefix already embeds the pool/miner tag, so the assembled diff --git a/sv2/channels-sv2/src/server/jobs/job_store.rs b/sv2/channels-sv2/src/server/jobs/job_store.rs index 71aa802a49..555ce5a1c4 100644 --- a/sv2/channels-sv2/src/server/jobs/job_store.rs +++ b/sv2/channels-sv2/src/server/jobs/job_store.rs @@ -267,6 +267,15 @@ impl JobStore { self.retire_active_to_past_uncapped(); } + /// Drops the active job (if any) without retaining it. + /// + /// For channels that never validate shares against stored jobs (group channels), routing + /// the job through the past → stale rotation would only retain state that can never be + /// referenced again. + pub fn clear_active_job(&mut self) { + self.active_job = None; + } + /// 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 diff --git a/sv2/channels-sv2/src/server/share_accounting.rs b/sv2/channels-sv2/src/server/share_accounting.rs index e578fd5bec..aebe2c76aa 100644 --- a/sv2/channels-sv2/src/server/share_accounting.rs +++ b/sv2/channels-sv2/src/server/share_accounting.rs @@ -69,6 +69,15 @@ pub enum ShareValidationError { InvalidCoinbase, /// No chain tip is set for the channel (required for share validation). NoChainTip, + /// The accepted-share dedup cache is full (see [`crate::seen_shares_budget`]). + /// + /// Forgetting a still-valid hash would re-enable duplicate-share replay (double-counted + /// shares are payout fraud), so on the server side the budget is a hard limit rather than an + /// eviction threshold. Reaching it means the share rate has far outrun the channel's + /// configured expectation for an entire worst-case tip (see [`crate::seen_shares_budget`], + /// including its block-cadence assumption), so the embedding application should close the + /// channel. + SeenSharesBudgetExhausted, } /// The state of share validation in the context of some specific channel (either Extended or @@ -87,6 +96,7 @@ pub struct ShareAccounting { batch_acknowledged: bool, share_batch_size: usize, seen_shares: HashSet, + seen_shares_budget: usize, best_diff: f64, blocks_found: u32, } @@ -95,7 +105,12 @@ impl ShareAccounting { /// Constructs a new `ShareAccounting` instance for a channel. /// /// `share_batch_size` controls how many accepted shares trigger a batch acknowledgment. - pub fn new(share_batch_size: usize) -> Self { + /// + /// `seen_shares_budget` bounds the accepted-share dedup cache; channels derive it from their + /// expected share rate via [`crate::seen_shares_budget`]. Once + /// [`is_seen_shares_budget_exhausted`](Self::is_seen_shares_budget_exhausted) reports `true`, + /// share validation fails with [`ShareValidationError::SeenSharesBudgetExhausted`]. + pub fn new(share_batch_size: usize, seen_shares_budget: usize) -> Self { Self { last_share_sequence_number: 0, shares_accepted: 0, @@ -106,6 +121,7 @@ impl ShareAccounting { batch_acknowledged: false, share_batch_size, seen_shares: HashSet::new(), + seen_shares_budget, best_diff: 0.0, blocks_found: 0, } @@ -156,12 +172,24 @@ impl ShareAccounting { /// Clears the set of seen share hashes. /// - /// Should be called on every chain tip update to avoid unbounded growth of memory - /// and allow new shares for the new tip. + /// Should be called on every chain tip update to allow new shares for the new tip. This is + /// also what makes the seen-shares budget per-tip: the set only ever holds one chain tip's + /// worth of accepted shares. pub fn flush_seen_shares(&mut self) { self.seen_shares.clear(); } + /// Returns `true` once the accepted-share dedup cache has reached its budget. + /// + /// Evicting a still-valid hash is never acceptable on a server (it would re-enable + /// duplicate-share replay), so overflow is an explicit failure instead: share validation + /// returns [`ShareValidationError::SeenSharesBudgetExhausted`] and the embedding application + /// should close the channel. The budget is unreachable by a well-behaved channel, see + /// [`crate::seen_shares_budget`]. + pub fn is_seen_shares_budget_exhausted(&self) -> bool { + self.seen_shares.len() >= self.seen_shares_budget + } + /// Returns the sequence number of the last accepted share. pub fn get_last_share_sequence_number(&self) -> u32 { self.last_share_sequence_number @@ -229,6 +257,10 @@ impl ShareAccounting { } /// Checks if the share hash has already been accepted (duplicate detection). + /// + /// The underlying set holds at most `seen_shares_budget` hashes (see + /// [`is_seen_shares_budget_exhausted`](Self::is_seen_shares_budget_exhausted)) and is flushed + /// on every chain-tip transition. pub fn is_share_seen(&self, share_hash: Hash) -> bool { self.seen_shares.contains(&share_hash) } @@ -274,7 +306,7 @@ mod tests { #[test] fn rejected_shares_are_tracked_by_error_code() { - let mut accounting = ShareAccounting::new(10); + let mut accounting = ShareAccounting::new(10, 1_200); accounting.increment_rejected_shares("difficulty-too-low"); accounting.increment_rejected_shares("duplicate-share"); @@ -293,7 +325,7 @@ mod tests { #[test] fn counters_saturate_at_u32_max() { - let mut accounting = ShareAccounting::new(10); + let mut accounting = ShareAccounting::new(10, 1_200); accounting.shares_accepted = u32::MAX - 1; accounting.blocks_found = u32::MAX; @@ -315,7 +347,7 @@ mod tests { #[test] fn rejected_shares_count_saturates() { - let mut accounting = ShareAccounting::new(10); + let mut accounting = ShareAccounting::new(10, 1_200); accounting.rejected_shares.insert("a".to_string(), u32::MAX); accounting.rejected_shares.insert("b".to_string(), 1); @@ -325,7 +357,7 @@ mod tests { #[test] fn increment_rejected_shares_saturates() { - let mut accounting = ShareAccounting::new(10); + let mut accounting = ShareAccounting::new(10, 1_200); accounting .rejected_shares @@ -334,4 +366,33 @@ mod tests { assert_eq!(accounting.rejected_shares.get("err"), Some(&u32::MAX)); } + + #[test] + fn seen_shares_budget_is_a_hard_limit() { + fn hash(i: u32) -> bitcoin::hashes::sha256d::Hash { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&i.to_le_bytes()); + ::from_slice(&bytes).unwrap() + } + + let budget = 100; + let mut accounting = ShareAccounting::new(10, budget); + + for i in 0..budget as u32 { + assert!(!accounting.is_seen_shares_budget_exhausted()); + accounting.update_share_accounting(1.0, i, hash(i)); + } + assert!(accounting.is_seen_shares_budget_exhausted()); + + // every hash accepted within the budget stays resident: nothing is evicted, so no + // duplicate can ever be re-admitted + for i in 0..budget as u32 { + assert!(accounting.is_share_seen(hash(i))); + } + + // a chain-tip transition resets the budget + accounting.flush_seen_shares(); + assert!(!accounting.is_seen_shares_budget_exhausted()); + assert!(!accounting.is_share_seen(hash(0))); + } } diff --git a/sv2/channels-sv2/src/server/standard.rs b/sv2/channels-sv2/src/server/standard.rs index d9318660a5..66a1e4ff44 100644 --- a/sv2/channels-sv2/src/server/standard.rs +++ b/sv2/channels-sv2/src/server/standard.rs @@ -44,7 +44,7 @@ use crate::{ share_accounting::{ShareAccounting, ShareValidationError, ShareValidationResult}, }, target::{bytes_to_hex, hash_rate_to_target, u256_to_block_hash}, - MAX_EXTRANONCE_LEN, VERSION_ROLLING_MASK, + MAX_EXTRANONCE_LEN, MAX_FUTURE_BLOCK_TIME, VERSION_ROLLING_MASK, }; use bitcoin::{ absolute::LockTime, @@ -67,7 +67,7 @@ use mining_sv2::{ }; use std::collections::HashMap; use template_distribution_sv2::{NewTemplateOwned, SetNewPrevHashOwned as SetNewPrevHash}; -use tracing::debug; +use tracing::{debug, warn}; /// Abstraction of a Sv2 Standard Channel. /// @@ -230,7 +230,10 @@ impl StandardChannel { job_id_to_target: HashMap::new(), nominal_hashrate, stable_hashrate: false, - share_accounting: ShareAccounting::new(share_batch_size), + share_accounting: ShareAccounting::new( + share_batch_size, + crate::seen_shares_budget(expected_share_per_minute as f64), + ), expected_share_per_minute, job_store: JobStore::new(), job_factory, @@ -558,8 +561,15 @@ impl StandardChannel { /// Updates the channel state with a new `SetNewPrevHash` message. /// - /// If there are no future jobs, returns an error. /// If there are future jobs, the active job is set to the job with the given `template_id`. + /// If future jobs are queued but none matches the `template_id`, returns an error, leaving + /// the chain tip untouched. + /// + /// If no future jobs are queued, the peer is not conforming to the Template Distribution + /// Protocol, which requires at least one future `NewTemplate` before every `SetNewPrevHash`. + /// The message is still applied rather than rejected: its chain-tip fields are self-contained + /// and remain usable, so discarding them would only leave this channel validating shares + /// against a dead tip. The active job (if any) is marked stale and the chain tip is updated. /// /// All past jobs are cleared. pub fn on_set_new_prev_hash( @@ -568,7 +578,24 @@ impl StandardChannel { ) -> Result<(), StandardChannelError> { match self.job_store.has_future_jobs() { false => { - return Err(StandardChannelError::TemplateIdNotFound); + // a chain-tip update with no queued future template means the peer broke + // the protocol, but the tip itself is still usable, so recover instead of + // leaving this channel committed to a dead prev_hash. + warn!( + "SetNewPrevHash with no queued future template: non-conforming Template \ + Distribution peer, recovering the chain tip" + ); + // + // demote the previously-active job to past so that the subsequent + // mark_past_jobs_as_stale call moves it into the stale set. without this, + // a late share for the still-active old job would skip the stale check in + // validate_share and panic on the missing job_id_to_target entry that we + // just cleared. + self.job_store.deactivate_job(); + self.job_store.mark_past_jobs_as_stale(); + // there is no active job in this branch, so any previous job target + // mappings are obsolete after the chain tip update. + self.job_id_to_target.clear(); } // try to activate the future job, and also mark past jobs as stale true => { @@ -605,12 +632,21 @@ impl StandardChannel { /// Validates a submitted share and updates accounting state. /// /// Returns the result of share validation, including block found, valid share, duplicate, or - /// error if the share is stale, does not meet target, or has ntime below the chain tip's - /// `min_ntime`. + /// error if the share is stale, does not meet target, or has ntime outside + /// `[min_ntime, min_ntime + MAX_FUTURE_BLOCK_TIME]` relative to the chain tip (see + /// [`MAX_FUTURE_BLOCK_TIME`] for how this clockless upper bound relates to the spec's + /// elapsed-time window). pub fn validate_share( &mut self, share: SubmitSharesStandardOwned, ) -> Result { + // the accepted-share dedup cache is a hard budget on servers: forgetting a + // still-valid hash would re-enable duplicate-share replay, so once the budget is hit + // the channel must be closed by the embedding application + if self.share_accounting.is_seen_shares_budget_exhausted() { + return Err(ShareValidationError::SeenSharesBudgetExhausted); + } + let job_id = share.job_id; // check if job_id is active job @@ -682,6 +718,16 @@ impl StandardChannel { )); } + // consensus caps block timestamps at ~2h in the future; the allowance is anchored at + // chain-tip receipt, since this crate has no clock (see MAX_FUTURE_BLOCK_TIME) + if share.ntime > chain_tip.min_ntime().saturating_add(MAX_FUTURE_BLOCK_TIME) { + self.share_accounting + .increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE); + return Err(ShareValidationError::Invalid( + ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, + )); + } + // Only the non-rollable version bits are compared: `!VERSION_ROLLING_MASK` zeroes // the BIP323 general-purpose bits the miner may change, so any remaining difference // from the job's advertised version means an unauthorized change. Standard channels @@ -1607,7 +1653,11 @@ mod tests { }]; // network target: 000000000000d7c0000000000000000000000000000000000000000000000000 - let ntime = 1745596910; + // anchor the tip within the pre-mined share's consensus window: the share was + // mined with ntime 1745611105, and validation rejects ntime beyond the tip's + // min_ntime + MAX_FUTURE_BLOCK_TIME (the header hash does not depend on the tip's + // timestamp, so the pre-mined vectors stay valid) + let ntime = 1745611105 - 60; 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, @@ -1706,7 +1756,11 @@ mod tests { script_pubkey: script, }]; - let ntime = 1745596910; + // anchor the tip within the pre-mined share's consensus window: the share was + // mined with ntime 1745611105, and validation rejects ntime beyond the tip's + // min_ntime + MAX_FUTURE_BLOCK_TIME (the header hash does not depend on the tip's + // timestamp, so the pre-mined vectors stay valid) + let ntime = 1745611105 - 60; 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, @@ -1814,7 +1868,11 @@ mod tests { script_pubkey: script, }]; - let ntime = 1745596910; + // anchor the tip within the pre-mined share's consensus window: the share was + // mined with ntime 1745611105, and validation rejects ntime beyond the tip's + // min_ntime + MAX_FUTURE_BLOCK_TIME (the header hash does not depend on the tip's + // timestamp, so the pre-mined vectors stay valid) + let ntime = 1745611105 - 60; 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, @@ -2127,12 +2185,12 @@ mod tests { } #[test] - fn test_set_new_prev_hash_without_future_jobs_preserves_state() { - // Regression test: when on_set_new_prev_hash is called with no future jobs to - // activate, it must return an error WITHOUT corrupting channel state. Previously - // the function cleared job_id_to_target before checking for future jobs, so a - // caller that treated the error as recoverable would crash on the next share at - // the `expect("job target must exist")` site. + fn test_set_new_prev_hash_without_future_jobs_updates_chain_tip() { + // Regression test: a SetNewPrevHash with no queued future job means the peer broke the + // Template Distribution Protocol, which requires at least one future NewTemplate + // beforehand. The channel must still recover from it — record the new chain tip and mark + // the previously active job stale, rather than reject the message and keep building jobs + // on a dead prev_hash. let standard_channel_id = 1; let user_identity = "user_identity".to_string(); @@ -2209,27 +2267,31 @@ mod tests { let active_job_id = active_standard_job.get_job_id(); assert!(!standard_channel.job_store.has_future_jobs()); - // No future jobs available -> on_set_new_prev_hash must return Err. + // No future jobs queued -> on_set_new_prev_hash must still record the chain tip. + let new_prev_hash: binary_sv2::U256Owned = [ + 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 snph = SetNewPrevHashTdp { template_id: 999, - 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(), + prev_hash: new_prev_hash.clone(), header_timestamp: ntime + 600, n_bits, target: [0xff; 32].into(), }; - let res = standard_channel.on_set_new_prev_hash(snph); - assert!(matches!(res, Err(StandardChannelError::TemplateIdNotFound))); - - // Channel state must be preserved: active job still active, target entry intact. - // A subsequent share submission for the still-active job must NOT panic on a - // missing job_id_to_target entry. The share itself does not meet target, so we - // expect DoesNotMeetTarget — the load-bearing assertion is that it returns - // without panicking. - let share_low_diff = SubmitSharesStandardOwned { + standard_channel.on_set_new_prev_hash(snph).unwrap(); + + let chain_tip = standard_channel.get_chain_tip().unwrap(); + assert_eq!(chain_tip.prev_hash(), new_prev_hash); + assert_eq!(chain_tip.min_ntime(), ntime + 600); + assert_eq!(chain_tip.nbits(), n_bits); + + // The previously active job committed to the old chain tip, so it must now be stale + // and a late share for it rejected accordingly (not panic on a missing + // job_id_to_target entry). + assert!(standard_channel.get_active_job().is_none()); + let share_for_old_job = SubmitSharesStandardOwned { channel_id: standard_channel_id, sequence_number: 0, job_id: active_job_id, @@ -2237,11 +2299,28 @@ mod tests { ntime: 1745596932, version: 536870912, }; - let res = standard_channel.validate_share(share_low_diff); - assert!(matches!( - res.unwrap_err(), - ShareValidationError::DoesNotMeetTarget(_) - )); + let res = standard_channel.validate_share(share_for_old_job); + assert!(matches!(res.unwrap_err(), ShareValidationError::Stale(_))); + + // and the channel is not wedged: the next non-future template can be processed, + // since the chain tip is set + let mut template = template; + template.template_id = 2; + standard_channel + .on_new_template( + template, + vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: { + let mut script_bytes = vec![0]; + script_bytes.push(20); + script_bytes.extend_from_slice(&pubkey_hash); + ScriptBuf::from(script_bytes) + }, + }], + ) + .unwrap(); + assert!(standard_channel.get_active_job().is_some()); } #[test] @@ -2493,4 +2572,199 @@ mod tests { // job plus one per retained past job assert_eq!(standard_channel.job_id_to_target.len(), MAX_PAST_JOBS + 1); } + + #[test] + fn test_share_validation_ntime_above_max_future_block_time() { + // Regression test: a share ntime beyond the chain tip's min_ntime + + // MAX_FUTURE_BLOCK_TIME would put a consensus-invalid timestamp in the block header, + // so it must be rejected; ntime exactly on the bound is still accepted. + let standard_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, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + let mut standard_channel = StandardChannel::new( + standard_channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1_000.0, + 100, + 1.0, + None, + None, + ) + .unwrap(); + + let template = NewTemplate { + template_id: 1, + 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(), + }; + + 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 coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: ScriptBuf::from(script_bytes), + }]; + + // anchor the tip so the pre-mined share (ntime 1745611105, nonce 92092, from + // test_share_validation_valid_share) sits exactly on the upper bound + let share_ntime: u32 = 1745611105; + let ntime = share_ntime - crate::MAX_FUTURE_BLOCK_TIME; + 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(); + let n_bits = 453040064; + standard_channel.set_chain_tip(ChainTip::new(prev_hash, n_bits, ntime)); + standard_channel + .on_new_template(template, coinbase_reward_outputs) + .unwrap(); + + let share = |sequence_number: u32, ntime: u32| SubmitSharesStandardOwned { + channel_id: standard_channel_id, + sequence_number, + job_id: 1, + nonce: 92092, + ntime, + version: 536870912, + }; + + // one second above the bound: rejected before any PoW evaluation + let res = standard_channel.validate_share(share(0, share_ntime + 1)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // u32::MAX is likewise rejected (the bound saturates instead of wrapping) + let res = standard_channel.validate_share(share(1, u32::MAX)); + assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_))); + + // exactly on the bound: the pre-mined share is accepted + let res = standard_channel.validate_share(share(2, share_ntime)); + assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); + } + + #[test] + fn test_validate_share_fails_once_seen_shares_budget_is_exhausted() { + // Regression test: `seen_shares` grows with every accepted share and is only flushed on + // chain-tip transitions, whose timing the peer controls. The budget derived from the + // channel's expected share rate must turn overflow into an explicit error (so the + // embedding application closes the channel) instead of unbounded memory growth. + let standard_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, 0, 0, 0, 0, 0, 1, + ] + .to_vec(); + let expected_share_per_minute = 1.0; + let mut standard_channel = StandardChannel::new( + standard_channel_id, + "user_identity".to_string(), + ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(), + Target::from_le_bytes([0xff; 32]), + 1_000.0, + 100, + expected_share_per_minute, + None, + None, + ) + .unwrap(); + + let template = NewTemplate { + template_id: 1, + 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(), + }; + + 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 coinbase_reward_outputs = vec![TxOut { + value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE), + script_pubkey: ScriptBuf::from(script_bytes), + }]; + + let share_ntime: u32 = 1745611105; + 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(); + standard_channel.set_chain_tip(ChainTip::new(prev_hash, 453040064, share_ntime - 60)); + standard_channel + .on_new_template(template, coinbase_reward_outputs) + .unwrap(); + + // fill the dedup cache up to the channel's budget (1 share/min derives 1 200, + // clamped up to MIN_SEEN_SHARES_CAP = 4 096) + let budget = crate::seen_shares_budget(expected_share_per_minute as f64); + for i in 0..budget as u32 { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&i.to_le_bytes()); + standard_channel.share_accounting.update_share_accounting( + 1.0, + i, + ::from_slice(&bytes) + .unwrap(), + ); + } + + // the pre-mined valid share (from test_share_validation_valid_share) must now be + // refused with the budget error rather than grow the cache further + let valid_share = SubmitSharesStandardOwned { + channel_id: standard_channel_id, + sequence_number: 1, + job_id: 1, + nonce: 92092, + ntime: share_ntime, + version: 536870912, + }; + let res = standard_channel.validate_share(valid_share); + assert!(matches!( + res.unwrap_err(), + ShareValidationError::SeenSharesBudgetExhausted + )); + } } diff --git a/sv2/subprotocols/template-distribution/src/set_new_prev_hash.rs b/sv2/subprotocols/template-distribution/src/set_new_prev_hash.rs index 99bf368042..d258fd9f1d 100644 --- a/sv2/subprotocols/template-distribution/src/set_new_prev_hash.rs +++ b/sv2/subprotocols/template-distribution/src/set_new_prev_hash.rs @@ -7,14 +7,27 @@ use core::{convert::TryInto, fmt}; /// /// Upon validating a new best block, the upstream **must** immediately send this message. /// -/// If a [`crate::NewTemplate`] message has previously been sent with the -/// [`crate::NewTemplate::future_template`] flag set, the [`SetNewPrevHash::template_id`] field -/// **should** be set to the [`crate::NewTemplate::template_id`]. +/// Prior to that, the upstream **must** have sent at least one, but potentially multiple, +/// [`crate::NewTemplate`] messages with the [`crate::NewTemplate::future_template`] flag set. A +/// downstream should keep track of all of them, and convert them into `NewMiningJob` or +/// `NewExtendedMiningJob` messages with an empty `min_ntime`, in case it is also acting as a +/// server under the Mining Protocol. +/// +/// [`SetNewPrevHash::template_id`] identifies which of those future templates is now valid to +/// mine on, given the [`SetNewPrevHash::prev_hash`] carried here. Once it has been activated, +/// the remaining future templates can be discarded, leaving room for the future templates +/// relative to the next `SetNewPrevHash`. +/// +/// Note the ordering requirement is on the upstream: receiving this message with no future +/// template queued means the peer is not conforming to the protocol, not that the +/// [`SetNewPrevHash::template_id`] is optional. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct SetNewPrevHash<'decoder> { /// Identifier of the template to mine on. /// - /// This must be identical to previously sent [`crate::NewTemplate`] message. + /// References a [`crate::NewTemplate`] previously sent with the + /// [`crate::NewTemplate::future_template`] flag set: the one that is now valid for + /// [`SetNewPrevHash::prev_hash`]. pub template_id: u64, /// Previous block’s hash, as it must appear in the next block’s header. pub prev_hash: U256<'decoder>,