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