Skip to main content

stratum_apps/
payout.rs

1//! Shared payout-mode parsing and coinbase-output distribution helpers.
2//!
3//! This module is meant for applications that accept SRI-style mining identities and need a
4//! single source of truth for reward distribution. Pool-like applications can use
5//! [`crate::payout::PayoutMode::coinbase_outputs`] to build outputs, while proxy/client
6//! applications can use [`crate::payout::PayoutMode::validate_coinbase_outputs`] or
7//! [`crate::payout::PayoutMode::validate_coinbase_tx_parts`] to verify upstream jobs.
8
9use std::fmt;
10
11use crate::{
12    config_helpers::CoinbaseRewardScript,
13    stratum_core::bitcoin::{Amount, ScriptBuf, Transaction, TxOut, consensus::deserialize},
14};
15
16// Legacy solo identities do not encode a fee policy, so allow at most a 10% service fee.
17const MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE: u8 = 90;
18
19/// Represents the payout mode encoded by a mining `user_identity`.
20///
21/// Supported patterns:
22/// - `sri/solo/<payout_address>/<worker_name>`: full reward goes to the miner.
23/// - `<payout_address>` or `<payout_address>.<worker_name>`: legacy solo mode; payout verification
24///   checks that the miner address receives at least 90% of spendable coinbase outputs.
25/// - `sri/donate/<percentage>/<payout_address>/<worker_name>`: pool receives `percentage`, miner
26///   receives the remainder.
27/// - `sri/donate/<worker_name>`: full reward goes to the pool.
28#[derive(Debug, Clone)]
29pub enum PayoutMode {
30    /// Solo mode: miner receives full block reward.
31    Solo {
32        /// Miner payout address as supplied in `user_identity`.
33        address: String,
34        /// Miner payout script.
35        script: CoinbaseRewardScript,
36    },
37    /// Legacy solo mode: miner payout address must receive at least 90% of spendable coinbase
38    /// outputs.
39    LegacySolo {
40        /// Miner payout address as supplied in `user_identity`.
41        address: String,
42        /// Miner payout script.
43        script: CoinbaseRewardScript,
44    },
45    /// Donate mode: pool receives specified percentage, miner gets remainder.
46    Donate {
47        /// Pool's portion, from 1 to 99.
48        percentage: u8,
49        /// Miner payout address as supplied in `user_identity`.
50        address: String,
51        /// Miner payout script.
52        script: CoinbaseRewardScript,
53    },
54    /// Full donation mode: full reward goes to the pool.
55    FullDonation,
56}
57
58impl PayoutMode {
59    /// Creates coinbase outputs for this payout mode.
60    pub fn coinbase_outputs(
61        &self,
62        total_value: u64,
63        pool_script: &CoinbaseRewardScript,
64    ) -> Vec<TxOut> {
65        match self {
66            Self::Solo {
67                script: coinbase_script,
68                ..
69            }
70            | Self::LegacySolo {
71                script: coinbase_script,
72                ..
73            } => {
74                vec![TxOut {
75                    value: Amount::from_sat(total_value),
76                    script_pubkey: coinbase_script.script_pubkey(),
77                }]
78            }
79
80            Self::Donate {
81                percentage,
82                script: miner_script,
83                ..
84            } => {
85                let pool_value = (total_value * *percentage as u64) / 100;
86                let miner_value = total_value.saturating_sub(pool_value);
87
88                vec![
89                    TxOut {
90                        value: Amount::from_sat(pool_value),
91                        script_pubkey: pool_script.script_pubkey(),
92                    },
93                    TxOut {
94                        value: Amount::from_sat(miner_value),
95                        script_pubkey: miner_script.script_pubkey(),
96                    },
97                ]
98            }
99
100            Self::FullDonation => {
101                vec![TxOut {
102                    value: Amount::from_sat(total_value),
103                    script_pubkey: pool_script.script_pubkey(),
104                }]
105            }
106        }
107    }
108
109    /// Returns whether two payout modes produce the same coinbase outputs.
110    ///
111    /// The 100-satoshi reference value distinguishes all supported donation percentages while
112    /// keeping the comparison independent of a particular template's reward value.
113    pub fn has_same_coinbase_outputs_as(
114        &self,
115        other: &Self,
116        pool_script: &CoinbaseRewardScript,
117    ) -> bool {
118        self.coinbase_outputs(100, pool_script) == other.coinbase_outputs(100, pool_script)
119    }
120
121    /// Verifies that spendable outputs match the miner-side payout encoded by this mode.
122    ///
123    /// OP_RETURN outputs are ignored. [`PayoutMode::FullDonation`] has no miner payout address, so
124    /// it returns success without checking a miner output.
125    pub fn validate_coinbase_outputs(
126        &self,
127        outputs: &[TxOut],
128    ) -> Result<(), PayoutValidationError> {
129        let Some(script_pubkey) = self.miner_script_pubkey() else {
130            return Ok(());
131        };
132
133        let total_spendable_sats = outputs
134            .iter()
135            .filter(|output| !output.script_pubkey.is_op_return())
136            .map(|output| output.value.to_sat())
137            .sum();
138        if total_spendable_sats == 0 {
139            return Err(PayoutValidationError::NoSpendableOutputs);
140        }
141
142        let actual_miner_sats = outputs
143            .iter()
144            .filter(|output| !output.script_pubkey.is_op_return())
145            .filter(|output| output.script_pubkey.as_bytes() == script_pubkey.as_bytes())
146            .map(|output| output.value.to_sat())
147            .sum();
148        if matches!(self, Self::LegacySolo { .. }) {
149            let expected_miner_sats = self.expected_legacy_solo_miner_sats(total_spendable_sats);
150            if actual_miner_sats < expected_miner_sats {
151                return Err(PayoutValidationError::PayoutMismatch {
152                    address: self
153                        .miner_address()
154                        .expect("miner script exists only when miner address exists")
155                        .to_string(),
156                    expected_sats: expected_miner_sats,
157                    expected_percentage: MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE,
158                    total_spendable_sats,
159                    actual_sats: actual_miner_sats,
160                });
161            }
162
163            return Ok(());
164        }
165
166        let expected_miner_sats = self.expected_miner_sats(total_spendable_sats);
167        if actual_miner_sats != expected_miner_sats {
168            return Err(PayoutValidationError::PayoutMismatch {
169                address: self
170                    .miner_address()
171                    .expect("miner script exists only when miner address exists")
172                    .to_string(),
173                expected_sats: expected_miner_sats,
174                expected_percentage: self.expected_miner_percentage(),
175                total_spendable_sats,
176                actual_sats: actual_miner_sats,
177            });
178        }
179
180        Ok(())
181    }
182
183    /// Verifies `NewExtendedMiningJob` coinbase transaction parts against this payout mode.
184    ///
185    /// The SV2 split only guarantees that `coinbase_tx_suffix` is the part after the full
186    /// extranonce. The suffix can still contain remaining coinbase scriptSig bytes before the input
187    /// sequence, so this reconstructs and deserializes the full transaction before checking
188    /// outputs.
189    ///
190    /// The extranonce bytes are zero-filled because payout verification only needs the transaction
191    /// to decode and expose its outputs; the actual extranonce value does not affect the output
192    /// set.
193    pub fn validate_coinbase_tx_parts(
194        &self,
195        coinbase_tx_prefix: &[u8],
196        coinbase_tx_suffix: &[u8],
197        full_extranonce_size: usize,
198    ) -> Result<(), PayoutValidationError> {
199        let mut coinbase = Vec::with_capacity(
200            coinbase_tx_prefix.len() + full_extranonce_size + coinbase_tx_suffix.len(),
201        );
202        coinbase.extend_from_slice(coinbase_tx_prefix);
203        coinbase.resize(coinbase.len() + full_extranonce_size, 0);
204        coinbase.extend_from_slice(coinbase_tx_suffix);
205
206        let coinbase: Transaction = deserialize(&coinbase)
207            .map_err(|e| PayoutValidationError::DecodeCoinbaseTransaction(e.to_string()))?;
208
209        self.validate_coinbase_outputs(&coinbase.output)
210    }
211
212    fn miner_address(&self) -> Option<&str> {
213        match self {
214            Self::Solo { address, .. }
215            | Self::LegacySolo { address, .. }
216            | Self::Donate { address, .. } => Some(address.as_str()),
217            Self::FullDonation => None,
218        }
219    }
220
221    fn miner_script_pubkey(&self) -> Option<ScriptBuf> {
222        match self {
223            Self::Solo { script, .. }
224            | Self::LegacySolo { script, .. }
225            | Self::Donate { script, .. } => Some(script.script_pubkey()),
226            Self::FullDonation => None,
227        }
228    }
229
230    fn expected_miner_percentage(&self) -> u8 {
231        match self {
232            Self::Solo { .. } | Self::LegacySolo { .. } => 100,
233            Self::Donate { percentage, .. } => 100 - percentage,
234            Self::FullDonation => 0,
235        }
236    }
237
238    fn expected_miner_sats(&self, total_spendable_sats: u64) -> u64 {
239        match self {
240            Self::Solo { .. } | Self::LegacySolo { .. } => total_spendable_sats,
241            Self::Donate { percentage, .. } => {
242                let pool_sats = (total_spendable_sats * *percentage as u64) / 100;
243                total_spendable_sats.saturating_sub(pool_sats)
244            }
245            Self::FullDonation => 0,
246        }
247    }
248
249    fn expected_legacy_solo_miner_sats(&self, total_spendable_sats: u64) -> u64 {
250        (total_spendable_sats * MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE as u64).div_ceil(100)
251    }
252}
253
254impl TryFrom<&str> for PayoutMode {
255    type Error = PayoutModeError;
256
257    fn try_from(user_identity: &str) -> Result<Self, Self::Error> {
258        if user_identity.is_empty() {
259            return Err(PayoutModeError::NoPayoutMode(user_identity.to_string()));
260        }
261
262        let addr = address_part_from_user_identity(user_identity);
263
264        if let Ok(script) = script_from_address(addr) {
265            return Ok(Self::LegacySolo {
266                address: addr.to_string(),
267                script,
268            });
269        }
270
271        let mut parts = user_identity.split('/');
272
273        match (parts.next(), parts.next(), parts.next(), parts.next()) {
274            (Some("sri"), Some("solo"), Some(payout_address), _) => {
275                let script = script_from_address(payout_address)?;
276                Ok(Self::Solo {
277                    address: payout_address.to_string(),
278                    script,
279                })
280            }
281
282            (Some("sri"), Some("donate"), None, _)
283            | (Some("sri"), Some("donate"), Some(_), None) => Ok(Self::FullDonation),
284
285            (Some("sri"), Some("donate"), Some(percentage), Some(payout_address)) => {
286                let percentage = percentage.parse::<u8>().map_err(|_| {
287                    PayoutModeError::InvalidDonationPercentage(percentage.to_string())
288                })?;
289                if !(1..100).contains(&percentage) {
290                    return Err(PayoutModeError::InvalidDonationPercentage(
291                        percentage.to_string(),
292                    ));
293                }
294
295                let script = script_from_address(payout_address)?;
296                Ok(Self::Donate {
297                    percentage,
298                    address: payout_address.to_string(),
299                    script,
300                })
301            }
302
303            (Some("sri"), Some(_), _, _) => Err(PayoutModeError::InvalidUserIdentity(
304                user_identity.to_string(),
305            )),
306
307            _ => Err(PayoutModeError::NoPayoutMode(user_identity.to_string())),
308        }
309    }
310}
311
312impl fmt::Display for PayoutMode {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        match self {
315            Self::Solo { address, .. } => {
316                write!(f, "100% miner payout to {address}")
317            }
318            Self::LegacySolo { address, .. } => write!(
319                f,
320                "at least {MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE}% miner payout to {address}"
321            ),
322            Self::Donate {
323                percentage,
324                address,
325                ..
326            } => write!(
327                f,
328                "{}% miner payout to {} ({}% pool donation)",
329                100 - percentage,
330                address,
331                percentage
332            ),
333            Self::FullDonation => write!(f, "100% pool payout"),
334        }
335    }
336}
337
338/// Errors produced while parsing a payout mode from a `user_identity`.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum PayoutModeError {
341    /// No payout mode was encoded in `user_identity`.
342    NoPayoutMode(String),
343    /// `sri/...` was used with an unsupported payout pattern.
344    InvalidUserIdentity(String),
345    /// A payout address was present but could not be converted into a script.
346    InvalidPayoutAddress { address: String, error: String },
347    /// Donation percentage was not an integer in the supported 1..100 range.
348    InvalidDonationPercentage(String),
349    /// Payout verification was requested but no miner payout address is present.
350    MissingMinerPayout {
351        user_identity: String,
352        mode: MissingMinerPayoutMode,
353    },
354}
355
356/// Payout modes that cannot be verified because they do not include a miner payout address.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum MissingMinerPayoutMode {
359    /// `sri/donate/<worker>` full donation mode: all reward goes to the pool.
360    FullDonation,
361    /// No SRI payout mode or legacy address payout was encoded.
362    NoPayoutMode,
363}
364
365impl fmt::Display for PayoutModeError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        match self {
368            Self::NoPayoutMode(user_identity) => {
369                write!(
370                    f,
371                    "no payout mode encoded in user_identity: {user_identity}"
372                )
373            }
374            Self::InvalidUserIdentity(user_identity) => {
375                write!(
376                    f,
377                    "invalid user_identity pattern for payout mode: {user_identity}"
378                )
379            }
380            Self::InvalidPayoutAddress { address, error } => {
381                write!(f, "invalid payout address `{address}`: {error}")
382            }
383            Self::InvalidDonationPercentage(percentage) => {
384                write!(f, "invalid donation percentage: {percentage}")
385            }
386            Self::MissingMinerPayout {
387                user_identity,
388                mode: MissingMinerPayoutMode::FullDonation,
389            } => write!(
390                f,
391                "verify_payout is enabled, but user_identity `{user_identity}` opts into full donation mode (`sri/donate/<worker>`), which has no miner payout to verify; disable verify_payout or use sri/solo/<address>/<worker>, sri/donate/<percentage>/<address>/<worker>, <address>, or <address>.<worker>"
392            ),
393            Self::MissingMinerPayout {
394                user_identity,
395                mode: MissingMinerPayoutMode::NoPayoutMode,
396            } => write!(
397                f,
398                "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/<address>/<worker>, sri/donate/<percentage>/<address>/<worker>, <address>, or <address>.<worker>"
399            ),
400        }
401    }
402}
403
404impl std::error::Error for PayoutModeError {}
405
406/// Errors produced while verifying coinbase outputs against a payout mode.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub enum PayoutValidationError {
409    /// The coinbase output set has no spendable outputs.
410    NoSpendableOutputs,
411    /// The miner payout did not match the expected distribution.
412    PayoutMismatch {
413        /// Address encoded by the payout mode.
414        address: String,
415        /// Expected miner payout in satoshis.
416        expected_sats: u64,
417        /// Expected miner payout percentage.
418        expected_percentage: u8,
419        /// Total spendable coinbase output value in satoshis.
420        total_spendable_sats: u64,
421        /// Actual amount paid to the miner script in satoshis.
422        actual_sats: u64,
423    },
424    /// Failed to decode the reconstructed coinbase transaction.
425    DecodeCoinbaseTransaction(String),
426}
427
428impl fmt::Display for PayoutValidationError {
429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        match self {
431            Self::NoSpendableOutputs => write!(f, "coinbase has no spendable outputs"),
432            Self::PayoutMismatch {
433                address,
434                expected_sats,
435                expected_percentage,
436                total_spendable_sats,
437                actual_sats,
438            } => write!(
439                f,
440                "coinbase payout mismatch for {address}: expected {expected_sats} sats ({expected_percentage}% of {total_spendable_sats} spendable sats), found {actual_sats} sats"
441            ),
442            Self::DecodeCoinbaseTransaction(e) => {
443                write!(f, "failed to decode coinbase transaction: {e}")
444            }
445        }
446    }
447}
448
449impl std::error::Error for PayoutValidationError {}
450
451fn script_from_address(address: &str) -> Result<CoinbaseRewardScript, PayoutModeError> {
452    CoinbaseRewardScript::from_descriptor(&format!("addr({address})")).map_err(|e| {
453        PayoutModeError::InvalidPayoutAddress {
454            address: address.to_string(),
455            error: e.to_string(),
456        }
457    })
458}
459
460fn address_part_from_user_identity(user_identity: &str) -> &str {
461    user_identity
462        .split_once('.')
463        .map(|(address, _)| address)
464        .unwrap_or(user_identity)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::stratum_core::bitcoin::{
471        Address,
472        consensus::serialize,
473        params::{MAINNET, TESTNET4},
474    };
475
476    const MINER_ADDRESS: &str = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x";
477    const OTHER_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
478    const TESTNET_ADDRESS: &str = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8";
479    const FULL_EXTRANONCE_SIZE: usize = 8;
480
481    fn tx_out(value: u64, address: &str) -> TxOut {
482        TxOut {
483            value: Amount::from_sat(value),
484            script_pubkey: script_from_address(address).unwrap().script_pubkey(),
485        }
486    }
487
488    fn coinbase_tx_parts(outputs: Vec<TxOut>, script_sig_suffix: &[u8]) -> (Vec<u8>, Vec<u8>) {
489        let script_sig_prefix = [0x03, 0x01, 0x02, 0x03];
490        let script_sig_len =
491            script_sig_prefix.len() + FULL_EXTRANONCE_SIZE + script_sig_suffix.len();
492        assert!(script_sig_len < 0xfd);
493
494        let mut prefix = Vec::new();
495        prefix.extend([0x02, 0x00, 0x00, 0x00]);
496        prefix.push(0x01);
497        prefix.extend([0; 32]);
498        prefix.extend([0xff, 0xff, 0xff, 0xff]);
499        prefix.push(script_sig_len as u8);
500        prefix.extend(script_sig_prefix);
501
502        let mut suffix = Vec::new();
503        suffix.extend(script_sig_suffix);
504        suffix.extend([0xff, 0xff, 0xff, 0xff]);
505        suffix.extend(serialize(&outputs));
506        suffix.extend([0, 0, 0, 0]);
507
508        (prefix, suffix)
509    }
510
511    fn validate_tx_outputs(
512        expected: &PayoutMode,
513        outputs: Vec<TxOut>,
514    ) -> Result<(), PayoutValidationError> {
515        let (prefix, suffix) = coinbase_tx_parts(outputs, &[]);
516        expected.validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE)
517    }
518
519    #[test]
520    fn parses_full_donation_identities() {
521        assert!(matches!(
522            PayoutMode::try_from("sri/donate/worker"),
523            Ok(PayoutMode::FullDonation)
524        ));
525        assert!(matches!(
526            PayoutMode::try_from("sri/donate"),
527            Ok(PayoutMode::FullDonation)
528        ));
529    }
530
531    #[test]
532    fn parses_solo_identities() {
533        assert!(matches!(
534            PayoutMode::try_from(format!("sri/solo/{TESTNET_ADDRESS}/worker").as_str()),
535            Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
536        ));
537        assert!(matches!(
538            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/worker/subworker").as_str()),
539            Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
540        ));
541        assert!(matches!(
542            PayoutMode::try_from(MINER_ADDRESS),
543            Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
544        ));
545    }
546
547    #[test]
548    fn parses_legacy_address_identity_with_worker_suffix() {
549        assert!(matches!(
550            PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1").as_str()),
551            Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
552        ));
553        assert!(matches!(
554            PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1.subworker").as_str()),
555            Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
556        ));
557    }
558
559    #[test]
560    fn arbitrary_pool_usernames_have_no_payout_mode() {
561        assert!(matches!(
562            PayoutMode::try_from("invalid_address.worker"),
563            Err(PayoutModeError::NoPayoutMode(_))
564        ));
565        assert!(matches!(
566            PayoutMode::try_from(""),
567            Err(PayoutModeError::NoPayoutMode(_))
568        ));
569        assert!(matches!(
570            PayoutMode::try_from("other/donate/worker"),
571            Err(PayoutModeError::NoPayoutMode(_))
572        ));
573    }
574
575    #[test]
576    fn permissive_parser_treats_address_like_typos_as_no_payout_mode() {
577        assert!(matches!(
578            PayoutMode::try_from("bc1q_typo.worker"),
579            Err(PayoutModeError::NoPayoutMode(_))
580        ));
581    }
582
583    #[test]
584    fn parses_partial_donation_identities() {
585        assert!(matches!(
586            PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}/worker").as_str()).unwrap(),
587            PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
588        ));
589
590        assert!(matches!(
591            PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}").as_str()).unwrap(),
592            PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
593        ));
594    }
595
596    #[test]
597    fn rejects_invalid_sri_patterns() {
598        assert!(PayoutMode::try_from("sri/invalid/worker").is_err());
599        assert!(PayoutMode::try_from("sri/solo").is_err());
600        assert!(PayoutMode::try_from("sri/solo/random_thing_here/worker").is_err());
601        assert!(PayoutMode::try_from("sri/solo/").is_err());
602        assert!(matches!(
603            PayoutMode::try_from("sri/donate/abc/addr/worker"),
604            Err(PayoutModeError::InvalidDonationPercentage(_))
605        ));
606        assert!(matches!(
607            PayoutMode::try_from("sri/donate/101/addr/worker"),
608            Err(PayoutModeError::InvalidDonationPercentage(_))
609        ));
610        assert!(matches!(
611            PayoutMode::try_from("sri/"),
612            Err(PayoutModeError::InvalidUserIdentity(_))
613        ));
614    }
615
616    #[test]
617    fn builds_pool_coinbase_outputs_for_all_modes() {
618        let pool_script = script_from_address(OTHER_ADDRESS).unwrap();
619
620        let solo = PayoutMode::try_from(MINER_ADDRESS).unwrap();
621        let solo_outputs = solo.coinbase_outputs(1_000, &pool_script);
622        assert_eq!(solo_outputs.len(), 1);
623        assert_eq!(solo_outputs[0].value.to_sat(), 1_000);
624
625        let donate =
626            PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w").as_str()).unwrap();
627        let donate_outputs = donate.coinbase_outputs(1_000, &pool_script);
628        assert_eq!(donate_outputs.len(), 2);
629        assert_eq!(donate_outputs[0].value.to_sat(), 100);
630        assert_eq!(donate_outputs[1].value.to_sat(), 900);
631
632        let full_donation = PayoutMode::FullDonation;
633        let full_donation_outputs = full_donation.coinbase_outputs(1_000, &pool_script);
634        assert_eq!(full_donation_outputs.len(), 1);
635        assert_eq!(full_donation_outputs[0].value.to_sat(), 1_000);
636    }
637
638    #[test]
639    fn compares_payout_modes_by_coinbase_outputs() {
640        let pool_script = script_from_address(OTHER_ADDRESS).unwrap();
641        let legacy_solo = PayoutMode::try_from(MINER_ADDRESS).unwrap();
642        let solo =
643            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/worker").as_str()).unwrap();
644        let other_solo =
645            PayoutMode::try_from(format!("sri/solo/{TESTNET_ADDRESS}/worker").as_str()).unwrap();
646        let full_donation = PayoutMode::FullDonation;
647
648        assert!(legacy_solo.has_same_coinbase_outputs_as(&solo, &pool_script));
649        assert!(!legacy_solo.has_same_coinbase_outputs_as(&other_solo, &pool_script));
650        assert!(!legacy_solo.has_same_coinbase_outputs_as(&full_donation, &pool_script));
651        assert!(
652            full_donation.has_same_coinbase_outputs_as(&PayoutMode::FullDonation, &pool_script)
653        );
654    }
655
656    #[test]
657    fn validates_full_solo_distribution() {
658        let expected =
659            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap();
660
661        validate_tx_outputs(&expected, vec![tx_out(1_000, MINER_ADDRESS)]).unwrap();
662    }
663
664    #[test]
665    fn rejects_full_solo_distribution_with_other_spendable_output() {
666        let expected =
667            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap();
668
669        let err = validate_tx_outputs(
670            &expected,
671            vec![tx_out(900, MINER_ADDRESS), tx_out(100, OTHER_ADDRESS)],
672        )
673        .unwrap_err();
674
675        assert!(matches!(
676            err,
677            PayoutValidationError::PayoutMismatch {
678                expected_sats: 1000,
679                actual_sats: 900,
680                ..
681            }
682        ));
683    }
684
685    #[test]
686    fn validates_legacy_solo_distribution_with_service_fee_output() {
687        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
688
689        validate_tx_outputs(
690            &expected,
691            vec![tx_out(991, MINER_ADDRESS), tx_out(9, OTHER_ADDRESS)],
692        )
693        .unwrap();
694    }
695
696    #[test]
697    fn rejects_legacy_solo_distribution_below_minimum() {
698        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
699
700        let err = validate_tx_outputs(
701            &expected,
702            vec![tx_out(899, MINER_ADDRESS), tx_out(101, OTHER_ADDRESS)],
703        )
704        .unwrap_err();
705
706        assert!(matches!(
707            err,
708            PayoutValidationError::PayoutMismatch {
709                expected_sats: 900,
710                expected_percentage: 90,
711                actual_sats: 899,
712                ..
713            }
714        ));
715    }
716
717    #[test]
718    fn rejects_legacy_solo_distribution_without_miner_address() {
719        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
720
721        let err = validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap_err();
722
723        assert!(matches!(
724            err,
725            PayoutValidationError::PayoutMismatch {
726                expected_sats: 900,
727                expected_percentage: 90,
728                total_spendable_sats: 1000,
729                actual_sats: 0,
730                ..
731            }
732        ));
733    }
734
735    #[test]
736    fn validates_partial_donation_distribution() {
737        let expected =
738            PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap();
739
740        validate_tx_outputs(
741            &expected,
742            vec![tx_out(100, OTHER_ADDRESS), tx_out(900, MINER_ADDRESS)],
743        )
744        .unwrap();
745    }
746
747    #[test]
748    fn rejects_wrong_partial_donation_distribution() {
749        let expected =
750            PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap();
751
752        let err = validate_tx_outputs(
753            &expected,
754            vec![tx_out(200, OTHER_ADDRESS), tx_out(800, MINER_ADDRESS)],
755        )
756        .unwrap_err();
757
758        assert!(matches!(
759            err,
760            PayoutValidationError::PayoutMismatch {
761                expected_sats: 900,
762                actual_sats: 800,
763                ..
764            }
765        ));
766    }
767
768    #[test]
769    fn full_donation_has_no_miner_payout_to_verify() {
770        let expected = PayoutMode::FullDonation;
771
772        validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap();
773    }
774
775    #[test]
776    fn validates_coinbase_with_remaining_scriptsig_bytes_after_extranonce() {
777        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
778        let (prefix, suffix) = coinbase_tx_parts(
779            vec![tx_out(1_000, MINER_ADDRESS), tx_out(1, OTHER_ADDRESS)],
780            b"/NexusPool/",
781        );
782
783        expected
784            .validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE)
785            .unwrap();
786    }
787}