diff --git a/integration-tests/tests/translator_integration.rs b/integration-tests/tests/translator_integration.rs index cb317c249..fa46940f9 100644 --- a/integration-tests/tests/translator_integration.rs +++ b/integration-tests/tests/translator_integration.rs @@ -96,7 +96,8 @@ async fn translate_sv1_to_sv2_successfully() { shutdown_all!(translator, pool); } -/// Checks that tProxy mines when payout verification passes for address and donation identities. +/// Checks that tProxy mines when payout verification passes for legacy address and donation +/// identities. #[tokio::test] async fn translator_mines_when_payout_matches_address_or_donation_identity() { start_tracing(); @@ -111,12 +112,18 @@ async fn translator_mines_when_payout_matches_address_or_donation_identity() { .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 legacy_solo_coinbase_tx_suffix = hex::decode("feffffff").unwrap(); + legacy_solo_coinbase_tx_suffix.extend(serialize(&vec![ + TxOut { + value: Amount::from_sat(4_955_000_000), + script_pubkey: miner_script_pubkey.clone(), + }, + TxOut { + value: Amount::from_sat(45_000_000), + script_pubkey: pool_script_pubkey.clone(), + }, + ])); + legacy_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![ @@ -135,7 +142,7 @@ async fn translator_mines_when_payout_matches_address_or_donation_identity() { ( "payout-address", PAYOUT_VERIFICATION_MINER_ADDRESS.to_string(), - solo_coinbase_tx_suffix, + legacy_solo_coinbase_tx_suffix, ), ( "payout-donation", diff --git a/miner-apps/translator/README.md b/miner-apps/translator/README.md index 71996f982..c8d55f74c 100644 --- a/miner-apps/translator/README.md +++ b/miner-apps/translator/README.md @@ -91,7 +91,7 @@ Payout verification is disabled by default. Set `verify_payout = true` for solo 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` +- `[.worker]`: legacy solo mode, verified by checking that at least 90% of spendable coinbase outputs go to `` - `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 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 3a0b68e5b..d4bdb4927 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 @@ -63,4 +63,4 @@ user_identity = "your_primary_username_here" address = "107.170.42.64" port = 3333 authority_pubkey = "9awtMD5KQgvRUh2yFbjVeT7b6hjipWcAsQHd6wEhgtDT9soosna" -user_identity = "your_backup_username_here" +user_identity = "your_backup_username_here" \ No newline at end of file diff --git a/miner-apps/translator/src/lib/config.rs b/miner-apps/translator/src/lib/config.rs index 8ec33819b..78425ae77 100644 --- a/miner-apps/translator/src/lib/config.rs +++ b/miner-apps/translator/src/lib/config.rs @@ -149,9 +149,11 @@ impl TranslatorConfig { } match PayoutMode::try_from(user_identity) { - Ok(payout_mode @ (PayoutMode::Solo { .. } | PayoutMode::Donate { .. })) => { - Ok(Some(payout_mode)) - } + Ok( + payout_mode @ (PayoutMode::Solo { .. } + | PayoutMode::LegacySolo { .. } + | PayoutMode::Donate { .. }), + ) => Ok(Some(payout_mode)), Ok(PayoutMode::FullDonation) => Err(PayoutModeError::MissingMinerPayout { user_identity: user_identity.to_string(), mode: MissingMinerPayoutMode::FullDonation, @@ -321,7 +323,7 @@ mod tests { enabled_config .expected_payout_distribution(payout_address) .unwrap(), - Some(PayoutMode::Solo { .. }) + Some(PayoutMode::LegacySolo { .. }) )); } diff --git a/stratum-apps/src/payout.rs b/stratum-apps/src/payout.rs index c4004aa5a..a727225dc 100644 --- a/stratum-apps/src/payout.rs +++ b/stratum-apps/src/payout.rs @@ -13,12 +13,15 @@ use crate::{ stratum_core::bitcoin::{consensus::Decodable, Amount, ScriptBuf, TxOut}, }; +// Legacy solo identities do not encode a fee policy, so allow at most a 10% service fee. +const MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE: u8 = 90; + /// 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. +/// - `` or `.`: legacy solo mode; payout verification +/// checks that the miner address receives at least 90% of spendable coinbase outputs. /// - `sri/donate///`: pool receives `percentage`, miner /// receives the remainder. /// - `sri/donate/`: full reward goes to the pool. @@ -31,6 +34,13 @@ pub enum PayoutMode { /// Miner payout script. script: CoinbaseRewardScript, }, + /// Legacy solo mode: miner payout address must receive at least 90% of spendable coinbase outputs. + LegacySolo { + /// 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. @@ -55,6 +65,10 @@ impl PayoutMode { Self::Solo { script: coinbase_script, .. + } + | Self::LegacySolo { + script: coinbase_script, + .. } => { vec![TxOut { value: Amount::from_sat(total_value), @@ -118,6 +132,24 @@ impl PayoutMode { .filter(|output| output.script_pubkey.as_bytes() == script_pubkey.as_bytes()) .map(|output| output.value.to_sat()) .sum(); + if matches!(self, Self::LegacySolo { .. }) { + let expected_miner_sats = self.expected_legacy_solo_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: MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE, + total_spendable_sats, + actual_sats: actual_miner_sats, + }); + } + + return Ok(()); + } + let expected_miner_sats = self.expected_miner_sats(total_spendable_sats); if actual_miner_sats != expected_miner_sats { return Err(PayoutValidationError::PayoutMismatch { @@ -155,21 +187,25 @@ impl PayoutMode { fn miner_address(&self) -> Option<&str> { match self { - Self::Solo { address, .. } | Self::Donate { address, .. } => Some(address.as_str()), + Self::Solo { address, .. } + | Self::LegacySolo { 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::Solo { script, .. } + | Self::LegacySolo { script, .. } + | Self::Donate { script, .. } => Some(script.script_pubkey()), Self::FullDonation => None, } } fn expected_miner_percentage(&self) -> u8 { match self { - Self::Solo { .. } => 100, + Self::Solo { .. } | Self::LegacySolo { .. } => 100, Self::Donate { percentage, .. } => 100 - percentage, Self::FullDonation => 0, } @@ -177,7 +213,7 @@ impl PayoutMode { fn expected_miner_sats(&self, total_spendable_sats: u64) -> u64 { match self { - Self::Solo { .. } => total_spendable_sats, + Self::Solo { .. } | Self::LegacySolo { .. } => total_spendable_sats, Self::Donate { percentage, .. } => { let pool_sats = (total_spendable_sats * *percentage as u64) / 100; total_spendable_sats.saturating_sub(pool_sats) @@ -185,6 +221,10 @@ impl PayoutMode { Self::FullDonation => 0, } } + + fn expected_legacy_solo_miner_sats(&self, total_spendable_sats: u64) -> u64 { + (total_spendable_sats * MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE as u64).div_ceil(100) + } } impl TryFrom<&str> for PayoutMode { @@ -198,7 +238,7 @@ impl TryFrom<&str> for PayoutMode { let addr = address_part_from_user_identity(user_identity); if let Ok(script) = script_from_address(addr) { - return Ok(Self::Solo { + return Ok(Self::LegacySolo { address: addr.to_string(), script, }); @@ -251,6 +291,10 @@ impl fmt::Display for PayoutMode { Self::Solo { address, .. } => { write!(f, "100% miner payout to {address}") } + Self::LegacySolo { address, .. } => write!( + f, + "at least {MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE}% miner payout to {address}" + ), Self::Donate { percentage, address, @@ -449,7 +493,7 @@ mod tests { )); 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 + Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS )); } @@ -457,11 +501,11 @@ mod tests { 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 + Ok(PayoutMode::LegacySolo { 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 + Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS )); } @@ -571,6 +615,51 @@ mod tests { )); } + #[test] + fn validates_legacy_solo_distribution_with_service_fee_output() { + let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(991, MINER_ADDRESS), tx_out(9, OTHER_ADDRESS)]); + + expected.validate_coinbase_tx_suffix(&suffix).unwrap(); + } + + #[test] + fn rejects_legacy_solo_distribution_below_minimum() { + let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(899, MINER_ADDRESS), tx_out(101, OTHER_ADDRESS)]); + + let err = expected.validate_coinbase_tx_suffix(&suffix).unwrap_err(); + + assert!(matches!( + err, + PayoutValidationError::PayoutMismatch { + expected_sats: 900, + expected_percentage: 90, + actual_sats: 899, + .. + } + )); + } + + #[test] + fn rejects_legacy_solo_distribution_without_miner_address() { + let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap(); + let suffix = coinbase_suffix(vec![tx_out(1_000, OTHER_ADDRESS)]); + + let err = expected.validate_coinbase_tx_suffix(&suffix).unwrap_err(); + + assert!(matches!( + err, + PayoutValidationError::PayoutMismatch { + expected_sats: 900, + expected_percentage: 90, + total_spendable_sats: 1000, + actual_sats: 0, + .. + } + )); + } + #[test] fn validates_partial_donation_distribution() { let expected =