diff --git a/integration-tests/Cargo.lock b/integration-tests/Cargo.lock index 95b33194..4ce0ef15 100644 --- a/integration-tests/Cargo.lock +++ b/integration-tests/Cargo.lock @@ -1649,6 +1649,7 @@ dependencies = [ "async-channel", "corepc-node", "flate2", + "hex", "jd_client_sv2", "jd_server", "mining_device", diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 60b4ac13..59b30786 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -28,6 +28,7 @@ tokio = { version="1.44.1", default-features = false, features = ["tracing"] } tokio-util = { version = "0.7", default-features = false } tracing = { version = "0.1.41", default-features = false } tracing-subscriber = { version = "0.3.19", default-features = false } +hex = "0.4.3" [lib] path = "lib/mod.rs" diff --git a/integration-tests/lib/mod.rs b/integration-tests/lib/mod.rs index 24332140..0c4a3a67 100644 --- a/integration-tests/lib/mod.rs +++ b/integration-tests/lib/mod.rs @@ -37,7 +37,7 @@ pub mod sv1_minerd; pub mod sv1_sniffer; pub mod template_provider; pub mod types; -pub(crate) mod utils; +pub mod utils; const SHARES_PER_MINUTE: f32 = 120.0; diff --git a/integration-tests/tests/jd_integration.rs b/integration-tests/tests/jd_integration.rs index 5072670f..88f057b0 100644 --- a/integration-tests/tests/jd_integration.rs +++ b/integration-tests/tests/jd_integration.rs @@ -1,6 +1,7 @@ // This file contains integration tests for the `JDC/S` module. use integration_tests_sv2::{ interceptor::{MessageDirection, ReplaceMessage}, + mock_roles::MockDownstream, template_provider::DifficultyLevel, *, }; @@ -8,7 +9,9 @@ use stratum_apps::stratum_core::{ binary_sv2::{Seq064K, B032, U256}, common_messages_sv2::*, job_declaration_sv2::{ProvideMissingTransactionsSuccess, PushSolution, *}, - parsers_sv2::{self, AnyMessage}, + mining_sv2::*, + parsers_sv2::{self, AnyMessage, CommonMessages, Mining}, + template_distribution_sv2::*, }; // This test verifies that jd-server does not exit when a connected jd-client shuts down. @@ -269,3 +272,576 @@ async fn jds_wont_exit_upon_receiving_unexpected_txids_in_provide_missing_transa assert!(tokio::net::TcpListener::bind(jds_addr).await.is_err()); } + +// This test launches a JDC and leverages a MockDownstream to test the correct functionalities of +// grouping extended channels. +#[tokio::test] +async fn jdc_group_extended_channels() { + start_tracing(); + let sv2_interval = Some(5); + let (tp, tp_addr) = start_template_provider(sv2_interval, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (_pool, pool_addr) = start_pool(sv2_tp_config(tp_addr), vec![], vec![]).await; + let (_jds, jds_addr) = start_jds(tp.rpc_info()); + + let (_jdc, jdc_addr) = start_jdc( + &[(pool_addr, jds_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + ); + + // Give JDC time to bind to its listening address + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + + let (sniffer, sniffer_addr) = start_sniffer("sniffer", jdc_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new(sniffer_addr); + let send_to_jdc = mock_downstream.start().await; + + // send SetupConnection message to jdc + let setup_connection = AnyMessage::Common(CommonMessages::SetupConnection(SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0, + endpoint_host: b"0.0.0.0".to_vec().try_into().unwrap(), + endpoint_port: 8081, + vendor: b"Bitmain".to_vec().try_into().unwrap(), + hardware_version: b"901".to_vec().try_into().unwrap(), + firmware: b"abcX".to_vec().try_into().unwrap(), + device_id: b"89567".to_vec().try_into().unwrap(), + })); + + send_to_jdc.send(setup_connection).await.unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const NUM_EXTENDED_CHANNELS: u32 = 10; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + + for i in 0..NUM_EXTENDED_CHANNELS { + let open_extended_mining_channel = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: i, + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 0, + }, + )); + send_to_jdc + .send(open_extended_mining_channel) + .await + .unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + // loop until we get the OpenExtendedMiningChannelSuccess message + // if we get any other message (e.g.: NewExtendedMiningJob), just continue the loop + let (channel_id, group_channel_id) = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(Mining::OpenExtendedMiningChannelSuccess(msg)))) => { + break (msg.channel_id, msg.group_channel_id); + } + _ => continue, + }; + }; + + assert_ne!( + channel_id, group_channel_id, + "Channel ID must be different from the group channel ID" + ); + + assert_eq!( + group_channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Group channel ID should be correct" + ); + + // also assert the correct message sequence after OpenExtendedMiningChannelSuccess + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // ok, up until this point, we were just initializing N_EXTENDED_CHANNELS extended channels + // now, let's see if a mempool change will trigger ONE (and not many) NewExtendedMiningJob + // message directed to the correct group channel ID + + // create a mempool transaction to trigger a new template + tp.create_mempool_transaction().unwrap(); + + // wait for a NewExtendedMiningJob message + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // assert that the NewExtendedMiningJob message is directed to the correct group channel ID + let new_extended_mining_job_msg = sniffer.next_message_from_upstream(); + let new_extended_mining_job_msg = match new_extended_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + + assert_eq!( + new_extended_mining_job_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "NewExtendedMiningJob message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra NewExtendedMiningJob messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB + ) + .await, + "There should be no extra NewExtendedMiningJob messages" + ); + + // now let's see if a chain tip update will trigger ONE (and not many) SetNewPrevHashMp message + // directed to the correct group channel ID + + tp.generate_blocks(1); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + let set_new_prev_hash_msg = sniffer.next_message_from_upstream(); + let set_new_prev_hash_msg = match set_new_prev_hash_msg { + Some((_, AnyMessage::Mining(Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + assert_eq!( + set_new_prev_hash_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "SetNewPrevHash message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra SetNewPrevHash messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_NEW_PREV_HASH + ) + .await, + "There should be no extra SetNewPrevHash messages" + ); +} + +// This test launches a JDC and leverages a MockDownstream to test the correct functionalities of +// grouping standard channels. +// temporarily disabled: see https://github.com/stratum-mining/sv2-apps/issues/152 +#[ignore] +#[tokio::test] +async fn jdc_group_standard_channels() { + start_tracing(); + let sv2_interval = Some(5); + let (tp, tp_addr) = start_template_provider(sv2_interval, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (_pool, pool_addr) = start_pool(sv2_tp_config(tp_addr), vec![], vec![]).await; + let (_jds, jds_addr) = start_jds(tp.rpc_info()); + + let (_jdc, jdc_addr) = start_jdc( + &[(pool_addr, jds_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + ); + + // Give JDC time to bind to its listening address + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + + let (sniffer, sniffer_addr) = start_sniffer("sniffer", jdc_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new(sniffer_addr); + let send_to_jdc = mock_downstream.start().await; + + // send SetupConnection message to jdc + let setup_connection = AnyMessage::Common(CommonMessages::SetupConnection(SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0, + endpoint_host: b"0.0.0.0".to_vec().try_into().unwrap(), + endpoint_port: 8081, + vendor: b"Bitmain".to_vec().try_into().unwrap(), + hardware_version: b"901".to_vec().try_into().unwrap(), + firmware: b"abcX".to_vec().try_into().unwrap(), + device_id: b"89567".to_vec().try_into().unwrap(), + })); + send_to_jdc.send(setup_connection).await.unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const NUM_STANDARD_CHANNELS: u32 = 10; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + + for i in 0..NUM_STANDARD_CHANNELS { + let open_standard_mining_channel = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: i.into(), + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + + send_to_jdc + .send(open_standard_mining_channel) + .await + .unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + let open_standard_mining_channel_success_msg = sniffer.next_message_from_upstream(); + let (channel_id, group_channel_id) = match open_standard_mining_channel_success_msg { + Some((_, AnyMessage::Mining(Mining::OpenStandardMiningChannelSuccess(msg)))) => { + (msg.channel_id, msg.group_channel_id) + } + msg => panic!( + "Expected OpenStandardMiningChannelSuccess message, found: {:?}", + msg + ), + }; + + assert_ne!( + channel_id, group_channel_id, + "Channel ID must be different from the group channel ID" + ); + + assert_eq!( + group_channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Group channel ID should be correct" + ); + + // also assert the correct message sequence after OpenStandardMiningChannelSuccess + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // ok, up until this point, we were just initializing two standard channels + // now, let's see if a mempool change will trigger ONE (and not many) NewMiningJob + // message directed to the correct group channel ID + + // send a mempool transaction to trigger a new template + tp.create_mempool_transaction().unwrap(); + + // wait for a NewExtendedMiningJob message targeted to the correct group channel ID + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // assert that the NewExtendedMiningJob message is directed to the correct group channel ID + let new_extended_mining_job_msg = sniffer.next_message_from_upstream(); + let new_extended_mining_job_msg = match new_extended_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + + assert_eq!( + new_extended_mining_job_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "NewExtendedMiningJob message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra NewExtendedMiningJob messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB + ) + .await, + "There should be no extra NewExtendedMiningJob messages" + ); + + // make sure there's no NewMiningJob message + assert!( + sniffer + .assert_message_not_present(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await, + "There should be no NewMiningJob message" + ); + + // now let's see if a chain tip update will trigger ONE (and not many) SetNewPrevHashMp message + // directed to the correct group channel ID + + tp.generate_blocks(1); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + let set_new_prev_hash_msg = sniffer.next_message_from_upstream(); + let set_new_prev_hash_msg = match set_new_prev_hash_msg { + Some((_, AnyMessage::Mining(Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + assert_eq!( + set_new_prev_hash_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "SetNewPrevHash message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra SetNewPrevHash messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_NEW_PREV_HASH + ) + .await, + "There should be no extra SetNewPrevHash messages" + ); +} + +// This test launches a JDC and leverages a MockDownstream to test the correct functionalities of +// NOT grouping standard channels when REQUIRES_STANDARD_JOBS is set. +// temporarily disabled: see https://github.com/stratum-mining/sv2-apps/issues/152 +#[ignore] +#[tokio::test] +async fn jdc_require_standard_jobs_set_does_not_group_standard_channels() { + start_tracing(); + let sv2_interval = Some(5); + let (tp, tp_addr) = start_template_provider(sv2_interval, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (_pool, pool_addr) = start_pool(sv2_tp_config(tp_addr), vec![], vec![]).await; + let (_jds, jds_addr) = start_jds(tp.rpc_info()); + + let (_jdc, jdc_addr) = start_jdc( + &[(pool_addr, jds_addr)], + sv2_tp_config(tp_addr), + vec![], + vec![], + ); + + // Give JDC time to bind to its listening address + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + + let (sniffer, sniffer_addr) = start_sniffer("sniffer", jdc_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new(sniffer_addr); + let send_to_jdc = mock_downstream.start().await; + + // send SetupConnection message to jdc + let setup_connection = AnyMessage::Common(CommonMessages::SetupConnection(SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0b0001, // REQUIRES_STANDARD_JOBS flag + endpoint_host: b"0.0.0.0".to_vec().try_into().unwrap(), + endpoint_port: 8081, + vendor: b"Bitmain".to_vec().try_into().unwrap(), + hardware_version: b"901".to_vec().try_into().unwrap(), + firmware: b"abcX".to_vec().try_into().unwrap(), + device_id: b"89567".to_vec().try_into().unwrap(), + })); + send_to_jdc.send(setup_connection).await.unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const NUM_STANDARD_CHANNELS: u32 = 10; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + + for i in 0..NUM_STANDARD_CHANNELS { + let open_standard_mining_channel = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: i.into(), + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + + send_to_jdc + .send(open_standard_mining_channel) + .await + .unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + let open_standard_mining_channel_success_msg = sniffer.next_message_from_upstream(); + let (channel_id, group_channel_id) = match open_standard_mining_channel_success_msg { + Some((_, AnyMessage::Mining(Mining::OpenStandardMiningChannelSuccess(msg)))) => { + (msg.channel_id, msg.group_channel_id) + } + msg => panic!( + "Expected OpenStandardMiningChannelSuccess message, found: {:?}", + msg + ), + }; + + assert_ne!( + channel_id, group_channel_id, + "Channel ID must be different from the group channel ID" + ); + + assert_eq!( + group_channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Group channel ID should be correct" /* even though we are not going to use it */ + ); + + // also assert the correct message sequence after OpenStandardMiningChannelSuccess + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // ok, up until this point, we were just initializing two standard channels + // now, let's see if a mempool change will trigger NUM_STANDARD_CHANNELS NewMiningJob messages + // and not ONE NewExtendedMiningJob message + + // send a mempool transaction to trigger a new template + tp.create_mempool_transaction().unwrap(); + + for _i in 0..NUM_STANDARD_CHANNELS { + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + + let new_mining_job_msg = sniffer.next_message_from_upstream(); + let channel_id = match new_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewMiningJob(msg)))) => msg.channel_id, + msg => panic!("Expected NewMiningJob message, found: {:?}", msg), + }; + + assert_ne!( + channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Channel ID must be different from the group channel ID" + ); + } + + // now let's see if a chain tip update will trigger NUM_STANDARD_CHANNELS pairs of NewMiningJob + // message and SetNewPrevHash message + + tp.generate_blocks(1); + + for _i in 0..NUM_STANDARD_CHANNELS { + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + + let new_mining_job_msg = sniffer.next_message_from_upstream(); + let channel_id = match new_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewMiningJob(msg)))) => msg.channel_id, + msg => panic!("Expected NewMiningJob message, found: {:?}", msg), + }; + + assert_ne!( + channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Channel ID must be different from the group channel ID" + ); + } + + for _i in 0..NUM_STANDARD_CHANNELS { + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + let set_new_prev_hash_msg = sniffer.next_message_from_upstream(); + let channel_id = match set_new_prev_hash_msg { + Some((_, AnyMessage::Mining(Mining::SetNewPrevHash(msg)))) => msg.channel_id, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + assert_ne!( + channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Channel ID must be different from the group channel ID" + ); + } +} diff --git a/integration-tests/tests/pool_integration.rs b/integration-tests/tests/pool_integration.rs index 474dae52..03855c59 100644 --- a/integration-tests/tests/pool_integration.rs +++ b/integration-tests/tests/pool_integration.rs @@ -3,6 +3,7 @@ // `PoolSv2` is a module that implements the Pool role in the Stratum V2 protocol. use integration_tests_sv2::{ interceptor::{MessageDirection, ReplaceMessage}, + mock_roles::MockDownstream, template_provider::DifficultyLevel, *, }; @@ -247,16 +248,23 @@ async fn pool_does_not_send_jobs_to_jdc() { vec![], vec![], ); - // Block NewExtendedMiningJob messages between JDC and translator proxy + // Block NewExtendedMiningJob and SetNewPrevHash messages between JDC and translator proxy let (_tproxy_jdc_sniffer, tproxy_jdc_sniffer_addr) = start_sniffer( "tproxy_jdc", jdc_addr, false, - vec![integration_tests_sv2::interceptor::IgnoreMessage::new( - MessageDirection::ToDownstream, - MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, - ) - .into()], + vec![ + integration_tests_sv2::interceptor::IgnoreMessage::new( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .into(), + integration_tests_sv2::interceptor::IgnoreMessage::new( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .into(), + ], None, ); let (_translator, tproxy_addr) = @@ -408,3 +416,538 @@ async fn pool_reject_setup_connection_with_non_mining_protocol() { "SetupConnectionError message error code should be unsupported-protocol" ); } + +// This test launches a Pool and leverages a MockDownstream to test the correct functionalities of +// grouping extended channels. +#[tokio::test] +async fn pool_group_extended_channels() { + start_tracing(); + let sv2_interval = Some(5); + let (tp, tp_addr) = start_template_provider(sv2_interval, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (_pool, pool_addr) = start_pool(sv2_tp_config(tp_addr), vec![], vec![]).await; + + let (sniffer, sniffer_addr) = start_sniffer("sniffer", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new(sniffer_addr); + let send_to_pool = mock_downstream.start().await; + + // send SetupConnection message to the pool + let setup_connection = AnyMessage::Common(CommonMessages::SetupConnection(SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0, + endpoint_host: b"0.0.0.0".to_vec().try_into().unwrap(), + endpoint_port: 8081, + vendor: b"Bitmain".to_vec().try_into().unwrap(), + hardware_version: b"901".to_vec().try_into().unwrap(), + firmware: b"abcX".to_vec().try_into().unwrap(), + device_id: b"89567".to_vec().try_into().unwrap(), + })); + send_to_pool.send(setup_connection).await.unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const NUM_EXTENDED_CHANNELS: u32 = 10; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + + for i in 0..NUM_EXTENDED_CHANNELS { + // send OpenExtendedMiningChannel message to the pool + let open_extended_mining_channel = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: i.into(), + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 0, + }, + )); + send_to_pool + .send(open_extended_mining_channel) + .await + .unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let group_channel_id = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(Mining::OpenExtendedMiningChannelSuccess(msg)))) => { + break msg.group_channel_id; + } + _ => continue, + }; + }; + + assert_eq!( + group_channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Group channel ID should be correct" + ); + + // also assert the correct message sequence after OpenExtendedMiningChannelSuccess + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // ok, up until this point, we were just initializing NUM_EXTENDED_CHANNELS extended channels + // now, let's see if a mempool change will trigger ONE (and not NUM_EXTENDED_CHANNELS) + // NewExtendedMiningJob message directed to the correct group channel ID + + // create a mempool transaction to trigger a new template + tp.create_mempool_transaction().unwrap(); + + // wait for a NewExtendedMiningJob message + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // assert that the NewExtendedMiningJob message is directed to the correct group channel ID + let new_extended_mining_job_msg = sniffer.next_message_from_upstream(); + let new_extended_mining_job_msg = match new_extended_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + + assert_eq!( + new_extended_mining_job_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "NewExtendedMiningJob message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra NewExtendedMiningJob messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB + ) + .await, + "There should be no extra NewExtendedMiningJob messages" + ); + + // now let's see if a chain tip update will trigger ONE (and not many) SetNewPrevHashMp message + // directed to the correct group channel ID + + tp.generate_blocks(1); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + let set_new_prev_hash_msg = sniffer.next_message_from_upstream(); + let set_new_prev_hash_msg = match set_new_prev_hash_msg { + Some((_, AnyMessage::Mining(Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + assert_eq!( + set_new_prev_hash_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "SetNewPrevHash message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra SetNewPrevHash messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SET_NEW_PREV_HASH + ) + .await, + "There should be no second SetNewPrevHash message" + ); +} + +// This test launches a Pool and leverages a MockDownstream to test the correct functionalities of +// grouping standard channels. +#[tokio::test] +async fn pool_group_standard_channels() { + start_tracing(); + let sv2_interval = Some(5); + let (tp, tp_addr) = start_template_provider(sv2_interval, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (_pool, pool_addr) = start_pool(sv2_tp_config(tp_addr), vec![], vec![]).await; + + let (sniffer, sniffer_addr) = start_sniffer("sniffer", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new(sniffer_addr); + let send_to_pool = mock_downstream.start().await; + + // send SetupConnection message to the pool + let setup_connection = AnyMessage::Common(CommonMessages::SetupConnection(SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0, // no REQUIRES_STANDARD_JOBS flag + endpoint_host: b"0.0.0.0".to_vec().try_into().unwrap(), + endpoint_port: 8081, + vendor: b"Bitmain".to_vec().try_into().unwrap(), + hardware_version: b"901".to_vec().try_into().unwrap(), + firmware: b"abcX".to_vec().try_into().unwrap(), + device_id: b"89567".to_vec().try_into().unwrap(), + })); + + send_to_pool.send(setup_connection).await.unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const NUM_STANDARD_CHANNELS: u32 = 10; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + + for i in 0..NUM_STANDARD_CHANNELS { + let open_standard_mining_channel = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: i.into(), + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + + send_to_pool + .send(open_standard_mining_channel) + .await + .unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + let (channel_id, group_channel_id) = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(Mining::OpenStandardMiningChannelSuccess(msg)))) => { + break (msg.channel_id, msg.group_channel_id); + } + _ => continue, + }; + }; + + assert_ne!( + channel_id, group_channel_id, + "Channel ID must be different from the group channel ID" + ); + + assert_eq!( + group_channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Group channel ID should be correct" + ); + + // also assert the correct message sequence after OpenStandardMiningChannelSuccess + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // ok, up until this point, we were just initializing NUM_STANDARD_CHANNELS standard channels + // now, let's see if a mempool change will trigger ONE (and not many) NewExtendedMiningJob + // message directed to the correct group channel ID + + // send a mempool transaction to trigger a new template + tp.create_mempool_transaction().unwrap(); + + // wait for a NewExtendedMiningJob message targeted to the correct group channel ID + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // assert that the NewExtendedMiningJob message is directed to the correct group channel ID + let new_extended_mining_job_msg = sniffer.next_message_from_upstream(); + let new_extended_mining_job_msg = match new_extended_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewMiningJob message, found: {:?}", msg), + }; + + assert_eq!( + new_extended_mining_job_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "NewMiningJob message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra NewExtendedMiningJob messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB + ) + .await, + "There should be no second NewMiningJob message" + ); + + // make sure there's no NewMiningJob message + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_MINING_JOB, + ) + .await, + "There should be no NewMiningJob message" + ); + + // now let's see if a chain tip update will trigger ONE (and not many) SetNewPrevHashMp message + // directed to the correct group channel ID + + tp.generate_blocks(1); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + let set_new_prev_hash_msg = sniffer.next_message_from_upstream(); + let set_new_prev_hash_msg = match set_new_prev_hash_msg { + Some((_, AnyMessage::Mining(Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + assert_eq!( + set_new_prev_hash_msg.channel_id, EXPECTED_GROUP_CHANNEL_ID, + "SetNewPrevHash message should be directed to the correct group channel ID" + ); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // make sure there's no extra SetNewPrevHash messages + assert!( + sniffer + .assert_message_not_present( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await, + "There should be no extra SetNewPrevHash messages" + ); +} + +// This test launches a Pool and leverages a MockDownstream to test the correct functionalities of +// NOT grouping standard channels when REQUIRES_STANDARD_JOBS is set. +#[tokio::test] +async fn pool_require_standard_jobs_set_does_not_group_standard_channels() { + start_tracing(); + let sv2_interval = Some(5); + let (tp, tp_addr) = start_template_provider(sv2_interval, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (_pool, pool_addr) = start_pool(sv2_tp_config(tp_addr), vec![], vec![]).await; + + let (sniffer, sniffer_addr) = start_sniffer("sniffer", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new(sniffer_addr); + let send_to_pool = mock_downstream.start().await; + + // send SetupConnection message to the pool + let setup_connection = AnyMessage::Common(CommonMessages::SetupConnection(SetupConnection { + protocol: Protocol::MiningProtocol, + min_version: 2, + max_version: 2, + flags: 0b0001, // REQUIRES_STANDARD_JOBS flag + endpoint_host: b"0.0.0.0".to_vec().try_into().unwrap(), + endpoint_port: 8081, + vendor: b"Bitmain".to_vec().try_into().unwrap(), + hardware_version: b"901".to_vec().try_into().unwrap(), + firmware: b"abcX".to_vec().try_into().unwrap(), + device_id: b"89567".to_vec().try_into().unwrap(), + })); + send_to_pool.send(setup_connection).await.unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const NUM_STANDARD_CHANNELS: u32 = 10; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + let mut channel_ids = Vec::new(); + + for i in 0..NUM_STANDARD_CHANNELS { + let open_standard_mining_channel = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: i.into(), + user_identity: b"user_identity".to_vec().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + + send_to_pool + .send(open_standard_mining_channel) + .await + .unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + let (channel_id, group_channel_id) = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(Mining::OpenStandardMiningChannelSuccess(msg)))) => { + break (msg.channel_id, msg.group_channel_id); + } + _ => continue, + }; + }; + + channel_ids.push(channel_id); + + assert_eq!( + group_channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Group channel ID should be correct" /* even though we are not going to use it */ + ); + + assert_ne!( + channel_id, group_channel_id, + "Channel ID must be different from the group channel ID" + ); + + // also assert the correct message sequence after OpenStandardMiningChannelSuccess + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // ok, up until this point, we were just initializing NUM_STANDARD_CHANNELS standard channels + // now, let's see if a mempool change will trigger NUM_STANDARD_CHANNELS NewMiningJob messages + // and not ONE NewExtendedMiningJob message + + // send a mempool transaction to trigger a new template + tp.create_mempool_transaction().unwrap(); + + for _i in 0..NUM_STANDARD_CHANNELS { + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + + let new_mining_job_msg = sniffer.next_message_from_upstream(); + let channel_id = match new_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewMiningJob(msg)))) => msg.channel_id, + msg => panic!("Expected NewMiningJob message, found: {:?}", msg), + }; + + assert!( + channel_ids.contains(&channel_id), + "Channel ID should be present in the list of channel IDs" + ); + + assert_ne!( + channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Channel ID must be different from the group channel ID" + ); + } + + // now let's see if a chain tip update will trigger NUM_STANDARD_CHANNELS pairs of NewMiningJob + // message and SetNewPrevHash message + + tp.generate_blocks(1); + + for _i in 0..NUM_STANDARD_CHANNELS { + sniffer + .wait_for_message_type(MessageDirection::ToDownstream, MESSAGE_TYPE_NEW_MINING_JOB) + .await; + + let new_mining_job_msg = sniffer.next_message_from_upstream(); + let channel_id = match new_mining_job_msg { + Some((_, AnyMessage::Mining(Mining::NewMiningJob(msg)))) => msg.channel_id, + msg => panic!("Expected NewMiningJob message, found: {:?}", msg), + }; + + assert_ne!( + channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Channel ID must be different from the group channel ID" + ); + } + + for _i in 0..NUM_STANDARD_CHANNELS { + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + let set_new_prev_hash_msg = sniffer.next_message_from_upstream(); + let channel_id = match set_new_prev_hash_msg { + Some((_, AnyMessage::Mining(Mining::SetNewPrevHash(msg)))) => msg.channel_id, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + assert_ne!( + channel_id, EXPECTED_GROUP_CHANNEL_ID, + "Channel ID must be different from the group channel ID" + ); + } +} diff --git a/integration-tests/tests/translator_integration.rs b/integration-tests/tests/translator_integration.rs index b7e5f97d..90e42e30 100644 --- a/integration-tests/tests/translator_integration.rs +++ b/integration-tests/tests/translator_integration.rs @@ -1,21 +1,27 @@ // This file contains integration tests for the `TranslatorSv2` module. use integration_tests_sv2::{ - interceptor::{MessageDirection, ReplaceMessage}, + interceptor::{IgnoreMessage, MessageDirection, ReplaceMessage}, + mock_roles::MockUpstream, template_provider::DifficultyLevel, + utils::get_available_address, *, }; use stratum_apps::stratum_core::mining_sv2::*; +use std::collections::{HashMap, HashSet}; use stratum_apps::stratum_core::{ + binary_sv2::{Seq0255, Sv2Option}, common_messages_sv2::{ - SetupConnectionError, MESSAGE_TYPE_SETUP_CONNECTION, MESSAGE_TYPE_SETUP_CONNECTION_ERROR, - MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + SetupConnectionError, SetupConnectionSuccess, MESSAGE_TYPE_SETUP_CONNECTION, + MESSAGE_TYPE_SETUP_CONNECTION_ERROR, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, }, mining_sv2::{ - OpenMiningChannelError, MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + CloseChannel, OpenMiningChannelError, MESSAGE_TYPE_CLOSE_CHANNEL, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, }, - parsers_sv2::{self, AnyMessage}, + parsers_sv2::{self, AnyMessage, CommonMessages}, + template_distribution_sv2::MESSAGE_TYPE_SUBMIT_SOLUTION, }; // This test runs an sv2 translator between an sv1 mining device and a pool. the connection between @@ -293,3 +299,1123 @@ async fn test_translator_keepalive_job_sent_and_share_received_by_pool() { ) .await; } + +// This test launches a tProxy in aggregated mode and leverages a MockUpstream to test the correct +// functionalities of grouping extended channels. +#[tokio::test] +async fn aggregated_translator_correctly_deals_with_group_channels() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + + // block SubmitSolution messages from arriving to TP + // so we avoid shares triggering chain tip updates + // which we want to do explicitly via generate_blocks() + let ignore_submit_solution = + IgnoreMessage::new(MessageDirection::ToUpstream, MESSAGE_TYPE_SUBMIT_SOLUTION); + let (_sniffer_pool_tp, sniffer_pool_tp_addr) = start_sniffer( + "0", + tp_addr, + false, + vec![ignore_submit_solution.into()], + None, + ); + + let (_pool, pool_addr) = start_pool(sv2_tp_config(sniffer_pool_tp_addr), vec![], vec![]).await; + + // ignore SubmitSharesSuccess messages, so we can keep the assertion flow simple + let ignore_submit_shares_success = IgnoreMessage::new( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + ); + let (sniffer, sniffer_addr) = start_sniffer( + "0", + pool_addr, + false, + vec![ignore_submit_shares_success.into()], + None, + ); + + // aggregated tProxy + let (_, tproxy_addr) = start_sv2_translator(&[sniffer_addr], true, vec![], vec![], None).await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + let mut minerd_vec = Vec::new(); + + // start the first minerd process, to trigger the first OpenExtendedMiningChannel message + let (minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + minerd_vec.push(minerd_process); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + // save the aggregated and group channel IDs + let (aggregated_channel_id, group_channel_id) = match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => (msg.channel_id, msg.group_channel_id), + msg => panic!( + "Expected OpenExtendedMiningChannelSuccess message, found: {:?}", + msg + ), + }; + + // wait for the expected NewExtendedMiningJob and SetNewPrevHash messages + // and clean the queue + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + // open a few more extended channels to be aggregated with the first one + const N_MINERDS: u32 = 5; + for _i in 0..N_MINERDS { + let (minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + minerd_vec.push(minerd_process); + + // wait a bit + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // assert no furter OpenExtendedMiningChannel messages are sent + sniffer + .assert_message_not_present( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + } + + // wait for a SubmitSharesExtended message + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + + let share_channel_id = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => { + msg.channel_id + } + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + assert_eq!( + aggregated_channel_id, share_channel_id, + "Share submitted to the correct channel ID" + ); + assert_ne!( + share_channel_id, group_channel_id, + "Share NOT submitted to the group channel ID" + ); + + // wait for another share, so we can clean the queue + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + + // now let's force a mempool update, so we trigger a NewExtendedMiningJob message + // it's actually directed to the group channel Id, not the aggregated channel Id + // nevertheless, tProxy should still submit the share to the aggregated channel Id + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + let new_extended_mining_job = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + + // here we're actually asserting pool behavior, not tProxy + // but still good to have, to ensure the global sanity of the test + assert_ne!(new_extended_mining_job.channel_id, aggregated_channel_id); + assert_eq!(new_extended_mining_job.channel_id, group_channel_id); + + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + // assert the share is submitted to the aggregated channel Id + assert_eq!(submit_shares_extended.channel_id, aggregated_channel_id); + assert_ne!(submit_shares_extended.channel_id, group_channel_id); + + if submit_shares_extended.job_id == 2 { + break; + } + } + + // now let's force a chain tip update, so we trigger a SetNewPrevHash + NewExtendedMiningJob + // message pair + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + let new_extended_mining_job = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + + // again, asserting pool behavior, not tProxy + // just to ensure the global sanity of the test + assert_ne!(new_extended_mining_job.channel_id, aggregated_channel_id); + assert_eq!(new_extended_mining_job.channel_id, group_channel_id); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + let set_new_prev_hash = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + + // again, asserting pool behavior, not tProxy + // just to ensure the global sanity of the test + assert_eq!(set_new_prev_hash.channel_id, group_channel_id); + assert_ne!(set_new_prev_hash.channel_id, aggregated_channel_id); + + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + // assert the share is submitted to the aggregated channel Id + assert_eq!(submit_shares_extended.channel_id, aggregated_channel_id); + assert_ne!(submit_shares_extended.channel_id, group_channel_id); + + if submit_shares_extended.job_id == 3 { + break; + } + } +} + +// This test launches a tProxy in non-aggregated mode and leverages a MockUpstream to test the +// correct functionalities of grouping extended channels. +#[tokio::test] +async fn non_aggregated_translator_correctly_deals_with_group_channels() { + start_tracing(); + + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + + // block SubmitSolution messages from arriving to TP + // so we avoid shares triggering chain tip updates + // which we want to do explicitly via generate_blocks() + let ignore_submit_solution = + IgnoreMessage::new(MessageDirection::ToUpstream, MESSAGE_TYPE_SUBMIT_SOLUTION); + let (_sniffer_pool_tp, sniffer_pool_tp_addr) = start_sniffer( + "0", + tp_addr, + false, + vec![ignore_submit_solution.into()], + None, + ); + + let (_pool, pool_addr) = start_pool(sv2_tp_config(sniffer_pool_tp_addr), vec![], vec![]).await; + + // ignore SubmitSharesSuccess messages, so we can keep the assertion flow simple + let ignore_submit_shares_success = IgnoreMessage::new( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SUBMIT_SHARES_SUCCESS, + ); + let (sniffer, sniffer_addr) = start_sniffer( + "0", + pool_addr, + false, + vec![ignore_submit_shares_success.into()], + None, + ); + let (_, tproxy_addr) = start_sv2_translator(&[sniffer_addr], false, vec![], vec![], None).await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SETUP_CONNECTION, + ) + .await; + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + const N_EXTENDED_CHANNELS: u32 = 5; + const EXPECTED_GROUP_CHANNEL_ID: u32 = 1; + let mut minerd_vec = Vec::new(); + let mut channel_ids = Vec::new(); + + for _i in 0..N_EXTENDED_CHANNELS { + let (minerd, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + minerd_vec.push(minerd); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + let open_extended_mining_channel_success = match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => msg, + msg => panic!( + "Expected OpenExtendedMiningChannelSuccess message, found: {:?}", + msg + ), + }; + let channel_id = open_extended_mining_channel_success.channel_id; + channel_ids.push(channel_id); + + // we expect this initial NewExtendedMiningJob message to be directed to the newly created + // channel ID, not the group channel ID this is actually asserting pool behavior, + // not tProxy but still good to have, to ensure the global sanity of the test + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + let new_extended_mining_job = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + assert_eq!(new_extended_mining_job.channel_id, channel_id); + assert_ne!( + new_extended_mining_job.channel_id, + EXPECTED_GROUP_CHANNEL_ID + ); + + // we expect this initial SetNewPrevHash message to be directed to the newly created channel + // ID, not the group channel ID this is actually asserting pool behavior, not tProxy + // but still good to have, to ensure the global sanity of the test + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + let set_new_prev_hash = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + assert_eq!(set_new_prev_hash.channel_id, channel_id); + assert_ne!(set_new_prev_hash.channel_id, EXPECTED_GROUP_CHANNEL_ID); + } + + // all channels must submit at least one share with job_id = 1 + let mut channel_submitted_to: HashSet = channel_ids.clone().into_iter().collect(); + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + if submit_shares_extended.job_id != 1 { + continue; + } + + assert_ne!(submit_shares_extended.channel_id, EXPECTED_GROUP_CHANNEL_ID); + + channel_submitted_to.remove(&submit_shares_extended.channel_id); + if channel_submitted_to.is_empty() { + break; + } + } + + // now let's force a mempool update, so we trigger a NewExtendedMiningJob message + // that's actually directed to the group channel ID, and not each individual channel + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + let new_extended_mining_job = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + assert_eq!( + new_extended_mining_job.channel_id, + EXPECTED_GROUP_CHANNEL_ID + ); + + // all channels must submit at least one share with job_id = 2 + let mut channel_submitted_to: HashSet = channel_ids.clone().into_iter().collect(); + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + if submit_shares_extended.job_id != 2 { + continue; + } + + assert_ne!(submit_shares_extended.channel_id, EXPECTED_GROUP_CHANNEL_ID); + + channel_submitted_to.remove(&submit_shares_extended.channel_id); + if channel_submitted_to.is_empty() { + break; + } + } + + // now let's force a chain tip update, so we trigger a NewExtendedMiningJob + SetNewPrevHash + // message pair + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + let new_extended_mining_job = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => msg, + msg => panic!("Expected NewExtendedMiningJob message, found: {:?}", msg), + }; + assert_eq!( + new_extended_mining_job.channel_id, + EXPECTED_GROUP_CHANNEL_ID + ); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + let set_new_prev_hash = match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(msg)))) => msg, + msg => panic!("Expected SetNewPrevHash message, found: {:?}", msg), + }; + assert_eq!(set_new_prev_hash.channel_id, EXPECTED_GROUP_CHANNEL_ID); + + // all channels must submit at least one share with job_id = 3 + let mut channel_submitted_to: HashSet = channel_ids.clone().into_iter().collect(); + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + if submit_shares_extended.job_id != 3 { + continue; + } + + assert_ne!(submit_shares_extended.channel_id, EXPECTED_GROUP_CHANNEL_ID); + + channel_submitted_to.remove(&submit_shares_extended.channel_id); + if channel_submitted_to.is_empty() { + break; + } + } +} + +/// This test launches a tProxy in non-aggregated mode and leverages a MockUpstream to test the +/// correct behavior of handling SetGroupChannel messages. +/// +/// We first send a SetGroupChannel message to set a group channel ID A and B, and then we send a +/// NewExtendedMiningJob + SetNewPrevHash message pair to group channel ID A. +/// +/// We then assert that all channels in group channel ID A must submit at least one share with +/// job_id = 2, and channels in group channel ID B must NOT submit any shares with job_id = 2. +#[tokio::test] +async fn non_aggregated_translator_handles_set_group_channel_message() { + start_tracing(); + + let mock_upstream_addr = get_available_address(); + let mock_upstream = MockUpstream::new(mock_upstream_addr); + let send_to_tproxy = mock_upstream.start().await; + + let (sniffer, sniffer_addr) = start_sniffer("", mock_upstream_addr, false, vec![], None); + + let (_tproxy, tproxy_addr) = + start_sv2_translator(&[sniffer_addr], false, vec![], vec![], None).await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SETUP_CONNECTION, + ) + .await; + + let setup_connection_success = AnyMessage::Common(CommonMessages::SetupConnectionSuccess( + SetupConnectionSuccess { + used_version: 2, + flags: 0, + }, + )); + send_to_tproxy.send(setup_connection_success).await.unwrap(); + + const N_EXTENDED_CHANNELS: u32 = 6; + const GROUP_CHANNEL_ID_A: u32 = 100; + const GROUP_CHANNEL_ID_B: u32 = 200; + + // we need to keep references to each minerd + // otherwise they would be dropped + let mut minerd_vec = Vec::new(); + + // spawn minerd processes to force opening N_EXTENDED_CHANNELS extended channels + for i in 0..N_EXTENDED_CHANNELS { + let (minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + minerd_vec.push(minerd_process); + + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + let open_extended_mining_channel: OpenExtendedMiningChannel = loop { + match sniffer.next_message_from_downstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannel(msg)), + )) => { + break msg; + } + _ => continue, + }; + }; + + let open_extended_mining_channel_success = + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess( + OpenExtendedMiningChannelSuccess { + request_id: open_extended_mining_channel.request_id, + channel_id: i, + target: hex::decode( + "0000137c578190689425e3ecf8449a1af39db0aed305d9206f45ac32fe8330fc", + ) + .unwrap() + .try_into() + .unwrap(), + // full extranonce has a total of 8 bytes + extranonce_size: 4, + extranonce_prefix: vec![0x00, 0x01, 0x00, i as u8].try_into().unwrap(), + group_channel_id: GROUP_CHANNEL_ID_A, + }, + )); + send_to_tproxy + .send(open_extended_mining_channel_success) + .await + .unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let new_extended_mining_job = AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(NewExtendedMiningJob { + channel_id: i, + job_id: 1, + min_ntime: Sv2Option::new(None), + version: 0x20000000, + version_rolling_allowed: true, + merkle_path: Seq0255::new(vec![]).unwrap(), + // scriptSig for a total of 8 bytes of extranonce + coinbase_tx_prefix: hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff225200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap().try_into().unwrap(), + coinbase_tx_suffix: hex::decode("feffffff0200f2052a01000000160014ebe1b7dcc293ccaa0ee743a86f89df8258c208fc0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf901000000").unwrap().try_into().unwrap(), + })); + + send_to_tproxy.send(new_extended_mining_job).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + let set_new_prev_hash = + AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(SetNewPrevHash { + channel_id: i, + job_id: 1, + prev_hash: hex::decode( + "3ab7089cd2cd30f133552cfde82c4cb239cd3c2310306f9d825e088a1772cc39", + ) + .unwrap() + .try_into() + .unwrap(), + min_ntime: 1766782170, + nbits: 0x207fffff, + })); + + send_to_tproxy.send(set_new_prev_hash).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // half of the channels belong to GROUP_CHANNEL_ID_A + let group_channel_a_ids = (0..N_EXTENDED_CHANNELS) + .filter(|i| i % 2 != 0) + .collect::>(); + + // half of the channels belong to GROUP_CHANNEL_ID_B + let group_channel_b_ids = (0..N_EXTENDED_CHANNELS) + .filter(|i| i % 2 == 0) + .collect::>(); + + // send a SetGroupChannel message to set GROUP_CHANNEL_ID_B + let set_group_channel = + AnyMessage::Mining(parsers_sv2::Mining::SetGroupChannel(SetGroupChannel { + channel_ids: group_channel_b_ids.clone().into(), + group_channel_id: GROUP_CHANNEL_ID_B, + })); + send_to_tproxy.send(set_group_channel).await.unwrap(); + + // send a NewExtendedMiningJob + SetNewPrevHash message pair ONLY to GROUP_CHANNEL_ID_B + let new_extended_mining_job = AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(NewExtendedMiningJob { + channel_id: GROUP_CHANNEL_ID_B, + job_id: 2, + min_ntime: Sv2Option::new(None), + version: 0x20000000, + version_rolling_allowed: true, + merkle_path: Seq0255::new(vec![]).unwrap(), + // scriptSig for a total of 8 bytes of extranonce + coinbase_tx_prefix: hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff225300162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap().try_into().unwrap(), + coinbase_tx_suffix: hex::decode("feffffff0200f2052a01000000160014ebe1b7dcc293ccaa0ee743a86f89df8258c208fc0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf901000000").unwrap().try_into().unwrap(), + })); + + send_to_tproxy.send(new_extended_mining_job).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + let set_new_prev_hash = + AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(SetNewPrevHash { + channel_id: GROUP_CHANNEL_ID_B, + job_id: 2, + prev_hash: hex::decode( + "2089973501ad229333ae0e9c52fa160f95616890db364a71ccfb77773a8b54cb", + ) + .unwrap() + .try_into() + .unwrap(), + min_ntime: 1766782171, + nbits: 0x207fffff, + })); + send_to_tproxy.send(set_new_prev_hash).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + // all channels in GROUP_CHANNEL_ID_B must submit at least one share with job_id = 2 + // channels in GROUP_CHANNEL_ID_A must NOT submit any shares with job_id = 2 + let mut channels_submitted_to: HashSet = group_channel_b_ids.clone().into_iter().collect(); + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + if submit_shares_extended.job_id != 2 { + continue; + } + + if group_channel_a_ids.contains(&submit_shares_extended.channel_id) { + panic!( + "Channel {} should not have submitted a share with job_id = 2", + submit_shares_extended.channel_id + ); + } + + channels_submitted_to.remove(&submit_shares_extended.channel_id); + if channels_submitted_to.is_empty() { + break; + } + } +} + +/// This test launches a tProxy in non-aggregated mode and leverages a MockUpstream to test the +/// correct behavior of handling CloseChannel messages. +/// +/// First we close a single channel, and assert that no shares are submitted from it. +/// Then we close the group channel, and assert that no shares are submitted from any channel. +#[tokio::test] +async fn non_aggregated_translator_correctly_deals_with_close_channel_message() { + start_tracing(); + + let mock_upstream_addr = get_available_address(); + let mock_upstream = MockUpstream::new(mock_upstream_addr); + let send_to_tproxy = mock_upstream.start().await; + + let (sniffer, sniffer_addr) = start_sniffer("", mock_upstream_addr, false, vec![], None); + + let (_tproxy, tproxy_addr) = + start_sv2_translator(&[sniffer_addr], false, vec![], vec![], None).await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SETUP_CONNECTION, + ) + .await; + + let setup_connection_success = AnyMessage::Common(CommonMessages::SetupConnectionSuccess( + SetupConnectionSuccess { + used_version: 2, + flags: 0, + }, + )); + send_to_tproxy.send(setup_connection_success).await.unwrap(); + + const N_EXTENDED_CHANNELS: u32 = 3; + const GROUP_CHANNEL_ID: u32 = 100; + + // we need to keep references to each minerd + // otherwise they would be dropped + let mut minerd_vec = Vec::new(); + + // spawn minerd processes to force opening N_EXTENDED_CHANNELS extended channels + for i in 0..N_EXTENDED_CHANNELS { + let (minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + minerd_vec.push(minerd_process); + + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + let open_extended_mining_channel: OpenExtendedMiningChannel = loop { + match sniffer.next_message_from_downstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannel(msg)), + )) => { + break msg; + } + _ => continue, + }; + }; + + let open_extended_mining_channel_success = + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess( + OpenExtendedMiningChannelSuccess { + request_id: open_extended_mining_channel.request_id, + channel_id: i, + target: hex::decode( + "0000137c578190689425e3ecf8449a1af39db0aed305d9206f45ac32fe8330fc", + ) + .unwrap() + .try_into() + .unwrap(), + // full extranonce has a total of 8 bytes + extranonce_size: 4, + extranonce_prefix: vec![0x00, 0x01, 0x00, i as u8].try_into().unwrap(), + group_channel_id: GROUP_CHANNEL_ID, + }, + )); + send_to_tproxy + .send(open_extended_mining_channel_success) + .await + .unwrap(); + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let new_extended_mining_job = AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(NewExtendedMiningJob { + channel_id: i, + job_id: 1, + min_ntime: Sv2Option::new(None), + version: 0x20000000, + version_rolling_allowed: true, + merkle_path: Seq0255::new(vec![]).unwrap(), + // scriptSig for a total of 8 bytes of extranonce + coinbase_tx_prefix: hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff225200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap().try_into().unwrap(), + coinbase_tx_suffix: hex::decode("feffffff0200f2052a01000000160014ebe1b7dcc293ccaa0ee743a86f89df8258c208fc0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf901000000").unwrap().try_into().unwrap(), + })); + + send_to_tproxy.send(new_extended_mining_job).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + let set_new_prev_hash = + AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(SetNewPrevHash { + channel_id: i, + job_id: 1, + prev_hash: hex::decode( + "3ab7089cd2cd30f133552cfde82c4cb239cd3c2310306f9d825e088a1772cc39", + ) + .unwrap() + .try_into() + .unwrap(), + min_ntime: 1766782170, + nbits: 0x207fffff, + })); + + send_to_tproxy.send(set_new_prev_hash).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + } + + // let's wait until all channels send at least one share + let mut channels_submitted_to: HashSet = (0..N_EXTENDED_CHANNELS).into_iter().collect(); + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + channels_submitted_to.remove(&submit_shares_extended.channel_id); + if channels_submitted_to.is_empty() { + break; + } + } + + // let's close one of the channels + const CLOSED_CHANNEL_ID: u32 = 0; + let close_channel = AnyMessage::Mining(parsers_sv2::Mining::CloseChannel(CloseChannel { + channel_id: CLOSED_CHANNEL_ID, + reason_code: "".to_string().try_into().unwrap(), + })); + send_to_tproxy.send(close_channel).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_CLOSE_CHANNEL, + ) + .await; + + // Drain all pending messages from the sniffer queue + while sniffer.next_message_from_downstream().is_some() { + // Keep draining until queue is empty + } + + // Small delay to let any in-flight messages arrive + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // let's wait until all open channels send at least 5 shares + // if the closed channel sends a share, the test fails + let mut share_submission_count = HashMap::new(); + loop { + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + let submit_shares_extended = match sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::SubmitSharesExtended(msg)))) => msg, + msg => panic!("Expected SubmitSharesExtended message, found: {:?}", msg), + }; + + if submit_shares_extended.channel_id == CLOSED_CHANNEL_ID { + panic!("Closed channel should not have submitted a share"); + } + + // update the share submission count for the channel + if let Some(count) = share_submission_count.get_mut(&submit_shares_extended.channel_id) { + *count += 1; + } else { + share_submission_count.insert(submit_shares_extended.channel_id, 1); + } + + // have all open channels submitted shares? + if share_submission_count.len() == (N_EXTENDED_CHANNELS - 1) as usize { + // check if all open channels submitted at least 5 shares + let all_open_channels_have_enough_shares = + share_submission_count.values().all(|count| *count >= 5); + + if all_open_channels_have_enough_shares { + // all open channels submitted at least 5 shares + break; + } + } + } + + // now let's send a CloseChannel for the group channel + let close_channel = AnyMessage::Mining(parsers_sv2::Mining::CloseChannel(CloseChannel { + channel_id: GROUP_CHANNEL_ID, + reason_code: "".to_string().try_into().unwrap(), + })); + send_to_tproxy.send(close_channel).await.unwrap(); + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_CLOSE_CHANNEL, + ) + .await; + + // wait enough time for any channels to submit some share (which they shouldn't) + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // no shares should arrive after the group channel is closed + sniffer + .assert_message_not_present( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; +} + +/// This test launches a tProxy in aggregated mode and leverages two MockUpstreams to test the +/// correct behavior of handling CloseChannel messages. +/// +/// We first send a CloseChannel message to a single channel, and assert that no shares are +/// submitted from it. Then we send a CloseChannel message to the group channel, and assert that no +/// shares are submitted from any channel. +#[tokio::test] +async fn aggregated_translator_triggers_fallback_on_close_channel_message() { + start_tracing(); + + // first upstream server mock + let mock_upstream_addr_a = get_available_address(); + let mock_upstream_a = MockUpstream::new(mock_upstream_addr_a); + let send_to_tproxy_a = mock_upstream_a.start().await; + let (sniffer_a, sniffer_addr_a) = start_sniffer("", mock_upstream_addr_a, false, vec![], None); + + // fallback upstream server mock + let mock_upstream_addr_b = get_available_address(); + let mock_upstream_b = MockUpstream::new(mock_upstream_addr_b); + let _send_to_tproxy_b = mock_upstream_b.start().await; + let (sniffer_b, sniffer_addr_b) = start_sniffer("", mock_upstream_addr_b, false, vec![], None); + + let (_tproxy, tproxy_addr) = start_sv2_translator( + &[sniffer_addr_a, sniffer_addr_b], + true, + vec![], + vec![], + None, + ) + .await; + + sniffer_a + .wait_for_message_type_and_clean_queue( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SETUP_CONNECTION, + ) + .await; + + let setup_connection_success = AnyMessage::Common(CommonMessages::SetupConnectionSuccess( + SetupConnectionSuccess { + used_version: 2, + flags: 0, + }, + )); + send_to_tproxy_a + .send(setup_connection_success) + .await + .unwrap(); + + // we need to keep references to each minerd + // otherwise they would be dropped + let mut minerd_vec = Vec::new(); + + let (minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + minerd_vec.push(minerd_process); + + sniffer_a + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + let open_extended_mining_channel: OpenExtendedMiningChannel = loop { + match sniffer_a.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannel(msg)))) => { + break msg; + } + _ => continue, + }; + }; + + let open_extended_mining_channel_success = AnyMessage::Mining( + parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(OpenExtendedMiningChannelSuccess { + request_id: open_extended_mining_channel.request_id, + channel_id: 0, + target: hex::decode("0000137c578190689425e3ecf8449a1af39db0aed305d9206f45ac32fe8330fc") + .unwrap() + .try_into() + .unwrap(), + // full extranonce has a total of 12 bytes + extranonce_size: 8, + extranonce_prefix: vec![0x00, 0x01, 0x00, 0x00].try_into().unwrap(), + group_channel_id: 100, + }), + ); + send_to_tproxy_a + .send(open_extended_mining_channel_success) + .await + .unwrap(); + + sniffer_a + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let new_extended_mining_job = AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(NewExtendedMiningJob { + channel_id: 0, + job_id: 1, + min_ntime: Sv2Option::new(None), + version: 0x20000000, + version_rolling_allowed: true, + merkle_path: Seq0255::new(vec![]).unwrap(), + // scriptSig for a total of 8 bytes of extranonce + coinbase_tx_prefix: hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff265200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap().try_into().unwrap(), + coinbase_tx_suffix: hex::decode("feffffff0200f2052a01000000160014ebe1b7dcc293ccaa0ee743a86f89df8258c208fc0000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf901000000").unwrap().try_into().unwrap(), + })); + + send_to_tproxy_a + .send(new_extended_mining_job) + .await + .unwrap(); + sniffer_a + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + let set_new_prev_hash = + AnyMessage::Mining(parsers_sv2::Mining::SetNewPrevHash(SetNewPrevHash { + channel_id: 0, + job_id: 1, + prev_hash: hex::decode( + "3ab7089cd2cd30f133552cfde82c4cb239cd3c2310306f9d825e088a1772cc39", + ) + .unwrap() + .try_into() + .unwrap(), + min_ntime: 1766782170, + nbits: 0x207fffff, + })); + + send_to_tproxy_a.send(set_new_prev_hash).await.unwrap(); + sniffer_a + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + // up until now, we have done the usual channel initialization process + // now, lets send a CloseChannel message for the channel + let close_channel = AnyMessage::Mining(parsers_sv2::Mining::CloseChannel(CloseChannel { + channel_id: 0, + reason_code: "".to_string().try_into().unwrap(), + })); + send_to_tproxy_a.send(close_channel).await.unwrap(); + + // this should trigger fallback + sniffer_b + .wait_for_message_type(MessageDirection::ToUpstream, MESSAGE_TYPE_SETUP_CONNECTION) + .await; +} diff --git a/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs b/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs index 170d6d00..979fde57 100644 --- a/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs +++ b/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs @@ -10,7 +10,6 @@ use stratum_apps::{ server::{ error::{ExtendedChannelError, StandardChannelError}, extended::ExtendedChannel, - group::GroupChannel, jobs::job_store::DefaultJobStore, share_accounting::{ShareValidationError, ShareValidationResult}, standard::StandardChannel, @@ -292,60 +291,13 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { downstream.downstream_data.super_safe_lock(|data| { let mut messages: Vec = vec![]; - if !data.require_std_job && data.group_channels.is_none() { - let group_channel_id = - data.channel_id_factory.fetch_add(1, Ordering::Relaxed); - let job_store = DefaultJobStore::new(); - let full_extranonce_size = channel_manager_data - .upstream_channel - .as_ref() - .map(|channel| channel.get_full_extranonce_size()) - // This default only hits in solo-mining scenario - .unwrap_or(FULL_EXTRANONCE_SIZE); - - let mut group_channel = - match GroupChannel::new_for_job_declaration_client( - group_channel_id, - job_store, - full_extranonce_size, - channel_manager_data.pool_tag_string.clone(), - self.miner_tag_string.clone(), - ) { - Ok(channel) => channel, - Err(e) => { - error!(?e, "Failed to create group channel"); - return Err(JDCError::shutdown(e)); - } - }; - - if let Err(e) = group_channel.on_new_template( - last_future_template.clone(), - coinbase_outputs.clone(), - ) { - error!(?e, "Failed to apply template to group channel"); - return Err(JDCError::shutdown(e)); - } - - if let Err(e) = - group_channel.on_set_new_prev_hash(last_new_prev_hash.clone()) - { - error!(?e, "Failed to apply prevhash to group channel"); - return Err(JDCError::shutdown(e)); - }; - - data.group_channels = Some(group_channel); - } - let nominal_hash_rate = msg.nominal_hash_rate; let requested_max_target = Target::from_le_bytes( msg.max_target.inner_as_ref().try_into().unwrap(), ); - let group_channel_id = data - .group_channels - .as_ref() - .map(|gc| gc.get_group_channel_id()) - .unwrap_or(0); + let group_channel_id = data.group_channel.get_group_channel_id(); + let standard_channel_id = data.channel_id_factory.fetch_add(1, Ordering::Relaxed); @@ -395,6 +347,8 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } }; + let extranonce_prefix_size = standard_channel.get_extranonce_prefix().len(); + let open_standard_mining_channel_success = OpenStandardMiningChannelSuccess { request_id: msg.request_id.clone(), @@ -485,8 +439,13 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { (downstream_id, standard_channel_id, future_standard_job_id).into(), last_future_template.template_id, ); - if let Some(group_channel) = data.group_channels.as_mut() { - group_channel.add_standard_channel_id(standard_channel_id); + if !data.require_std_job { + data.group_channel + .add_channel_id(standard_channel_id, extranonce_prefix_size) + .map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + JDCError::shutdown(e) + })?; } Ok(messages) @@ -540,76 +499,105 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }) }; - let messages = - self.channel_manager_data - .super_safe_lock(|channel_manager_data| { - - let Some(last_future_template) = channel_manager_data.last_future_template.clone() else { - error!("No template to share"); - return Err(JDCError::disconnect(JDCErrorKind::FutureTemplateNotPresent, downstream_id)); - }; - - let Some(last_new_prev_hash) = channel_manager_data.last_new_prev_hash.clone() else { - error!("No prevhash in system"); - return Err(JDCError::disconnect(JDCErrorKind::LastNewPrevhashNotFound, downstream_id)); - }; + let messages = self + .channel_manager_data + .super_safe_lock(|channel_manager_data| { + let Some(last_future_template) = channel_manager_data.last_future_template.clone() + else { + error!("No template to share"); + return Err(JDCError::disconnect( + JDCErrorKind::FutureTemplateNotPresent, + downstream_id, + )); + }; - let Some(downstream) = channel_manager_data.downstream.get_mut(&downstream_id) else { - error!(downstream_id, "Downstream not found"); - return Err(JDCError::disconnect(JDCErrorKind::DownstreamNotFound(downstream_id), downstream_id)); - }; + let Some(last_new_prev_hash) = channel_manager_data.last_new_prev_hash.clone() + else { + error!("No prevhash in system"); + return Err(JDCError::disconnect( + JDCErrorKind::LastNewPrevhashNotFound, + downstream_id, + )); + }; - downstream.downstream_data.super_safe_lock(|data| { + let Some(downstream) = channel_manager_data.downstream.get_mut(&downstream_id) + else { + error!(downstream_id, "Downstream not found"); + return Err(JDCError::disconnect( + JDCErrorKind::DownstreamNotFound(downstream_id), + downstream_id, + )); + }; + downstream.downstream_data.super_safe_lock(|data| { let mut messages: Vec = vec![]; - let extended_channel_id = data.channel_id_factory.fetch_add(1, Ordering::Relaxed); + let extended_channel_id = + data.channel_id_factory.fetch_add(1, Ordering::Relaxed); - let extranonce_prefix = match channel_manager_data.extranonce_prefix_factory_extended + let extranonce_prefix = match channel_manager_data + .extranonce_prefix_factory_extended .next_prefix_extended(requested_min_rollable_extranonce_size.into()) { Ok(p) => p, Err(e) => { error!(?e, "Extranonce prefix error"); - return Err(JDCError::shutdown(e)); + return Ok(vec![( + downstream_id, + build_error("min-extranonce-size-too-large"), + ) + .into()]); } }; - let job_store = DefaultJobStore::new(); - let mut extended_channel = match ExtendedChannel::new_for_job_declaration_client( - extended_channel_id, - user_identity.to_string(), - extranonce_prefix.into(), - requested_max_target, - nominal_hash_rate, - true, - requested_min_rollable_extranonce_size, - self.share_batch_size, - self.shares_per_minute, - job_store, - channel_manager_data.pool_tag_string.clone(), - self.miner_tag_string.clone(), - ) { - Ok(c) => c, - Err(e) => { - error!(?e, "Failed to create ExtendedChannel"); - return match e { - ExtendedChannelError::InvalidNominalHashrate => { - Ok(vec![(downstream_id, build_error("invalid-nominal-hashrate")).into()]) - } - ExtendedChannelError::RequestedMaxTargetOutOfRange => { - Ok(vec![(downstream_id, build_error("max-target-out-of-range")).into()]) - } - ExtendedChannelError::RequestedMinExtranonceSizeTooLarge => { - Ok(vec![(downstream_id, build_error("min-extranonce-size-too-large")).into()]) - } - other => Err( - JDCError::disconnect(other, downstream_id) - ), + let full_extranonce_size = channel_manager_data + .upstream_channel + .as_ref() + .map(|channel| channel.get_full_extranonce_size()) + .unwrap_or(FULL_EXTRANONCE_SIZE); // Default to FULL_EXTRANONCE_SIZE if + // upstream channel is not present (solo mining mode) + + let rollable_extranonce_size = + full_extranonce_size - extranonce_prefix.clone().to_vec().len(); + + let mut extended_channel = + match ExtendedChannel::new_for_job_declaration_client( + extended_channel_id, + user_identity.to_string(), + extranonce_prefix.into(), + requested_max_target, + nominal_hash_rate, + true, + rollable_extranonce_size as u16, + self.share_batch_size, + self.shares_per_minute, + job_store, + channel_manager_data.pool_tag_string.clone(), + self.miner_tag_string.clone(), + ) { + Ok(c) => c, + Err(e) => { + error!(?e, "Failed to create ExtendedChannel"); + return match e { + ExtendedChannelError::InvalidNominalHashrate => Ok(vec![( + downstream_id, + build_error("invalid-nominal-hashrate"), + ) + .into()]), + ExtendedChannelError::RequestedMaxTargetOutOfRange => { + Ok(vec![( + downstream_id, + build_error("max-target-out-of-range"), + ) + .into()]) + } + other => Err(JDCError::disconnect(other, downstream_id)), + }; } - } - }; + }; + + let group_channel_id = data.group_channel.get_group_channel_id(); let open_extended_mining_channel_success = OpenExtendedMiningChannelSuccess { @@ -622,27 +610,38 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { .try_into() .expect("valid extranonce prefix"), extranonce_size: extended_channel.get_rollable_extranonce_size(), + group_channel_id, } .into_static(); - messages.push(( - downstream_id, - Mining::OpenExtendedMiningChannelSuccess( - open_extended_mining_channel_success, - ), - ).into()); + let full_extranonce_size = extended_channel.get_full_extranonce_size(); - let mut coinbase_outputs = match deserialize_outputs(channel_manager_data.coinbase_outputs.clone()) { + messages.push( + ( + downstream_id, + Mining::OpenExtendedMiningChannelSuccess( + open_extended_mining_channel_success, + ), + ) + .into(), + ); + + let mut coinbase_outputs = match deserialize_outputs( + channel_manager_data.coinbase_outputs.clone(), + ) { Ok(outputs) => outputs, - Err(_) => return Err(JDCError::shutdown(JDCErrorKind::ChannelManagerHasBadCoinbaseOutputs)), + Err(_) => { + return Err(JDCError::shutdown( + JDCErrorKind::ChannelManagerHasBadCoinbaseOutputs, + )) + } }; coinbase_outputs[0].value = Amount::from_sat(last_future_template.coinbase_tx_value_remaining); - // create a future extended job based on the last future template - if let Err(e) = - extended_channel.on_new_template(last_future_template.clone(), coinbase_outputs) + if let Err(e) = extended_channel + .on_new_template(last_future_template.clone(), coinbase_outputs) { error!(?e, "Failed to apply template to extended channel"); return Err(JDCError::shutdown(e)); @@ -660,12 +659,13 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { // send this future job as new job message // to be immediately activated with the subsequent SetNewPrevHash message - messages.push(( - downstream_id, - Mining::NewExtendedMiningJob( - future_extended_job_message, - ), - ).into()); + messages.push( + ( + downstream_id, + Mining::NewExtendedMiningJob(future_extended_job_message), + ) + .into(), + ); // SetNewPrevHash message activates the future job let prev_hash = last_new_prev_hash.prev_hash.clone(); @@ -682,20 +682,38 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { error!(?e, "Failed to set prevhash on extended channel"); return Err(JDCError::shutdown(e)); } - messages.push(( - downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_mining), - ).into()); + messages.push( + ( + downstream_id, + Mining::SetNewPrevHash(set_new_prev_hash_mining), + ) + .into(), + ); let vardiff = VardiffState::new().expect("Vardiff should instantiate."); - data.extended_channels.insert(extended_channel_id, extended_channel); + data.extended_channels + .insert(extended_channel_id, extended_channel); - channel_manager_data.downstream_channel_id_and_job_id_to_template_id.insert((downstream_id, extended_channel_id, future_extended_job_id).into(), last_future_template.template_id); - channel_manager_data.vardiff.insert((downstream_id, extended_channel_id).into(), vardiff); + channel_manager_data + .downstream_channel_id_and_job_id_to_template_id + .insert( + (downstream_id, extended_channel_id, future_extended_job_id).into(), + last_future_template.template_id, + ); + channel_manager_data + .vardiff + .insert((downstream_id, extended_channel_id).into(), vardiff); + + data.group_channel + .add_channel_id(extended_channel_id, full_extranonce_size) + .map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + JDCError::shutdown(e) + })?; Ok(messages) }) - })?; + })?; for messages in messages { let _ = messages.forward(&self.channel_manager_channel).await; @@ -1214,6 +1232,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { ShareValidationError::InvalidJobId => "invalid-job-id", ShareValidationError::DoesNotMeetTarget => "difficulty-too-low", ShareValidationError::DuplicateShare => "duplicate-share", + ShareValidationError::BadExtranonceSize => "bad-extranonce-size", _ => unreachable!(), }; error!("❌ SubmitSharesError on downstream channel: ch={}, seq={}, error={code}", channel_id, msg.sequence_number); @@ -1287,6 +1306,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { client::share_accounting::ShareValidationError::InvalidJobId=>"invalid-job-id", client::share_accounting::ShareValidationError::DoesNotMeetTarget=>"difficulty-too-low", client::share_accounting::ShareValidationError::DuplicateShare=>"duplicate-share", + client::share_accounting::ShareValidationError::BadExtranonceSize=>"bad-extranonce-size", _ => unreachable!(), }; debug!("❌ SubmitSharesError not forwarding it to upstream: ch={}, seq={}, error={code}", channel_id, upstream_message.sequence_number); diff --git a/miner-apps/jd-client/src/lib/channel_manager/mod.rs b/miner-apps/jd-client/src/lib/channel_manager/mod.rs index 8781a40e..5f8a9843 100644 --- a/miner-apps/jd-client/src/lib/channel_manager/mod.rs +++ b/miner-apps/jd-client/src/lib/channel_manager/mod.rs @@ -14,10 +14,12 @@ use stratum_apps::{ key_utils::{Secp256k1PublicKey, Secp256k1SecretKey}, network_helpers::noise_stream::NoiseTcpStream, stratum_core::{ - bitcoin::{Target, TxOut}, + bitcoin::{Amount, Target, TxOut}, channels_sv2::{ client::extended::ExtendedChannel, + outputs::deserialize_outputs, server::{ + group::GroupChannel, jobs::{ extended::ExtendedJob, factory::JobFactory, job_store::DefaultJobStore, standard::StandardJob, @@ -339,6 +341,76 @@ impl ChannelManager { Ok(channel_manager) } + // Bootstraps a group channel with the given parameters. + // Returns a `GroupChannel` if successful, otherwise returns `None`. + // + // To be called before calling Downstream::new. + fn bootstrap_group_channel( + &self, + channel_id: ChannelId, + ) -> Option>>> { + let (full_extranonce_size, pool_tag_string, last_future_template, last_new_prev_hash) = + self.channel_manager_data.super_safe_lock(|data| { + ( + data.upstream_channel + .as_ref() + .map(|channel| channel.get_full_extranonce_size()) + .unwrap_or(FULL_EXTRANONCE_SIZE), /* Default to FULL_EXTRANONCE_SIZE if + * upstream channel is not present + * (solo mining mode) */ + data.pool_tag_string.clone(), + data.last_future_template + .clone() + .expect("No future template found after readiness check"), + data.last_new_prev_hash + .clone() + .expect("No new prevhash found after readiness check"), + ) + }); + let miner_tag_string = self.miner_tag_string.clone(); + let mut group_channel = match GroupChannel::new_for_job_declaration_client( + channel_id, + DefaultJobStore::new(), + full_extranonce_size, + pool_tag_string.clone(), + miner_tag_string.clone(), + ) { + Ok(channel) => channel, + Err(e) => { + error!(error = ?e, "Failed to create group channel"); + return None; + } + }; + + let coinbase_outputs = self + .channel_manager_data + .super_safe_lock(|data| data.coinbase_outputs.clone()); + let mut coinbase_outputs = match deserialize_outputs(coinbase_outputs) { + Ok(outputs) => outputs, + Err(e) => { + error!(error = ?e, "Failed to deserialize coinbase outputs"); + return None; + } + }; + + coinbase_outputs[0].value = + Amount::from_sat(last_future_template.coinbase_tx_value_remaining); + + if let Err(e) = + group_channel.on_new_template(last_future_template, coinbase_outputs.clone()) + { + error!(error = ?e, "Failed to add template to group channel"); + return None; + } + + if let Err(e) = group_channel.on_set_new_prev_hash(last_new_prev_hash) { + error!(error = ?e, "Failed to set new prevhash for group channel"); + return None; + } + + Some(group_channel) + } + /// Starts the downstream server, and accepts new connection request. #[allow(clippy::too_many_arguments)] pub async fn start_downstream_server( @@ -359,14 +431,44 @@ impl ChannelManager { supported_extensions: Vec, required_extensions: Vec, ) -> JDCResult<(), error::ChannelManager> { + let mut shutdown_rx = notify_shutdown.subscribe(); + + // Wait for initial template and prevhash before accepting connections + loop { + let has_required_data = self.channel_manager_data.super_safe_lock(|data| { + data.last_future_template.is_some() && data.last_new_prev_hash.is_some() + }); + + if has_required_data { + info!("Required template data received, ready to accept connections"); + break; + } + + warn!("Waiting for initial template and prevhash from Template Provider..."); + select! { + message = shutdown_rx.recv() => { + match message { + Ok(ShutdownMessage::ShutdownAll) => { + info!("Channel Manager: received shutdown while waiting for templates"); + return Ok(()); + } + Err(e) => { + warn!(error = ?e, "shutdown channel closed unexpectedly"); + return Ok(()); + } + _ => {} + } + } + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {} + } + } + info!("Starting downstream server at {listening_address}"); let server = TcpListener::bind(listening_address).await.map_err(|e| { error!(error = ?e, "Failed to bind downstream server at {listening_address}"); JDCError::shutdown(e) })?; - let mut shutdown_rx = notify_shutdown.subscribe(); - let task_manager_clone = task_manager.clone(); task_manager.spawn(async move { @@ -425,8 +527,25 @@ impl ChannelManager { .channel_manager_data .super_safe_lock(|data| data.downstream_id_factory.fetch_add(1, Ordering::Relaxed)); + let channel_id_factory = AtomicU32::new(1); + let group_channel_id = channel_id_factory.fetch_add(1, Ordering::SeqCst); + + let group_channel = match self.bootstrap_group_channel( + group_channel_id, + ) { + Some(group_channel) => group_channel, + None => { + error!("Failed to bootstrap group channel - disconnecting downstream client with id {downstream_id}"); + let e = JDCError::::disconnect(JDCErrorKind::CouldNotInitiateSystem, downstream_id); + handle_error(&StatusSender::ChannelManager(status_sender.clone()), e).await; + break; + } + }; + let downstream = Downstream::new( downstream_id, + channel_id_factory, + group_channel, channel_manager_sender.clone(), channel_manager_receiver.clone(), noise_stream, diff --git a/miner-apps/jd-client/src/lib/channel_manager/template_message_handler.rs b/miner-apps/jd-client/src/lib/channel_manager/template_message_handler.rs index f2edb74a..6dbcca69 100644 --- a/miner-apps/jd-client/src/lib/channel_manager/template_message_handler.rs +++ b/miner-apps/jd-client/src/lib/channel_manager/template_message_handler.rs @@ -82,35 +82,24 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { for (downstream_id, downstream) in channel_manager_data.downstream.iter_mut() { - let messages_ = downstream.downstream_data.super_safe_lock(|data| { + let messages_ = downstream.downstream_data.super_safe_lock(|data| { + data.group_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()).map_err(|e| { + tracing::error!("Error while adding template to group channel: {e:?}"); + JDCError::shutdown(e) + })?; - let mut messages: Vec = vec![]; - - let group_channel_job = if let Some(ref mut group_channel) = data.group_channels { - if group_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()).is_ok() { - match msg.future_template { - true => { - let future_job_id = group_channel - .get_future_job_id_from_template_id(msg.template_id) - .expect("job_id must exist"); - Some(group_channel - .get_future_job(future_job_id) - .expect("future job must exist")) - }, - false => { - Some(group_channel - .get_active_job() - .expect("active job must exist")) - } - } - } else { - tracing::error!("Some issue with downstream: {downstream_id}, group channel"); - None + let group_channel_job = match msg.future_template { + true => { + let future_job_id = data.group_channel.get_future_job_id_from_template_id(msg.template_id).expect("future job id must exist"); + data.group_channel.get_future_job(future_job_id).expect("future job must exist") + } + false => { + data.group_channel.get_active_job().expect("active job must exist") } - } else { - None }; + let mut messages: Vec = vec![]; + if let Some(upstream_channel) = channel_manager_data.upstream_channel.as_mut() { if !msg.future_template && get_jd_mode() == JdMode::CoinbaseOnly { if let (Some(token), Some(prevhash)) = ( @@ -141,103 +130,58 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { } } } - match msg.future_template { - true => { - for (channel_id, standard_channel) in data.standard_channels.iter_mut() { - if data.group_channels.is_none() { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let standard_job_id = standard_channel.get_future_job_id_from_template_id(msg.template_id).expect("job_id must exist"); - let standard_job = standard_channel.get_future_job(standard_job_id).expect("standard job must exist"); - channel_manager_data.downstream_channel_id_and_job_id_to_template_id.insert((*downstream_id, *channel_id, standard_job_id).into(), msg.template_id); - let standard_job_message = standard_job.get_job_message(); - messages.push((*downstream_id, Mining::NewMiningJob(standard_job_message.clone())).into()); - } - if let Some(ref group_channel_job) = group_channel_job { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - _ = standard_channel - .on_group_channel_job(group_channel_job.clone()); - } - } - if let Some(group_channel_job) = group_channel_job { - let job_message = group_channel_job.get_job_message(); - messages.push((*downstream_id, Mining::NewExtendedMiningJob(job_message.clone())).into()); - } - for (channel_id, extended_channel) in data.extended_channels.iter_mut() { - if let Err(e) = extended_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let extended_job_id = extended_channel - .get_future_job_id_from_template_id(msg.template_id) - .expect("job_id must exist"); - - let extended_job = extended_channel - .get_future_job(extended_job_id) - .expect("extended job must exist"); - - channel_manager_data.downstream_channel_id_and_job_id_to_template_id.insert((*downstream_id, *channel_id, extended_job_id).into(), msg.template_id); - let extended_job_message = extended_job.get_job_message(); + // if REQUIRES_STANDARD_JOBS is not set and the group channel is not empty, + // we need to send the NewExtendedMiningJob message to the group channel + let requires_standard_jobs = data.require_std_job; + let empty_group_channel = data.group_channel.get_channel_ids().is_empty(); + if !requires_standard_jobs && !empty_group_channel { + messages.push((*downstream_id, Mining::NewExtendedMiningJob(group_channel_job.get_job_message().clone())).into()); + } - messages.push((*downstream_id,Mining::NewExtendedMiningJob(extended_job_message.clone())).into()); - } - } - false => { - for (channel_id, standard_channel) in data.standard_channels.iter_mut() { - if data.group_channels.is_none() { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let standard_job = standard_channel.get_active_job().expect("standard job must exist"); - channel_manager_data.downstream_channel_id_and_job_id_to_template_id.insert((*downstream_id, *channel_id, standard_job.get_job_id()).into(), msg.template_id); - let standard_job_message = standard_job.get_job_message(); - messages.push((*downstream_id, Mining::NewMiningJob(standard_job_message.clone())).into()); - } - if let Some(ref group_channel_job) = group_channel_job { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - _ = standard_channel - .on_group_channel_job(group_channel_job.clone()); + // loop over every standard channel + // if REQUIRES_STANDARD_JOBS is not set, we need to call on_group_channel_job on each standard channel + // if REQUIRES_STANDARD_JOBS is set, we need to call on_new_template, and send individual NewMiningJob messages for each standard channel + for (channel_id, standard_channel) in data.standard_channels.iter_mut() { + if !requires_standard_jobs { + standard_channel.on_group_channel_job(group_channel_job.clone()).map_err(|e| { + tracing::error!("Error while adding group channel job to standard channel: {channel_id:?} {e:?}"); + JDCError::shutdown(e) + })?; + } else { + standard_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()).map_err(|e| { + tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); + JDCError::shutdown(e) + })?; + match msg.future_template { + true => { + let standard_job_id = standard_channel.get_future_job_id_from_template_id(msg.template_id).expect("future job id must exist"); + let standard_job = standard_channel.get_future_job(standard_job_id).expect("future job must exist"); + messages.push((*downstream_id, Mining::NewMiningJob(standard_job.get_job_message().clone())).into()); } - } - if let Some(group_channel_job) = group_channel_job { - let job_message = group_channel_job.get_job_message(); - messages.push((*downstream_id, Mining::NewExtendedMiningJob(job_message.clone())).into()); - } - - for (channel_id, extended_channel) in data.extended_channels.iter_mut() { - if let Err(e) = extended_channel.on_new_template(msg.clone().into_static(), coinbase_outputs.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; + false => { + let standard_job = standard_channel.get_active_job().expect("active job must exist"); + messages.push((*downstream_id, Mining::NewMiningJob(standard_job.get_job_message().clone())).into()); } - let extended_job = extended_channel - .get_active_job() - .expect("extended job must exist"); - - channel_manager_data.downstream_channel_id_and_job_id_to_template_id.insert((*downstream_id, *channel_id, extended_job.get_job_id()).into(), msg.template_id); - let extended_job_message = extended_job.get_job_message(); - - messages.push((*downstream_id,Mining::NewExtendedMiningJob(extended_job_message.clone())).into()); } } } - messages + // loop over every extended channel, and call on_group_channel_job on each extended channel + for (channel_id, extended_channel) in data.extended_channels.iter_mut() { + extended_channel.on_group_channel_job(group_channel_job.clone()).map_err(|e| { + tracing::error!("Error while adding group channel job to extended channel: {channel_id:?} {e:?}"); + JDCError::shutdown(e) + })?; + } - }); + Ok::, Self::Error>(messages) + + })?; messages.extend(messages_); } - messages - }); + Ok::, Self::Error>(messages) + })?; if get_jd_mode() == JdMode::CoinbaseOnly && !msg.future_template { _ = self.allocate_tokens(1).await; @@ -507,97 +451,71 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { for (downstream_id, downstream) in data.downstream.iter_mut() { let downstream_messages = downstream.downstream_data.super_safe_lock(|data| { let mut messages: Vec = vec![]; - if let Some(ref mut group_channel) = data.group_channels { - _ = group_channel.on_set_new_prev_hash(msg.clone().into_static()); - let group_channel_id = group_channel.get_group_channel_id(); - let activated_group_job_id = group_channel - .get_active_job() - .expect("active job must exist") - .get_job_id(); - - let set_new_prev_hash_message = SetNewPrevHashMp { + + // call on_set_new_prev_hash on the group channel to update the channel state + data.group_channel.on_set_new_prev_hash(msg.clone().into_static()).map_err(|e| { + tracing::error!("Error while adding new prev hash to group channel: {e:?}"); + JDCError::fallback(e) + })?; + + // did SetupConnection have the REQUIRES_STANDARD_JOBS flags set? + // if no, and the group channel is not empty, we need to send the SetNewPrevHash to the group channel + let requires_standard_jobs = data.require_std_job; + let empty_group_channel = data.group_channel.get_channel_ids().is_empty(); + if !requires_standard_jobs && !empty_group_channel { + let group_channel_id = data.group_channel.get_group_channel_id(); + + let activated_group_job_id = data.group_channel.get_active_job().expect("active job must exist").get_job_id(); + + let group_set_new_prev_hash_message = SetNewPrevHashMp { channel_id: group_channel_id, job_id: activated_group_job_id, prev_hash: msg.prev_hash.clone(), min_ntime: msg.header_timestamp, nbits: msg.n_bits, }; - messages.push( - ( - *downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_message), - ) - .into(), - ); + messages.push((*downstream_id, Mining::SetNewPrevHash(group_set_new_prev_hash_message)).into()); } for (channel_id, standard_channel) in data.standard_channels.iter_mut() { - if let Err(_e) = - standard_channel.on_set_new_prev_hash(msg.clone().into_static()) - { - continue; - }; - - // did SetupConnection have the REQUIRES_STANDARD_JOBS flag set? - // if yes, there's no group channel, so we need to send the SetNewPrevHashMp - // to each standard channel - if data.group_channels.is_none() { - let activated_standard_job_id = standard_channel - .get_active_job() - .expect("active job must exist") - .get_job_id(); - let set_new_prev_hash_message = SetNewPrevHashMp { + // call on_set_new_prev_hash on the standard channel to update the channel state + standard_channel.on_set_new_prev_hash(msg.clone().into_static()).map_err(|e| { + tracing::error!("Error while adding new prev hash to standard channel: {channel_id:?} {e:?}"); + JDCError::fallback(e) + })?; + + // did SetupConnection have the REQUIRES_STANDARD_JOBS flags set? + // if yes, we need to send the SetNewPrevHashMp to the standard channel + if data.require_std_job { + let activated_standard_job_id = standard_channel.get_active_job().expect("active job must exist").get_job_id(); + let standard_set_new_prev_hash_message = SetNewPrevHashMp { channel_id: *channel_id, job_id: activated_standard_job_id, prev_hash: msg.prev_hash.clone(), min_ntime: msg.header_timestamp, nbits: msg.n_bits, }; - messages.push( - ( - *downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_message), - ) - .into(), - ); + messages.push((*downstream_id, Mining::SetNewPrevHash(standard_set_new_prev_hash_message)).into()); } } + // loop over every extended channel, and call on_set_new_prev_hash on each extended channel to update the channel state + // we're already sending the SetNewPrevHash message to the group channel for (channel_id, extended_channel) in data.extended_channels.iter_mut() { - if let Err(_e) = - extended_channel.on_set_new_prev_hash(msg.clone().into_static()) - { - continue; - }; - - let activated_extended_job_id = extended_channel - .get_active_job() - .expect("active job must exist") - .get_job_id(); - let set_new_prev_hash_message = SetNewPrevHashMp { - channel_id: *channel_id, - job_id: activated_extended_job_id, - prev_hash: msg.prev_hash.clone(), - min_ntime: msg.header_timestamp, - nbits: msg.n_bits, - }; - messages.push( - ( - *downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_message), - ) - .into(), - ); + extended_channel.on_set_new_prev_hash(msg.clone().into_static()).map_err(|e| { + tracing::error!("Error while adding new prev hash to extended channel: {channel_id:?} {e:?}"); + JDCError::fallback(e) + })?; } - messages - }); + Ok::, Self::Error>(messages) + })?; messages.extend(downstream_messages); } - messages - }); + Ok::, Self::Error>(messages) + })?; if get_jd_mode() == JdMode::CoinbaseOnly { _ = self.allocate_tokens(1).await; diff --git a/miner-apps/jd-client/src/lib/channel_manager/upstream_message_handler.rs b/miner-apps/jd-client/src/lib/channel_manager/upstream_message_handler.rs index 3a33624b..048510a2 100644 --- a/miner-apps/jd-client/src/lib/channel_manager/upstream_message_handler.rs +++ b/miner-apps/jd-client/src/lib/channel_manager/upstream_message_handler.rs @@ -198,12 +198,25 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { None }; + let full_extranonce_size = extended_channel.get_full_extranonce_size(); + data.extranonce_prefix_factory_extended = extranonces.clone(); data.extranonce_prefix_factory_standard = extranonces; data.upstream_channel = Some(extended_channel); data.job_factory = Some(job_factory); self.upstream_state.set(UpstreamState::Connected); + // set the full extranonce size for the group channel of all downstream clients + for (_downstream_id, downstream) in data.downstream.iter_mut() { + downstream + .downstream_data + .super_safe_lock(|downstream_data| { + downstream_data + .group_channel + .set_full_extranonce_size(full_extranonce_size); + }); + } + info!("Extended mining channel successfully initialized"); ( self.upstream_state.get(), diff --git a/miner-apps/jd-client/src/lib/downstream/mod.rs b/miner-apps/jd-client/src/lib/downstream/mod.rs index b08461bd..c24b1d92 100644 --- a/miner-apps/jd-client/src/lib/downstream/mod.rs +++ b/miner-apps/jd-client/src/lib/downstream/mod.rs @@ -46,7 +46,7 @@ mod extensions_message_handler; /// - Active [`StandardChannel`]s keyed by channel ID. pub struct DownstreamData { pub require_std_job: bool, - pub group_channels: Option>>>, + pub group_channel: GroupChannel<'static, DefaultJobStore>>, pub extended_channels: HashMap>>>, pub standard_channels: @@ -87,9 +87,11 @@ pub struct Downstream { #[cfg_attr(not(test), hotpath::measure_all)] impl Downstream { /// Creates a new [`Downstream`] instance and spawns the necessary I/O tasks. - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments, clippy::result_large_err)] pub fn new( downstream_id: DownstreamId, + channel_id_factory: AtomicU32, + group_channel: GroupChannel<'static, DefaultJobStore>>, channel_manager_sender: Sender<(DownstreamId, Mining<'static>, Option>)>, channel_manager_receiver: broadcast::Sender<( DownstreamId, @@ -126,16 +128,18 @@ impl Downstream { downstream_sender: outbound_tx, downstream_receiver: inbound_rx, }; + let downstream_data = Arc::new(Mutex::new(DownstreamData { require_std_job: false, extended_channels: HashMap::new(), standard_channels: HashMap::new(), - group_channels: None, - channel_id_factory: AtomicU32::new(0), + group_channel, + channel_id_factory, negotiated_extensions: vec![], supported_extensions, required_extensions, })); + Downstream { downstream_channel, downstream_data, diff --git a/miner-apps/translator/src/lib/error.rs b/miner-apps/translator/src/lib/error.rs index b86a044f..f1304f37 100644 --- a/miner-apps/translator/src/lib/error.rs +++ b/miner-apps/translator/src/lib/error.rs @@ -16,7 +16,9 @@ use std::{ }; use stratum_apps::{ stratum_core::{ - binary_sv2, framing_sv2, + binary_sv2, + channels_sv2::client::error::GroupChannelError, + framing_sv2, handlers_sv2::HandlerErrorType, noise_sv2, parsers_sv2::{self, ParserError, TlvError}, @@ -186,6 +188,16 @@ pub enum TproxyErrorKind { OpenMiningChannelError, /// Could not initiate subsystem CouldNotInitiateSystem, + /// Channel not found + ChannelNotFound, + /// Failed to process SetNewPrevHash message + FailedToProcessSetNewPrevHash, + /// Failed to process NewExtendedMiningJob message + FailedToProcessNewExtendedMiningJob, + /// Failed to add channel id to group channel + FailedToAddChannelIdToGroupChannel(GroupChannelError), + /// Aggregated channel was closed + AggregatedChannelClosed, } impl std::error::Error for TproxyErrorKind {} @@ -250,6 +262,15 @@ impl fmt::Display for TproxyErrorKind { OpenMiningChannelError => write!(f, "failed to open mining channel"), SetupConnectionError => write!(f, "failed to setup connection with upstream"), CouldNotInitiateSystem => write!(f, "Could not initiate subsystem"), + ChannelNotFound => write!(f, "Channel not found"), + FailedToProcessSetNewPrevHash => write!(f, "Failed to process SetNewPrevHash message"), + FailedToProcessNewExtendedMiningJob => { + write!(f, "Failed to process NewExtendedMiningJob message") + } + FailedToAddChannelIdToGroupChannel(ref e) => { + write!(f, "Failed to add channel id to group channel: {e:?}") + } + AggregatedChannelClosed => write!(f, "Aggregated channel was closed"), } } } diff --git a/miner-apps/translator/src/lib/sv1/downstream/downstream.rs b/miner-apps/translator/src/lib/sv1/downstream/downstream.rs index 149feec4..6a6920f4 100644 --- a/miner-apps/translator/src/lib/sv1/downstream/downstream.rs +++ b/miner-apps/translator/src/lib/sv1/downstream/downstream.rs @@ -6,7 +6,7 @@ use crate::{ downstream::{channel::DownstreamChannelState, data::DownstreamData}, sv1_server::data::Sv1ServerData, }, - utils::ShutdownMessage, + utils::{ShutdownMessage, AGGREGATED_CHANNEL_ID}, }; use async_channel::{Receiver, Sender}; use std::{sync::Arc, time::Instant}; @@ -200,7 +200,8 @@ impl Downstream { .load(std::sync::atomic::Ordering::SeqCst), ) }); - let id_matches = (my_channel_id == Some(channel_id) || channel_id == 0) + let id_matches = (my_channel_id == Some(channel_id) + || channel_id == AGGREGATED_CHANNEL_ID) && (downstream_id.is_none() || downstream_id == Some(my_downstream_id)); if !id_matches { return Ok(()); // Message not intended for this downstream diff --git a/miner-apps/translator/src/lib/sv1/sv1_server/data.rs b/miner-apps/translator/src/lib/sv1/sv1_server/data.rs index 056c6716..eea32e33 100644 --- a/miner-apps/translator/src/lib/sv1/sv1_server/data.rs +++ b/miner-apps/translator/src/lib/sv1/sv1_server/data.rs @@ -26,7 +26,9 @@ pub struct Sv1ServerData { pub downstreams: HashMap>, pub request_id_to_downstream_id: HashMap, pub vardiff: HashMap>>, - pub prevhash: Option>, + /// HashMap to store the SetNewPrevHash for each channel + /// Used in both aggregated and non-aggregated mode + pub prevhashes: HashMap>, pub downstream_id_factory: AtomicUsize, pub request_id_factory: AtomicU32, /// Job storage for aggregated mode - all Sv1 downstreams share the same jobs @@ -52,7 +54,7 @@ impl Sv1ServerData { downstreams: HashMap::new(), request_id_to_downstream_id: HashMap::new(), vardiff: HashMap::new(), - prevhash: None, + prevhashes: HashMap::new(), downstream_id_factory: AtomicUsize::new(1), request_id_factory: AtomicU32::new(1), aggregated_valid_jobs: aggregate_channels.then(Vec::new), @@ -87,6 +89,16 @@ impl Sv1ServerData { job_id.contains(KEEPALIVE_JOB_ID_DELIMITER) } + /// Gets the prevhash for a given channel. + pub fn get_prevhash(&self, channel_id: u32) -> Option> { + self.prevhashes.get(&channel_id).cloned() + } + + /// Sets the prevhash for a given channel. + pub fn set_prevhash(&mut self, channel_id: u32, prevhash: SetNewPrevHash<'static>) { + self.prevhashes.insert(channel_id, prevhash); + } + /// Gets the last job from the jobs storage. /// In aggregated mode, returns the last job from the shared job list. /// In non-aggregated mode, returns the last job for the specified channel. diff --git a/miner-apps/translator/src/lib/sv1/sv1_server/difficulty_manager.rs b/miner-apps/translator/src/lib/sv1/sv1_server/difficulty_manager.rs index 85803a2f..10a87e33 100644 --- a/miner-apps/translator/src/lib/sv1/sv1_server/difficulty_manager.rs +++ b/miner-apps/translator/src/lib/sv1/sv1_server/difficulty_manager.rs @@ -1,4 +1,7 @@ -use crate::sv1::sv1_server::data::{PendingTargetUpdate, Sv1ServerData}; +use crate::{ + sv1::sv1_server::data::{PendingTargetUpdate, Sv1ServerData}, + utils::AGGREGATED_CHANNEL_ID, +}; use async_channel::Sender; use std::sync::Arc; use stratum_apps::{ @@ -576,9 +579,9 @@ impl DifficultyManager { let (total_hashrate, min_target, channel_id, downstream_count) = sv1_server_data .super_safe_lock(|data| { - // Hardcoded channel_id 0 (the ChannelManager will set this channel_id to the - // upstream extended channel id) - let channel_id = 0; + // Hardcoded channel_id AGGREGATED_CHANNEL_ID (the ChannelManager will set this + // channel_id to the upstream extended channel) + let channel_id = AGGREGATED_CHANNEL_ID; let total_hashrate: Hashrate = data .downstreams diff --git a/miner-apps/translator/src/lib/sv1/sv1_server/sv1_server.rs b/miner-apps/translator/src/lib/sv1/sv1_server/sv1_server.rs index b240666b..4edf4ea1 100644 --- a/miner-apps/translator/src/lib/sv1/sv1_server/sv1_server.rs +++ b/miner-apps/translator/src/lib/sv1/sv1_server/sv1_server.rs @@ -16,7 +16,7 @@ use std::{ collections::HashMap, net::SocketAddr, sync::{ - atomic::{AtomicBool, AtomicU32, Ordering}, + atomic::{AtomicU32, Ordering}, Arc, RwLock, }, time::{Duration, Instant}, @@ -65,7 +65,6 @@ pub struct Sv1Server { shares_per_minute: SharesPerMinute, listener_addr: SocketAddr, config: TranslatorConfig, - clean_job: AtomicBool, sequence_counter: AtomicU32, miner_counter: AtomicU32, } @@ -103,7 +102,6 @@ impl Sv1Server { config, listener_addr, shares_per_minute, - clean_job: AtomicBool::new(true), miner_counter: AtomicU32::new(0), sequence_counter: AtomicU32::new(0), } @@ -578,16 +576,14 @@ impl Sv1Server { "Received NewExtendedMiningJob for channel id: {}", m.channel_id ); - if let Some(prevhash) = self.sv1_server_data.super_safe_lock(|v| v.prevhash.clone()) + if let Some(prevhash) = self + .sv1_server_data + .super_safe_lock(|v| v.get_prevhash(m.channel_id)) { - let notify = build_sv1_notify_from_sv2( - prevhash, - m.clone().into_static(), - self.clean_job.load(Ordering::SeqCst), - ) - .map_err(TproxyError::shutdown)?; - let clean_jobs = self.clean_job.load(Ordering::SeqCst); - self.clean_job.store(false, Ordering::SeqCst); + let clean_jobs = m.job_id == prevhash.job_id; + let notify = + build_sv1_notify_from_sv2(prevhash, m.clone().into_static(), clean_jobs) + .map_err(TproxyError::shutdown)?; // Update job storage based on the configured mode let notify_parsed = notify.clone(); @@ -621,9 +617,8 @@ impl Sv1Server { Mining::SetNewPrevHash(m) => { debug!("Received SetNewPrevHash for channel id: {}", m.channel_id); - self.clean_job.store(true, Ordering::SeqCst); self.sv1_server_data - .super_safe_lock(|v| v.prevhash = Some(m.clone().into_static())); + .super_safe_lock(|v| v.set_prevhash(m.channel_id, m.clone().into_static())); } Mining::SetTarget(m) => { @@ -1189,20 +1184,4 @@ mod tests { assert_eq!(seq_id, 0); assert_eq!(server.sequence_counter.load(Ordering::SeqCst), 1); } - - #[test] - fn test_sv1_server_clean_job_flag() { - let server = create_test_sv1_server(); - - // Test initial value - assert!(server.clean_job.load(Ordering::SeqCst)); - - // Test setting to false - server.clean_job.store(false, Ordering::SeqCst); - assert!(!server.clean_job.load(Ordering::SeqCst)); - - // Test setting back to true - server.clean_job.store(true, Ordering::SeqCst); - assert!(server.clean_job.load(Ordering::SeqCst)); - } } diff --git a/miner-apps/translator/src/lib/sv2/channel_manager/channel_manager.rs b/miner-apps/translator/src/lib/sv2/channel_manager/channel_manager.rs index b8b52244..8d17ff44 100644 --- a/miner-apps/translator/src/lib/sv2/channel_manager/channel_manager.rs +++ b/miner-apps/translator/src/lib/sv2/channel_manager/channel_manager.rs @@ -344,6 +344,8 @@ impl ChannelManager { target: target.to_le_bytes().into(), extranonce_size: new_extranonce_size as u16, extranonce_prefix: new_extranonce_prefix.clone().into(), + group_channel_id: 0, /* use a dummy value, this shouldn't + * matter for the Sv1 server */ }, ); diff --git a/miner-apps/translator/src/lib/sv2/channel_manager/data.rs b/miner-apps/translator/src/lib/sv2/channel_manager/data.rs index 4cc8a59f..471f6f17 100644 --- a/miner-apps/translator/src/lib/sv2/channel_manager/data.rs +++ b/miner-apps/translator/src/lib/sv2/channel_manager/data.rs @@ -5,7 +5,8 @@ use std::{ use stratum_apps::{ custom_mutex::Mutex, stratum_core::{ - channels_sv2::client::extended::ExtendedChannel, mining_sv2::ExtendedExtranonce, + channels_sv2::client::{extended::ExtendedChannel, group::GroupChannel}, + mining_sv2::ExtendedExtranonce, }, utils::types::{ChannelId, DownstreamId, Hashrate}, }; @@ -40,6 +41,8 @@ pub struct ChannelManagerData { pub pending_channels: HashMap, /// Map of active extended channels by channel ID pub extended_channels: HashMap>>>, + /// Map of active group channels by group channel ID + pub group_channels: HashMap>>>, /// The upstream extended channel used in aggregated mode pub upstream_extended_channel: Option>>>, /// Extranonce prefix factory for allocating unique prefixes in aggregated mode @@ -72,6 +75,7 @@ impl ChannelManagerData { Self { pending_channels: HashMap::new(), extended_channels: HashMap::new(), + group_channels: HashMap::new(), upstream_extended_channel: None, extranonce_prefix_factory: None, mode, diff --git a/miner-apps/translator/src/lib/sv2/channel_manager/mining_message_handler.rs b/miner-apps/translator/src/lib/sv2/channel_manager/mining_message_handler.rs index 38d7c715..59e6a5d6 100644 --- a/miner-apps/translator/src/lib/sv2/channel_manager/mining_message_handler.rs +++ b/miner-apps/translator/src/lib/sv2/channel_manager/mining_message_handler.rs @@ -3,13 +3,13 @@ use std::sync::{Arc, RwLock}; use crate::{ error::{self, TproxyError, TproxyErrorKind}, sv2::{channel_manager::ChannelMode, ChannelManager}, - utils::proxy_extranonce_prefix_len, + utils::{proxy_extranonce_prefix_len, AGGREGATED_CHANNEL_ID}, }; use stratum_apps::{ custom_mutex::Mutex, stratum_core::{ bitcoin::Target, - channels_sv2::client::extended::ExtendedChannel, + channels_sv2::client::{extended::ExtendedChannel, group::GroupChannel}, handlers_sv2::{HandleMiningMessagesFromServerAsync, SupportedChannelTypes}, mining_sv2::{ CloseChannel, ExtendedExtranonce, Extranonce, NewExtendedMiningJob, NewMiningJob, @@ -19,7 +19,6 @@ use stratum_apps::{ SubmitSharesSuccess, UpdateChannelError, MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_ERROR, MESSAGE_TYPE_SET_CUSTOM_MINING_JOB_SUCCESS, - MESSAGE_TYPE_SET_GROUP_CHANNEL, }, parsers_sv2::{Mining, Tlv}, }, @@ -32,7 +31,7 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { type Error = TproxyError; fn get_channel_type_for_server(&self, _server_id: Option) -> SupportedChannelTypes { - SupportedChannelTypes::Extended + SupportedChannelTypes::GroupAndExtended } fn is_work_selection_enabled_for_server(&self, _server_id: Option) -> bool { @@ -86,11 +85,36 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { let success = self .channel_manager_data - .safe_lock(|channel_manager_data| { + .super_safe_lock(|channel_manager_data| { info!( "Received: {}, user_identity: {}, nominal_hashrate: {}", m, user_identity, nominal_hashrate ); + + let full_extranonce_size = m.extranonce_size as usize + m.extranonce_prefix.len(); + + // add the channel to the group channel + match channel_manager_data.group_channels.get(&m.group_channel_id) { + Some(group_channel_arc) => { + let mut group_channel = group_channel_arc.write().map_err(|e| { + error!("Failed to write group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + group_channel.add_channel_id(m.channel_id, full_extranonce_size).map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + TproxyError::fallback(TproxyErrorKind::FailedToAddChannelIdToGroupChannel(e)) + })?; + } + None => { + let mut group_channel = GroupChannel::new(m.group_channel_id); + group_channel.add_channel_id(m.channel_id, full_extranonce_size).map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + TproxyError::fallback(TproxyErrorKind::FailedToAddChannelIdToGroupChannel(e)) + })?; + channel_manager_data.group_channels.insert(m.group_channel_id, Arc::new(RwLock::new(group_channel))); + } + } + let extranonce_prefix = m.extranonce_prefix.clone().into_static().to_vec(); let target = Target::from_le_bytes(m.target.clone().inner_as_ref().try_into().unwrap()); let version_rolling = true; // we assume this is always true on extended channels @@ -167,8 +191,9 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { extranonce_prefix: new_extranonce_prefix, extranonce_size: new_extranonce_size, target: m.target.clone(), + group_channel_id: m.group_channel_id, }; - new_open_extended_mining_channel_success.into_static() + Ok::, Self::Error>(new_open_extended_mining_channel_success.into_static()) } else { // Non-aggregated mode: check if we need to adjust extranonce size if m.extranonce_size as usize != downstream_extranonce_len { @@ -228,14 +253,15 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { extranonce_prefix: new_extranonce_prefix, extranonce_size: downstream_extranonce_len as u16, target: m.target.clone(), + group_channel_id: m.group_channel_id, }; - new_open_extended_mining_channel_success.into_static() + Ok::, Self::Error>(new_open_extended_mining_channel_success.into_static()) } else { // Extranonce size matches, use as-is channel_manager_data .extended_channels .insert(m.channel_id, Arc::new(RwLock::new(extended_channel))); - m.into_static() + Ok::, Self::Error>(m.into_static()) } } }) @@ -288,15 +314,60 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { info!("Received: {}", m); - _ = self.channel_manager_data.safe_lock(|channel_data_manager| { - if channel_data_manager.mode == ChannelMode::Aggregated { - if channel_data_manager.upstream_extended_channel.is_some() { - channel_data_manager.upstream_extended_channel = None; + self.channel_manager_data + .super_safe_lock(|channel_data_manager| { + // are we working in aggregated mode? + if channel_data_manager.mode == ChannelMode::Aggregated { + // even if aggregated channel_id != m.channel_id, we should trigger fallback + // because why would a sane server send a CloseChannel message to a different + // channel? + return Err(TproxyError::fallback( + TproxyErrorKind::AggregatedChannelClosed, + )); + // we're not in aggregated mode + // was the message sent to a group channel? + } else if let Some(group_channel_arc) = + channel_data_manager.group_channels.get(&m.channel_id) + { + let group_channel = group_channel_arc.read().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + for channel_id in group_channel.get_channel_ids() { + channel_data_manager.extended_channels.remove(channel_id); + } + + drop(group_channel); + channel_data_manager.group_channels.remove(&m.channel_id); + // if the message was not sent to a group channel, and we're not working in + // aggregated mode, + } else if channel_data_manager + .extended_channels + .contains_key(&m.channel_id) + { + // remove the channel from the extended channels map + channel_data_manager.extended_channels.remove(&m.channel_id); + + // remove the channel from any group channels that contain it + for group_channel in channel_data_manager.group_channels.values() { + let mut group_channel = group_channel.write().map_err(|e| { + error!("Failed to write group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + if group_channel.get_channel_ids().contains(&m.channel_id) { + group_channel.remove_channel_id(m.channel_id); + } + } + } else { + error!( + "Channel Id not found: {}, ignoring CloseChannel message", + m.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); } - } else { - channel_data_manager.extended_channels.remove(&m.channel_id); - } - }); + Ok::<(), Self::Error>(()) + })?; Ok(()) } @@ -349,40 +420,178 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { info!("Received: {}", m); - let mut m_static = m.clone().into_static(); - _ = self.channel_manager_data.safe_lock(|channel_manage_data| { - if channel_manage_data.mode == ChannelMode::Aggregated { - if let Some(upstream_channel) = &channel_manage_data.upstream_extended_channel { - if let Ok(mut upstream_extended_channel) = upstream_channel.write() { - let _ = - upstream_extended_channel.on_new_extended_mining_job(m_static.clone()); - m_static.channel_id = 0; // this is done so that every aggregated downstream - // will - // receive the NewExtendedMiningJob message + let m_static = m.clone().into_static(); + + // we update the channel states and keep track of the messages that need to be sent to the + // SV1Server + let new_extended_mining_job_messages_sv1_server = self + .channel_manager_data + .super_safe_lock(|channel_manager_data| { + let mut new_extended_mining_job_messages = Vec::new(); + + // are we in aggregated mode? + if channel_manager_data.mode == ChannelMode::Aggregated { + let mut aggregated_channel = channel_manager_data + .upstream_extended_channel + .as_ref() + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))? + .write() + .map_err(|e| { + error!("Failed to write upstream channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // here, we are assuming that since we are in aggregated mode, there should be + // only one single group channel and the aggregated channel + // must belong to it + let group_channel = channel_manager_data.group_channels.values().next(); + let Some(group_channel) = group_channel else { + error!("Aggregated channel does not belong to any group channel"); + return Err(TproxyError::fallback(TproxyErrorKind::ChannelNotFound)); + }; + + let group_channel_guard = group_channel.read().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + let group_channel_id = group_channel_guard.get_group_channel_id(); + + // was the message sent to the aggregated channel? + if aggregated_channel.get_channel_id() == m_static.channel_id + || group_channel_id == m_static.channel_id + { + // update upstream channel state + aggregated_channel + .on_new_extended_mining_job(m_static.clone()) + .map_err(|e| { + error!("Failed to process new extended mining job: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessNewExtendedMiningJob, + ) + })?; + + // update each extended channel state + for (_, channel) in channel_manager_data.extended_channels.iter() { + let mut channel = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + channel + .on_new_extended_mining_job(m_static.clone()) + .map_err(|e| { + error!("Failed to process new extended mining job: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessNewExtendedMiningJob, + ) + })?; + } + + // only send this message to the SV1Server if it's not a future job + if !m_static.is_future() { + let mut new_extended_mining_job_message = m_static.clone(); + new_extended_mining_job_message.channel_id = AGGREGATED_CHANNEL_ID; // this is done so that every aggregated downstream + // will receive the NewExtendedMiningJob message + new_extended_mining_job_messages.push(new_extended_mining_job_message); + } + } else { + // we got a nonsense channel id, we should log an error and ignore the + // message + error!( + "Channel not found: {}, ignoring NewExtendedMiningJob message", + m_static.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); } - } - channel_manage_data - .extended_channels - .iter() - .for_each(|(_, channel)| { - if let Ok(mut channel) = channel.write() { - let _ = channel.on_new_extended_mining_job(m_static.clone()); + // we're not in aggregated mode + // was the message sent to a group channel? + } else if let Some(group_channel_arc) = + channel_manager_data.group_channels.get(&m.channel_id) + { + let mut group_channel = group_channel_arc.write().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // update group channel state + group_channel.on_new_extended_mining_job(m_static.clone()); + + // process the message for each individual channel on the group + for channel_id in group_channel.get_channel_ids() { + let channel = channel_manager_data + .extended_channels + .get(channel_id) + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))?; + let mut channel = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + let mut job = m_static.clone(); + job.channel_id = *channel_id; + + // update each channel state + channel + .on_new_extended_mining_job(job.clone()) + .map_err(|e| { + error!("Failed to process new extended mining job: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessNewExtendedMiningJob, + ) + })?; + + // only send this message to the SV1Server if it's not a future job + if !job.is_future() { + new_extended_mining_job_messages.push(job); } - }); - } else if let Some(channel) = channel_manage_data - .extended_channels - .get(&m_static.channel_id) - { - if let Ok(mut channel) = channel.write() { - let _ = channel.on_new_extended_mining_job(m_static.clone()); + } + // if the message was not sent to a group channel, we need to check if we're + // working in aggregated mode + } else { + let Some(channel) = channel_manager_data + .extended_channels + .get(&m_static.channel_id) + else { + // we got a nonsense channel id, we should log an error and ignore the + // message + error!( + "Channel not found: {}, ignoring NewExtendedMiningJob message", + m_static.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); + }; + + let mut channel = channel.write().map_err(|e| { + error!("Failed to write to channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // update channel state + channel + .on_new_extended_mining_job(m_static.clone()) + .map_err(|e| { + error!("Failed to process new extended mining job: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessNewExtendedMiningJob, + ) + })?; + + // only send this message to the SV1Server if it's not a future job + if !m_static.is_future() { + let new_extended_mining_job_message = m_static.clone(); + new_extended_mining_job_messages.push(new_extended_mining_job_message); + } } - } - }); - let job = m_static; - if !job.is_future() { + Ok::>, Self::Error>( + new_extended_mining_job_messages, + ) + })?; + + // now we need to send the NewExtendedMiningJob message(s) to the SV1Server + for message in new_extended_mining_job_messages_sv1_server { self.channel_state .sv1_server_sender - .send((Mining::NewExtendedMiningJob(job), None)) + .send((Mining::NewExtendedMiningJob(message), None)) .await .map_err(|e| { error!("Failed to send immediate NewExtendedMiningJob: {:?}", e); @@ -399,74 +608,228 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { info!("Received: {}", m); - let m_static = m.clone().into_static(); - _ = self.channel_manager_data.safe_lock(|channel_manager_data| { - if channel_manager_data.mode == ChannelMode::Aggregated { - if let Some(upstream_channel) = &channel_manager_data.upstream_extended_channel { - if let Ok(mut upstream_extended_channel) = upstream_channel.write() { - _ = upstream_extended_channel.on_set_new_prev_hash(m_static.clone()); - } - } - channel_manager_data - .extended_channels - .iter() - .for_each(|(_, channel)| { - if let Ok(mut channel) = channel.write() { - _ = channel.on_set_new_prev_hash(m_static.clone()); + let mut m_static = m.clone().into_static(); + + // we update the channel states and keep track of the messages that need to be sent to the + // SV1Server + let (set_new_prev_hash_messages_sv1_server, new_extended_mining_job_messages_sv1_server) = + self.channel_manager_data + .super_safe_lock(|channel_manager_data| { + let mut set_new_prev_hash_messages = Vec::new(); + let mut new_extended_mining_job_messages = Vec::new(); + + if channel_manager_data.mode == ChannelMode::Aggregated { + let aggregated_channel_guard = channel_manager_data + .upstream_extended_channel + .as_ref() + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))?; + let mut aggregated_channel = + aggregated_channel_guard.write().map_err(|e| { + error!("Failed to read upstream channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // does aggregated channel belong to some group channel? + // here, we are assuming that since we are in aggregated mode, there should + // be only one single group channle and the + // aggregated channel must belong to it + let group_channel = channel_manager_data.group_channels.values().next(); + let Some(group_channel) = group_channel else { + error!("Aggregated channel does not belong to any group channel"); + return Err(TproxyError::fallback(TproxyErrorKind::ChannelNotFound)); + }; + + let group_channel_guard = group_channel.read().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + let group_channel_id = group_channel_guard.get_group_channel_id(); + + // was the message sent to the aggregated channel? + if aggregated_channel.get_channel_id() == m.channel_id + || group_channel_id == m.channel_id + { + // update aggregated channel state + aggregated_channel + .on_set_new_prev_hash(m_static.clone()) + .map_err(|e| { + error!("Failed to set new prev hash: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessSetNewPrevHash, + ) + })?; + + // update each extended channel state + for (_, channel) in channel_manager_data.extended_channels.iter() { + let mut channel = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + channel + .on_set_new_prev_hash(m_static.clone()) + .map_err(|e| { + error!("Failed to set new prev hash: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessSetNewPrevHash, + ) + })?; + } + + // make sure the SetNewPrevHash message is sent to the aggregated + // channel + m_static.channel_id = AGGREGATED_CHANNEL_ID; + set_new_prev_hash_messages.push(m_static.clone()); + + // for the aggregated channel, send one NewExtendedMiningJob message to + // the SV1Server + let mut new_extended_mining_job_message = aggregated_channel + .get_active_job() + .expect("active job must exist") + .clone(); + new_extended_mining_job_message.0.channel_id = AGGREGATED_CHANNEL_ID; + new_extended_mining_job_messages + .push(new_extended_mining_job_message.0); + } else { + // we got a nonsense channel id, we should log an error and ignore the + // message + warn!( + "Channel not found: {}, ignoring SetNewPrevHash message", + m_static.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); } - }); - } else if let Some(channel) = channel_manager_data - .extended_channels - .get(&m_static.channel_id) - { - if let Ok(mut channel) = channel.write() { - _ = channel.on_set_new_prev_hash(m_static.clone()); - } - } - }); + // we are not in aggregated mode.. was the message sent to a group channel? + } else if let Some(group_channel_arc) = + channel_manager_data.group_channels.get(&m.channel_id) + { + let mut group_channel = group_channel_arc.write().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; - self.channel_state - .sv1_server_sender - .send((Mining::SetNewPrevHash(m_static.clone()), None)) - .await - .map_err(|e| { - error!("Failed to send SetNewPrevHash: {:?}", e); - TproxyError::shutdown(TproxyErrorKind::ChannelErrorSender) - })?; + // update group channel state + group_channel + .on_set_new_prev_hash(m_static.clone()) + .map_err(|e| { + error!("Failed to set new prev hash: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessSetNewPrevHash, + ) + })?; - let mode = self - .channel_manager_data - .super_safe_lock(|c| c.mode.clone()); - - let active_job = if mode == ChannelMode::Aggregated { - self.channel_manager_data.super_safe_lock(|c| { - c.upstream_extended_channel - .as_ref() - .and_then(|ch| ch.read().ok()) - .and_then(|ch| ch.get_active_job().map(|j| j.0.clone())) - }) - } else { - self.channel_manager_data.super_safe_lock(|c| { - c.extended_channels - .get(&m.channel_id) - .and_then(|ch| ch.read().ok()) - .and_then(|ch| ch.get_active_job().map(|j| j.0.clone())) - }) - }; + // there's no aggregated channel, so we need to process the message for each + // individual channel on the group + for channel_id in group_channel.get_channel_ids() { + let channel = channel_manager_data + .extended_channels + .get(channel_id) + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))?; + let mut channel = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + channel + .on_set_new_prev_hash(m_static.clone()) + .map_err(|e| { + error!("Failed to set new prev hash: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessSetNewPrevHash, + ) + })?; + + // for each extended channel, send one SetNewPrevHash message to the + // SV1Server + let mut set_new_prev_hash_message = m_static.clone(); + set_new_prev_hash_message.channel_id = *channel_id; + set_new_prev_hash_messages.push(set_new_prev_hash_message); + + // for each extended channel, send one NewExtendedMiningJob message to + // the SV1Server + let new_extended_mining_job_message = channel + .get_active_job() + .expect("active job must exist") + .clone(); + new_extended_mining_job_messages + .push(new_extended_mining_job_message.0); + } + // if the message was not sent to a group channel, and we're not in aggregated + // mode, we need to process the message for a specific channel + } else { + let Some(channel) = channel_manager_data + .extended_channels + .get(&m_static.channel_id) + else { + // we got a nonsense channel id, we should log an error and ignore the + // message + warn!( + "Channel not found: {}, ignoring SetNewPrevHash message", + m_static.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); + }; - if let Some(mut job) = active_job { - if mode == ChannelMode::Aggregated { - job.channel_id = 0; - } + let mut channel = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // update channel state + channel + .on_set_new_prev_hash(m_static.clone()) + .map_err(|e| { + error!("Failed to set new prev hash: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToProcessSetNewPrevHash, + ) + })?; + + // make sure the SetNewPrevHash message is sent to the channel + set_new_prev_hash_messages.push(m_static.clone()); + + // for the channel, send one NewExtendedMiningJob message to the SV1Server + let new_extended_mining_job_message = channel + .get_active_job() + .expect("active job must exist") + .clone(); + new_extended_mining_job_messages.push(new_extended_mining_job_message.0); + } + Ok::< + ( + Vec>, + Vec>, + ), + Self::Error, + >(( + set_new_prev_hash_messages, + new_extended_mining_job_messages, + )) + })?; + + // we need to send the SetNewPrevHash message(s) to the SV1Server + for message in set_new_prev_hash_messages_sv1_server { self.channel_state .sv1_server_sender - .send((Mining::NewExtendedMiningJob(job), None)) + .send((Mining::SetNewPrevHash(message), None)) + .await + .map_err(|e| { + error!("Failed to send SetNewPrevHash: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::ChannelErrorSender) + })?; + } + + // we need to send the NewExtendedMiningJob message(s) to the SV1Server + for message in new_extended_mining_job_messages_sv1_server { + self.channel_state + .sv1_server_sender + .send((Mining::NewExtendedMiningJob(message), None)) .await .map_err(|e| { error!("Failed to send NewExtendedMiningJob: {:?}", e); TproxyError::shutdown(TproxyErrorKind::ChannelErrorSender) })?; } + Ok(()) } @@ -506,45 +869,150 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { ) -> Result<(), Self::Error> { info!("Received: {}", m); + let m_static = m.clone().into_static(); + // Update the channel targets in the channel manager - _ = self.channel_manager_data.safe_lock(|channel_manager_data| { - if channel_manager_data.mode == ChannelMode::Aggregated { - if let Some(upstream_channel) = &channel_manager_data.upstream_extended_channel { - if let Ok(mut upstream_extended_channel) = upstream_channel.write() { - upstream_extended_channel.set_target(Target::from_le_bytes( - m.maximum_target.inner_as_ref().try_into().unwrap(), - )); - } - } - channel_manager_data - .extended_channels - .iter() - .for_each(|(_, channel)| { - if let Ok(mut channel) = channel.write() { + let set_target_messages_sv1_server = + self.channel_manager_data + .super_safe_lock(|channel_manager_data| { + let mut set_target_messages = Vec::new(); + + // are in aggregated mode? + if channel_manager_data.mode == ChannelMode::Aggregated { + let aggregated_channel = channel_manager_data + .upstream_extended_channel + .as_ref() + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))?; + let mut aggregated_extended_channel = + aggregated_channel.write().map_err(|e| { + error!("Failed to write upstream channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // does aggregated channel belong to some group channel? + // here, we are assuming that since we are in aggregated mode, there should + // be only one single group channle and the + // aggregated channel must belong to it + let group_channel = channel_manager_data.group_channels.values().next(); + let Some(group_channel) = group_channel else { + error!("Aggregated channel does not belong to any group channel"); + return Err(TproxyError::fallback(TproxyErrorKind::ChannelNotFound)); + }; + + let group_channel_guard = group_channel.read().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + let group_channel_id = group_channel_guard.get_group_channel_id(); + + // was the message sent to the aggregated channel? + if aggregated_extended_channel.get_channel_id() == m.channel_id + || group_channel_id == m.channel_id + { + aggregated_extended_channel.set_target(Target::from_le_bytes( + m.maximum_target + .inner_as_ref() + .try_into() + .expect("target deserialization should never fail"), + )); + channel_manager_data.extended_channels.iter().for_each( + |(_, channel)| { + if let Ok(mut channel) = channel.write() { + channel.set_target(Target::from_le_bytes( + m.maximum_target + .inner_as_ref() + .try_into() + .expect("target deserialization should never fail"), + )); + } + }, + ); + + let mut message = m_static.clone(); + message.channel_id = AGGREGATED_CHANNEL_ID; + set_target_messages.push(message); + } else { + // we got a nonsense channel id, we should log an error and ignore the + // message + warn!( + "Channel not found: {}, ignoring SetTarget message", + m_static.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); + } + // we are not in aggregated mode... was the message sent to a group channel? + } else if let Some(group_channel_arc) = + channel_manager_data.group_channels.get(&m.channel_id) + { + let group_channel = group_channel_arc.read().map_err(|e| { + error!("Failed to read group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + + // process the message for each individual channel on the group + for channel_id in group_channel.get_channel_ids() { + let channel = channel_manager_data + .extended_channels + .get(channel_id) + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))?; + let mut channel = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; channel.set_target(Target::from_le_bytes( - m.maximum_target.inner_as_ref().try_into().unwrap(), + m.maximum_target + .inner_as_ref() + .try_into() + .expect("target deserialization should never fail"), )); + + let mut message = m_static.clone(); + message.channel_id = *channel_id; + set_target_messages.push(message); } - }); - } else if let Some(channel) = channel_manager_data.extended_channels.get(&m.channel_id) - { - if let Ok(mut channel) = channel.write() { - channel.set_target(Target::from_le_bytes( - m.maximum_target.inner_as_ref().try_into().unwrap(), - )); - } - } - }); + // if the message was not sent to a group channel, and we're not in aggregated + // mode, we need to process the message for a specific channel + } else { + let Some(channel) = + channel_manager_data.extended_channels.get(&m.channel_id) + else { + // we got a nonsense channel id, we should log an error and ignore the + // message + warn!( + "Channel not found: {}, ignoring SetTarget message", + m_static.channel_id + ); + return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); + }; + let mut channel_guard = channel.write().map_err(|e| { + error!("Failed to write channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + channel_guard.set_target(Target::from_le_bytes( + m.maximum_target + .inner_as_ref() + .try_into() + .expect("target deserialization should never fail"), + )); - // Forward SetTarget message to SV1Server for vardiff processing - self.channel_state - .sv1_server_sender - .send((Mining::SetTarget(m.clone().into_static()), None)) - .await - .map_err(|e| { - error!("Failed to forward SetTarget message to SV1Server: {:?}", e); - TproxyError::shutdown(TproxyErrorKind::ChannelErrorSender) - })?; + set_target_messages.push(m_static.clone()); + } + + Ok::>, Self::Error>(set_target_messages) + })?; + + // now we need to send the SetTarget message(s) to the SV1Server + for message in set_target_messages_sv1_server { + self.channel_state + .sv1_server_sender + .send((Mining::SetTarget(message), None)) + .await + .map_err(|e| { + error!("Failed to send SetTarget: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::ChannelErrorSender) + })?; + } Ok(()) } @@ -555,11 +1023,123 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { m: SetGroupChannel<'_>, _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { - warn!("Received: {}", m); - warn!("⚠️ Cannot process SetGroupChannel since Translator Proxy does not support group channels. Ignoring."); - Err(TproxyError::log(TproxyErrorKind::UnexpectedMessage( - 0, - MESSAGE_TYPE_SET_GROUP_CHANNEL, - ))) + info!("Received: {}", m); + + self.channel_manager_data + .super_safe_lock(|channel_manager_data| { + // remove every channel from any group channels that end up empty + let mut group_channels_to_remove = Vec::new(); + + // check every group channel if it contains any of the channels in the new group + // channel + for (group_channel_id, group_channel) in channel_manager_data.group_channels.iter() + { + let mut group_channel = group_channel.write().map_err(|e| { + error!("Failed to write group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + let channel_ids_to_remove = m.channel_ids.clone().into_inner(); + for channel_id in channel_ids_to_remove { + group_channel.remove_channel_id(channel_id); + } + + if group_channel.get_channel_ids().is_empty() { + group_channels_to_remove.push(*group_channel_id); + } + } + + // Now remove the empty group channels + for group_channel_id in group_channels_to_remove { + channel_manager_data + .group_channels + .remove(&group_channel_id); + } + + // does the group channel already exist? + match channel_manager_data.group_channels.get(&m.group_channel_id) { + // if yes, clean up any channels that are no longer in the new group channel + Some(group_channel_arc) => { + let mut group_channel = group_channel_arc.write().map_err(|e| { + error!("Failed to write group channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })?; + let current_channel_ids = group_channel.get_channel_ids().clone(); + let new_channel_ids = m.channel_ids.clone().into_inner(); + + // Remove channels that are no longer in the new list + for channel_id in ¤t_channel_ids { + if !new_channel_ids.contains(channel_id) { + group_channel.remove_channel_id(*channel_id); + } + } + + // Add all channels from the message (inner HashSet ingores duplicates) + for channel_id in new_channel_ids { + let full_extranonce_size = { + let extended_channel_guard = channel_manager_data + .extended_channels + .get(&channel_id) + .ok_or(TproxyError::fallback( + TproxyErrorKind::ChannelNotFound, + ))?; + extended_channel_guard + .read() + .map_err(|e| { + error!("Failed to read extended channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })? + .get_full_extranonce_size() + }; + group_channel + .add_channel_id(channel_id, full_extranonce_size) + .map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToAddChannelIdToGroupChannel(e), + ) + })?; + } + } + // if no, create a new group channel, and add all the channels to it + None => { + let mut group_channel = GroupChannel::new(m.group_channel_id); + + // Add all channels to the newly created group channel + for channel_id in m.channel_ids.clone().into_inner() { + let full_extranonce_size = { + let extended_channel_guard = channel_manager_data + .extended_channels + .get(&channel_id) + .ok_or(TproxyError::fallback( + TproxyErrorKind::ChannelNotFound, + ))?; + extended_channel_guard + .read() + .map_err(|e| { + error!("Failed to read extended channel: {:?}", e); + TproxyError::shutdown(TproxyErrorKind::PoisonLock) + })? + .get_full_extranonce_size() + }; + group_channel + .add_channel_id(channel_id, full_extranonce_size) + .map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + TproxyError::fallback( + TproxyErrorKind::FailedToAddChannelIdToGroupChannel(e), + ) + })?; + } + + channel_manager_data + .group_channels + .insert(m.group_channel_id, Arc::new(RwLock::new(group_channel))); + } + } + + Ok::<(), Self::Error>(()) + })?; + + Ok(()) } } diff --git a/miner-apps/translator/src/lib/utils.rs b/miner-apps/translator/src/lib/utils.rs index cc9531ac..fa727f72 100644 --- a/miner-apps/translator/src/lib/utils.rs +++ b/miner-apps/translator/src/lib/utils.rs @@ -24,6 +24,10 @@ use tracing::debug; use crate::error::TproxyErrorKind; +/// Channel ID used to broadcast messages to all downstreams in aggregated mode. +/// This sentinel value distinguishes broadcast from a legitimate channel 0. +pub const AGGREGATED_CHANNEL_ID: ChannelId = u32::MAX; + /// Validates an SV1 share against the target difficulty and job parameters. /// /// This function performs complete share validation by: diff --git a/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs b/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs index 0637f1ec..643716fb 100644 --- a/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs +++ b/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs @@ -7,7 +7,6 @@ use stratum_apps::stratum_core::{ server::{ error::{ExtendedChannelError, StandardChannelError}, extended::ExtendedChannel, - group::GroupChannel, jobs::job_store::DefaultJobStore, share_accounting::{ShareValidationError, ShareValidationResult}, standard::StandardChannel, @@ -25,7 +24,7 @@ use stratum_apps::stratum_core::{ use tracing::{error, info}; use crate::{ - channel_manager::{ChannelManager, RouteMessageTo, FULL_EXTRANONCE_SIZE}, + channel_manager::{ChannelManager, RouteMessageTo, CLIENT_SEARCH_SPACE_BYTES}, error::{self, PoolError, PoolErrorKind}, utils::create_close_channel_msg, }; @@ -149,22 +148,6 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }; downstream.downstream_data.super_safe_lock(|downstream_data| { - if !downstream.requires_standard_jobs.load(Ordering::SeqCst) && downstream_data.group_channels.is_none() { - let group_channel_id = downstream_data.channel_id_factory.fetch_add(1, Ordering::SeqCst); - let job_store = DefaultJobStore::new(); - - let mut group_channel = match GroupChannel::new_for_pool(group_channel_id, job_store, FULL_EXTRANONCE_SIZE, self.pool_tag_string.clone()) { - Ok(channel) => channel, - Err(e) => { - error!(?e, "Failed to create group channel"); - return Err(PoolError::shutdown(e)); - } - }; - group_channel.on_new_template(last_future_template.clone(), vec![pool_coinbase_output.clone()]).map_err(PoolError::shutdown)?; - - group_channel.on_set_new_prev_hash(last_set_new_prev_hash_tdp.clone()).map_err(PoolError::shutdown)?; - downstream_data.group_channels = Some(group_channel); - } let nominal_hash_rate = msg.nominal_hash_rate; let requested_max_target = Target::from_le_bytes(msg.max_target.inner_as_ref().try_into().unwrap()); let extranonce_prefix = channel_manager_data.extranonce_prefix_factory_standard.next_prefix_standard().map_err(PoolError::shutdown)?; @@ -204,7 +187,8 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }, }; - let group_channel_id = downstream_data.group_channels.as_ref().map(|channel| channel.get_group_channel_id()).unwrap_or(0); + let group_channel_id = downstream_data.group_channel.get_group_channel_id(); + let extranonce_prefix_size = standard_channel.get_extranonce_prefix().len(); let open_standard_mining_channel_success = OpenStandardMiningChannelSuccess { request_id: msg.request_id, @@ -249,8 +233,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { messages.push((downstream_id, Mining::SetNewPrevHash(set_new_prev_hash_mining)).into()); downstream_data.standard_channels.insert(channel_id, standard_channel); - if let Some(group_channel) = downstream_data.group_channels.as_mut() { - group_channel.add_standard_channel_id(channel_id); + if !downstream.requires_standard_jobs.load(Ordering::SeqCst) { + downstream_data.group_channel.add_channel_id(channel_id, extranonce_prefix_size).map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + PoolError::shutdown(e) + })?; } let vardiff = VardiffState::new().map_err(PoolError::shutdown)?; channel_manager_data.vardiff.insert((downstream_id, channel_id).into(), vardiff); @@ -327,11 +314,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { let mut extended_channel = match ExtendedChannel::new_for_pool( channel_id, user_identity.to_string(), - extranonce_prefix, + extranonce_prefix.clone(), requested_max_target, nominal_hash_rate, true, // version rolling always allowed - requested_min_rollable_extranonce_size, + CLIENT_SEARCH_SPACE_BYTES as u16, self.share_batch_size, self.shares_per_minute, job_store, @@ -400,6 +387,8 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }, }; + let group_channel_id = downstream_data.group_channel.get_group_channel_id(); + let open_extended_mining_channel_success = OpenExtendedMiningChannelSuccess { request_id, @@ -410,6 +399,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { .clone() .try_into().map_err(PoolError::shutdown)?, extranonce_size: extended_channel.get_rollable_extranonce_size(), + group_channel_id, } .into_static(); info!("Sending OpenExtendedMiningChannel.Success (downstream_id: {downstream_id}): {open_extended_mining_channel_success}"); @@ -499,6 +489,12 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { ) .into(), ); + + let full_extranonce_size = extended_channel.get_full_extranonce_size(); + downstream_data.group_channel.add_channel_id(channel_id, full_extranonce_size).map_err(|e| { + error!("Failed to add channel id to group channel: {:?}", e); + PoolError::shutdown(e) + })?; } downstream_data diff --git a/pool-apps/pool/src/lib/channel_manager/mod.rs b/pool-apps/pool/src/lib/channel_manager/mod.rs index 94ab0cc4..59422251 100644 --- a/pool-apps/pool/src/lib/channel_manager/mod.rs +++ b/pool-apps/pool/src/lib/channel_manager/mod.rs @@ -1,7 +1,10 @@ use std::{ collections::HashMap, net::SocketAddr, - sync::{atomic::AtomicUsize, Arc}, + sync::{ + atomic::{AtomicU32, AtomicUsize}, + Arc, + }, }; use async_channel::{Receiver, Sender}; @@ -13,10 +16,11 @@ use stratum_apps::{ key_utils::{Secp256k1PublicKey, Secp256k1SecretKey}, network_helpers::noise_stream::NoiseTcpStream, stratum_core::{ - bitcoin::TxOut, + bitcoin::{Amount, TxOut}, channels_sv2::{ server::{ extended::ExtendedChannel, + group::GroupChannel, jobs::{extended::ExtendedJob, job_store::DefaultJobStore, standard::StandardJob}, standard::StandardChannel, }, @@ -166,6 +170,56 @@ impl ChannelManager { Ok(channel_manager) } + // Bootstraps a group channel with the given parameters. + // Returns a `GroupChannel` if successful, otherwise returns `None`. + // + // To be called before calling Downstream::new. + fn bootstrap_group_channel( + &self, + channel_id: ChannelId, + ) -> Option>>> { + let (last_future_template, last_set_new_prev_hash) = + self.channel_manager_data.super_safe_lock(|data| { + ( + data.last_future_template + .clone() + .expect("No future template found after readiness check"), + data.last_new_prev_hash + .clone() + .expect("No new prevhash found after readiness check"), + ) + }); + let mut group_channel = match GroupChannel::new_for_pool( + channel_id, + DefaultJobStore::new(), + FULL_EXTRANONCE_SIZE, + self.pool_tag_string.clone(), + ) { + Ok(channel) => channel, + Err(e) => { + error!(error = ?e, "Failed to bootstrap group channel"); + return None; + } + }; + + let coinbase_output = TxOut { + value: Amount::from_sat(last_future_template.coinbase_tx_value_remaining), + script_pubkey: self.coinbase_reward_script.script_pubkey(), + }; + + if let Err(e) = group_channel.on_new_template(last_future_template, vec![coinbase_output]) { + error!(error = ?e, "Failed to add template to group channel"); + return None; + } + + if let Err(e) = group_channel.on_set_new_prev_hash(last_set_new_prev_hash) { + error!(error = ?e, "Failed to set new prevhash for group channel"); + return None; + } + + Some(group_channel) + } + /// Starts the downstream server, and accepts new connection request. #[allow(clippy::too_many_arguments)] pub async fn start_downstream_server( @@ -184,6 +238,38 @@ impl ChannelManager { Option>, )>, ) -> PoolResult<(), error::ChannelManager> { + let mut shutdown_rx = notify_shutdown.subscribe(); + + // Wait for initial template and prevhash before accepting connections + loop { + let has_required_data = self.channel_manager_data.super_safe_lock(|data| { + data.last_future_template.is_some() && data.last_new_prev_hash.is_some() + }); + + if has_required_data { + info!("Required template data received, ready to accept connections"); + break; + } + + warn!("Waiting for initial template and prevhash from Template Provider..."); + select! { + message = shutdown_rx.recv() => { + match message { + Ok(ShutdownMessage::ShutdownAll) => { + info!("Channel Manager: received shutdown while waiting for templates"); + return Ok(()); + } + Err(e) => { + warn!(error = ?e, "shutdown channel closed unexpectedly"); + return Ok(()); + } + _ => {} + } + } + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {} + } + } + info!("Starting downstream server at {listening_address}"); let server = TcpListener::bind(listening_address) .await @@ -193,8 +279,6 @@ impl ChannelManager { }) .map_err(PoolError::shutdown)?; - let mut shutdown_rx = notify_shutdown.subscribe(); - let task_manager_clone = task_manager.clone(); task_manager.spawn(async move { @@ -245,9 +329,22 @@ impl ChannelManager { .channel_manager_data .super_safe_lock(|data| data.downstream_id_factory.fetch_add(1, Ordering::SeqCst)); + let channel_id_factory = AtomicU32::new(1); + let group_channel_id = channel_id_factory.fetch_add(1, Ordering::SeqCst); + let group_channel = match self.bootstrap_group_channel(group_channel_id) { + Some(group_channel) => group_channel, + None => { + error!("Failed to bootstrap group channel"); + let error = PoolError::::shutdown(PoolErrorKind::CouldNotInitiateSystem); + handle_error(&StatusSender::ChannelManager(status_sender.clone()), error).await; + break; + } + }; let downstream = Downstream::new( downstream_id, + channel_id_factory, + group_channel, channel_manager_sender.clone(), channel_manager_receiver.clone(), noise_stream, @@ -258,7 +355,6 @@ impl ChannelManager { self.required_extensions.clone(), ); - self.channel_manager_data.super_safe_lock(|data| { data.downstream.insert(downstream_id, downstream.clone()); }); diff --git a/pool-apps/pool/src/lib/channel_manager/template_distribution_message_handler.rs b/pool-apps/pool/src/lib/channel_manager/template_distribution_message_handler.rs index 62d2fb85..bc1f7bdb 100644 --- a/pool-apps/pool/src/lib/channel_manager/template_distribution_message_handler.rs +++ b/pool-apps/pool/src/lib/channel_manager/template_distribution_message_handler.rs @@ -12,7 +12,7 @@ use tracing::{info, warn}; use crate::{ channel_manager::{ChannelManager, RouteMessageTo}, - error::{self, PoolError}, + error::{self, PoolError, PoolErrorKind}, }; #[cfg_attr(not(test), hotpath::measure_all)] @@ -44,133 +44,90 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { coinbase_output[0].value = Amount::from_sat(msg.coinbase_tx_value_remaining); for (downstream_id, downstream) in channel_manager_data.downstream.iter_mut() { - // If downstream requires custom work, skip template handling entirely (see https://github.com/stratum-mining/sv2-apps/issues/55) - if downstream.requires_custom_work.load(Ordering::SeqCst) { + let requires_custom_work = downstream.requires_custom_work.load(Ordering::SeqCst); + if requires_custom_work { continue; } - let messages_ = downstream.downstream_data.super_safe_lock(|data| { + let messages_: Vec> = downstream.downstream_data.super_safe_lock(|data| { + data.group_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()).map_err(|e| { + tracing::error!("Error while adding template to group channel"); + PoolError::shutdown(e) + })?; + + let group_channel_job = match msg.future_template { + true => { + let future_job_id = data.group_channel.get_future_job_id_from_template_id(msg.template_id).ok_or( + PoolError::shutdown(PoolErrorKind::JobNotFound) + )?; + data.group_channel.get_future_job(future_job_id).ok_or( + PoolError::shutdown(PoolErrorKind::JobNotFound) + )? + }, + false => { + data.group_channel.get_active_job().ok_or( + PoolError::shutdown(PoolErrorKind::JobNotFound) + )? + }, + }; let mut messages: Vec = vec![]; - let group_channel_job = if let Some(ref mut group_channel) = data.group_channels { - if group_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()).is_ok() { + // if REQUIRES_STANDARD_JOBS and REQUIRES_CUSTOM_WORK are not set and the group channel is not empty + // we need to send the NewExtendedMiningJob message to the group channel + let requires_standard_jobs = downstream.requires_standard_jobs.load(Ordering::SeqCst); + let empty_group_channel = data.group_channel.get_channel_ids().is_empty(); + if !requires_standard_jobs && !requires_custom_work && !empty_group_channel { + messages.push((*downstream_id, Mining::NewExtendedMiningJob(group_channel_job.get_job_message().clone())).into()); + } + + // loop over every standard channel + // if REQUIRES_STANDARD_JOBS is not set, we need to call on_group_channel_job on each standard channel + // if REQUIRES_STANDARD_JOBS is set, we need to call on_new_template, and send individual NewMiningJob messages for each standard channel + for (channel_id, standard_channel) in data.standard_channels.iter_mut() { + if !requires_standard_jobs { + standard_channel.on_group_channel_job(group_channel_job.clone()).map_err(|e| { + tracing::error!("Error while adding group channel job to standard channel with id: {channel_id:?}"); + PoolError::shutdown(e) + })?; + } else { + standard_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()).map_err(|e| { + tracing::error!("Error while adding template to standard channel"); + PoolError::shutdown(e) + })?; + match msg.future_template { true => { - let future_job_id = group_channel - .get_future_job_id_from_template_id(msg.template_id) - .expect("job_id must exist"); - Some(group_channel - .get_future_job(future_job_id) - .expect("future job must exist")) + let standard_job_id = standard_channel.get_future_job_id_from_template_id(msg.template_id).expect("future job id must exist"); + let standard_job = standard_channel.get_future_job(standard_job_id).expect("future job must exist"); + messages.push((*downstream_id, Mining::NewMiningJob(standard_job.get_job_message().clone())).into()); }, false => { - Some(group_channel - .get_active_job() - .expect("active job must exist")) - } - } - } else { - tracing::error!("Some issue with downstream: {downstream_id}, group channel"); - None - } - } else { - None - }; - - match msg.future_template { - true => { - for (channel_id, standard_channel) in data.standard_channels.iter_mut() { - if data.group_channels.is_none() { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let standard_job_id = standard_channel.get_future_job_id_from_template_id(msg.template_id).expect("job_id must exist"); - let standard_job = standard_channel.get_future_job(standard_job_id).expect("standard job must exist"); - let standard_job_message = standard_job.get_job_message(); - messages.push((*downstream_id, Mining::NewMiningJob(standard_job_message.clone())).into()); - } - if let Some(ref group_channel_job) = group_channel_job { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - _ = standard_channel - .on_group_channel_job(group_channel_job.clone()); - } - } - if let Some(group_channel_job) = group_channel_job { - let job_message = group_channel_job.get_job_message(); - messages.push((*downstream_id, Mining::NewExtendedMiningJob(job_message.clone())).into()); - } - - for (channel_id, extended_channel) in data.extended_channels.iter_mut() { - if let Err(e) = extended_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let extended_job_id = extended_channel - .get_future_job_id_from_template_id(msg.template_id) - .expect("job_id must exist"); - - let extended_job = extended_channel - .get_future_job(extended_job_id) - .expect("extended job must exist"); - - let extended_job_message = extended_job.get_job_message(); - - messages.push((*downstream_id, Mining::NewExtendedMiningJob(extended_job_message.clone())).into()); + let standard_job = standard_channel.get_active_job().expect("active job must exist"); + messages.push((*downstream_id, Mining::NewMiningJob(standard_job.get_job_message().clone())).into()); + }, } } - false => { - for (channel_id, standard_channel) in data.standard_channels.iter_mut() { - if data.group_channels.is_none() { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let standard_job = standard_channel.get_active_job().expect("standard job must exist"); - let standard_job_message = standard_job.get_job_message(); - messages.push((*downstream_id, Mining::NewMiningJob(standard_job_message.clone())).into()); - } - if let Some(ref group_channel_job) = group_channel_job { - if let Err(e) = standard_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - _ = standard_channel - .on_group_channel_job(group_channel_job.clone()); - } - } - if let Some(group_channel_job) = group_channel_job { - let job_message = group_channel_job.get_job_message(); - messages.push((*downstream_id, Mining::NewExtendedMiningJob(job_message.clone())).into()); - } + } - for (channel_id, extended_channel) in data.extended_channels.iter_mut() { - if let Err(e) = extended_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()) { - tracing::error!("Error while adding template to standard channel: {channel_id:?} {e:?}"); - continue; - } - let extended_job = extended_channel - .get_active_job() - .expect("extended job must exist"); - let extended_job_message = extended_job.get_job_message(); - - messages.push((*downstream_id, Mining::NewExtendedMiningJob(extended_job_message.clone())).into()); - } + // loop over every extended channel, and call on_group_channel_job on each extended channel + for (channel_id, extended_channel) in data.extended_channels.iter_mut() { + if !requires_custom_work { + extended_channel.on_group_channel_job(group_channel_job.clone()).map_err(|e| { + tracing::error!("Error while adding group channel job to extended channel with id: {channel_id:?}"); + PoolError::shutdown(e) + })?; } } - messages - }); + Ok::>, Self::Error>(messages) + })?; messages.extend(messages_); } - messages - }); + Ok::>, Self::Error>(messages) + })?; for message in messages { message.forward(&self.channel_manager_channel).await; @@ -213,103 +170,82 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { let mut messages: Vec = vec![]; for (downstream_id, downstream) in data.downstream.iter_mut() { + // If downstream requires custom work, skip template handling entirely (see https://github.com/stratum-mining/sv2-apps/issues/55) + let requires_custom_work = downstream.requires_custom_work.load(Ordering::SeqCst); + if requires_custom_work { + continue; + } + let downstream_messages = downstream.downstream_data.super_safe_lock(|data| { let mut messages: Vec = vec![]; - if let Some(ref mut group_channel) = data.group_channels { - _ = group_channel.on_set_new_prev_hash(msg.clone().into_static()); - let group_channel_id = group_channel.get_group_channel_id(); - let activated_group_job_id = group_channel - .get_active_job() - .expect("active job must exist") - .get_job_id(); - - let set_new_prev_hash_message = SetNewPrevHashMp { + + // call on_set_new_prev_hash on the group channel to update the channel state + data.group_channel.on_set_new_prev_hash(msg.clone().into_static()).map_err(|e| { + tracing::error!("Error while adding new prev hash to group channel"); + PoolError::shutdown(e) + })?; + + // did SetupConnection have the REQUIRES_CUSTOM_WORK or REQUIRES_STANDARD_JOBS flags set? + // if no, and the group channel is not empty, we need to send the SetNewPrevHashMp to the group channel + let requires_custom_work = downstream.requires_custom_work.load(Ordering::SeqCst); + let requires_standard_jobs = downstream.requires_standard_jobs.load(Ordering::SeqCst); + let empty_group_channel = data.group_channel.get_channel_ids().is_empty(); + if !requires_custom_work && !requires_standard_jobs && !empty_group_channel { + let group_channel_id = data.group_channel.get_group_channel_id(); + let activated_group_job_id = data.group_channel.get_active_job().expect("active job must exist").get_job_id(); + let group_set_new_prev_hash_message = SetNewPrevHashMp { channel_id: group_channel_id, job_id: activated_group_job_id, prev_hash: msg.prev_hash.clone(), min_ntime: msg.header_timestamp, nbits: msg.n_bits, }; - messages.push( - ( - *downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_message), - ) - .into(), - ); + + // send the SetNewPrevHash message to the group channel + messages.push((*downstream_id, Mining::SetNewPrevHash(group_set_new_prev_hash_message)).into()); + } + + // loop over every extended channel, and call on_set_new_prev_hash on each extended channel to update the channel state + for (channel_id, extended_channel) in data.extended_channels.iter_mut() { + extended_channel.on_set_new_prev_hash(msg.clone().into_static()).map_err(|e| { + tracing::error!("Error while adding new prev hash to extended channel: {channel_id:?} {e:?}"); + PoolError::shutdown(e) + })?; } + // loop over every standard channel, and call on_set_new_prev_hash on each standard channel to update the channel state for (channel_id, standard_channel) in data.standard_channels.iter_mut() { - if let Err(e) = standard_channel.on_set_new_prev_hash(msg.clone().into_static()) { + // call on_set_new_prev_hash on the standard channel to update the channel state + standard_channel.on_set_new_prev_hash(msg.clone().into_static()).map_err(|e| { tracing::error!("Error while adding new prev hash to standard channel: {channel_id:?} {e:?}"); - continue; - }; + PoolError::shutdown(e) + })?; // did SetupConnection have the REQUIRES_STANDARD_JOBS flag set? - // if yes, there's no group channel, so we need to send the SetNewPrevHashMp - // to each standard channel - if data.group_channels.is_none() { - let activated_standard_job_id = standard_channel - .get_active_job() - .expect("active job must exist") - .get_job_id(); - let set_new_prev_hash_message = SetNewPrevHashMp { + // if yes, we need to send the SetNewPrevHashMp to each standard channel + if downstream.requires_standard_jobs.load(Ordering::SeqCst) { + let activated_standard_job_id = standard_channel.get_active_job().ok_or( + PoolError::shutdown(PoolErrorKind::JobNotFound) + )?.get_job_id(); + let standard_set_new_prev_hash_message = SetNewPrevHashMp { channel_id: *channel_id, job_id: activated_standard_job_id, prev_hash: msg.prev_hash.clone(), min_ntime: msg.header_timestamp, nbits: msg.n_bits, }; - messages.push( - ( - *downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_message), - ) - .into(), - ); + messages.push((*downstream_id, Mining::SetNewPrevHash(standard_set_new_prev_hash_message)).into()); } } - for (channel_id, extended_channel) in data.extended_channels.iter_mut() { - if let Err(e) = extended_channel.on_set_new_prev_hash(msg.clone().into_static()) { - tracing::error!("Error while adding new prev hash to extended channel: {channel_id:?} {e:?}"); - continue; - }; - - // don't send any SetNewPrevHash messages to Extended Channels - // if the downstream requires custom work - if downstream.requires_custom_work.load(Ordering::SeqCst) { - continue; - } - - let activated_extended_job_id = extended_channel - .get_active_job() - .expect("active job must exist") - .get_job_id(); - let set_new_prev_hash_message = SetNewPrevHashMp { - channel_id: *channel_id, - job_id: activated_extended_job_id, - prev_hash: msg.prev_hash.clone(), - min_ntime: msg.header_timestamp, - nbits: msg.n_bits, - }; - messages.push( - ( - *downstream_id, - Mining::SetNewPrevHash(set_new_prev_hash_message), - ) - .into(), - ); - } - - messages - }); + Ok::>, Self::Error>(messages) + })?; messages.extend(downstream_messages); } - messages - }); + Ok::>, Self::Error>(messages) + })?; for message in messages { message.forward(&self.channel_manager_channel).await; diff --git a/pool-apps/pool/src/lib/downstream/mod.rs b/pool-apps/pool/src/lib/downstream/mod.rs index e84f31b6..479987b8 100644 --- a/pool-apps/pool/src/lib/downstream/mod.rs +++ b/pool-apps/pool/src/lib/downstream/mod.rs @@ -45,12 +45,12 @@ mod extensions_message_handler; /// /// This includes: /// - Whether the downstream requires a standard job (`require_std_job`). -/// - An optional [`GroupChannel`] if group channeling is used. +/// - A [`GroupChannel`]. /// - Active [`ExtendedChannel`]s keyed by channel ID. /// - Active [`StandardChannel`]s keyed by channel ID. /// - Extensions that have been successfully negotiated with this client pub struct DownstreamData { - pub group_channels: Option>>>, + pub group_channel: GroupChannel<'static, DefaultJobStore>>, pub extended_channels: HashMap>>>, pub standard_channels: @@ -93,9 +93,11 @@ pub struct Downstream { #[cfg_attr(not(test), hotpath::measure_all)] impl Downstream { /// Creates a new [`Downstream`] instance and spawns the necessary I/O tasks. - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments, clippy::result_large_err)] pub fn new( downstream_id: DownstreamId, + channel_id_factory: AtomicU32, + group_channel: GroupChannel<'static, DefaultJobStore>>, channel_manager_sender: Sender<(DownstreamId, Mining<'static>, Option>)>, channel_manager_receiver: broadcast::Sender<( DownstreamId, @@ -132,13 +134,15 @@ impl Downstream { downstream_sender: outbound_tx, downstream_receiver: inbound_rx, }; + let downstream_data = Arc::new(Mutex::new(DownstreamData { extended_channels: HashMap::new(), standard_channels: HashMap::new(), - group_channels: None, - channel_id_factory: AtomicU32::new(1), + group_channel, + channel_id_factory, negotiated_extensions: vec![], })); + Downstream { downstream_channel, downstream_data, diff --git a/pool-apps/pool/src/lib/error.rs b/pool-apps/pool/src/lib/error.rs index 73291a69..c5b6e419 100644 --- a/pool-apps/pool/src/lib/error.rs +++ b/pool-apps/pool/src/lib/error.rs @@ -163,8 +163,6 @@ pub enum PoolErrorKind { VardiffNotFound(ChannelId), /// Errors on bad `String` to `int` conversion. ParseInt(std::num::ParseIntError), - /// Failed to create group channel - FailedToCreateGroupChannel(GroupChannelError), /// Invalid unsupported extensions sequence InvalidUnsupportedExtensionsSequence(binary_sv2::Error), /// Invalid required extensions sequence @@ -189,6 +187,8 @@ pub enum PoolErrorKind { CouldNotInitiateSystem, /// Configuration error Configuration(String), + /// Job not found + JobNotFound, } impl std::fmt::Display for PoolErrorKind { @@ -235,9 +235,6 @@ impl std::fmt::Display for PoolErrorKind { ChannelSv2(channel_error) => { write!(f, "Channel error: {channel_error:?}") } - FailedToCreateGroupChannel(ref e) => { - write!(f, "Failed to create group channel: {e:?}") - } InvalidUnsupportedExtensionsSequence(e) => { write!( f, @@ -280,6 +277,7 @@ impl std::fmt::Display for PoolErrorKind { } CouldNotInitiateSystem => write!(f, "Could not initiate subsystem"), Configuration(e) => write!(f, "Configuration error: {e}"), + JobNotFound => write!(f, "Job not found"), } } }