diff --git a/integration-tests/lib/mod.rs b/integration-tests/lib/mod.rs index 878e8f6a9..69985e540 100644 --- a/integration-tests/lib/mod.rs +++ b/integration-tests/lib/mod.rs @@ -53,6 +53,7 @@ macro_rules! shutdown_all { const SHARES_PER_MINUTE: f32 = 120.0; +pub const POOL_COINBASE_REWARD_ADDRESS: &str = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8"; const POOL_COINBASE_REWARD_DESCRIPTOR: &str = "addr(tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8)"; const JDS_COINBASE_REWARD_DESCRIPTOR: &str = POOL_COINBASE_REWARD_DESCRIPTOR; const JDC_COINBASE_REWARD_DESCRIPTOR: &str = "addr(tb1qpusf5256yxv50qt0pm0tue8k952fsu5lzsphft)"; diff --git a/integration-tests/tests/pool_solo_mining.rs b/integration-tests/tests/pool_solo_mining.rs new file mode 100644 index 000000000..4207beaa0 --- /dev/null +++ b/integration-tests/tests/pool_solo_mining.rs @@ -0,0 +1,1296 @@ +//! Tests for pool solo mining mode functionality. +//! +//! These tests validate the pool's handling of different `user_identity` patterns +//! when opening extended mining channels. The user_identity determines how block +//! rewards are distributed between the miner and pool. +//! +//! ## User Identity Patterns +//! +//! | Pattern | Description | Reward Distribution | +//! |---------|-------------|---------------------| +//! | `sri/solo/{payout_addr}/worker.1` | Full solo mining | Miner receives 100% | +//! | `{payout_addr}` (legacy) | Legacy solo mining | Miner receives 100% | +//! | `sri/donate/worker.1` | Full donation to pool | Pool receives 100% | +//! | `sri/donate/{percentage}/{payout_addr}/worker.1` | Partial donation | Pool gets specified %, miner gets rest | +//! | Other patterns | Regular pool mining | Pool receives 100% | +//! +//! ## Test Categories +//! +//! - **Error cases**: Invalid user_identity patterns should return errors +//! - **Solo mining**: Miner specifies payout address, receives full reward +//! - **Donation**: Miner can donate portion or all of reward to pool +//! - **Regular pool**: Default behavior when no solo pattern detected + +use integration_tests_sv2::{ + interceptor::MessageDirection, + mock_roles::{MockDownstream, WithSetup}, + template_provider::DifficultyLevel, + POOL_COINBASE_REWARD_ADDRESS, *, +}; +use stratum_apps::stratum_core::{ + bitcoin::{consensus::deserialize, params::TESTNET4, Address, Transaction}, + common_messages_sv2::*, + mining_sv2::*, + parsers_sv2::{self, AnyMessage, Mining}, +}; + +const MINER_COINBASE_REWARD_ADDR: &str = "tb1qpusf5256yxv50qt0pm0tue8k952fsu5lzsphft"; + +fn build_coinbase_tx( + channel_success: &OpenExtendedMiningChannelSuccess, + new_job: &NewExtendedMiningJob, +) -> Transaction { + let prefix = new_job.coinbase_tx_prefix.inner_as_ref(); + let suffix = new_job.coinbase_tx_suffix.inner_as_ref(); + let extranonce_prefix = channel_success.extranonce_prefix.inner_as_ref(); + let extranonce_suffix = vec![0; channel_success.extranonce_size as usize]; + let mut coinbase = Vec::new(); + + coinbase.extend_from_slice(prefix); + coinbase.extend_from_slice(extranonce_prefix); + coinbase.extend_from_slice(&extranonce_suffix); + coinbase.extend_from_slice(suffix); + + deserialize(&coinbase).expect("coinbase bytes should be valid") +} + +struct PayoutInfo { + addresses: Vec, + amounts: Vec, + total: u64, +} + +fn extract_payout_info(coinbase_tx: &Transaction) -> PayoutInfo { + let payouts: Vec = coinbase_tx + .output + .iter() + .filter(|o| !o.script_pubkey.is_op_return()) + .map(|o| o.value.to_sat()) + .collect(); + + let addresses: Vec = coinbase_tx + .output + .iter() + .filter(|o| !o.script_pubkey.is_op_return()) + .map(|o| { + Address::from_script(&o.script_pubkey, TESTNET4.clone()) + .expect("scriptPubKey should be valid") + .to_string() + }) + .collect(); + + let total: u64 = payouts.iter().sum(); + + PayoutInfo { + addresses, + amounts: payouts, + total, + } +} + +fn assert_payout_percentage(payout_info: &PayoutInfo, expected_percentages: &[(String, f64)]) { + for (addr, expected_pct) in expected_percentages { + let idx = payout_info + .addresses + .iter() + .position(|a| a == addr) + .expect(&format!("Address {} not found in coinbase", addr)); + let actual_pct = (payout_info.amounts[idx] as f64 / payout_info.total as f64) * 100.0; + assert!( + (actual_pct - expected_pct).abs() < 0.1, + "Address {} should receive ~{}%, got {}%", + addr, + expected_pct, + actual_pct + ); + } +} + +#[tokio::test] +async fn pool_solo_mining_invalid_payout_address() { + start_tracing(); + let (_tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - invalid payout address === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: "sri/solo/tb1qbalieiro/worker.1" + .to_string() + .try_into() + .unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, + ) + .await; + + let error_ext: OpenMiningChannelError = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::OpenMiningChannelError(msg)))) => { + break msg; + } + _ => continue, + } + }; + assert_eq!( + error_ext.error_code.as_utf8_or_hex(), + "invalid-user-identity" + ); + + // === Standard Channel - invalid payout address === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: "sri/solo/tb1qbalieiro/worker.1" + .to_string() + .try_into() + .unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, + ) + .await; + + let error_std: OpenMiningChannelError = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::OpenMiningChannelError(msg)))) => { + break msg; + } + _ => continue, + } + }; + assert_eq!( + error_std.error_code.as_utf8_or_hex(), + "invalid-user-identity" + ); + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_wrong_user_identity() { + start_tracing(); + let (_tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - missing keyword === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: "sri/worker.1".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, + ) + .await; + + let error_ext: OpenMiningChannelError = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::OpenMiningChannelError(msg)))) => { + break msg; + } + _ => continue, + } + }; + assert_eq!( + error_ext.error_code.as_utf8_or_hex(), + "invalid-user-identity" + ); + + // === Standard Channel - missing keyword === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: "sri/worker.1".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_MINING_CHANNEL_ERROR, + ) + .await; + + let error_std: OpenMiningChannelError = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::OpenMiningChannelError(msg)))) => { + break msg; + } + _ => continue, + } + }; + assert_eq!( + error_std.error_code.as_utf8_or_hex(), + "invalid-user-identity" + ); + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_random_user_identity() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - random user_identity, pool gets 100% === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: "cool_miner/worker.1".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let channel_success_ext: OpenExtendedMiningChannelSuccess = loop { + match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => break msg, + _ => continue, + } + }; + + let new_job_ext: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_ext = build_coinbase_tx(&channel_success_ext, &new_job_ext); + let payout_info_ext = extract_payout_info(&coinbase_tx_ext); + + assert_eq!(payout_info_ext.addresses.len(), 1); + assert_eq!(payout_info_ext.addresses[0], POOL_COINBASE_REWARD_ADDRESS); + assert_payout_percentage( + &payout_info_ext, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Trigger new template via mempool transaction === + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for second job (from mempool) and verify payout === + let new_job_ext_second: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_second = build_coinbase_tx(&channel_success_ext, &new_job_ext_second); + let payout_info_second = extract_payout_info(&coinbase_tx_second); + + assert_eq!( + payout_info_second.addresses.len(), + 1, + "Second job (mempool) should have exactly 1 output" + ); + assert_eq!( + payout_info_second.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Second job (mempool) payout should go to pool address" + ); + assert_payout_percentage( + &payout_info_second, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Trigger new template to force pool to send a new job === + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for third job (from generate blocks) and verify payout === + let new_job_ext_third: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_third = build_coinbase_tx(&channel_success_ext, &new_job_ext_third); + let payout_info_third = extract_payout_info(&coinbase_tx_third); + + assert_eq!( + payout_info_third.addresses.len(), + 1, + "Third job (generate blocks) should have exactly 1 output" + ); + assert_eq!( + payout_info_third.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Third job (generate blocks) payout should STILL go to pool address" + ); + assert_payout_percentage( + &payout_info_third, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Standard Channel - random user_identity === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: "cool_miner/worker.1".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_legacy_pattern() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - legacy pattern, miner gets 100% === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: MINER_COINBASE_REWARD_ADDR.to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let channel_success_ext: OpenExtendedMiningChannelSuccess = loop { + match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => break msg, + _ => continue, + } + }; + + let new_job_ext: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_ext = build_coinbase_tx(&channel_success_ext, &new_job_ext); + let payout_info_ext = extract_payout_info(&coinbase_tx_ext); + + assert_eq!(payout_info_ext.addresses.len(), 1); + assert_eq!(payout_info_ext.addresses[0], MINER_COINBASE_REWARD_ADDR); + assert_payout_percentage( + &payout_info_ext, + &[(MINER_COINBASE_REWARD_ADDR.to_string(), 100.0)], + ); + + // === Trigger new template via mempool transaction === + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for second job (from mempool) and verify payout === + let new_job_ext_second: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_second = build_coinbase_tx(&channel_success_ext, &new_job_ext_second); + let payout_info_second = extract_payout_info(&coinbase_tx_second); + + assert_eq!( + payout_info_second.addresses.len(), + 1, + "Second job (mempool) should have exactly 1 output" + ); + assert_eq!( + payout_info_second.addresses[0], MINER_COINBASE_REWARD_ADDR, + "Second job (mempool) payout should go to miner address" + ); + assert_payout_percentage( + &payout_info_second, + &[(MINER_COINBASE_REWARD_ADDR.to_string(), 100.0)], + ); + + // === Trigger new template to force pool to send a new job === + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for third job (from generate blocks) and verify payout === + let new_job_ext_third: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_third = build_coinbase_tx(&channel_success_ext, &new_job_ext_third); + let payout_info_third = extract_payout_info(&coinbase_tx_third); + + assert_eq!( + payout_info_third.addresses.len(), + 1, + "Third job (generate blocks) should have exactly 1 output" + ); + assert_eq!( + payout_info_third.addresses[0], MINER_COINBASE_REWARD_ADDR, + "Third job (generate blocks) payout should STILL go to miner address" + ); + assert_payout_percentage( + &payout_info_third, + &[(MINER_COINBASE_REWARD_ADDR.to_string(), 100.0)], + ); + + // === Standard Channel - legacy pattern === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: MINER_COINBASE_REWARD_ADDR.to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_solo_pattern() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - sri/solo pattern, miner gets 100% === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: format!("sri/solo/{}/worker.1", MINER_COINBASE_REWARD_ADDR) + .try_into() + .unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let channel_success_ext: OpenExtendedMiningChannelSuccess = loop { + match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => break msg, + _ => continue, + } + }; + + let new_job_ext: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_ext = build_coinbase_tx(&channel_success_ext, &new_job_ext); + let payout_info_ext = extract_payout_info(&coinbase_tx_ext); + + assert_eq!(payout_info_ext.addresses.len(), 1); + assert_eq!(payout_info_ext.addresses[0], MINER_COINBASE_REWARD_ADDR); + assert_payout_percentage( + &payout_info_ext, + &[(MINER_COINBASE_REWARD_ADDR.to_string(), 100.0)], + ); + + // === Trigger new template via mempool transaction === + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for second job (from mempool) and verify payout === + let new_job_ext_second: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_second = build_coinbase_tx(&channel_success_ext, &new_job_ext_second); + let payout_info_second = extract_payout_info(&coinbase_tx_second); + + assert_eq!( + payout_info_second.addresses.len(), + 1, + "Second job (mempool) should have exactly 1 output" + ); + assert_eq!( + payout_info_second.addresses[0], MINER_COINBASE_REWARD_ADDR, + "Second job (mempool) payout should go to miner address" + ); + assert_payout_percentage( + &payout_info_second, + &[(MINER_COINBASE_REWARD_ADDR.to_string(), 100.0)], + ); + + // === Trigger new template to force pool to send a new job === + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for third job (from generate blocks) and verify payout === + let new_job_ext_third: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_third = build_coinbase_tx(&channel_success_ext, &new_job_ext_third); + let payout_info_third = extract_payout_info(&coinbase_tx_third); + + assert_eq!( + payout_info_third.addresses.len(), + 1, + "Third job (generate blocks) should have exactly 1 output" + ); + assert_eq!( + payout_info_third.addresses[0], MINER_COINBASE_REWARD_ADDR, + "Third job (generate blocks) payout should STILL go to miner address" + ); + assert_payout_percentage( + &payout_info_third, + &[(MINER_COINBASE_REWARD_ADDR.to_string(), 100.0)], + ); + + // === Standard Channel - sri/solo pattern === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: format!("sri/solo/{}/worker.1", MINER_COINBASE_REWARD_ADDR) + .try_into() + .unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_full_donate() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - sri/donate, pool gets 100% === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: "sri/donate/worker.1".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let channel_success_ext: OpenExtendedMiningChannelSuccess = loop { + match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => break msg, + _ => continue, + } + }; + + let new_job_ext: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_ext = build_coinbase_tx(&channel_success_ext, &new_job_ext); + let payout_info_ext = extract_payout_info(&coinbase_tx_ext); + + assert_eq!(payout_info_ext.addresses.len(), 1); + assert_eq!(payout_info_ext.addresses[0], POOL_COINBASE_REWARD_ADDRESS); + assert_payout_percentage( + &payout_info_ext, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Trigger new template via mempool transaction === + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for second job (from mempool) and verify payout === + let new_job_ext_second: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_second = build_coinbase_tx(&channel_success_ext, &new_job_ext_second); + let payout_info_second = extract_payout_info(&coinbase_tx_second); + + assert_eq!( + payout_info_second.addresses.len(), + 1, + "Second job (mempool) should have exactly 1 output" + ); + assert_eq!( + payout_info_second.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Second job (mempool) payout should go to pool address" + ); + assert_payout_percentage( + &payout_info_second, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Trigger new template to force pool to send a new job === + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for third job (from generate blocks) and verify payout === + let new_job_ext_third: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_third = build_coinbase_tx(&channel_success_ext, &new_job_ext_third); + let payout_info_third = extract_payout_info(&coinbase_tx_third); + + assert_eq!( + payout_info_third.addresses.len(), + 1, + "Third job (generate blocks) should have exactly 1 output" + ); + assert_eq!( + payout_info_third.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Third job (generate blocks) payout should STILL go to pool address" + ); + assert_payout_percentage( + &payout_info_third, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Standard Channel - sri/donate === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: "sri/donate/worker.1".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_full_donate_no_worker_name() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - sri/donate (no worker name), pool gets 100% === + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: "sri/donate".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let channel_success_ext: OpenExtendedMiningChannelSuccess = loop { + match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => break msg, + _ => continue, + } + }; + + let new_job_ext: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_ext = build_coinbase_tx(&channel_success_ext, &new_job_ext); + let payout_info_ext = extract_payout_info(&coinbase_tx_ext); + + assert_eq!(payout_info_ext.addresses.len(), 1); + assert_eq!(payout_info_ext.addresses[0], POOL_COINBASE_REWARD_ADDRESS); + assert_payout_percentage( + &payout_info_ext, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Trigger new template via mempool transaction === + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for second job (from mempool) and verify payout === + let new_job_ext_second: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_second = build_coinbase_tx(&channel_success_ext, &new_job_ext_second); + let payout_info_second = extract_payout_info(&coinbase_tx_second); + + assert_eq!( + payout_info_second.addresses.len(), + 1, + "Second job (mempool) should have exactly 1 output" + ); + assert_eq!( + payout_info_second.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Second job (mempool) payout should go to pool address" + ); + assert_payout_percentage( + &payout_info_second, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Trigger new template to force pool to send a new job === + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for third job (from generate blocks) and verify payout === + let new_job_ext_third: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_third = build_coinbase_tx(&channel_success_ext, &new_job_ext_third); + let payout_info_third = extract_payout_info(&coinbase_tx_third); + + assert_eq!( + payout_info_third.addresses.len(), + 1, + "Third job (generate blocks) should have exactly 1 output" + ); + assert_eq!( + payout_info_third.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Third job (generate blocks) payout should STILL go to pool address" + ); + assert_payout_percentage( + &payout_info_third, + &[(POOL_COINBASE_REWARD_ADDRESS.to_string(), 100.0)], + ); + + // === Standard Channel - sri/donate (no worker name) === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: "sri/donate".to_string().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + shutdown_all!(pool); +} + +#[tokio::test] +async fn pool_solo_mining_partial_donation() { + start_tracing(); + let (tp, tp_addr) = start_template_provider(None, DifficultyLevel::Low); + tp.fund_wallet().unwrap(); + let (pool, pool_addr, _) = start_pool(sv2_tp_config(tp_addr), vec![], vec![], false).await; + let (sniffer, sniffer_addr) = start_sniffer("solo_test", pool_addr, false, vec![], None); + + let mock_downstream = MockDownstream::new( + sniffer_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ); + let send_to_pool = mock_downstream.start().await; + + sniffer + .wait_for_message_type_and_clean_queue( + MessageDirection::ToDownstream, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, + ) + .await; + + // === Extended Channel - sri/donate/5%, pool gets 5%, miner gets 95% === + let user_identity = format!("sri/donate/5/{}/worker.1", MINER_COINBASE_REWARD_ADDR); + let open_extended = AnyMessage::Mining(Mining::OpenExtendedMiningChannel( + OpenExtendedMiningChannel { + request_id: 0u32, + user_identity: user_identity.clone().try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + min_extranonce_size: 8, + }, + )); + send_to_pool.send(open_extended).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL_SUCCESS, + ) + .await; + + let channel_success_ext: OpenExtendedMiningChannelSuccess = loop { + match sniffer.next_message_from_upstream() { + Some(( + _, + AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannelSuccess(msg)), + )) => break msg, + _ => continue, + } + }; + + let new_job_ext: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_ext = build_coinbase_tx(&channel_success_ext, &new_job_ext); + + assert_eq!(coinbase_tx_ext.output.len(), 3); + + let payout_info_ext = extract_payout_info(&coinbase_tx_ext); + + assert_eq!(payout_info_ext.addresses.len(), 2); + assert_eq!(payout_info_ext.addresses[0], POOL_COINBASE_REWARD_ADDRESS); + assert_eq!(payout_info_ext.addresses[1], MINER_COINBASE_REWARD_ADDR); + + assert_payout_percentage( + &payout_info_ext, + &[ + (POOL_COINBASE_REWARD_ADDRESS.to_string(), 5.0), + (MINER_COINBASE_REWARD_ADDR.to_string(), 95.0), + ], + ); + + // === Trigger new template via mempool transaction === + tp.create_mempool_transaction().unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for second job (from mempool) and verify payout === + let new_job_ext_second: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_second = build_coinbase_tx(&channel_success_ext, &new_job_ext_second); + let payout_info_second = extract_payout_info(&coinbase_tx_second); + + assert_eq!( + payout_info_second.addresses.len(), + 2, + "Second job (mempool) should have exactly 2 outputs" + ); + assert_eq!( + payout_info_second.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Second job (mempool) payout should have pool address first" + ); + assert_eq!( + payout_info_second.addresses[1], MINER_COINBASE_REWARD_ADDR, + "Second job (mempool) payout should have miner address second" + ); + assert_payout_percentage( + &payout_info_second, + &[ + (POOL_COINBASE_REWARD_ADDRESS.to_string(), 5.0), + (MINER_COINBASE_REWARD_ADDR.to_string(), 95.0), + ], + ); + + // === Trigger new template to force pool to send a new job === + tp.generate_blocks(1); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + // === Wait for third job (from generate blocks) and verify payout === + let new_job_ext_third: NewExtendedMiningJob = loop { + match sniffer.next_message_from_upstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::NewExtendedMiningJob(msg)))) => { + break msg; + } + _ => continue, + } + }; + + let coinbase_tx_third = build_coinbase_tx(&channel_success_ext, &new_job_ext_third); + let payout_info_third = extract_payout_info(&coinbase_tx_third); + + assert_eq!( + payout_info_third.addresses.len(), + 2, + "Third job (generate blocks) should have exactly 2 outputs" + ); + assert_eq!( + payout_info_third.addresses[0], POOL_COINBASE_REWARD_ADDRESS, + "Third job (generate blocks) payout should STILL have pool address first" + ); + assert_eq!( + payout_info_third.addresses[1], MINER_COINBASE_REWARD_ADDR, + "Third job (generate blocks) payout should STILL have miner address second" + ); + assert_payout_percentage( + &payout_info_third, + &[ + (POOL_COINBASE_REWARD_ADDRESS.to_string(), 5.0), + (MINER_COINBASE_REWARD_ADDR.to_string(), 95.0), + ], + ); + + // === Standard Channel - sri/donate/5% === + let open_standard = AnyMessage::Mining(Mining::OpenStandardMiningChannel( + OpenStandardMiningChannel { + request_id: 0u32.into(), + user_identity: user_identity.try_into().unwrap(), + nominal_hash_rate: 1000.0, + max_target: vec![0xff; 32].try_into().unwrap(), + }, + )); + send_to_pool.send(open_standard).await.unwrap(); + + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_OPEN_STANDARD_MINING_CHANNEL_SUCCESS, + ) + .await; + + shutdown_all!(pool); +} diff --git a/pool-apps/pool/README.md b/pool-apps/pool/README.md index 7525c5c6b..c7375892f 100644 --- a/pool-apps/pool/README.md +++ b/pool-apps/pool/README.md @@ -84,4 +84,30 @@ Run the Pool (example using hosted Sv2 TP): ```bash cd pool-apps/pool cargo run -- -c config-examples/pool-config-hosted-sv2-tp-example.toml -``` +``` + +## Solo Mining Mode + +The solo mining mode is computed during runtime and expects that the `user_identity` value of `OpenStandardMiningChannel`/`OpenExtendMiningChannel` to be crafted in specific patterns. +If the `user_identity` does not match any of the patterns, the pool continues with the payout to the pool. If the `user_identity` matches the magic bytes: `sri` but the pattern is malformed we send a `OpenMiningChannelError`. +### User Identity Patterns + +Miners must specify their payout mode via `user_identity`: + +| Pattern | Mode | Description | +|---------|------|-------------| +| `sri/donate/optional_worker_name` | FullDonation | Full reward goes to pool | +| `sri/solo/payout_address/optional_worker_name` | Solo | Full reward goes to miner's address | +| `bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x.optional_worker_name`| Solo | Full reward goes to miner's address | +| `sri/donate/percentage/payout_address/optional_worker_name` | Donate | Pool gets %, miner gets remainder | + + +Any payout address valid for the descriptor `addr` (see [BIP-385](https://github.com/bitcoin/bips/blob/master/bip-0385.mediawiki)) is a valid payout address. +In all the patterns on the list, the worker name is optional. + +### Error Scenarios + +| Error Code | Cause | +|------------|-------| +| `invalid-user-identity` | Pattern doesn't match expected format | + 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 643716fbc..264ba788e 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 @@ -1,8 +1,9 @@ +use std::convert::TryFrom; use std::sync::atomic::Ordering; use stratum_apps::stratum_core::{ binary_sv2::Str0255, - bitcoin::{consensus::Decodable, Amount, Target, TxOut}, + bitcoin::{consensus::Decodable, Target, TxOut}, channels_sv2::{ server::{ error::{ExtendedChannelError, StandardChannelError}, @@ -26,7 +27,7 @@ use tracing::{error, info}; use crate::{ channel_manager::{ChannelManager, RouteMessageTo, CLIENT_SEARCH_SPACE_BYTES}, error::{self, PoolError, PoolErrorKind}, - utils::create_close_channel_msg, + utils::{create_close_channel_msg, PayoutMode}, }; #[cfg_attr(not(test), hotpath::measure_all)] @@ -141,13 +142,29 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { return Err(PoolError::disconnect(PoolErrorKind::LastNewPrevhashNotFound, downstream_id)); }; - - let pool_coinbase_output = TxOut { - value: Amount::from_sat(last_future_template.coinbase_tx_value_remaining), - script_pubkey: self.coinbase_reward_script.script_pubkey(), + let payout_mode = match PayoutMode::try_from(user_identity.as_str()) { + Ok(mode) => mode, + Err(_) => { + error!("Invalid user_identity '{}': does not match any supported identity format", user_identity); + let open_standard_mining_channel_error = OpenMiningChannelError { + request_id, + error_code: "invalid-user-identity" + .to_string() + .try_into() + .expect("error code must be valid string"), + }; + return Ok(vec![(downstream_id, Mining::OpenMiningChannelError(open_standard_mining_channel_error)).into()]); + } }; + let coinbase_outputs = payout_mode.coinbase_outputs( + last_future_template.coinbase_tx_value_remaining, + &self.coinbase_reward_script, + ); + downstream.downstream_data.super_safe_lock(|downstream_data| { + downstream_data.payout_mode = Some(payout_mode); + 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)?; @@ -205,7 +222,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { let template_id = last_future_template.template_id; // create a future standard job based on the last future template - standard_channel.on_new_template(last_future_template, vec![pool_coinbase_output.clone()]).map_err(PoolError::shutdown)?; + standard_channel.on_new_template(last_future_template, coinbase_outputs.clone()).map_err(PoolError::shutdown)?; let future_standard_job_id = standard_channel .get_future_job_id_from_template_id(template_id) .expect("future job id must exist"); @@ -306,6 +323,29 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } }; + let payout_mode = match PayoutMode::try_from(user_identity.as_str()) { + Ok(mode) => mode, + Err(_) => { + error!("Invalid user_identity '{}': does not match any supported identity format", user_identity); + let open_extended_mining_channel_error = OpenMiningChannelError { + request_id, + error_code: "invalid-user-identity" + .to_string() + .try_into() + .expect("error code must be valid string"), + }; + return Ok(vec![( + downstream_id, + Mining::OpenMiningChannelError( + open_extended_mining_channel_error, + ), + ) + .into()]); + } + }; + + downstream_data.payout_mode = Some(payout_mode.clone()); + let channel_id = downstream_data .channel_id_factory .fetch_add(1, Ordering::SeqCst); @@ -435,16 +475,14 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { // future extended job // and the SetNewPrevHash message } else { - let pool_coinbase_output = TxOut { - value: Amount::from_sat( - last_future_template.coinbase_tx_value_remaining, - ), - script_pubkey: self.coinbase_reward_script.script_pubkey(), - }; + let coinbase_outputs = payout_mode.coinbase_outputs( + last_future_template.coinbase_tx_value_remaining, + &self.coinbase_reward_script, + ); extended_channel.on_new_template( last_future_template.clone(), - vec![pool_coinbase_output], + coinbase_outputs, ).map_err(PoolError::shutdown)?; let future_extended_job_id = extended_channel diff --git a/pool-apps/pool/src/lib/channel_manager/mod.rs b/pool-apps/pool/src/lib/channel_manager/mod.rs index 47c7e34f2..f94f8861d 100644 --- a/pool-apps/pool/src/lib/channel_manager/mod.rs +++ b/pool-apps/pool/src/lib/channel_manager/mod.rs @@ -11,7 +11,7 @@ use async_channel::{Receiver, Sender}; use bitcoin_core_sv2::CancellationToken; use core::sync::atomic::Ordering; use stratum_apps::{ - coinbase_output_constraints::coinbase_output_constraints_message, + coinbase_output_constraints::coinbase_output_constraints_message_with_offset, config_helpers::CoinbaseRewardScript, custom_mutex::Mutex, key_utils::{Secp256k1PublicKey, Secp256k1SecretKey}, @@ -631,7 +631,7 @@ impl ChannelManager { &self, coinbase_outputs: Vec, ) -> PoolResult<(), error::ChannelManager> { - let msg = coinbase_output_constraints_message(coinbase_outputs); + let msg = coinbase_output_constraints_message_with_offset(coinbase_outputs); self.channel_manager_channel .tp_sender 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 5024e11cb..8502ba684 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 @@ -51,7 +51,13 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { } 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| { + let downstream_coinbase_outputs = if let Some(ref payout_mode) = data.payout_mode { + payout_mode.coinbase_outputs(msg.coinbase_tx_value_remaining, &self.coinbase_reward_script) + } else { + coinbase_output.clone() + }; + + data.group_channel.on_new_template(msg.clone().into_static(), downstream_coinbase_outputs.clone()).map_err(|e| { tracing::error!("Error while adding template to group channel"); PoolError::shutdown(e) })?; @@ -92,7 +98,7 @@ impl HandleTemplateDistributionMessagesFromServerAsync for ChannelManager { PoolError::shutdown(e) })?; } else { - standard_channel.on_new_template(msg.clone().into_static(), coinbase_output.clone()).map_err(|e| { + standard_channel.on_new_template(msg.clone().into_static(), downstream_coinbase_outputs.clone()).map_err(|e| { tracing::error!("Error while adding template to standard channel"); PoolError::shutdown(e) })?; diff --git a/pool-apps/pool/src/lib/downstream/mod.rs b/pool-apps/pool/src/lib/downstream/mod.rs index 7917f1045..19866b2e5 100644 --- a/pool-apps/pool/src/lib/downstream/mod.rs +++ b/pool-apps/pool/src/lib/downstream/mod.rs @@ -36,6 +36,7 @@ use crate::{ error::{self, PoolError, PoolErrorKind, PoolResult}, io_task::spawn_io_tasks, status::{handle_error, Status, StatusSender}, + utils::PayoutMode, }; mod common_message_handler; @@ -58,6 +59,8 @@ pub struct DownstreamData { pub channel_id_factory: AtomicU32, /// Extensions that have been successfully negotiated with this client pub negotiated_extensions: Vec, + /// Payout mode derived from user_identity (None until channel is opened) + pub payout_mode: Option, } /// Communication layer for a downstream connection. @@ -144,6 +147,7 @@ impl Downstream { group_channel, channel_id_factory, negotiated_extensions: vec![], + payout_mode: None, })); Downstream { diff --git a/pool-apps/pool/src/lib/error.rs b/pool-apps/pool/src/lib/error.rs index dd9f37d9c..7b6e9cd8c 100644 --- a/pool-apps/pool/src/lib/error.rs +++ b/pool-apps/pool/src/lib/error.rs @@ -191,6 +191,8 @@ pub enum PoolErrorKind { JobNotFound, /// Invalid Key InvalidKey, + ///Error when parsing the PayoutMode for Solo Mining Mode + PayoutModeError(String), } impl std::fmt::Display for PoolErrorKind { @@ -280,7 +282,8 @@ 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"), - InvalidKey => write!(f, "Invalid key used during noise handshake") + InvalidKey => write!(f, "Invalid key used during noise handshake"), + PayoutModeError(e) => write!(f, "Unable to parse the PayoutMode: {e}") } } } diff --git a/pool-apps/pool/src/lib/utils.rs b/pool-apps/pool/src/lib/utils.rs index 75d9240f2..92ee75cc9 100644 --- a/pool-apps/pool/src/lib/utils.rs +++ b/pool-apps/pool/src/lib/utils.rs @@ -1,5 +1,8 @@ +use std::convert::TryFrom; use std::net::SocketAddr; use stratum_apps::{ + config_helpers::CoinbaseRewardScript, + stratum_core::bitcoin::{Amount, TxOut}, stratum_core::{ binary_sv2::Str0255, common_messages_sv2::{Protocol, SetupConnection}, @@ -71,3 +74,259 @@ pub(crate) fn create_close_channel_msg(channel_id: ChannelId, msg: &str) -> Clos reason_code: Str0255::try_from(msg.to_string()).expect("Could not convert message."), } } + +/// Represents the payout mode for a mining connection. +/// +/// This determines how the coinbase reward is distributed: +/// - `Solo`: Full reward goes to the miner's specified payout address. Pattern: +/// `sri/solo//` or plain Bitcoin address +/// - `Donate`: Partial donation to pool, remainder to miner. Pattern: +/// `sri/donate///` (percentage is 0-100, representing +/// pool's portion) +/// - `FullDonation`: Full reward goes to the pool. +#[derive(Debug, Clone)] +pub enum PayoutMode { + /// Solo mode: miner receives full block reward. + Solo { script: CoinbaseRewardScript }, + /// Donate mode: pool receives specified percentage, miner gets remainder. + Donate { + percentage: u8, + script: CoinbaseRewardScript, + }, + /// Full donation mode: full reward goes to the pool. + FullDonation, +} + +impl PayoutMode { + pub fn coinbase_outputs( + &self, + total_value: u64, + pool_script: &CoinbaseRewardScript, + ) -> Vec { + match self { + PayoutMode::Solo { + script: coinbase_script, + } => { + vec![TxOut { + value: Amount::from_sat(total_value), + script_pubkey: coinbase_script.script_pubkey(), + }] + } + + PayoutMode::Donate { + percentage, + script: miner_script, + } => { + let pool_value = (total_value * *percentage as u64) / 100; + let miner_value = total_value.saturating_sub(pool_value); + + vec![ + TxOut { + value: Amount::from_sat(pool_value), + script_pubkey: pool_script.script_pubkey(), + }, + TxOut { + value: Amount::from_sat(miner_value), + script_pubkey: miner_script.script_pubkey(), + }, + ] + } + + PayoutMode::FullDonation => { + vec![TxOut { + value: Amount::from_sat(total_value), + script_pubkey: pool_script.script_pubkey(), + }] + } + } + } +} + +#[allow(clippy::result_large_err)] +impl TryFrom<&str> for PayoutMode { + type Error = PoolErrorKind; + + fn try_from(user_identity: &str) -> Result { + if user_identity.is_empty() { + return Ok(PayoutMode::FullDonation); + } + + let addr = user_identity + .split_once('.') + .map(|(addr, _)| addr) + .unwrap_or(user_identity); + + let descriptor = format!("addr({addr})"); + if let Ok(script) = CoinbaseRewardScript::from_descriptor(&descriptor) { + return Ok(PayoutMode::Solo { script }); + } + + let mut parts = user_identity.split('/'); + + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some("sri"), Some("solo"), Some(payout_address), _) => { + let descriptor = format!("addr({payout_address})"); + if let Ok(script) = CoinbaseRewardScript::from_descriptor(&descriptor) { + Ok(PayoutMode::Solo { script }) + } else { + Err(PoolErrorKind::PayoutModeError( + "Invalid user_identity pattern for solo mining mode.".to_string(), + )) + } + } + + (Some("sri"), Some("donate"), None, _) + | (Some("sri"), Some("donate"), Some(_), None) => Ok(PayoutMode::FullDonation), + + (Some("sri"), Some("donate"), Some(percentage), Some(payout_address)) => { + let descriptor = format!("addr({payout_address})"); + + match ( + percentage.parse::(), + CoinbaseRewardScript::from_descriptor(&descriptor), + ) { + (Ok(p), Ok(script)) if (1..100).contains(&p) => Ok(PayoutMode::Donate { + percentage: p, + script, + }), + _ => Err(PoolErrorKind::PayoutModeError( + "Invalid user_identity pattern for solo mining mode.".to_string(), + )), + } + } + + (Some("sri"), Some(_), _, _) => Err(PoolErrorKind::PayoutModeError( + "Invalid user_identity pattern for solo mining mode.".to_string(), + )), + + _ => Ok(PayoutMode::FullDonation), + } + } +} + +#[cfg(test)] +mod tests { + use stratum_apps::stratum_core::bitcoin::{ + params::{MAINNET, TESTNET4}, + Address, + }; + + use super::*; + + #[test] + fn test_valid_pool_donate() { + assert!(matches!( + PayoutMode::try_from("sri/donate/worker"), + Ok(PayoutMode::FullDonation) + )); + assert!(matches!( + PayoutMode::try_from("sri/donate"), + Ok(PayoutMode::FullDonation) + )); + } + + #[test] + fn test_valid_solo() { + let valid_testnet_addr = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8"; + let valid_mainnet_addr = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x"; + + assert!(matches!( + PayoutMode::try_from(format!("sri/solo/{}/worker", valid_testnet_addr).as_str()), + Ok(PayoutMode::Solo { script }) if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == valid_testnet_addr + )); + assert!(matches!( + PayoutMode::try_from(format!("sri/solo/{}/worker/subworker", valid_mainnet_addr).as_str()), + Ok(PayoutMode::Solo { script }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string()== valid_mainnet_addr + )); + assert!(matches!( + PayoutMode::try_from(valid_mainnet_addr), + Ok(PayoutMode::Solo { script }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == valid_mainnet_addr + )); + + assert!(matches!( + PayoutMode::try_from(valid_testnet_addr), + Ok(PayoutMode::Solo { script }) if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == valid_testnet_addr)) + } + + #[test] + fn test_valid_solo_with_worker_suffix() { + let valid_mainnet_addr = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x"; + + assert!(matches!( + PayoutMode::try_from(format!("{}.worker1", valid_mainnet_addr).as_str()), + Ok(PayoutMode::Solo { script }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string()== valid_mainnet_addr + )); + assert!(matches!( + PayoutMode::try_from(format!("{}.worker1.subworker", valid_mainnet_addr).as_str()), + Ok(PayoutMode::Solo { script }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == valid_mainnet_addr + )); + } + + #[test] + fn test_invalid_address_with_suffix() { + assert!(matches!( + PayoutMode::try_from("invalid_address.worker"), + Ok(PayoutMode::FullDonation) + )); + } + + #[test] + fn test_valid_donate_with_percentage() { + let valid_testnet_addr = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8"; + + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/50/{}/worker", valid_testnet_addr).as_str()).unwrap(), + PayoutMode::Donate { percentage: 50, script } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == valid_testnet_addr + )); + + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/50/{}/worker/subworker", valid_testnet_addr).as_str()).unwrap(), + PayoutMode::Donate { percentage: 50, script } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == valid_testnet_addr + )); + + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/0/{}/worker", valid_testnet_addr).as_str()), + Err(PoolErrorKind::PayoutModeError(_)) + )); + + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/100/{}/worker", valid_testnet_addr).as_str()), + Err(PoolErrorKind::PayoutModeError(_)) + )); + } + + #[test] + fn test_invalid_patterns() { + let valid_testnet_addr = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8"; + + assert!(PayoutMode::try_from("sri/invalid/worker").is_err()); + assert!(PayoutMode::try_from("sri/solo").is_err()); + assert!(PayoutMode::try_from("sri/solo/random_thing_here/worker").is_err()); + assert!(PayoutMode::try_from("sri/solo/").is_err()); + assert!(matches!( + PayoutMode::try_from("sri/donate/abc/addr/worker"), + Err(PoolErrorKind::PayoutModeError(_)) + )); + assert!(matches!( + PayoutMode::try_from("sri/donate/101/addr/worker"), + Err(PoolErrorKind::PayoutModeError(_)) + )); + + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/50/{}", valid_testnet_addr).as_str()).unwrap(), + PayoutMode::Donate { percentage: 50, script } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == valid_testnet_addr + )); + assert!(matches!( + PayoutMode::try_from("other/donate/worker"), + Ok(PayoutMode::FullDonation) + )); + assert!(matches!( + PayoutMode::try_from("sri/"), + Err(PoolErrorKind::PayoutModeError(_)) + )); + assert!(matches!( + PayoutMode::try_from(""), + Ok(PayoutMode::FullDonation) + )); + } +} diff --git a/stratum-apps/src/coinbase_output_constraints.rs b/stratum-apps/src/coinbase_output_constraints.rs index aa950b365..20cc83ca9 100644 --- a/stratum-apps/src/coinbase_output_constraints.rs +++ b/stratum-apps/src/coinbase_output_constraints.rs @@ -8,7 +8,35 @@ use stratum_core::{ template_distribution_sv2::CoinbaseOutputConstraints, }; -/// Creates a CoinbaseOutputConstraints message from a list of coinbase outputs +/// Offset added to the calculated max size to account for the additional space required by +/// Pay-to-Taproot (p2tr) addresses in solo mining scenarios. +/// +/// Rationale: p2tr (Taproot) addresses produce the largest coinbase outputs among standard +/// address types. When the actual mining address is different from what was used to calculate +/// the constraints, we need extra space to accommodate the largest possible address type. +/// The value 43 bytes accounts for the difference between typical outputs and p2tr outputs. +const OFFSET_ADDITIONAL_SIZE: u32 = 43; + +/// Offset added to the calculated max sigops to account for the additional signature operations +/// required by Pay-to-Public-Key-Hash (p2pkh) addresses in solo mining scenarios. +/// +/// Rationale: p2pkh addresses have higher sigop counts compared to modern address types. +/// When the actual mining address differs from what was used to calculate constraints, +/// we need extra sigops budget to handle the worst-case scenario. +/// The value 4 sigops accounts for p2pkh's higher signature operation count. +const OFFSET_MAX_SIGOPS: u16 = 4; + +/// Creates a CoinbaseOutputConstraints message from a list of coinbase outputs. +/// +/// This function calculates the exact maximum additional size and sigops required +/// by the given coinbase outputs. No safety margins are added - the values reflect +/// precisely what the provided outputs require. +/// +/// # Arguments +/// * `coinbase_outputs` - List of transaction outputs that will be included in the coinbase +/// +/// # Returns +/// CoinbaseOutputConstraints with exact size and sigops values based on the provided outputs pub fn coinbase_output_constraints_message( coinbase_outputs: Vec, ) -> CoinbaseOutputConstraints { @@ -42,3 +70,209 @@ pub fn coinbase_output_constraints_message( coinbase_output_max_additional_sigops: max_sigops, } } + +/// Creates a CoinbaseOutputConstraints message with safety margins (offsets) applied. +/// +/// This function first calculates the exact constraints using [`coinbase_output_constraints_message`], +/// then adds safety margins to account for address type variation in solo mining scenarios. +/// +/// The offsets ensure that the coinbase can accommodate the largest possible address types +/// (p2tr for size, p2pkh for sigops) even when the actual mining address differs from what +/// was used to generate the coinbase outputs. This prevents validation failures when the +/// pool assigns a different address type than what the miner expected. +/// +/// # Arguments +/// * `coinbase_outputs` - List of transaction outputs that will be included in the coinbase +/// +/// # Returns +/// CoinbaseOutputConstraints with added safety margins for address type variation +/// +/// # Offsets Applied +/// - **Size**: +43 bytes to accommodate Pay-to-Taproot (p2tr) addresses +/// - **Sigops**: +4 to accommodate Pay-to-Public-Key-Hash (p2pkh) addresses +pub fn coinbase_output_constraints_message_with_offset( + coinbase_outputs: Vec, +) -> CoinbaseOutputConstraints { + let constraints = coinbase_output_constraints_message(coinbase_outputs); + + CoinbaseOutputConstraints { + coinbase_output_max_additional_size: constraints.coinbase_output_max_additional_size + + OFFSET_ADDITIONAL_SIZE, + coinbase_output_max_additional_sigops: constraints.coinbase_output_max_additional_sigops + + OFFSET_MAX_SIGOPS, + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + use stratum_core::bitcoin::{Address, Amount, Network, TxOut}; + + /// Informational test to showcase how the offset values were calculated. + /// + /// This test iterates through all standard Bitcoin address types and prints their + /// sizes and sigop counts. Run with `--nocapture` to see the output. + /// + /// The offsets were determined by finding the worst-case address types: + /// - **Size**: p2tr (Taproot) produces the largest coinbase output (43 bytes larger than others) + /// - **Sigops**: p2pkh has higher sigop count than modern address types (4 sigops) + /// + /// Since the pool cannot know in advance which address type the miner will use, + /// we add these safety margins to ensure the coinbase can accommodate any address type. + #[test] + fn test_offset_values_rationale() { + let addresses = vec![ + ("p2pkh", "19drg6CgjcvqFZSW5FLWdmqTBBeFLS5iC7"), + ("p2sh", "18tRWCdi2Fc9CM57fUfmFK3ZC6cpGQeBkV"), + ("p2wpkh", "bc1qwq787dzgj2w8hh58t4clr594y0cjgjashr0fz5"), + ("p2wsh", "bc1qn04san36d0j76j0xksz2tesmtww7uf24j2x6v3"), + ( + "p2tr", + "bc1p8fltq0npm605tzl22gqewhy9dt5l25m7x67832vyhh9aem24rgdsgqwtpu", + ), + ]; + + let mut max_size = 0u32; + let mut max_sigops = 0u16; + let mut max_size_type = ""; + let mut max_sigops_type = ""; + + for (name, addr_str) in addresses { + let addr = Address::from_str(addr_str) + .unwrap() + .require_network(Network::Bitcoin) + .unwrap(); + + let script = addr.script_pubkey(); + + let coinbase_outputs = vec![TxOut { + value: Amount::from_sat(50_0000_0000), + script_pubkey: script.clone(), + }]; + + let dummy_coinbase = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::from(vec![vec![0; 32]]), + }], + output: coinbase_outputs.clone(), + }; + + let size_of_current_address: u32 = + coinbase_outputs.iter().map(|o| o.size() as u32).sum(); + let sigops_of_current_address = dummy_coinbase.total_sigop_cost(|_| None) as u16; + + if size_of_current_address > max_size { + max_size = size_of_current_address; + max_size_type = name; + } + + if sigops_of_current_address > max_sigops { + max_sigops = sigops_of_current_address; + max_sigops_type = name; + } + + println!("--- {}", name); + println!("address: {}", addr); + println!("script: {}", script.to_hex_string()); + println!( + "max_size={} max_sigops={}", + size_of_current_address, sigops_of_current_address + ); + } + + println!("\n=== worst case summary ==="); + println!("largest output size: {} ({})", max_size, max_size_type); + println!("largest sigops: {} ({})", max_sigops, max_sigops_type); + } + + /// Tests that `coinbase_output_constraints_message` returns exact values without offsets. + #[test] + fn test_coinbase_output_constraints_message_exact() { + let coinbase_outputs = vec![TxOut { + value: Amount::from_sat(50_0000_0000), + script_pubkey: ScriptBuf::from_hex( + "0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(), + }]; + + let result = coinbase_output_constraints_message(coinbase_outputs.clone()); + + let expected_size: u32 = coinbase_outputs.iter().map(|o| o.size() as u32).sum(); + let dummy_coinbase = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::from(vec![vec![0; 32]]), + }], + output: coinbase_outputs, + }; + let expected_sigops = dummy_coinbase.total_sigop_cost(|_| None) as u16; + + assert_eq!(result.coinbase_output_max_additional_size, expected_size); + assert_eq!( + result.coinbase_output_max_additional_sigops, + expected_sigops + ); + } + + /// Tests that `coinbase_output_constraints_message_with_offset` adds the correct offsets. + #[test] + fn test_coinbase_output_constraints_message_with_offset() { + let coinbase_outputs = vec![TxOut { + value: Amount::from_sat(50_0000_0000), + script_pubkey: ScriptBuf::from_hex( + "0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(), + }]; + + let result_with_offset = + coinbase_output_constraints_message_with_offset(coinbase_outputs.clone()); + let result_exact = coinbase_output_constraints_message(coinbase_outputs); + + assert_eq!( + result_with_offset.coinbase_output_max_additional_size, + result_exact.coinbase_output_max_additional_size + OFFSET_ADDITIONAL_SIZE + ); + assert_eq!( + result_with_offset.coinbase_output_max_additional_sigops, + result_exact.coinbase_output_max_additional_sigops + OFFSET_MAX_SIGOPS + ); + } + + /// Tests that offsets are applied correctly regardless of input. + /// Uses a p2wpkh address to verify the offset logic. + #[test] + fn test_offset_values_are_applied_correctly() { + let addr = Address::from_str("bc1qwq787dzgj2w8hh58t4clr594y0cjgjashr0fz5") + .unwrap() + .require_network(Network::Bitcoin) + .unwrap(); + + let coinbase_outputs = vec![TxOut { + value: Amount::from_sat(50_0000_0000), + script_pubkey: addr.script_pubkey(), + }]; + + let exact = coinbase_output_constraints_message(coinbase_outputs.clone()); + let with_offset = coinbase_output_constraints_message_with_offset(coinbase_outputs); + + assert_eq!( + with_offset.coinbase_output_max_additional_size, + exact.coinbase_output_max_additional_size + OFFSET_ADDITIONAL_SIZE + ); + assert_eq!( + with_offset.coinbase_output_max_additional_sigops, + exact.coinbase_output_max_additional_sigops + OFFSET_MAX_SIGOPS + ); + } +}