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    /// 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!(
358                    f,
359                    "no payout mode encoded in user_identity: {user_identity}"
360                )
361            }
362            Self::InvalidUserIdentity(user_identity) => {
363                write!(
364                    f,
365                    "invalid user_identity pattern for payout mode: {user_identity}"
366                )
367            }
368            Self::InvalidPayoutAddress { address, error } => {
369                write!(f, "invalid payout address `{address}`: {error}")
370            }
371            Self::InvalidDonationPercentage(percentage) => {
372                write!(f, "invalid donation percentage: {percentage}")
373            }
374            Self::MissingMinerPayout {
375                user_identity,
376                mode: MissingMinerPayoutMode::FullDonation,
377            } => write!(
378                f,
379                "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>"
380            ),
381            Self::MissingMinerPayout {
382                user_identity,
383                mode: MissingMinerPayoutMode::NoPayoutMode,
384            } => write!(
385                f,
386                "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>"
387            ),
388        }
389    }
390}
391
392impl std::error::Error for PayoutModeError {}
393
394/// Errors produced while verifying coinbase outputs against a payout mode.
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub enum PayoutValidationError {
397    /// The coinbase output set has no spendable outputs.
398    NoSpendableOutputs,
399    /// The miner payout did not match the expected distribution.
400    PayoutMismatch {
401        /// Address encoded by the payout mode.
402        address: String,
403        /// Expected miner payout in satoshis.
404        expected_sats: u64,
405        /// Expected miner payout percentage.
406        expected_percentage: u8,
407        /// Total spendable coinbase output value in satoshis.
408        total_spendable_sats: u64,
409        /// Actual amount paid to the miner script in satoshis.
410        actual_sats: u64,
411    },
412    /// Failed to decode the reconstructed coinbase transaction.
413    DecodeCoinbaseTransaction(String),
414}
415
416impl fmt::Display for PayoutValidationError {
417    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418        match self {
419            Self::NoSpendableOutputs => write!(f, "coinbase has no spendable outputs"),
420            Self::PayoutMismatch {
421                address,
422                expected_sats,
423                expected_percentage,
424                total_spendable_sats,
425                actual_sats,
426            } => write!(
427                f,
428                "coinbase payout mismatch for {address}: expected {expected_sats} sats ({expected_percentage}% of {total_spendable_sats} spendable sats), found {actual_sats} sats"
429            ),
430            Self::DecodeCoinbaseTransaction(e) => {
431                write!(f, "failed to decode coinbase transaction: {e}")
432            }
433        }
434    }
435}
436
437impl std::error::Error for PayoutValidationError {}
438
439fn script_from_address(address: &str) -> Result<CoinbaseRewardScript, PayoutModeError> {
440    CoinbaseRewardScript::from_descriptor(&format!("addr({address})")).map_err(|e| {
441        PayoutModeError::InvalidPayoutAddress {
442            address: address.to_string(),
443            error: e.to_string(),
444        }
445    })
446}
447
448fn address_part_from_user_identity(user_identity: &str) -> &str {
449    user_identity
450        .split_once('.')
451        .map(|(address, _)| address)
452        .unwrap_or(user_identity)
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::stratum_core::bitcoin::{
459        Address,
460        consensus::serialize,
461        params::{MAINNET, TESTNET4},
462    };
463
464    const MINER_ADDRESS: &str = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x";
465    const OTHER_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
466    const TESTNET_ADDRESS: &str = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8";
467    const FULL_EXTRANONCE_SIZE: usize = 8;
468
469    fn tx_out(value: u64, address: &str) -> TxOut {
470        TxOut {
471            value: Amount::from_sat(value),
472            script_pubkey: script_from_address(address).unwrap().script_pubkey(),
473        }
474    }
475
476    fn coinbase_tx_parts(outputs: Vec<TxOut>, script_sig_suffix: &[u8]) -> (Vec<u8>, Vec<u8>) {
477        let script_sig_prefix = [0x03, 0x01, 0x02, 0x03];
478        let script_sig_len =
479            script_sig_prefix.len() + FULL_EXTRANONCE_SIZE + script_sig_suffix.len();
480        assert!(script_sig_len < 0xfd);
481
482        let mut prefix = Vec::new();
483        prefix.extend([0x02, 0x00, 0x00, 0x00]);
484        prefix.push(0x01);
485        prefix.extend([0; 32]);
486        prefix.extend([0xff, 0xff, 0xff, 0xff]);
487        prefix.push(script_sig_len as u8);
488        prefix.extend(script_sig_prefix);
489
490        let mut suffix = Vec::new();
491        suffix.extend(script_sig_suffix);
492        suffix.extend([0xff, 0xff, 0xff, 0xff]);
493        suffix.extend(serialize(&outputs));
494        suffix.extend([0, 0, 0, 0]);
495
496        (prefix, suffix)
497    }
498
499    fn validate_tx_outputs(
500        expected: &PayoutMode,
501        outputs: Vec<TxOut>,
502    ) -> Result<(), PayoutValidationError> {
503        let (prefix, suffix) = coinbase_tx_parts(outputs, &[]);
504        expected.validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE)
505    }
506
507    #[test]
508    fn parses_full_donation_identities() {
509        assert!(matches!(
510            PayoutMode::try_from("sri/donate/worker"),
511            Ok(PayoutMode::FullDonation)
512        ));
513        assert!(matches!(
514            PayoutMode::try_from("sri/donate"),
515            Ok(PayoutMode::FullDonation)
516        ));
517    }
518
519    #[test]
520    fn parses_solo_identities() {
521        assert!(matches!(
522            PayoutMode::try_from(format!("sri/solo/{TESTNET_ADDRESS}/worker").as_str()),
523            Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
524        ));
525        assert!(matches!(
526            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/worker/subworker").as_str()),
527            Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
528        ));
529        assert!(matches!(
530            PayoutMode::try_from(MINER_ADDRESS),
531            Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
532        ));
533    }
534
535    #[test]
536    fn parses_legacy_address_identity_with_worker_suffix() {
537        assert!(matches!(
538            PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1").as_str()),
539            Ok(PayoutMode::LegacySolo { 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(format!("{MINER_ADDRESS}.worker1.subworker").as_str()),
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 arbitrary_pool_usernames_have_no_payout_mode() {
549        assert!(matches!(
550            PayoutMode::try_from("invalid_address.worker"),
551            Err(PayoutModeError::NoPayoutMode(_))
552        ));
553        assert!(matches!(
554            PayoutMode::try_from(""),
555            Err(PayoutModeError::NoPayoutMode(_))
556        ));
557        assert!(matches!(
558            PayoutMode::try_from("other/donate/worker"),
559            Err(PayoutModeError::NoPayoutMode(_))
560        ));
561    }
562
563    #[test]
564    fn permissive_parser_treats_address_like_typos_as_no_payout_mode() {
565        assert!(matches!(
566            PayoutMode::try_from("bc1q_typo.worker"),
567            Err(PayoutModeError::NoPayoutMode(_))
568        ));
569    }
570
571    #[test]
572    fn parses_partial_donation_identities() {
573        assert!(matches!(
574            PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}/worker").as_str()).unwrap(),
575            PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
576        ));
577
578        assert!(matches!(
579            PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}").as_str()).unwrap(),
580            PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
581        ));
582    }
583
584    #[test]
585    fn rejects_invalid_sri_patterns() {
586        assert!(PayoutMode::try_from("sri/invalid/worker").is_err());
587        assert!(PayoutMode::try_from("sri/solo").is_err());
588        assert!(PayoutMode::try_from("sri/solo/random_thing_here/worker").is_err());
589        assert!(PayoutMode::try_from("sri/solo/").is_err());
590        assert!(matches!(
591            PayoutMode::try_from("sri/donate/abc/addr/worker"),
592            Err(PayoutModeError::InvalidDonationPercentage(_))
593        ));
594        assert!(matches!(
595            PayoutMode::try_from("sri/donate/101/addr/worker"),
596            Err(PayoutModeError::InvalidDonationPercentage(_))
597        ));
598        assert!(matches!(
599            PayoutMode::try_from("sri/"),
600            Err(PayoutModeError::InvalidUserIdentity(_))
601        ));
602    }
603
604    #[test]
605    fn builds_pool_coinbase_outputs_for_all_modes() {
606        let pool_script = script_from_address(OTHER_ADDRESS).unwrap();
607
608        let solo = PayoutMode::try_from(MINER_ADDRESS).unwrap();
609        let solo_outputs = solo.coinbase_outputs(1_000, &pool_script);
610        assert_eq!(solo_outputs.len(), 1);
611        assert_eq!(solo_outputs[0].value.to_sat(), 1_000);
612
613        let donate =
614            PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w").as_str()).unwrap();
615        let donate_outputs = donate.coinbase_outputs(1_000, &pool_script);
616        assert_eq!(donate_outputs.len(), 2);
617        assert_eq!(donate_outputs[0].value.to_sat(), 100);
618        assert_eq!(donate_outputs[1].value.to_sat(), 900);
619
620        let full_donation = PayoutMode::FullDonation;
621        let full_donation_outputs = full_donation.coinbase_outputs(1_000, &pool_script);
622        assert_eq!(full_donation_outputs.len(), 1);
623        assert_eq!(full_donation_outputs[0].value.to_sat(), 1_000);
624    }
625
626    #[test]
627    fn validates_full_solo_distribution() {
628        let expected =
629            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap();
630
631        validate_tx_outputs(&expected, vec![tx_out(1_000, MINER_ADDRESS)]).unwrap();
632    }
633
634    #[test]
635    fn rejects_full_solo_distribution_with_other_spendable_output() {
636        let expected =
637            PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap();
638
639        let err = validate_tx_outputs(
640            &expected,
641            vec![tx_out(900, MINER_ADDRESS), tx_out(100, OTHER_ADDRESS)],
642        )
643        .unwrap_err();
644
645        assert!(matches!(
646            err,
647            PayoutValidationError::PayoutMismatch {
648                expected_sats: 1000,
649                actual_sats: 900,
650                ..
651            }
652        ));
653    }
654
655    #[test]
656    fn validates_legacy_solo_distribution_with_service_fee_output() {
657        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
658
659        validate_tx_outputs(
660            &expected,
661            vec![tx_out(991, MINER_ADDRESS), tx_out(9, OTHER_ADDRESS)],
662        )
663        .unwrap();
664    }
665
666    #[test]
667    fn rejects_legacy_solo_distribution_below_minimum() {
668        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
669
670        let err = validate_tx_outputs(
671            &expected,
672            vec![tx_out(899, MINER_ADDRESS), tx_out(101, OTHER_ADDRESS)],
673        )
674        .unwrap_err();
675
676        assert!(matches!(
677            err,
678            PayoutValidationError::PayoutMismatch {
679                expected_sats: 900,
680                expected_percentage: 90,
681                actual_sats: 899,
682                ..
683            }
684        ));
685    }
686
687    #[test]
688    fn rejects_legacy_solo_distribution_without_miner_address() {
689        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
690
691        let err = validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap_err();
692
693        assert!(matches!(
694            err,
695            PayoutValidationError::PayoutMismatch {
696                expected_sats: 900,
697                expected_percentage: 90,
698                total_spendable_sats: 1000,
699                actual_sats: 0,
700                ..
701            }
702        ));
703    }
704
705    #[test]
706    fn validates_partial_donation_distribution() {
707        let expected =
708            PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap();
709
710        validate_tx_outputs(
711            &expected,
712            vec![tx_out(100, OTHER_ADDRESS), tx_out(900, MINER_ADDRESS)],
713        )
714        .unwrap();
715    }
716
717    #[test]
718    fn rejects_wrong_partial_donation_distribution() {
719        let expected =
720            PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap();
721
722        let err = validate_tx_outputs(
723            &expected,
724            vec![tx_out(200, OTHER_ADDRESS), tx_out(800, MINER_ADDRESS)],
725        )
726        .unwrap_err();
727
728        assert!(matches!(
729            err,
730            PayoutValidationError::PayoutMismatch {
731                expected_sats: 900,
732                actual_sats: 800,
733                ..
734            }
735        ));
736    }
737
738    #[test]
739    fn full_donation_has_no_miner_payout_to_verify() {
740        let expected = PayoutMode::FullDonation;
741
742        validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap();
743    }
744
745    #[test]
746    fn validates_coinbase_with_remaining_scriptsig_bytes_after_extranonce() {
747        let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
748        let (prefix, suffix) = coinbase_tx_parts(
749            vec![tx_out(1_000, MINER_ADDRESS), tx_out(1, OTHER_ADDRESS)],
750            b"/NexusPool/",
751        );
752
753        expected
754            .validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE)
755            .unwrap();
756    }
757}