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