From 877dbec6bc700b021f799800e640327d414e0fbb Mon Sep 17 00:00:00 2001 From: vnprc <9425366+vnprc@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:24:11 -0400 Subject: [PATCH 1/5] channels_sv2: clamp target to max_target instead of erroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a pool's vardiff computes a target easier than the client's declared max_target, update_channel() returned Err(RequestedMaxTargetOutOfRange). This silently broke vardiff: once triggered, no further difficulty adjustments succeeded for that channel. The pool logged a WARN every ~60s per affected channel while the miner stayed stuck at its last successfully-set difficulty. The max_target field in OpenExtendedMiningChannel means "the easiest difficulty I will accept." If the pool's calculation produces something easier, the correct response is to assign exactly max_target — not to error. The client explicitly declared this difficulty acceptable. Apply the same clamping logic to both ExtendedChannel and StandardChannel, in both new() (channel creation) and update_channel() (vardiff updates). Update the corresponding tests to assert clamping behavior rather than expecting an error. Also remove two stray println! debug statements from ExtendedChannel::new(). Co-Authored-By: Claude Sonnet 4.6 --- sv2/channels-sv2/src/server/extended.rs | 40 ++++++++++++++----------- sv2/channels-sv2/src/server/standard.rs | 40 ++++++++++++++----------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/sv2/channels-sv2/src/server/extended.rs b/sv2/channels-sv2/src/server/extended.rs index d07ec91d04..4fb90b262d 100644 --- a/sv2/channels-sv2/src/server/extended.rs +++ b/sv2/channels-sv2/src/server/extended.rs @@ -208,11 +208,14 @@ where } }; - if target > max_target { - println!("target: {:?}", target.to_be_bytes()); - println!("max_target: {:?}", max_target.to_be_bytes()); - return Err(ExtendedChannelError::RequestedMaxTargetOutOfRange); - } + // Clamp to max_target rather than error. The client declared max_target as + // an acceptable difficulty floor, so using it when the initial target would + // otherwise exceed it is always valid. + let target = if target > max_target { + max_target + } else { + target + }; if extranonce_prefix.len() > MAX_EXTRANONCE_PREFIX_LEN { return Err(ExtendedChannelError::ExtranoncePrefixTooLarge); @@ -396,11 +399,14 @@ where bytes_to_hex(&max_target_bytes) ); - let new_target: Target = target; - - if new_target > *requested_max_target { - return Err(ExtendedChannelError::RequestedMaxTargetOutOfRange); - } + // Clamp to max_target rather than error. The client declared max_target as + // an acceptable difficulty floor, so using it when vardiff would otherwise + // exceed it is always valid. + let new_target: Target = if target > *requested_max_target { + *requested_max_target + } else { + target + }; self.nominal_hashrate = new_nominal_hashrate; self.target = new_target; @@ -1604,17 +1610,15 @@ mod tests { 0xff, 0xff, 0xff, 0x00, ]); - // Try to update with a hashrate that would result in a target exceeding the max_target - // new target: 2492492492492492492492492492492492492492492492492492492492492491 - // max target: 00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + // Update with a hashrate that would compute a target exceeding max_target. + // The channel should clamp to not_so_permissive_max_target instead of erroring. + // calculated target: 2492492492492492492492492492492492492492492492492492492492492491 + // max target: 00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff let very_small_hashrate = 0.1; let result = channel.update_channel(very_small_hashrate, Some(not_so_permissive_max_target)); - assert!(result.is_err()); - assert!(matches!( - result, - Err(ExtendedChannelError::RequestedMaxTargetOutOfRange) - )); + assert!(result.is_ok()); + assert_eq!(channel.get_target(), ¬_so_permissive_max_target); // Test successful update with not_so_permissive_max_target // new target: 0001179d9861a761ffdadd11c307c4fc04eea3a418f7d687584e4434af158205 diff --git a/sv2/channels-sv2/src/server/standard.rs b/sv2/channels-sv2/src/server/standard.rs index 813f536783..a0251b3e25 100644 --- a/sv2/channels-sv2/src/server/standard.rs +++ b/sv2/channels-sv2/src/server/standard.rs @@ -195,11 +195,14 @@ where } }; - let target: Target = calculated_target; - - if target > requested_max_target { - return Err(StandardChannelError::RequestedMaxTargetOutOfRange); - } + // Clamp to max_target rather than error. The client declared max_target as + // an acceptable difficulty floor, so using it when the initial target would + // otherwise exceed it is always valid. + let target: Target = if calculated_target > requested_max_target { + requested_max_target + } else { + calculated_target + }; if extranonce_prefix.len() > MAX_EXTRANONCE_PREFIX_LEN { return Err(StandardChannelError::ExtranoncePrefixTooLarge); @@ -344,11 +347,14 @@ where bytes_to_hex(&max_target_bytes) ); - let new_target: Target = target; - - if new_target > requested_max_target { - return Err(StandardChannelError::RequestedMaxTargetOutOfRange); - } + // Clamp to max_target rather than error. The client declared max_target as + // an acceptable difficulty floor, so using it when vardiff would otherwise + // exceed it is always valid. + let new_target: Target = if target > requested_max_target { + requested_max_target + } else { + target + }; self.target = new_target; self.nominal_hashrate = nominal_hashrate; @@ -1368,17 +1374,15 @@ mod tests { 0xff, 0xff, 0xff, 0x00, ]); - // Try to update with a hashrate that would result in a target exceeding the max_target - // new target: 2492492492492492492492492492492492492492492492492492492492492491 - // max target: 00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + // Update with a hashrate that would compute a target exceeding max_target. + // The channel should clamp to not_so_permissive_max_target instead of erroring. + // calculated target: 2492492492492492492492492492492492492492492492492492492492492491 + // max target: 00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff let very_small_hashrate = 0.1; let result = channel.update_channel(very_small_hashrate, Some(not_so_permissive_max_target)); - assert!(result.is_err()); - assert!(matches!( - result, - Err(StandardChannelError::RequestedMaxTargetOutOfRange) - )); + assert!(result.is_ok()); + assert_eq!(channel.get_target(), ¬_so_permissive_max_target); // Test successful update with not_so_permissive_max_target // new target: 0001179d9861a761ffdadd11c307c4fc04eea3a418f7d687584e4434af158205 From b158ea5ac3c229abfcb811e95e98281f57c6fa95 Mon Sep 17 00:00:00 2001 From: plebhash Date: Tue, 21 Apr 2026 18:10:23 -0300 Subject: [PATCH 2/5] add test_new_clamps_target_to_max_target to channels_sv2::server::{extended,standard} to protect against constructor regressions with the changes introduced in #2118 --- sv2/channels-sv2/src/server/extended.rs | 42 +++++++++++++++++++++++++ sv2/channels-sv2/src/server/standard.rs | 38 ++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/sv2/channels-sv2/src/server/extended.rs b/sv2/channels-sv2/src/server/extended.rs index 4fb90b262d..b2213bd230 100644 --- a/sv2/channels-sv2/src/server/extended.rs +++ b/sv2/channels-sv2/src/server/extended.rs @@ -1539,6 +1539,48 @@ mod tests { assert!(matches!(res, Err(ShareValidationError::DuplicateShare))); } + #[test] + fn test_new_clamps_target_to_max_target() { + let channel_id = 1; + let user_identity = "user_identity".to_string(); + let extranonce_prefix = [0, 0, 0, 1].to_vec(); + let version_rolling_allowed = true; + let rollable_extranonce_size = 4u16; + let share_batch_size = 100; + let expected_share_per_minute = 1.0; + let very_small_hashrate = 0.1; + let job_store = DefaultJobStore::new(); + + // less permissive max_target to exercise constructor clamp path + let not_so_permissive_max_target = Target::from_le_bytes([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x00, + ]); + + let channel = ExtendedChannel::new( + channel_id, + user_identity, + extranonce_prefix, + not_so_permissive_max_target, + very_small_hashrate, + version_rolling_allowed, + rollable_extranonce_size, + share_batch_size, + expected_share_per_minute, + job_store, + None, + None, + ) + .unwrap(); + + assert_eq!( + channel.get_requested_max_target(), + ¬_so_permissive_max_target + ); + assert_eq!(channel.get_target(), ¬_so_permissive_max_target); + } + #[test] fn test_update_channel() { let channel_id = 1; diff --git a/sv2/channels-sv2/src/server/standard.rs b/sv2/channels-sv2/src/server/standard.rs index a0251b3e25..e25b6bc846 100644 --- a/sv2/channels-sv2/src/server/standard.rs +++ b/sv2/channels-sv2/src/server/standard.rs @@ -1308,6 +1308,44 @@ mod tests { assert!(matches!(res, Ok(ShareValidationResult::Valid(_)))); } + #[test] + fn test_new_clamps_target_to_max_target() { + let channel_id = 1; + let user_identity = "user_identity".to_string(); + let extranonce_prefix = [0, 0, 0, 1].to_vec(); + let share_batch_size = 100; + let expected_share_per_minute = 1.0; + let very_small_hashrate = 0.1; + let job_store = DefaultJobStore::::new(); + + // less permissive max_target to exercise constructor clamp path + let not_so_permissive_max_target = Target::from_le_bytes([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x00, + ]); + + let channel = StandardChannel::new( + channel_id, + user_identity, + extranonce_prefix, + not_so_permissive_max_target, + very_small_hashrate, + share_batch_size, + expected_share_per_minute, + job_store, + None, + None, + ) + .unwrap(); + + assert_eq!( + channel.get_requested_max_target(), + ¬_so_permissive_max_target + ); + assert_eq!(channel.get_target(), ¬_so_permissive_max_target); + } + #[test] fn test_update_channel() { let channel_id = 1; From 5d9cc7379989b62bdeb6bc9b17c95230823d1ca2 Mon Sep 17 00:00:00 2001 From: plebhash Date: Tue, 21 Apr 2026 18:12:32 -0300 Subject: [PATCH 3/5] update rust docs of channels_sv2::server::{extended,standard}::update_channel to accurately describe the behavior introduced via #2118 --- sv2/channels-sv2/src/server/extended.rs | 9 +++++++-- sv2/channels-sv2/src/server/standard.rs | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/sv2/channels-sv2/src/server/extended.rs b/sv2/channels-sv2/src/server/extended.rs index b2213bd230..913772cf0d 100644 --- a/sv2/channels-sv2/src/server/extended.rs +++ b/sv2/channels-sv2/src/server/extended.rs @@ -350,8 +350,13 @@ where /// Updates channel configuration with a new nominal hashrate. /// - /// Adjusts target difficulty and internal state. Returns an error if - /// any input parameters are invalid or constraints are violated. + /// Recomputes target difficulty and updates channel state. + /// + /// If the recomputed target is easier than the effective `requested_max_target`, + /// the target is clamped to `requested_max_target`. + /// + /// Returns [`ExtendedChannelError::InvalidNominalHashrate`] when + /// `new_nominal_hashrate` cannot be converted into a valid target. /// /// This can be used in two scenarios: /// - Client sent `UpdateChannel` message, which contains a `requested_max_target` parameter diff --git a/sv2/channels-sv2/src/server/standard.rs b/sv2/channels-sv2/src/server/standard.rs index e25b6bc846..944a269f09 100644 --- a/sv2/channels-sv2/src/server/standard.rs +++ b/sv2/channels-sv2/src/server/standard.rs @@ -299,8 +299,13 @@ where /// Updates channel configuration with a new nominal hashrate. /// - /// Adjusts target difficulty and internal state. Returns an error if - /// any input parameters are invalid or constraints are violated. + /// Recomputes target difficulty and updates channel state. + /// + /// If the recomputed target is easier than the effective `requested_max_target`, + /// the target is clamped to `requested_max_target`. + /// + /// Returns [`StandardChannelError::InvalidNominalHashrate`] when + /// `nominal_hashrate` cannot be converted into a valid target. /// /// This can be used in two scenarios: /// - Client sent `UpdateChannel` message, which contains a `requested_max_target` parameter From e834da2cf3a95bf1d7224ed68509dfc958ce6ee3 Mon Sep 17 00:00:00 2001 From: plebhash Date: Tue, 21 Apr 2026 18:14:40 -0300 Subject: [PATCH 4/5] remove obsolete RequestedMaxTargetOutOfRange variant from channels_sv2::server::error::{ExtendedChannelError,StandardChannelError} --- sv2/channels-sv2/src/server/error.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/sv2/channels-sv2/src/server/error.rs b/sv2/channels-sv2/src/server/error.rs index f629250ca2..74922711ef 100644 --- a/sv2/channels-sv2/src/server/error.rs +++ b/sv2/channels-sv2/src/server/error.rs @@ -6,7 +6,6 @@ use crate::server::jobs::error::JobFactoryError; pub enum ExtendedChannelError { JobFactoryError(JobFactoryError), InvalidNominalHashrate, - RequestedMaxTargetOutOfRange, ChainTipNotSet, TemplateIdNotFound, JobIdNotFound, @@ -29,7 +28,6 @@ pub enum GroupChannelError { pub enum StandardChannelError { TemplateIdNotFound, InvalidNominalHashrate, - RequestedMaxTargetOutOfRange, ExtranoncePrefixTooLarge, JobFactoryError(JobFactoryError), ChainTipNotSet, From 5effe0412b0a291de3f841ddc132d17be4a38c05 Mon Sep 17 00:00:00 2001 From: plebhash Date: Tue, 21 Apr 2026 18:38:23 -0300 Subject: [PATCH 5/5] bump channels_sv2 to 5.0.0 --- Cargo.lock | 2 +- stratum-core/Cargo.toml | 2 +- stratum-core/stratum-translation/Cargo.toml | 2 +- sv2/channels-sv2/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24d28135c4..9bf762b3b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,7 +286,7 @@ dependencies = [ [[package]] name = "channels_sv2" -version = "4.0.0" +version = "5.0.0" dependencies = [ "binary_sv2", "bitcoin", diff --git a/stratum-core/Cargo.toml b/stratum-core/Cargo.toml index 877743b8ad..a042ccb2d1 100644 --- a/stratum-core/Cargo.toml +++ b/stratum-core/Cargo.toml @@ -20,7 +20,7 @@ framing_sv2 = { path = "../sv2/framing-sv2", version = "^6.0.0" } noise_sv2 = { path = "../sv2/noise-sv2", version = "^1.0.0" } parsers_sv2 = { path = "../sv2/parsers-sv2", version = "^0.2.0" } handlers_sv2 = { path = "../sv2/handlers-sv2", version = "^0.2.0" } -channels_sv2 = { path = "../sv2/channels-sv2", version = "^4.0.0" } +channels_sv2 = { path = "../sv2/channels-sv2", version = "^5.0.0" } common_messages_sv2 = { path = "../sv2/subprotocols/common-messages", version = "^7.0.0" } mining_sv2 = { path = "../sv2/subprotocols/mining", version = "^8.0.0" } template_distribution_sv2 = { path = "../sv2/subprotocols/template-distribution", version = "^5.0.0" } diff --git a/stratum-core/stratum-translation/Cargo.toml b/stratum-core/stratum-translation/Cargo.toml index 96961b90ca..d4eeac0ab6 100644 --- a/stratum-core/stratum-translation/Cargo.toml +++ b/stratum-core/stratum-translation/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" [dependencies] binary_sv2 = { path = "../../sv2/binary-sv2", version = "^5.0.0" } mining_sv2 = { path = "../../sv2/subprotocols/mining", version = "^8.0.0" } -channels_sv2 = { path = "../../sv2/channels-sv2", version = "^4.0.0" } +channels_sv2 = { path = "../../sv2/channels-sv2", version = "^5.0.0" } v1 = { path = "../../sv1", package = "sv1_api", version = "^4.0.0" } tracing = { workspace = true } bitcoin = { workspace = true } diff --git a/sv2/channels-sv2/Cargo.toml b/sv2/channels-sv2/Cargo.toml index 0b8c768af9..863a2e0b55 100644 --- a/sv2/channels-sv2/Cargo.toml +++ b/sv2/channels-sv2/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "channels_sv2" -version = "4.0.0" +version = "5.0.0" authors = ["The Stratum V2 Developers"] edition = "2021" readme = "README.md"