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