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