diff --git a/README.md b/README.md index 69aac419b..06615d0b5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ If you're looking for the low-level protocol libraries, check out the [`stratum` - `stratum-apps/` - Shared application utilities - Configuration helpers (TOML, coinbase outputs, logging) - Network connection utilities (Noise protocol, plain TCP, SV1 connections) + - Payout identity parsing and coinbase output construction/verification helpers + - Runtime fallback coordination helpers - RPC client implementation - Key management utilities - Custom synchronization primitives @@ -102,4 +104,4 @@ Minimum Supported Rust Version: 1.85.0 > Website [stratumprotocol.org](https://www.stratumprotocol.org)  ·  > Discord [SV2 Discord](https://discord.gg/fsEW23wFYs)  ·  -> Twitter [@Stratumv2](https://twitter.com/StratumV2)  ·  \ No newline at end of file +> Twitter [@Stratumv2](https://twitter.com/StratumV2)  ·  diff --git a/docker/README.md b/docker/README.md index 722e88778..c4e1ab0e6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -131,6 +131,8 @@ If something behaves weirdly, 99% of the time your `docker_env` is the culprit. * Port **34255** * Upstream target (JDC or pool) is fully controlled via `docker_env` variables +* `TPROXY_VERIFY_PAYOUT=false` is the standard pool-mining default; set it to `true` only for + solo/donation identities where `TPROXY_USER_IDENTITY` encodes the expected on-chain payout --- diff --git a/docker/config/translator-proxy-config.toml.template b/docker/config/translator-proxy-config.toml.template index c6c50c188..443ed2c8d 100644 --- a/docker/config/translator-proxy-config.toml.template +++ b/docker/config/translator-proxy-config.toml.template @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "${TPROXY_USER_IDENTITY}" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = ${TPROXY_VERIFY_PAYOUT} + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = ${TPROXY_AGGREGATE_CHANNELS} diff --git a/docker/docker_env.example b/docker/docker_env.example index 7106e08cf..1064d693e 100644 --- a/docker/docker_env.example +++ b/docker/docker_env.example @@ -24,6 +24,7 @@ JDC_UPSTREAM_JDS_PORT=3334 #Tproxy Settings TPROXY_USER_IDENTITY=your_username_here +TPROXY_VERIFY_PAYOUT=false TPROXY_AGGREGATE_CHANNELS=false TPROXY_MIN_INDIVIDUAL_MINER_HASHRATE=10_000_000_000_000.0 TPROXY_SHARES_PER_MINUTE=6.0 @@ -31,4 +32,3 @@ TPROXY_ENABLE_VARDIFF=true TPROXY_UPSTREAM_ADDRESS=jd_client_sv2 # Use jd_client_sv2 for pool_and_miner_apps, pool_sv2 for pool_and_miner_apps_no_jd TPROXY_UPSTREAM_PORT=34265 # Use 34265 for pool_and_miner_apps, 3333 for pool_and_miner_apps_no_jd TPROXY_UPSTREAM_AUTHORITY_PUBKEY=9auqWEzQDVyd2oe1JVGFLMLHZtCo2FFqZwtKA5gd9xbuEu7PH72 - diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 380d08883..fc867c448 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -14,7 +14,7 @@ keywords = ["stratum", "mining", "bitcoin", "protocol"] exclude = ["resources/high_diff_chain.tar.gz"] [dependencies] -stratum-apps = { version = "0.5.0", path = "../stratum-apps", features = ["network", "config"] } +stratum-apps = { version = "0.5.0", path = "../stratum-apps", features = ["network", "config", "payout"] } jd_client_sv2 = { version = "0.3.0", path = "../miner-apps/jd-client" } pool_sv2 = { version = "0.4.0", path = "../pool-apps/pool" } translator_sv2 = { version = "0.3.0", path = "../miner-apps/translator" } diff --git a/integration-tests/lib/mod.rs b/integration-tests/lib/mod.rs index 360250be9..f272a3629 100644 --- a/integration-tests/lib/mod.rs +++ b/integration-tests/lib/mod.rs @@ -348,6 +348,30 @@ pub async fn start_sv2_translator( required_extensions: Vec, job_keepalive_interval_secs: Option, enable_monitoring: bool, +) -> (TranslatorSv2, SocketAddr, Option) { + start_sv2_translator_with_user_identity( + upstreams, + aggregate_channels, + supported_extensions, + required_extensions, + job_keepalive_interval_secs, + "user_identity".to_string(), + false, + enable_monitoring, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn start_sv2_translator_with_user_identity( + upstreams: &[SocketAddr], + aggregate_channels: bool, + supported_extensions: Vec, + required_extensions: Vec, + job_keepalive_interval_secs: Option, + user_identity: String, + verify_payout: bool, + enable_monitoring: bool, ) -> (TranslatorSv2, SocketAddr, Option) { let job_keepalive_interval_secs = job_keepalive_interval_secs.unwrap_or(60); let upstreams = upstreams @@ -399,7 +423,8 @@ pub async fn start_sv2_translator( 2, 2, downstream_extranonce2_size, - "user_identity".to_string(), + user_identity, + verify_payout, aggregate_channels, supported_extensions, required_extensions, diff --git a/integration-tests/tests/translator_integration.rs b/integration-tests/tests/translator_integration.rs index e6112ce31..d03eeea37 100644 --- a/integration-tests/tests/translator_integration.rs +++ b/integration-tests/tests/translator_integration.rs @@ -7,7 +7,13 @@ use integration_tests_sv2::{ utils::get_available_address, *, }; -use stratum_apps::stratum_core::mining_sv2::*; +use stratum_apps::{ + config_helpers::CoinbaseRewardScript, + stratum_core::{ + bitcoin::{consensus::serialize, Amount, TxOut}, + mining_sv2::*, + }, +}; use tokio::net::{TcpListener, TcpStream}; use std::{ @@ -30,6 +36,8 @@ use stratum_apps::stratum_core::{ template_distribution_sv2::MESSAGE_TYPE_SUBMIT_SOLUTION, }; +const PAYOUT_VERIFICATION_MINER_ADDRESS: &str = "tb1qpusf5256yxv50qt0pm0tue8k952fsu5lzsphft"; + // This test runs an sv2 translator between an sv1 mining device and a pool. the connection between // the translator and the pool is intercepted by a sniffer. The test checks if the translator and // the pool exchange the correct messages upon connection. And that the miner is able to submit @@ -87,6 +95,302 @@ async fn translate_sv1_to_sv2_successfully() { shutdown_all!(translator, pool); } +/// Checks that tProxy mines when payout verification passes for address and donation identities. +#[tokio::test] +async fn translator_mines_when_payout_matches_address_or_donation_identity() { + start_tracing(); + + let miner_script_pubkey = CoinbaseRewardScript::from_descriptor(&format!( + "addr({PAYOUT_VERIFICATION_MINER_ADDRESS})" + )) + .unwrap() + .script_pubkey(); + let pool_script_pubkey = + CoinbaseRewardScript::from_descriptor(&format!("addr({POOL_COINBASE_REWARD_ADDRESS})")) + .unwrap() + .script_pubkey(); + + let mut solo_coinbase_tx_suffix = hex::decode("feffffff").unwrap(); + solo_coinbase_tx_suffix.extend(serialize(&vec![TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: miner_script_pubkey.clone(), + }])); + solo_coinbase_tx_suffix.extend([0, 0, 0, 0]); + + let mut partial_donation_coinbase_tx_suffix = hex::decode("feffffff").unwrap(); + partial_donation_coinbase_tx_suffix.extend(serialize(&vec![ + TxOut { + value: Amount::from_sat(500_000_000), + script_pubkey: pool_script_pubkey, + }, + TxOut { + value: Amount::from_sat(4_500_000_000), + script_pubkey: miner_script_pubkey, + }, + ])); + partial_donation_coinbase_tx_suffix.extend([0, 0, 0, 0]); + + for (identifier, user_identity, coinbase_tx_suffix) in [ + ( + "payout-address", + PAYOUT_VERIFICATION_MINER_ADDRESS.to_string(), + solo_coinbase_tx_suffix, + ), + ( + "payout-donation", + format!("sri/donate/10/{PAYOUT_VERIFICATION_MINER_ADDRESS}/worker"), + partial_donation_coinbase_tx_suffix, + ), + ] { + let mock_upstream_addr = get_available_address(); + let send_to_tproxy = MockUpstream::new( + mock_upstream_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + let (sniffer, sniffer_addr) = + start_sniffer(identifier, mock_upstream_addr, false, vec![], None); + + let (translator, tproxy_addr, _) = start_sv2_translator_with_user_identity( + &[sniffer_addr], + false, + vec![], + vec![], + None, + user_identity, + true, + false, + ) + .await; + let (_minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + + 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, + }; + }; + + send_to_tproxy + .send(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(), + extranonce_size: 4, + extranonce_prefix: vec![0x00, 0x01, 0x00, 0x00].try_into().unwrap(), + group_channel_id: 100, + }, + ), + )) + .await + .unwrap(); + + send_to_tproxy + .send(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(), + coinbase_tx_prefix: hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff225200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap().try_into().unwrap(), + coinbase_tx_suffix: coinbase_tx_suffix.try_into().unwrap(), + }))) + .await + .unwrap(); + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + send_to_tproxy + .send(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, + }, + ))) + .await + .unwrap(); + sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_MINING_SET_NEW_PREV_HASH, + ) + .await; + + sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + ) + .await; + + translator.shutdown().await; + } +} + +/// Checks that tProxy falls back when the upstream job pays the wrong address. +#[tokio::test] +async fn translator_falls_back_when_payout_does_not_match_user_identity() { + start_tracing(); + + let mut wrong_coinbase_tx_suffix = hex::decode("feffffff").unwrap(); + wrong_coinbase_tx_suffix.extend(serialize(&vec![TxOut { + value: Amount::from_sat(5_000_000_000), + script_pubkey: CoinbaseRewardScript::from_descriptor(&format!( + "addr({POOL_COINBASE_REWARD_ADDRESS})" + )) + .unwrap() + .script_pubkey(), + }])); + wrong_coinbase_tx_suffix.extend([0, 0, 0, 0]); + + let primary_upstream_addr = get_available_address(); + let primary_sender = MockUpstream::new( + primary_upstream_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + let (primary_sniffer, primary_sniffer_addr) = start_sniffer( + "payout-bad-primary", + primary_upstream_addr, + false, + vec![], + None, + ); + + let fallback_upstream_addr = get_available_address(); + let _fallback_sender = MockUpstream::new( + fallback_upstream_addr, + WithSetup::yes_with_defaults(Protocol::MiningProtocol, 0), + ) + .start() + .await; + let (fallback_sniffer, fallback_sniffer_addr) = start_sniffer( + "payout-fallback", + fallback_upstream_addr, + false, + vec![], + None, + ); + + let (translator, tproxy_addr, _) = start_sv2_translator_with_user_identity( + &[primary_sniffer_addr, fallback_sniffer_addr], + false, + vec![], + vec![], + None, + PAYOUT_VERIFICATION_MINER_ADDRESS.to_string(), + true, + false, + ) + .await; + let (minerd_process, _minerd_addr) = start_minerd(tproxy_addr, None, None, false).await; + + primary_sniffer + .wait_for_message_type( + MessageDirection::ToUpstream, + MESSAGE_TYPE_OPEN_EXTENDED_MINING_CHANNEL, + ) + .await; + let open_extended_mining_channel: OpenExtendedMiningChannel = loop { + match primary_sniffer.next_message_from_downstream() { + Some((_, AnyMessage::Mining(parsers_sv2::Mining::OpenExtendedMiningChannel(msg)))) => { + break msg + } + _ => continue, + }; + }; + + primary_sender + .send(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(), + extranonce_size: 4, + extranonce_prefix: vec![0x00, 0x01, 0x00, 0x00].try_into().unwrap(), + group_channel_id: 100, + }, + ), + )) + .await + .unwrap(); + + primary_sender + .send(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(), + coinbase_tx_prefix: hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff225200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap().try_into().unwrap(), + coinbase_tx_suffix: wrong_coinbase_tx_suffix.try_into().unwrap(), + }))) + .await + .unwrap(); + primary_sniffer + .wait_for_message_type( + MessageDirection::ToDownstream, + MESSAGE_TYPE_NEW_EXTENDED_MINING_JOB, + ) + .await; + + assert!( + primary_sniffer + .assert_message_not_present( + MessageDirection::ToUpstream, + MESSAGE_TYPE_SUBMIT_SHARES_EXTENDED, + Duration::from_secs(2), + ) + .await, + "tProxy should not submit shares to the upstream that failed payout verification" + ); + + fallback_sniffer + .wait_for_message_type(MessageDirection::ToUpstream, MESSAGE_TYPE_SETUP_CONNECTION) + .await; + + drop(minerd_process); + translator.shutdown().await; +} + // Demonstrates the scenario where TProxy falls back to the secondary pool // after the primary pool returns a `SetupConnection.Error`. #[tokio::test] diff --git a/miner-apps/translator/README.md b/miner-apps/translator/README.md index c1c7ce56a..71996f982 100644 --- a/miner-apps/translator/README.md +++ b/miner-apps/translator/README.md @@ -37,9 +37,13 @@ min_supported_version = 2 # Extranonce Configuration downstream_extranonce2_size = 4 # Min: 2, Max: 16 (CGminer max: 8) -# User Identity (appended with counter for each miner) +# User Identity (appended with counter for each miner unless it starts with `sri/`) user_identity = "your_username_here" +# Payout verification is opt-in. Keep false for standard pool mining, +# including pools that use a Bitcoin address as the username. +verify_payout = false + # Channel Configuration aggregate_channels = true # true: shared channel, false: individual channels @@ -78,6 +82,20 @@ Make sure the machine running the Translator Proxy has its clock synced with an - `true`: All miners share one upstream extended channel (more efficient) - `false`: Each miner gets its own upstream extended channel (more isolated) - `user_identity`: Username for pool authentication (auto-suffixed per miner) +- `verify_payout`: When `true`, verify upstream coinbase payouts against a payout address encoded + by `user_identity`. Keep `false` for standard pool mining, including pools that use a Bitcoin + address as the username. + +#### **Solo/Donation Payout Verification** +Payout verification is disabled by default. Set `verify_payout = true` for solo mining or +donation configurations where `user_identity` intentionally encodes an on-chain payout address: + +- `sri/solo//`: tProxy verifies every upstream extended job pays 100% of spendable coinbase outputs to `` +- `[.worker]`: legacy solo mode, verified as 100% miner payout when `verify_payout = true` +- `sri/donate///`: tProxy verifies the miner address receives the remaining percentage +- `sri/donate/`: full donation mode; keep `verify_payout = false` because no miner payout address is present + +If verification fails, tProxy triggers upstream fallback instead of forwarding the job to SV1 miners. #### **Difficulty Configuration** - `min_individual_miner_hashrate`: Expected hashrate of weakest miner (in H/s) @@ -144,6 +162,7 @@ For connecting to a local SV2 pool server: downstream_address = "0.0.0.0" downstream_port = 34255 user_identity = "miner_farm_1" +verify_payout = false aggregate_channels = true [downstream_difficulty_config] @@ -164,6 +183,7 @@ For production environments with failover: downstream_address = "0.0.0.0" downstream_port = 34255 user_identity = "production_farm" +verify_payout = false aggregate_channels = true [downstream_difficulty_config] diff --git a/miner-apps/translator/config-examples/mainnet/tproxy-config-hosted-pool-example.toml b/miner-apps/translator/config-examples/mainnet/tproxy-config-hosted-pool-example.toml index dfac8a5cc..92195992f 100644 --- a/miner-apps/translator/config-examples/mainnet/tproxy-config-hosted-pool-example.toml +++ b/miner-apps/translator/config-examples/mainnet/tproxy-config-hosted-pool-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = true diff --git a/miner-apps/translator/config-examples/mainnet/tproxy-config-local-jdc-example.toml b/miner-apps/translator/config-examples/mainnet/tproxy-config-local-jdc-example.toml index ae9565ad6..0a0359cd7 100644 --- a/miner-apps/translator/config-examples/mainnet/tproxy-config-local-jdc-example.toml +++ b/miner-apps/translator/config-examples/mainnet/tproxy-config-local-jdc-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = false diff --git a/miner-apps/translator/config-examples/mainnet/tproxy-config-local-pool-example.toml b/miner-apps/translator/config-examples/mainnet/tproxy-config-local-pool-example.toml index 5a1e919b0..848872650 100644 --- a/miner-apps/translator/config-examples/mainnet/tproxy-config-local-pool-example.toml +++ b/miner-apps/translator/config-examples/mainnet/tproxy-config-local-pool-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = true diff --git a/miner-apps/translator/config-examples/signet/tproxy-config-local-jdc-example.toml b/miner-apps/translator/config-examples/signet/tproxy-config-local-jdc-example.toml index ae9565ad6..0a0359cd7 100644 --- a/miner-apps/translator/config-examples/signet/tproxy-config-local-jdc-example.toml +++ b/miner-apps/translator/config-examples/signet/tproxy-config-local-jdc-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = false diff --git a/miner-apps/translator/config-examples/signet/tproxy-config-local-pool-example.toml b/miner-apps/translator/config-examples/signet/tproxy-config-local-pool-example.toml index fc9a632d5..2f6713cc8 100644 --- a/miner-apps/translator/config-examples/signet/tproxy-config-local-pool-example.toml +++ b/miner-apps/translator/config-examples/signet/tproxy-config-local-pool-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = true diff --git a/miner-apps/translator/config-examples/testnet4/tproxy-config-hosted-pool-example.toml b/miner-apps/translator/config-examples/testnet4/tproxy-config-hosted-pool-example.toml index ce10d1918..2da43398d 100644 --- a/miner-apps/translator/config-examples/testnet4/tproxy-config-hosted-pool-example.toml +++ b/miner-apps/translator/config-examples/testnet4/tproxy-config-hosted-pool-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = true diff --git a/miner-apps/translator/config-examples/testnet4/tproxy-config-local-jdc-example.toml b/miner-apps/translator/config-examples/testnet4/tproxy-config-local-jdc-example.toml index ae9565ad6..0a0359cd7 100644 --- a/miner-apps/translator/config-examples/testnet4/tproxy-config-local-jdc-example.toml +++ b/miner-apps/translator/config-examples/testnet4/tproxy-config-local-jdc-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = false diff --git a/miner-apps/translator/config-examples/testnet4/tproxy-config-local-pool-example.toml b/miner-apps/translator/config-examples/testnet4/tproxy-config-local-pool-example.toml index b496fe38b..e0c039c4b 100644 --- a/miner-apps/translator/config-examples/testnet4/tproxy-config-local-pool-example.toml +++ b/miner-apps/translator/config-examples/testnet4/tproxy-config-local-pool-example.toml @@ -16,6 +16,12 @@ downstream_extranonce2_size = 4 # This will be appended with a counter for each mining client (e.g., username.miner1, username.miner2) user_identity = "your_username_here" +# If true, tProxy verifies upstream coinbase outputs against the payout encoded by user_identity. +# Keep false for standard pool mining, including pools that use a Bitcoin address as the username. +# Enable for solo/donation identities: sri/solo/
/, +# sri/donate//
/, or legacy
[.worker]. +verify_payout = false + # Aggregate channels: if true, all miners share one upstream channel; if false, each miner gets its own channel aggregate_channels = true diff --git a/miner-apps/translator/src/lib/config.rs b/miner-apps/translator/src/lib/config.rs index 5cf04c790..6beb69d33 100644 --- a/miner-apps/translator/src/lib/config.rs +++ b/miner-apps/translator/src/lib/config.rs @@ -17,6 +17,7 @@ use std::net::SocketAddr; use stratum_apps::{ config_helpers::opt_path_from_toml, key_utils::Secp256k1PublicKey, + payout::{MissingMinerPayoutMode, PayoutMode, PayoutModeError}, utils::types::{Hashrate, SharesPerMinute}, }; @@ -38,6 +39,10 @@ pub struct TranslatorConfig { /// This will be appended with a counter for each mining channel (e.g., username.miner1, /// username.miner2). pub user_identity: String, + /// Whether to verify upstream coinbase outputs against a payout address encoded in + /// `user_identity`. + #[serde(default)] + pub verify_payout: bool, /// Configuration settings for managing difficulty on the downstream connection. pub downstream_difficulty_config: DownstreamDifficultyConfig, /// Whether to aggregate all downstream connections into a single upstream channel. @@ -95,6 +100,7 @@ impl TranslatorConfig { min_supported_version: u16, downstream_extranonce2_size: u16, user_identity: String, + verify_payout: bool, aggregate_channels: bool, supported_extensions: Vec, required_extensions: Vec, @@ -109,6 +115,7 @@ impl TranslatorConfig { min_supported_version, downstream_extranonce2_size, user_identity, + verify_payout, downstream_difficulty_config, aggregate_channels, supported_extensions, @@ -129,6 +136,29 @@ impl TranslatorConfig { self.monitoring_cache_refresh_secs } + pub(crate) fn expected_payout_distribution( + &self, + ) -> Result, PayoutModeError> { + if !self.verify_payout { + return Ok(None); + } + + match PayoutMode::try_from(self.user_identity.as_str()) { + Ok(payout_mode @ (PayoutMode::Solo { .. } | PayoutMode::Donate { .. })) => { + Ok(Some(payout_mode)) + } + Ok(PayoutMode::FullDonation) => Err(PayoutModeError::MissingMinerPayout { + user_identity: self.user_identity.clone(), + mode: MissingMinerPayoutMode::FullDonation, + }), + Err(PayoutModeError::NoPayoutMode(_)) => Err(PayoutModeError::MissingMinerPayout { + user_identity: self.user_identity.clone(), + mode: MissingMinerPayoutMode::NoPayoutMode, + }), + Err(e) => Err(e), + } + } + pub fn set_log_dir(&mut self, log_dir: Option) { if let Some(dir) = log_dir { self.log_file = Some(dir); @@ -219,6 +249,7 @@ mod tests { 1, 4, "test_user".to_string(), + false, true, vec![], vec![], @@ -233,12 +264,131 @@ mod tests { assert_eq!(config.min_supported_version, 1); assert_eq!(config.downstream_extranonce2_size, 4); assert_eq!(config.user_identity, "test_user"); + assert!(!config.verify_payout); assert!(config.aggregate_channels); assert!(config.supported_extensions.is_empty()); assert!(config.required_extensions.is_empty()); assert!(config.log_file.is_none()); } + #[test] + fn payout_verification_requires_explicit_opt_in() { + let upstreams = vec![create_test_upstream()]; + let difficulty_config = create_test_difficulty_config(); + let payout_address = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x"; + + let disabled_config = TranslatorConfig::new( + upstreams.clone(), + "0.0.0.0".to_string(), + 3333, + difficulty_config.clone(), + 2, + 1, + 4, + payout_address.to_string(), + false, + true, + vec![], + vec![], + None, + None, + ); + + assert!(disabled_config + .expected_payout_distribution() + .unwrap() + .is_none()); + + let enabled_config = TranslatorConfig::new( + upstreams, + "0.0.0.0".to_string(), + 3333, + difficulty_config, + 2, + 1, + 4, + payout_address.to_string(), + true, + true, + vec![], + vec![], + None, + None, + ); + + assert!(matches!( + enabled_config.expected_payout_distribution().unwrap(), + Some(PayoutMode::Solo { .. }) + )); + } + + #[test] + fn payout_verification_requires_miner_payout_identity() { + let config = TranslatorConfig::new( + vec![create_test_upstream()], + "0.0.0.0".to_string(), + 3333, + create_test_difficulty_config(), + 2, + 1, + 4, + "sri/donate/worker".to_string(), + true, + true, + vec![], + vec![], + None, + None, + ); + + assert!(matches!( + config.expected_payout_distribution().unwrap_err(), + PayoutModeError::MissingMinerPayout { + mode: MissingMinerPayoutMode::FullDonation, + .. + } + )); + + let mut config = config; + config.user_identity = "invalid_address.worker".to_string(); + + assert!(matches!( + config.expected_payout_distribution().unwrap_err(), + PayoutModeError::MissingMinerPayout { + mode: MissingMinerPayoutMode::NoPayoutMode, + .. + } + )); + } + + #[test] + fn payout_verification_rejects_address_like_typos() { + let config = TranslatorConfig::new( + vec![create_test_upstream()], + "0.0.0.0".to_string(), + 3333, + create_test_difficulty_config(), + 2, + 1, + 4, + "bc1q_typo.worker".to_string(), + true, + true, + vec![], + vec![], + None, + None, + ); + + assert!(matches!( + config.expected_payout_distribution().unwrap_err(), + PayoutModeError::MissingMinerPayout { + mode: MissingMinerPayoutMode::NoPayoutMode, + .. + } + )); + } + #[test] fn test_translator_config_log_dir() { let upstreams = vec![create_test_upstream()]; @@ -254,6 +404,7 @@ mod tests { 4, "test_user".to_string(), false, + false, vec![], vec![], None, @@ -289,6 +440,7 @@ mod tests { 1, 4, "test_user".to_string(), + false, true, vec![], vec![], @@ -319,6 +471,7 @@ mod tests { 4, "test_user".to_string(), false, + false, vec![], vec![], None, diff --git a/miner-apps/translator/src/lib/error.rs b/miner-apps/translator/src/lib/error.rs index f90adbc35..7eac6da0a 100644 --- a/miner-apps/translator/src/lib/error.rs +++ b/miner-apps/translator/src/lib/error.rs @@ -210,6 +210,8 @@ pub enum TproxyErrorKind { InvalidKey, /// Downstream not found with given downstream_id DownstreamNotPresent(DownstreamId), + /// Coinbase payout verification failed for a solo/donation user identity + PayoutVerificationFailed(String), } impl std::error::Error for TproxyErrorKind {} @@ -288,6 +290,7 @@ impl fmt::Display for TproxyErrorKind { f, "downstream not found with downstream_id: {downstream_id}" ), + PayoutVerificationFailed(e) => write!(f, "Payout verification failed: {e}"), } } } diff --git a/miner-apps/translator/src/lib/mod.rs b/miner-apps/translator/src/lib/mod.rs index 121093202..a6758390a 100644 --- a/miner-apps/translator/src/lib/mod.rs +++ b/miner-apps/translator/src/lib/mod.rs @@ -85,6 +85,21 @@ impl TranslatorSv2 { let cancellation_token = self.cancellation_token.clone(); let mut fallback_coordinator = FallbackCoordinator::new(); let tproxy_mode = TproxyMode::from(self.config.aggregate_channels); + let expected_payout_distribution = match self.config.expected_payout_distribution() { + Ok(distribution) => distribution, + Err(e) => { + error!("Invalid payout user_identity configuration: {e}"); + self.shutdown_notify.notify_waiters(); + self.is_alive.store(false, Ordering::Relaxed); + return; + } + }; + if let Some(distribution) = &expected_payout_distribution { + info!( + "Payout verification enabled for configured user_identity: {}", + distribution + ); + } let task_manager = Arc::new(TaskManager::new()); @@ -152,6 +167,7 @@ impl TranslatorSv2 { sv1_server_to_channel_manager_receiver, self.config.supported_extensions.clone(), self.config.required_extensions.clone(), + expected_payout_distribution.clone(), tproxy_mode, #[cfg(feature = "monitoring")] self.config.downstream_difficulty_config.enable_vardiff, @@ -279,6 +295,7 @@ impl TranslatorSv2 { sv1_server_to_channel_manager_receiver, self.config.supported_extensions.clone(), self.config.required_extensions.clone(), + expected_payout_distribution.clone(), tproxy_mode, #[cfg(feature = "monitoring")] self.config.downstream_difficulty_config.enable_vardiff diff --git a/miner-apps/translator/src/lib/monitoring.rs b/miner-apps/translator/src/lib/monitoring.rs index 091e78541..ee6039fdc 100644 --- a/miner-apps/translator/src/lib/monitoring.rs +++ b/miner-apps/translator/src/lib/monitoring.rs @@ -123,6 +123,7 @@ mod tests { sv1_server_receiver, vec![], vec![], + None, TproxyMode::Aggregated, true, ) diff --git a/miner-apps/translator/src/lib/sv1/sv1_server/mod.rs b/miner-apps/translator/src/lib/sv1/sv1_server/mod.rs index c1f414e71..704039f45 100644 --- a/miner-apps/translator/src/lib/sv1/sv1_server/mod.rs +++ b/miner-apps/translator/src/lib/sv1/sv1_server/mod.rs @@ -1459,6 +1459,7 @@ mod tests { 1, // min_supported_version 4, // downstream_extranonce2_size "test_user".to_string(), + false, // verify_payout true, // aggregate_channels vec![], // supported_extensions vec![], // required_extensions 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 988cb4d0a..508eb4a87 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 @@ -547,6 +547,14 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { _tlv_fields: Option<&[Tlv]>, ) -> Result<(), Self::Error> { info!("Received: {}", m); + if let Some(expected_payout_distribution) = &self.expected_payout_distribution { + expected_payout_distribution + .validate_coinbase_tx_suffix(m.coinbase_tx_suffix.inner_as_ref()) + .map_err(|e| { + error!("NewExtendedMiningJob failed payout verification: {e}"); + TproxyError::fallback(TproxyErrorKind::PayoutVerificationFailed(e.to_string())) + })?; + } 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 diff --git a/miner-apps/translator/src/lib/sv2/channel_manager/mod.rs b/miner-apps/translator/src/lib/sv2/channel_manager/mod.rs index 08e361d29..a3d5568b7 100644 --- a/miner-apps/translator/src/lib/sv2/channel_manager/mod.rs +++ b/miner-apps/translator/src/lib/sv2/channel_manager/mod.rs @@ -13,6 +13,7 @@ use stratum_apps::{ channel_utils::ReceiverCleanup, custom_mutex::Mutex, fallback_coordinator::FallbackCoordinator, + payout::PayoutMode, stratum_core::{ channels_sv2::{ client::{extended::ExtendedChannel, group::GroupChannel}, @@ -164,6 +165,8 @@ pub struct ChannelManager { /// Tracks whether the single upstream channel in aggregated mode is absent, /// being established, or connected. pub aggregated_channel_state: AtomicAggregatedState, + /// Expected coinbase payout distribution derived from `user_identity`. + pub(crate) expected_payout_distribution: Option, /// Current mode Tproxy is operating in. pub(crate) mode: TproxyMode, /// Required to show or not show hashrate on monitoring. @@ -267,6 +270,7 @@ impl ChannelManager { sv1_server_receiver: Receiver<(Mining<'static>, Option>)>, supported_extensions: Vec, required_extensions: Vec, + expected_payout_distribution: Option, tproxy_mode: TproxyMode, #[cfg(feature = "monitoring")] report_hashrate: bool, ) -> Self { @@ -288,6 +292,7 @@ impl ChannelManager { negotiated_extensions: Arc::new(Mutex::new(Vec::new())), aggregated_extranonce_allocator: Arc::new(Mutex::new(None)), aggregated_channel_state: AtomicAggregatedState::new(AggregatedState::NoChannel), + expected_payout_distribution, mode: tproxy_mode, #[cfg(feature = "monitoring")] report_hashrate, @@ -1014,6 +1019,7 @@ mod tests { sv1_server_receiver, vec![], vec![], + None, TproxyMode::from(true), #[cfg(feature = "monitoring")] true, diff --git a/pool-apps/pool/README.md b/pool-apps/pool/README.md index c7375892f..9d4d1339b 100644 --- a/pool-apps/pool/README.md +++ b/pool-apps/pool/README.md @@ -88,8 +88,10 @@ 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`. +The solo/donation payout mode is computed during runtime from the `user_identity` value of `OpenStandardMiningChannel`/`OpenExtendedMiningChannel`. +Pool uses the shared `stratum-apps` payout helper so this parsing is consistent with other applications that need to verify the same distribution. +If the `user_identity` does not match any supported pattern, the pool continues with the payout to the pool. If the `user_identity` starts with the `sri` prefix but the pattern is malformed, the pool sends an `OpenMiningChannelError`. + ### User Identity Patterns Miners must specify their payout mode via `user_identity`: @@ -98,16 +100,16 @@ Miners must specify their payout mode via `user_identity`: |---------|------|-------------| | `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 | +| `payout_address[.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. +In all the patterns on the list, the worker name is optional. The `percentage` in donate mode is +the pool's portion and must be between 1 and 99. ### 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 5f16b09d5..84cb76e2f 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 @@ -28,7 +28,7 @@ use jd_server_sv2::job_declarator::SetCustomMiningJobResponse; use crate::{ channel_manager::{ChannelManager, RouteMessageTo, CLIENT_SEARCH_SPACE_BYTES}, error::{self, PoolError, PoolErrorKind}, - utils::{create_close_channel_msg, PayoutMode}, + utils::{create_close_channel_msg, PayoutMode, PayoutModeError}, }; #[cfg_attr(not(test), hotpath::measure_all)] @@ -142,6 +142,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { let payout_mode = match PayoutMode::try_from(user_identity.as_str()) { Ok(mode) => mode, + Err(PayoutModeError::NoPayoutMode(_)) => PayoutMode::FullDonation, Err(_) => { error!("Invalid user_identity '{}': does not match any supported identity format", user_identity); let open_standard_mining_channel_error = OpenMiningChannelError { @@ -317,6 +318,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { let payout_mode = match PayoutMode::try_from(user_identity.as_str()) { Ok(mode) => mode, + Err(PayoutModeError::NoPayoutMode(_)) => PayoutMode::FullDonation, Err(_) => { error!("Invalid user_identity '{}': does not match any supported identity format", user_identity); let open_extended_mining_channel_error = OpenMiningChannelError { diff --git a/pool-apps/pool/src/lib/error.rs b/pool-apps/pool/src/lib/error.rs index 8656d875c..d5413752f 100644 --- a/pool-apps/pool/src/lib/error.rs +++ b/pool-apps/pool/src/lib/error.rs @@ -348,6 +348,13 @@ impl From for PoolErrorKind { PoolErrorKind::Custom(e) } } + +impl From for PoolErrorKind { + fn from(e: stratum_apps::payout::PayoutModeError) -> PoolErrorKind { + PoolErrorKind::PayoutModeError(e.to_string()) + } +} + impl From for PoolErrorKind { fn from(e: framing_sv2::Error) -> PoolErrorKind { PoolErrorKind::Framing(e) diff --git a/pool-apps/pool/src/lib/utils.rs b/pool-apps/pool/src/lib/utils.rs index c804ffc55..b80a2751c 100644 --- a/pool-apps/pool/src/lib/utils.rs +++ b/pool-apps/pool/src/lib/utils.rs @@ -1,9 +1,7 @@ use std::{convert::TryFrom, net::SocketAddr}; use stratum_apps::{ - config_helpers::CoinbaseRewardScript, stratum_core::{ binary_sv2::Str0255, - bitcoin::{Amount, TxOut}, common_messages_sv2::{Protocol, SetupConnection}, mining_sv2::CloseChannel, parsers_sv2::{Mining, Tlv}, @@ -13,6 +11,8 @@ use stratum_apps::{ use crate::error::PoolErrorKind; +pub use stratum_apps::payout::{PayoutMode, PayoutModeError}; + pub(crate) type DownstreamMessage = (Mining<'static>, Option>); /// Constructs a `SetupConnection` message for the mining protocol. @@ -76,259 +76,3 @@ 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/Cargo.toml b/stratum-apps/Cargo.toml index 48b878cd2..7b8422e6d 100644 --- a/stratum-apps/Cargo.toml +++ b/stratum-apps/Cargo.toml @@ -57,11 +57,13 @@ ext-config = { version = "0.14.0", features = ["toml"], package = "config" } shellexpand = "3.1.1" [features] -default = ["network", "config", "std"] +default = ["network", "fallback-coordinator", "config", "std"] # Core module features network = ["tokio-util", "core"] +fallback-coordinator = ["tokio-util"] config = [] +payout = ["config", "core"] rpc = ["serde_json", "hex", "base64", "hyper", "hyper-util", "http-body-util"] monitoring = ["serde_json", "axum", "prometheus", "utoipa", "utoipa-swagger-ui"] std = ["bs58/std", "secp256k1/rand-std", "rand/std", "rand/std_rng"] @@ -72,11 +74,11 @@ sv1 = ["stratum-core/sv1", "stratum-core/translation", "tokio-util", "serde_json with_buffer_pool = ["stratum-core/with_buffer_pool"] # Convenience feature bundles for different role types -pool = ["network", "config", "with_buffer_pool", "core"] -jd_client = ["network", "config", "with_buffer_pool", "core"] +pool = ["network", "config", "with_buffer_pool", "core", "payout"] +jd_client = ["network", "fallback-coordinator", "config", "with_buffer_pool", "core"] # Note: jd_server intentionally excludes 'core', 'network', and 'rpc' - it uses crates.io crates directly jd_server = ["config"] -translator = ["network", "config", "sv1", "with_buffer_pool", "core"] +translator = ["network", "fallback-coordinator", "config", "sv1", "with_buffer_pool", "core", "payout"] # Note: mining_device intentionally excludes 'core', 'network', and 'rpc' - it uses crates.io crates directly mining_device = ["config"] diff --git a/stratum-apps/README.md b/stratum-apps/README.md index 82b56c539..eedbf7d48 100644 --- a/stratum-apps/README.md +++ b/stratum-apps/README.md @@ -8,10 +8,12 @@ Complete Stratum V2 application development kit - all utilities in one crate. ## Architecture -This crate is organized into three main modules: +This crate is organized into several main modules: - **`network_helpers`** - High-level networking utilities (from `network_helpers_sv2`) - **`config_helpers`** - Configuration management helpers (from `config_helpers_sv2`) +- **`payout`** - Shared payout-mode parsing and coinbase-output distribution helpers +- **`fallback_coordinator`** - Runtime fallback cancellation and acknowledgement helpers - **`rpc`** - RPC utilities with custom serializable types (from `rpc_sv2`) - *feature-gated* The crate also re-exports `stratum-core`, the central hub for the Stratum V2 ecosystem that provides a cohesive API for all low-level protocol functionality. @@ -22,7 +24,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -stratum-apps = { version = "0.1.0", features = ["pool"] } +stratum-apps = { version = "0.4.0", features = ["pool"] } ``` Basic usage: @@ -39,21 +41,34 @@ use stratum_apps::rpc::{BlockHash, MiniRpcClient}; ### Core Features - `network` - Networking utilities (enabled by default) +- `fallback-coordinator` - Runtime fallback coordination helpers (enabled by default) - `config` - Configuration helpers (enabled by default) +- `payout` - Shared payout-mode parsing and coinbase-output distribution helpers (optional) - `rpc` - RPC utilities with custom serializable types (optional) - Provides `Hash`, `BlockHash`, `Amount` types with proper JSON serialization - `MiniRpcClient` for Bitcoin RPC communication +- `monitoring` - HTTP and Prometheus monitoring helpers (optional) +- `std` - Standard-library support for key and random utilities (enabled by default) +- `core` - Re-export and enable `stratum-core` ### Protocol Features - `sv1` - Enable SV1 protocol support (includes translation utilities) - `with_buffer_pool` - Enable buffer pooling for better performance ### Role-Specific Bundles -- `pool` - Everything needed for pool applications -- `jd_client` - Everything needed for JD client applications -- `jd_server` - Everything needed for JD server applications (includes RPC) -- `translator` - Everything needed for translator applications (includes SV1 + translation) -- `mining_device` - Everything needed for mining device applications +- `pool` - Pool application helpers, including networking, config, buffer pooling, core protocol types, and payout helpers +- `jd_client` - Job Declaration Client helpers, including networking, fallback coordination, config, buffer pooling, and core protocol types +- `jd_server` - Job Declaration Server config helpers +- `translator` - Translator Proxy helpers, including networking, fallback coordination, config, SV1 translation, buffer pooling, and payout helpers +- `mining_device` - Mining device config helpers + +Third-party applications that only need payout parsing or verification can use the smaller feature +set: + +```toml +[dependencies] +stratum-apps = { version = "0.4.0", default-features = false, features = ["payout"] } +``` ## Usage Examples @@ -61,7 +76,7 @@ use stratum_apps::rpc::{BlockHash, MiniRpcClient}; ```toml [dependencies] -stratum-apps = { version = "1.0", features = ["pool"] } +stratum-apps = { version = "0.4.0", features = ["pool"] } ``` ```rust @@ -78,15 +93,14 @@ let config: PoolConfig = config_helpers::parse_config("pool.toml")?; ```toml [dependencies] -stratum-apps = { version = "1.0", features = ["jd_server"] } +stratum-apps = { version = "0.4.0", features = ["jd_server", "rpc"] } ``` ```rust -use stratum_apps::{network_helpers, config_helpers, rpc}; +use stratum_apps::{config_helpers, rpc}; // RPC functionality with custom types use stratum_apps::rpc::{BlockHash, MiniRpcClient}; -// All networking and configuration utilities available -// Plus RPC server utilities with proper serialization -``` \ No newline at end of file +// Configuration helpers plus RPC server utilities with proper serialization +``` diff --git a/stratum-apps/src/lib.rs b/stratum-apps/src/lib.rs index d1875d268..e7868d98c 100644 --- a/stratum-apps/src/lib.rs +++ b/stratum-apps/src/lib.rs @@ -8,20 +8,26 @@ //! //! ### Core Features //! - `network` - High-level networking utilities (enabled by default) +//! - `fallback-coordinator` - Runtime fallback coordination helpers (enabled by default) //! - `config` - Configuration management helpers (enabled by default) +//! - `payout` - Shared payout-mode parsing and coinbase-output distribution helpers //! - `rpc` - RPC utilities with custom types for JSON-RPC communication (optional) +//! - `monitoring` - HTTP and Prometheus monitoring helpers (optional) +//! - `std` - Standard-library support for key and random utilities (enabled by default) +//! - `core` - Re-export and enable `stratum-core` //! //! ### Role-Specific Feature Bundles //! - `pool` - Everything needed for pool applications //! - `jd_client` - Everything needed for JD client applications -//! - `jd_server` - Everything needed for JD server applications (includes RPC) -//! - `translator` - Everything needed for translator applications (includes SV1) -//! - `mining_device` - Everything needed for mining device applications +//! - `jd_server` - Configuration helpers for JD server applications +//! - `translator` - Everything needed for translator applications (includes SV1 and payout helpers) +//! - `mining_device` - Configuration helpers for mining device applications //! //! ## Modules //! //! - [`network_helpers`] - High-level networking utilities for SV2 connections //! - [`config_helpers`] - Configuration management and parsing utilities +//! - [`payout`] - Payout-mode parsing and coinbase-output construction/verification helpers //! - [`rpc`] - RPC utilities with custom serializable types (`Hash`, `BlockHash`, `Amount`) /// Re-export all the modules from `stratum_core` @@ -76,7 +82,12 @@ pub mod tp_type; /// Creates a CoinbaseOutputConstraints message from a list of coinbase outputs pub mod coinbase_output_constraints; +/// Shared payout-mode parsing and coinbase-output distribution helpers. +#[cfg(feature = "payout")] +pub mod payout; + /// Fallback coordinator +#[cfg(feature = "fallback-coordinator")] pub mod fallback_coordinator; /// Shared async channel cleanup helpers. diff --git a/stratum-apps/src/payout.rs b/stratum-apps/src/payout.rs new file mode 100644 index 000000000..5cc205dec --- /dev/null +++ b/stratum-apps/src/payout.rs @@ -0,0 +1,608 @@ +//! Shared payout-mode parsing and coinbase-output distribution helpers. +//! +//! This module is meant for applications that accept SRI-style mining identities and need a +//! single source of truth for reward distribution. Pool-like applications can use +//! [`PayoutMode::coinbase_outputs`] to build outputs, while proxy/client applications can use +//! [`PayoutMode::validate_coinbase_outputs`] or [`PayoutMode::validate_coinbase_tx_suffix`] to +//! verify upstream jobs. + +use std::fmt; + +use crate::{ + config_helpers::CoinbaseRewardScript, + stratum_core::bitcoin::{consensus::Decodable, Amount, ScriptBuf, TxOut}, +}; + +/// Represents the payout mode encoded by a mining `user_identity`. +/// +/// Supported patterns: +/// - `sri/solo//`: full reward goes to the miner. +/// - `` or `.`: legacy solo mode, full reward goes +/// to the miner. +/// - `sri/donate///`: pool receives `percentage`, miner +/// receives the remainder. +/// - `sri/donate/`: full reward goes to the pool. +#[derive(Debug, Clone)] +pub enum PayoutMode { + /// Solo mode: miner receives full block reward. + Solo { + /// Miner payout address as supplied in `user_identity`. + address: String, + /// Miner payout script. + script: CoinbaseRewardScript, + }, + /// Donate mode: pool receives specified percentage, miner gets remainder. + Donate { + /// Pool's portion, from 1 to 99. + percentage: u8, + /// Miner payout address as supplied in `user_identity`. + address: String, + /// Miner payout script. + script: CoinbaseRewardScript, + }, + /// Full donation mode: full reward goes to the pool. + FullDonation, +} + +impl PayoutMode { + /// Creates coinbase outputs for this payout mode. + pub fn coinbase_outputs( + &self, + total_value: u64, + pool_script: &CoinbaseRewardScript, + ) -> Vec { + match self { + Self::Solo { + script: coinbase_script, + .. + } => { + vec![TxOut { + value: Amount::from_sat(total_value), + script_pubkey: coinbase_script.script_pubkey(), + }] + } + + Self::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(), + }, + ] + } + + Self::FullDonation => { + vec![TxOut { + value: Amount::from_sat(total_value), + script_pubkey: pool_script.script_pubkey(), + }] + } + } + } + + /// Verifies that spendable outputs match the miner-side payout encoded by this mode. + /// + /// OP_RETURN outputs are ignored. [`PayoutMode::FullDonation`] has no miner payout address, so + /// it returns success without checking a miner output. + pub fn validate_coinbase_outputs( + &self, + outputs: &[TxOut], + ) -> Result<(), PayoutValidationError> { + let Some(script_pubkey) = self.miner_script_pubkey() else { + return Ok(()); + }; + + let total_spendable_sats = outputs + .iter() + .filter(|output| !output.script_pubkey.is_op_return()) + .map(|output| output.value.to_sat()) + .sum(); + if total_spendable_sats == 0 { + return Err(PayoutValidationError::NoSpendableOutputs); + } + + let actual_miner_sats = outputs + .iter() + .filter(|output| !output.script_pubkey.is_op_return()) + .filter(|output| output.script_pubkey.as_bytes() == script_pubkey.as_bytes()) + .map(|output| output.value.to_sat()) + .sum(); + let expected_miner_sats = self.expected_miner_sats(total_spendable_sats); + if actual_miner_sats != expected_miner_sats { + return Err(PayoutValidationError::PayoutMismatch { + address: self + .miner_address() + .expect("miner script exists only when miner address exists") + .to_string(), + expected_sats: expected_miner_sats, + expected_percentage: self.expected_miner_percentage(), + total_spendable_sats, + actual_sats: actual_miner_sats, + }); + } + + Ok(()) + } + + /// Verifies a `NewExtendedMiningJob.coinbase_tx_suffix` against this payout mode. + /// + /// The suffix starts with the coinbase input sequence, followed by the serialized output vector + /// and locktime. This helper decodes the output vector and delegates to + /// [`PayoutMode::validate_coinbase_outputs`]. + pub fn validate_coinbase_tx_suffix( + &self, + coinbase_tx_suffix: &[u8], + ) -> Result<(), PayoutValidationError> { + let Some(outputs_bytes) = coinbase_tx_suffix.get(4..) else { + return Err(PayoutValidationError::CoinbaseTxSuffixTooShort); + }; + let outputs = Vec::::consensus_decode(&mut &outputs_bytes[..]) + .map_err(|e| PayoutValidationError::DecodeCoinbaseOutputs(e.to_string()))?; + + self.validate_coinbase_outputs(&outputs) + } + + fn miner_address(&self) -> Option<&str> { + match self { + Self::Solo { address, .. } | Self::Donate { address, .. } => Some(address.as_str()), + Self::FullDonation => None, + } + } + + fn miner_script_pubkey(&self) -> Option { + match self { + Self::Solo { script, .. } | Self::Donate { script, .. } => Some(script.script_pubkey()), + Self::FullDonation => None, + } + } + + fn expected_miner_percentage(&self) -> u8 { + match self { + Self::Solo { .. } => 100, + Self::Donate { percentage, .. } => 100 - percentage, + Self::FullDonation => 0, + } + } + + fn expected_miner_sats(&self, total_spendable_sats: u64) -> u64 { + match self { + Self::Solo { .. } => total_spendable_sats, + Self::Donate { percentage, .. } => { + let pool_sats = (total_spendable_sats * *percentage as u64) / 100; + total_spendable_sats.saturating_sub(pool_sats) + } + Self::FullDonation => 0, + } + } +} + +impl TryFrom<&str> for PayoutMode { + type Error = PayoutModeError; + + fn try_from(user_identity: &str) -> Result { + if user_identity.is_empty() { + return Err(PayoutModeError::NoPayoutMode(user_identity.to_string())); + } + + let addr = address_part_from_user_identity(user_identity); + + if let Ok(script) = script_from_address(addr) { + return Ok(Self::Solo { + address: addr.to_string(), + script, + }); + } + + let mut parts = user_identity.split('/'); + + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some("sri"), Some("solo"), Some(payout_address), _) => { + let script = script_from_address(payout_address)?; + Ok(Self::Solo { + address: payout_address.to_string(), + script, + }) + } + + (Some("sri"), Some("donate"), None, _) + | (Some("sri"), Some("donate"), Some(_), None) => Ok(Self::FullDonation), + + (Some("sri"), Some("donate"), Some(percentage), Some(payout_address)) => { + let percentage = percentage.parse::().map_err(|_| { + PayoutModeError::InvalidDonationPercentage(percentage.to_string()) + })?; + if !(1..100).contains(&percentage) { + return Err(PayoutModeError::InvalidDonationPercentage( + percentage.to_string(), + )); + } + + let script = script_from_address(payout_address)?; + Ok(Self::Donate { + percentage, + address: payout_address.to_string(), + script, + }) + } + + (Some("sri"), Some(_), _, _) => Err(PayoutModeError::InvalidUserIdentity( + user_identity.to_string(), + )), + + _ => Err(PayoutModeError::NoPayoutMode(user_identity.to_string())), + } + } +} + +impl fmt::Display for PayoutMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Solo { address, .. } => { + write!(f, "100% miner payout to {address}") + } + Self::Donate { + percentage, + address, + .. + } => write!( + f, + "{}% miner payout to {} ({}% pool donation)", + 100 - percentage, + address, + percentage + ), + Self::FullDonation => write!(f, "100% pool payout"), + } + } +} + +/// Errors produced while parsing a payout mode from a `user_identity`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayoutModeError { + /// No payout mode was encoded in `user_identity`. + NoPayoutMode(String), + /// `sri/...` was used with an unsupported payout pattern. + InvalidUserIdentity(String), + /// A payout address was present but could not be converted into a script. + InvalidPayoutAddress { address: String, error: String }, + /// Donation percentage was not an integer in the supported 1..100 range. + InvalidDonationPercentage(String), + /// Payout verification was requested but no miner payout address is present. + MissingMinerPayout { + user_identity: String, + mode: MissingMinerPayoutMode, + }, +} + +/// Payout modes that cannot be verified because they do not include a miner payout address. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MissingMinerPayoutMode { + /// `sri/donate/` full donation mode: all reward goes to the pool. + FullDonation, + /// No SRI payout mode or legacy address payout was encoded. + NoPayoutMode, +} + +impl fmt::Display for PayoutModeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoPayoutMode(user_identity) => { + write!(f, "no payout mode encoded in user_identity: {user_identity}") + } + Self::InvalidUserIdentity(user_identity) => { + write!( + f, + "invalid user_identity pattern for payout mode: {user_identity}" + ) + } + Self::InvalidPayoutAddress { address, error } => { + write!(f, "invalid payout address `{address}`: {error}") + } + Self::InvalidDonationPercentage(percentage) => { + write!(f, "invalid donation percentage: {percentage}") + } + Self::MissingMinerPayout { + user_identity, + mode: MissingMinerPayoutMode::FullDonation, + } => write!( + f, + "verify_payout is enabled, but user_identity `{user_identity}` opts into full donation mode (`sri/donate/`), which has no miner payout to verify; disable verify_payout or use sri/solo/
/, sri/donate//
/,
, or
." + ), + Self::MissingMinerPayout { + user_identity, + mode: MissingMinerPayoutMode::NoPayoutMode, + } => write!( + f, + "verify_payout is enabled, but user_identity `{user_identity}` does not opt into a payout mode, so there is no miner payout to verify; disable verify_payout for pool usernames or use sri/solo/
/, sri/donate//
/,
, or
." + ), + } + } +} + +impl std::error::Error for PayoutModeError {} + +/// Errors produced while verifying coinbase outputs against a payout mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayoutValidationError { + /// The coinbase output set has no spendable outputs. + NoSpendableOutputs, + /// The miner payout did not match the expected distribution. + PayoutMismatch { + /// Address encoded by the payout mode. + address: String, + /// Expected miner payout in satoshis. + expected_sats: u64, + /// Expected miner payout percentage. + expected_percentage: u8, + /// Total spendable coinbase output value in satoshis. + total_spendable_sats: u64, + /// Actual amount paid to the miner script in satoshis. + actual_sats: u64, + }, + /// `NewExtendedMiningJob.coinbase_tx_suffix` was too short to contain outputs. + CoinbaseTxSuffixTooShort, + /// Failed to decode serialized coinbase outputs. + DecodeCoinbaseOutputs(String), +} + +impl fmt::Display for PayoutValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoSpendableOutputs => write!(f, "coinbase has no spendable outputs"), + Self::PayoutMismatch { + address, + expected_sats, + expected_percentage, + total_spendable_sats, + actual_sats, + } => write!( + f, + "coinbase payout mismatch for {address}: expected {expected_sats} sats ({expected_percentage}% of {total_spendable_sats} spendable sats), found {actual_sats} sats" + ), + Self::CoinbaseTxSuffixTooShort => { + write!(f, "coinbase_tx_suffix is too short to contain an input sequence") + } + Self::DecodeCoinbaseOutputs(e) => { + write!(f, "failed to decode coinbase outputs: {e}") + } + } + } +} + +impl std::error::Error for PayoutValidationError {} + +fn script_from_address(address: &str) -> Result { + CoinbaseRewardScript::from_descriptor(&format!("addr({address})")).map_err(|e| { + PayoutModeError::InvalidPayoutAddress { + address: address.to_string(), + error: e.to_string(), + } + }) +} + +fn address_part_from_user_identity(user_identity: &str) -> &str { + user_identity + .split_once('.') + .map(|(address, _)| address) + .unwrap_or(user_identity) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stratum_core::bitcoin::{ + consensus::serialize, + params::{MAINNET, TESTNET4}, + Address, + }; + + const MINER_ADDRESS: &str = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x"; + const OTHER_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + const TESTNET_ADDRESS: &str = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8"; + + fn tx_out(value: u64, address: &str) -> TxOut { + TxOut { + value: Amount::from_sat(value), + script_pubkey: script_from_address(address).unwrap().script_pubkey(), + } + } + + fn coinbase_suffix(outputs: Vec) -> Vec { + let mut suffix = vec![0xff, 0xff, 0xff, 0xff]; + suffix.extend(serialize(&outputs)); + suffix.extend([0, 0, 0, 0]); + suffix + } + + #[test] + fn parses_full_donation_identities() { + assert!(matches!( + PayoutMode::try_from("sri/donate/worker"), + Ok(PayoutMode::FullDonation) + )); + assert!(matches!( + PayoutMode::try_from("sri/donate"), + Ok(PayoutMode::FullDonation) + )); + } + + #[test] + fn parses_solo_identities() { + assert!(matches!( + PayoutMode::try_from(format!("sri/solo/{TESTNET_ADDRESS}/worker").as_str()), + Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS + )); + assert!(matches!( + PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/worker/subworker").as_str()), + Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS + )); + assert!(matches!( + PayoutMode::try_from(MINER_ADDRESS), + Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS + )); + } + + #[test] + fn parses_legacy_address_identity_with_worker_suffix() { + assert!(matches!( + PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1").as_str()), + Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS + )); + assert!(matches!( + PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1.subworker").as_str()), + Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS + )); + } + + #[test] + fn arbitrary_pool_usernames_have_no_payout_mode() { + assert!(matches!( + PayoutMode::try_from("invalid_address.worker"), + Err(PayoutModeError::NoPayoutMode(_)) + )); + assert!(matches!( + PayoutMode::try_from(""), + Err(PayoutModeError::NoPayoutMode(_)) + )); + assert!(matches!( + PayoutMode::try_from("other/donate/worker"), + Err(PayoutModeError::NoPayoutMode(_)) + )); + } + + #[test] + fn permissive_parser_treats_address_like_typos_as_no_payout_mode() { + assert!(matches!( + PayoutMode::try_from("bc1q_typo.worker"), + Err(PayoutModeError::NoPayoutMode(_)) + )); + } + + #[test] + fn parses_partial_donation_identities() { + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}/worker").as_str()).unwrap(), + PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS + )); + + assert!(matches!( + PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}").as_str()).unwrap(), + PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS + )); + } + + #[test] + fn rejects_invalid_sri_patterns() { + 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(PayoutModeError::InvalidDonationPercentage(_)) + )); + assert!(matches!( + PayoutMode::try_from("sri/donate/101/addr/worker"), + Err(PayoutModeError::InvalidDonationPercentage(_)) + )); + assert!(matches!( + PayoutMode::try_from("sri/"), + Err(PayoutModeError::InvalidUserIdentity(_)) + )); + } + + #[test] + fn builds_pool_coinbase_outputs_for_all_modes() { + let pool_script = script_from_address(OTHER_ADDRESS).unwrap(); + + let solo = PayoutMode::try_from(MINER_ADDRESS).unwrap(); + let solo_outputs = solo.coinbase_outputs(1_000, &pool_script); + assert_eq!(solo_outputs.len(), 1); + assert_eq!(solo_outputs[0].value.to_sat(), 1_000); + + let donate = + PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w").as_str()).unwrap(); + let donate_outputs = donate.coinbase_outputs(1_000, &pool_script); + assert_eq!(donate_outputs.len(), 2); + assert_eq!(donate_outputs[0].value.to_sat(), 100); + assert_eq!(donate_outputs[1].value.to_sat(), 900); + + let full_donation = PayoutMode::FullDonation; + let full_donation_outputs = full_donation.coinbase_outputs(1_000, &pool_script); + assert_eq!(full_donation_outputs.len(), 1); + assert_eq!(full_donation_outputs[0].value.to_sat(), 1_000); + } + + #[test] + fn validates_full_solo_distribution() { + let expected = + PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(1_000, MINER_ADDRESS)]); + + expected.validate_coinbase_tx_suffix(&suffix).unwrap(); + } + + #[test] + fn rejects_full_solo_distribution_with_other_spendable_output() { + let expected = + PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(900, MINER_ADDRESS), tx_out(100, OTHER_ADDRESS)]); + + let err = expected.validate_coinbase_tx_suffix(&suffix).unwrap_err(); + + assert!(matches!( + err, + PayoutValidationError::PayoutMismatch { + expected_sats: 1000, + actual_sats: 900, + .. + } + )); + } + + #[test] + fn validates_partial_donation_distribution() { + let expected = + PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(100, OTHER_ADDRESS), tx_out(900, MINER_ADDRESS)]); + + expected.validate_coinbase_tx_suffix(&suffix).unwrap(); + } + + #[test] + fn rejects_wrong_partial_donation_distribution() { + let expected = + PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(200, OTHER_ADDRESS), tx_out(800, MINER_ADDRESS)]); + + let err = expected.validate_coinbase_tx_suffix(&suffix).unwrap_err(); + + assert!(matches!( + err, + PayoutValidationError::PayoutMismatch { + expected_sats: 900, + actual_sats: 800, + .. + } + )); + } + + #[test] + fn full_donation_has_no_miner_payout_to_verify() { + let expected = PayoutMode::FullDonation; + let suffix = coinbase_suffix(vec![tx_out(1_000, OTHER_ADDRESS)]); + + expected.validate_coinbase_tx_suffix(&suffix).unwrap(); + } +}