diff --git a/integration-tests/Cargo.lock b/integration-tests/Cargo.lock index e404a8eeb..99d996c21 100644 --- a/integration-tests/Cargo.lock +++ b/integration-tests/Cargo.lock @@ -2669,7 +2669,7 @@ dependencies = [ [[package]] name = "integration_tests_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel 1.9.0", "clap", @@ -2747,7 +2747,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jd_client_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel 1.9.0", "clap", @@ -2762,7 +2762,7 @@ dependencies = [ [[package]] name = "jd_server_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel 1.9.0", "async-trait", @@ -3430,7 +3430,7 @@ dependencies = [ [[package]] name = "pool_sv2" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-channel 1.9.0", "clap", @@ -4369,7 +4369,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stratum-apps" -version = "0.6.0" +version = "0.7.0" dependencies = [ "asic-rs", "async-channel 1.9.0", @@ -4897,7 +4897,7 @@ dependencies = [ [[package]] name = "translator_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel 1.9.0", "clap", diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 00ed1bc4d..b9b143e76 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "integration_tests_sv2" -version = "0.4.0" +version = "0.5.0" authors = ["The Stratum V2 Developers"] edition = "2021" description = "Sv2 Integration Tests Framework" @@ -14,10 +14,10 @@ keywords = ["stratum", "mining", "bitcoin", "protocol"] exclude = ["resources/high_diff_chain.tar.gz"] [dependencies] -stratum-apps = { version = "0.6.0", path = "../stratum-apps", features = ["network", "config", "payout", "bitcoin-core-sv2"] } -jd_client_sv2 = { version = "0.4.0", path = "../miner-apps/jd-client" } -pool_sv2 = { version = "0.5.0", path = "../pool-apps/pool" } -translator_sv2 = { version = "0.4.0", path = "../miner-apps/translator" } +stratum-apps = { version = "0.7.0", path = "../stratum-apps", features = ["network", "config", "payout", "bitcoin-core-sv2"] } +jd_client_sv2 = { version = "0.5.0", path = "../miner-apps/jd-client" } +pool_sv2 = { version = "0.6.0", path = "../pool-apps/pool" } +translator_sv2 = { version = "0.5.0", path = "../miner-apps/translator" } async-channel = { version = "1.5.1", default-features = false } corepc-node = { version = "0.7.0", default-features = false, features = ["28_0"] } minreq = { version = "2.12.0", default-features = false, features = ["https"] } diff --git a/integration-tests/tests/translator_integration.rs b/integration-tests/tests/translator_integration.rs index fa46940f9..32348e1d4 100644 --- a/integration-tests/tests/translator_integration.rs +++ b/integration-tests/tests/translator_integration.rs @@ -129,26 +129,53 @@ async fn translator_mines_when_payout_matches_address_or_donation_identity() { partial_donation_coinbase_tx_suffix.extend(serialize(&vec![ TxOut { value: Amount::from_sat(500_000_000), - script_pubkey: pool_script_pubkey, + script_pubkey: pool_script_pubkey.clone(), }, TxOut { value: Amount::from_sat(4_500_000_000), - script_pubkey: miner_script_pubkey, + script_pubkey: miner_script_pubkey.clone(), }, ])); partial_donation_coinbase_tx_suffix.extend([0, 0, 0, 0]); - for (identifier, user_identity, coinbase_tx_suffix) in [ + // Simulates pools that leave extra coinbase scriptSig bytes after the extranonce. + let mut suffix_with_remaining_scriptsig_bytes = + hex::decode("2f4e65787573506f6f6c2ffeffffff").unwrap(); + suffix_with_remaining_scriptsig_bytes.extend(serialize(&vec![ + TxOut { + value: Amount::from_sat(4_955_000_000), + script_pubkey: miner_script_pubkey, + }, + TxOut { + value: Amount::from_sat(45_000_000), + script_pubkey: pool_script_pubkey, + }, + ])); + suffix_with_remaining_scriptsig_bytes.extend([0, 0, 0, 0]); + + let default_coinbase_tx_prefix = hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff225200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap(); + // Extra `/NexusPool/` scriptSig bytes increase the prefix scriptSig length from 0x22 to 0x2d. + let coinbase_tx_prefix_with_remaining_scriptsig_bytes = hex::decode("02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2d5200162f5374726174756d2056322053524920506f6f6c2f2f08").unwrap(); + + for (identifier, user_identity, coinbase_tx_prefix, coinbase_tx_suffix) in [ ( "payout-address", PAYOUT_VERIFICATION_MINER_ADDRESS.to_string(), + default_coinbase_tx_prefix.clone(), legacy_solo_coinbase_tx_suffix, ), ( "payout-donation", format!("sri/donate/10/{PAYOUT_VERIFICATION_MINER_ADDRESS}/worker"), + default_coinbase_tx_prefix, partial_donation_coinbase_tx_suffix, ), + ( + "payout-address-scriptsig-suffix", + PAYOUT_VERIFICATION_MINER_ADDRESS.to_string(), + coinbase_tx_prefix_with_remaining_scriptsig_bytes, + suffix_with_remaining_scriptsig_bytes, + ), ] { let mock_upstream_addr = get_available_address(); let send_to_tproxy = MockUpstream::new( @@ -211,16 +238,18 @@ async fn translator_mines_when_payout_matches_address_or_donation_identity() { .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(), - }))) + .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: coinbase_tx_prefix.try_into().unwrap(), + coinbase_tx_suffix: coinbase_tx_suffix.try_into().unwrap(), + }), + )) .await .unwrap(); sniffer diff --git a/miner-apps/Cargo.lock b/miner-apps/Cargo.lock index 71e9e9d1d..c0d15c15b 100644 --- a/miner-apps/Cargo.lock +++ b/miner-apps/Cargo.lock @@ -2339,7 +2339,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jd_client_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel", "clap", @@ -3740,7 +3740,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stratum-apps" -version = "0.6.0" +version = "0.7.0" dependencies = [ "asic-rs", "async-channel", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "translator_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel", "clap", diff --git a/miner-apps/jd-client/Cargo.toml b/miner-apps/jd-client/Cargo.toml index 4950e4da3..7abb82987 100644 --- a/miner-apps/jd-client/Cargo.toml +++ b/miner-apps/jd-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jd_client_sv2" -version = "0.4.0" +version = "0.5.0" authors = ["The Stratum V2 Developers"] edition = "2021" description = "Job Declarator Client (JDC) role" @@ -16,7 +16,7 @@ name = "jd_client_sv2" path = "src/lib/mod.rs" [dependencies] -stratum-apps = { version = "0.6.0", path = "../../stratum-apps", features = ["jd_client"] } +stratum-apps = { version = "0.7.0", path = "../../stratum-apps", features = ["jd_client"] } async-channel = "1.5.1" serde = { version = "1.0.89", default-features = false, features = ["derive", "alloc"] } tokio = { version = "1.44.1", features = ["full"] } diff --git a/miner-apps/translator/Cargo.toml b/miner-apps/translator/Cargo.toml index 00aa1cf78..14c61a22c 100644 --- a/miner-apps/translator/Cargo.toml +++ b/miner-apps/translator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "translator_sv2" -version = "0.4.0" +version = "0.5.0" authors = ["The Stratum V2 Developers"] edition = "2021" description = "SV1 to SV2 translation proxy" @@ -20,7 +20,7 @@ name = "translator_sv2" path = "src/main.rs" [dependencies] -stratum-apps = { version = "0.6.0", path = "../../stratum-apps", features = ["translator"] } +stratum-apps = { version = "0.7.0", path = "../../stratum-apps", features = ["translator"] } async-channel = "1.5.1" serde = { version = "1.0.89", default-features = false, features = ["derive", "alloc"] } serde_json = { version = "1.0.64", default-features = false, features = ["alloc"] } 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 88f854b9c..d3d255b7d 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,14 +547,6 @@ 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.as_bytes()) - .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 @@ -565,11 +557,16 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { // are we in aggregated mode? if self.mode.is_aggregated() { // Validate that the message is for the aggregated channel or its group - let aggregated_channel_id = self - .extended_channels - .get(&AGGREGATED_CHANNEL_ID) - .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))? - .get_channel_id(); + let (aggregated_channel_id, full_extranonce_size) = { + let aggregated_channel = self + .extended_channels + .get(&AGGREGATED_CHANNEL_ID) + .ok_or(TproxyError::fallback(TproxyErrorKind::ChannelNotFound))?; + ( + aggregated_channel.get_channel_id(), + aggregated_channel.get_full_extranonce_size(), + ) + }; // here, we are assuming that since we are in aggregated mode, there should // be only one single group channel and the @@ -586,6 +583,8 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { if aggregated_channel_id == m_static.channel_id || group_channel_id == m_static.channel_id { + self.verify_payout_distribution(&m_static, full_extranonce_size)?; + // update all extended channel states for mut extended_channel in self.extended_channels.iter_mut() { extended_channel @@ -617,6 +616,13 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { // we're not in aggregated mode // was the message sent to a group channel? } else if let Some(mut group_channel) = self.group_channels.get_mut(&m.channel_id) { + let full_extranonce_size = + group_channel.get_full_extranonce_size().ok_or_else(|| { + error!("Group channel {} has no full extranonce size", m.channel_id); + TproxyError::fallback(TproxyErrorKind::ChannelNotFound) + })?; + self.verify_payout_distribution(&m_static, full_extranonce_size)?; + // update group channel state group_channel.on_new_extended_mining_job(m_static.clone()); @@ -658,6 +664,8 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { return Err(TproxyError::log(TproxyErrorKind::ChannelNotFound)); }; + self.verify_payout_distribution(&m_static, channel.get_full_extranonce_size())?; + // update channel state channel .on_new_extended_mining_job(m_static.clone()) @@ -1102,3 +1110,27 @@ impl HandleMiningMessagesFromServerAsync for ChannelManager { Ok(()) } } + +impl ChannelManager { + #[allow(clippy::result_large_err)] + fn verify_payout_distribution( + &self, + job: &NewExtendedMiningJob<'_>, + full_extranonce_size: usize, + ) -> Result<(), TproxyError> { + if let Some(expected_payout_distribution) = self.expected_payout_distribution() { + expected_payout_distribution + .validate_coinbase_tx_parts( + job.coinbase_tx_prefix.as_bytes(), + job.coinbase_tx_suffix.as_bytes(), + full_extranonce_size, + ) + .map_err(|e| { + error!("NewExtendedMiningJob failed payout verification: {e}"); + TproxyError::fallback(TproxyErrorKind::PayoutVerificationFailed(e.to_string())) + })?; + } + + Ok(()) + } +} diff --git a/pool-apps/Cargo.lock b/pool-apps/Cargo.lock index ce4189680..e5086d1b7 100644 --- a/pool-apps/Cargo.lock +++ b/pool-apps/Cargo.lock @@ -1519,7 +1519,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jd_server_sv2" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-channel", "async-trait", @@ -1921,7 +1921,7 @@ dependencies = [ [[package]] name = "pool_sv2" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-channel", "clap", @@ -2448,7 +2448,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stratum-apps" -version = "0.6.0" +version = "0.7.0" dependencies = [ "async-channel", "axum", diff --git a/pool-apps/jd-server/Cargo.toml b/pool-apps/jd-server/Cargo.toml index 2e6de64a4..152261c28 100644 --- a/pool-apps/jd-server/Cargo.toml +++ b/pool-apps/jd-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jd_server_sv2" -version = "0.4.0" +version = "0.5.0" authors = ["The Stratum V2 Developers"] edition = "2021" description = "Sv2 Job Declaration Server" @@ -16,7 +16,7 @@ name = "jd_server_sv2" path = "src/lib/mod.rs" [dependencies] -stratum-apps = { version = "0.6.0", path = "../../stratum-apps", features = ["jd_server"] } +stratum-apps = { version = "0.7.0", path = "../../stratum-apps", features = ["jd_server"] } async-channel = "1.5.1" serde = { version = "1.0.89", features = ["derive", "alloc"], default-features = false } tracing = { version = "0.1" } diff --git a/pool-apps/pool/Cargo.toml b/pool-apps/pool/Cargo.toml index ca7b552ed..195e382a1 100644 --- a/pool-apps/pool/Cargo.toml +++ b/pool-apps/pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pool_sv2" -version = "0.5.0" +version = "0.6.0" authors = ["The Stratum V2 Developers"] edition = "2021" description = "SV2 pool role" @@ -17,14 +17,14 @@ name = "pool_sv2" path = "src/lib/mod.rs" [dependencies] -stratum-apps = { version = "0.6.0", path = "../../stratum-apps", features = ["pool"] } +stratum-apps = { version = "0.7.0", path = "../../stratum-apps", features = ["pool"] } async-channel = "1.5.1" serde = { version = "1.0.89", features = ["derive", "alloc"], default-features = false } tokio = { version = "1.44.1", features = ["full"] } ext-config = { version = "0.14.0", features = ["toml"], package = "config" } tracing = { version = "0.1" } clap = { version = "4.5.39", features = ["derive"] } -jd_server_sv2 = { version = "0.4.0", path = "../jd-server" } +jd_server_sv2 = { version = "0.5.0", path = "../jd-server" } hex = "0.4.3" hotpath = "0.14.0" diff --git a/stratum-apps/Cargo.lock b/stratum-apps/Cargo.lock index b7f1315c2..8a59e4e40 100644 --- a/stratum-apps/Cargo.lock +++ b/stratum-apps/Cargo.lock @@ -3408,7 +3408,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stratum-apps" -version = "0.6.0" +version = "0.7.0" dependencies = [ "asic-rs", "async-channel", diff --git a/stratum-apps/Cargo.toml b/stratum-apps/Cargo.toml index 83f3d1af9..cf35695af 100644 --- a/stratum-apps/Cargo.toml +++ b/stratum-apps/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stratum-apps" -version = "0.6.0" +version = "0.7.0" authors = ["The Stratum V2 Developers"] edition = "2021" readme = "README.md" diff --git a/stratum-apps/src/payout.rs b/stratum-apps/src/payout.rs index b0907544e..38cb9fd75 100644 --- a/stratum-apps/src/payout.rs +++ b/stratum-apps/src/payout.rs @@ -3,14 +3,14 @@ //! 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 //! [`crate::payout::PayoutMode::coinbase_outputs`] to build outputs, while proxy/client applications can use -//! [`crate::payout::PayoutMode::validate_coinbase_outputs`] or [`crate::payout::PayoutMode::validate_coinbase_tx_suffix`] to +//! [`crate::payout::PayoutMode::validate_coinbase_outputs`] or [`crate::payout::PayoutMode::validate_coinbase_tx_parts`] to //! verify upstream jobs. use std::fmt; use crate::{ config_helpers::CoinbaseRewardScript, - stratum_core::bitcoin::{consensus::Decodable, Amount, ScriptBuf, TxOut}, + stratum_core::bitcoin::{consensus::deserialize, Amount, ScriptBuf, Transaction, TxOut}, }; // Legacy solo identities do not encode a fee policy, so allow at most a 10% service fee. @@ -167,22 +167,31 @@ impl PayoutMode { Ok(()) } - /// Verifies a `NewExtendedMiningJob.coinbase_tx_suffix` against this payout mode. + /// Verifies `NewExtendedMiningJob` coinbase transaction parts 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( + /// The SV2 split only guarantees that `coinbase_tx_suffix` is the part after the full + /// extranonce. The suffix can still contain remaining coinbase scriptSig bytes before the input + /// sequence, so this reconstructs and deserializes the full transaction before checking outputs. + /// + /// The extranonce bytes are zero-filled because payout verification only needs the transaction + /// to decode and expose its outputs; the actual extranonce value does not affect the output set. + pub fn validate_coinbase_tx_parts( &self, + coinbase_tx_prefix: &[u8], coinbase_tx_suffix: &[u8], + full_extranonce_size: usize, ) -> 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()))?; + let mut coinbase = Vec::with_capacity( + coinbase_tx_prefix.len() + full_extranonce_size + coinbase_tx_suffix.len(), + ); + coinbase.extend_from_slice(coinbase_tx_prefix); + coinbase.resize(coinbase.len() + full_extranonce_size, 0); + coinbase.extend_from_slice(coinbase_tx_suffix); + + let coinbase: Transaction = deserialize(&coinbase) + .map_err(|e| PayoutValidationError::DecodeCoinbaseTransaction(e.to_string()))?; - self.validate_coinbase_outputs(&outputs) + self.validate_coinbase_outputs(&coinbase.output) } fn miner_address(&self) -> Option<&str> { @@ -394,10 +403,8 @@ pub enum PayoutValidationError { /// 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), + /// Failed to decode the reconstructed coinbase transaction. + DecodeCoinbaseTransaction(String), } impl fmt::Display for PayoutValidationError { @@ -414,11 +421,8 @@ impl fmt::Display for PayoutValidationError { 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}") + Self::DecodeCoinbaseTransaction(e) => { + write!(f, "failed to decode coinbase transaction: {e}") } } } @@ -454,6 +458,7 @@ mod tests { const MINER_ADDRESS: &str = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x"; const OTHER_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; const TESTNET_ADDRESS: &str = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8"; + const FULL_EXTRANONCE_SIZE: usize = 8; fn tx_out(value: u64, address: &str) -> TxOut { TxOut { @@ -462,11 +467,35 @@ mod tests { } } - fn coinbase_suffix(outputs: Vec) -> Vec { - let mut suffix = vec![0xff, 0xff, 0xff, 0xff]; + fn coinbase_tx_parts(outputs: Vec, script_sig_suffix: &[u8]) -> (Vec, Vec) { + let script_sig_prefix = [0x03, 0x01, 0x02, 0x03]; + let script_sig_len = + script_sig_prefix.len() + FULL_EXTRANONCE_SIZE + script_sig_suffix.len(); + assert!(script_sig_len < 0xfd); + + let mut prefix = Vec::new(); + prefix.extend([0x02, 0x00, 0x00, 0x00]); + prefix.push(0x01); + prefix.extend([0; 32]); + prefix.extend([0xff, 0xff, 0xff, 0xff]); + prefix.push(script_sig_len as u8); + prefix.extend(script_sig_prefix); + + let mut suffix = Vec::new(); + suffix.extend(script_sig_suffix); + suffix.extend([0xff, 0xff, 0xff, 0xff]); suffix.extend(serialize(&outputs)); suffix.extend([0, 0, 0, 0]); - suffix + + (prefix, suffix) + } + + fn validate_tx_outputs( + expected: &PayoutMode, + outputs: Vec, + ) -> Result<(), PayoutValidationError> { + let (prefix, suffix) = coinbase_tx_parts(outputs, &[]); + expected.validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE) } #[test] @@ -592,18 +621,20 @@ mod tests { 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(); + validate_tx_outputs(&expected, vec![tx_out(1_000, MINER_ADDRESS)]).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(); + let err = validate_tx_outputs( + &expected, + vec![tx_out(900, MINER_ADDRESS), tx_out(100, OTHER_ADDRESS)], + ) + .unwrap_err(); assert!(matches!( err, @@ -618,17 +649,23 @@ 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(); + validate_tx_outputs( + &expected, + vec![tx_out(991, MINER_ADDRESS), tx_out(9, OTHER_ADDRESS)], + ) + .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(); + let err = validate_tx_outputs( + &expected, + vec![tx_out(899, MINER_ADDRESS), tx_out(101, OTHER_ADDRESS)], + ) + .unwrap_err(); assert!(matches!( err, @@ -644,9 +681,8 @@ mod tests { #[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(); + let err = validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap_err(); assert!(matches!( err, @@ -664,18 +700,24 @@ mod tests { 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(); + validate_tx_outputs( + &expected, + vec![tx_out(100, OTHER_ADDRESS), tx_out(900, MINER_ADDRESS)], + ) + .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(); + let err = validate_tx_outputs( + &expected, + vec![tx_out(200, OTHER_ADDRESS), tx_out(800, MINER_ADDRESS)], + ) + .unwrap_err(); assert!(matches!( err, @@ -690,8 +732,20 @@ mod tests { #[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(); + validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap(); + } + + #[test] + fn validates_coinbase_with_remaining_scriptsig_bytes_after_extranonce() { + let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap(); + let (prefix, suffix) = coinbase_tx_parts( + vec![tx_out(1_000, MINER_ADDRESS), tx_out(1, OTHER_ADDRESS)], + b"/NexusPool/", + ); + + expected + .validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE) + .unwrap(); } }