1use std::fmt;
10
11use crate::{
12 config_helpers::CoinbaseRewardScript,
13 stratum_core::bitcoin::{consensus::Decodable, Amount, ScriptBuf, TxOut},
14};
15
16const MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE: u8 = 90;
18
19#[derive(Debug, Clone)]
29pub enum PayoutMode {
30 Solo {
32 address: String,
34 script: CoinbaseRewardScript,
36 },
37 LegacySolo {
39 address: String,
41 script: CoinbaseRewardScript,
43 },
44 Donate {
46 percentage: u8,
48 address: String,
50 script: CoinbaseRewardScript,
52 },
53 FullDonation,
55}
56
57impl PayoutMode {
58 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum PayoutModeError {
317 NoPayoutMode(String),
319 InvalidUserIdentity(String),
321 InvalidPayoutAddress { address: String, error: String },
323 InvalidDonationPercentage(String),
325 MissingMinerPayout {
327 user_identity: String,
328 mode: MissingMinerPayoutMode,
329 },
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum MissingMinerPayoutMode {
335 FullDonation,
337 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#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum PayoutValidationError {
382 NoSpendableOutputs,
384 PayoutMismatch {
386 address: String,
388 expected_sats: u64,
390 expected_percentage: u8,
392 total_spendable_sats: u64,
394 actual_sats: u64,
396 },
397 CoinbaseTxSuffixTooShort,
399 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}