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