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 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!(
358 f,
359 "no payout mode encoded in user_identity: {user_identity}"
360 )
361 }
362 Self::InvalidUserIdentity(user_identity) => {
363 write!(
364 f,
365 "invalid user_identity pattern for payout mode: {user_identity}"
366 )
367 }
368 Self::InvalidPayoutAddress { address, error } => {
369 write!(f, "invalid payout address `{address}`: {error}")
370 }
371 Self::InvalidDonationPercentage(percentage) => {
372 write!(f, "invalid donation percentage: {percentage}")
373 }
374 Self::MissingMinerPayout {
375 user_identity,
376 mode: MissingMinerPayoutMode::FullDonation,
377 } => write!(
378 f,
379 "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>"
380 ),
381 Self::MissingMinerPayout {
382 user_identity,
383 mode: MissingMinerPayoutMode::NoPayoutMode,
384 } => write!(
385 f,
386 "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>"
387 ),
388 }
389 }
390}
391
392impl std::error::Error for PayoutModeError {}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
396pub enum PayoutValidationError {
397 NoSpendableOutputs,
399 PayoutMismatch {
401 address: String,
403 expected_sats: u64,
405 expected_percentage: u8,
407 total_spendable_sats: u64,
409 actual_sats: u64,
411 },
412 DecodeCoinbaseTransaction(String),
414}
415
416impl fmt::Display for PayoutValidationError {
417 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418 match self {
419 Self::NoSpendableOutputs => write!(f, "coinbase has no spendable outputs"),
420 Self::PayoutMismatch {
421 address,
422 expected_sats,
423 expected_percentage,
424 total_spendable_sats,
425 actual_sats,
426 } => write!(
427 f,
428 "coinbase payout mismatch for {address}: expected {expected_sats} sats ({expected_percentage}% of {total_spendable_sats} spendable sats), found {actual_sats} sats"
429 ),
430 Self::DecodeCoinbaseTransaction(e) => {
431 write!(f, "failed to decode coinbase transaction: {e}")
432 }
433 }
434 }
435}
436
437impl std::error::Error for PayoutValidationError {}
438
439fn script_from_address(address: &str) -> Result<CoinbaseRewardScript, PayoutModeError> {
440 CoinbaseRewardScript::from_descriptor(&format!("addr({address})")).map_err(|e| {
441 PayoutModeError::InvalidPayoutAddress {
442 address: address.to_string(),
443 error: e.to_string(),
444 }
445 })
446}
447
448fn address_part_from_user_identity(user_identity: &str) -> &str {
449 user_identity
450 .split_once('.')
451 .map(|(address, _)| address)
452 .unwrap_or(user_identity)
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::stratum_core::bitcoin::{
459 Address,
460 consensus::serialize,
461 params::{MAINNET, TESTNET4},
462 };
463
464 const MINER_ADDRESS: &str = "bc1qtzqxqaxyy6lda2fhdtp5dp0v56vlf6g0tljy2x";
465 const OTHER_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
466 const TESTNET_ADDRESS: &str = "tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8";
467 const FULL_EXTRANONCE_SIZE: usize = 8;
468
469 fn tx_out(value: u64, address: &str) -> TxOut {
470 TxOut {
471 value: Amount::from_sat(value),
472 script_pubkey: script_from_address(address).unwrap().script_pubkey(),
473 }
474 }
475
476 fn coinbase_tx_parts(outputs: Vec<TxOut>, script_sig_suffix: &[u8]) -> (Vec<u8>, Vec<u8>) {
477 let script_sig_prefix = [0x03, 0x01, 0x02, 0x03];
478 let script_sig_len =
479 script_sig_prefix.len() + FULL_EXTRANONCE_SIZE + script_sig_suffix.len();
480 assert!(script_sig_len < 0xfd);
481
482 let mut prefix = Vec::new();
483 prefix.extend([0x02, 0x00, 0x00, 0x00]);
484 prefix.push(0x01);
485 prefix.extend([0; 32]);
486 prefix.extend([0xff, 0xff, 0xff, 0xff]);
487 prefix.push(script_sig_len as u8);
488 prefix.extend(script_sig_prefix);
489
490 let mut suffix = Vec::new();
491 suffix.extend(script_sig_suffix);
492 suffix.extend([0xff, 0xff, 0xff, 0xff]);
493 suffix.extend(serialize(&outputs));
494 suffix.extend([0, 0, 0, 0]);
495
496 (prefix, suffix)
497 }
498
499 fn validate_tx_outputs(
500 expected: &PayoutMode,
501 outputs: Vec<TxOut>,
502 ) -> Result<(), PayoutValidationError> {
503 let (prefix, suffix) = coinbase_tx_parts(outputs, &[]);
504 expected.validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE)
505 }
506
507 #[test]
508 fn parses_full_donation_identities() {
509 assert!(matches!(
510 PayoutMode::try_from("sri/donate/worker"),
511 Ok(PayoutMode::FullDonation)
512 ));
513 assert!(matches!(
514 PayoutMode::try_from("sri/donate"),
515 Ok(PayoutMode::FullDonation)
516 ));
517 }
518
519 #[test]
520 fn parses_solo_identities() {
521 assert!(matches!(
522 PayoutMode::try_from(format!("sri/solo/{TESTNET_ADDRESS}/worker").as_str()),
523 Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
524 ));
525 assert!(matches!(
526 PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/worker/subworker").as_str()),
527 Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
528 ));
529 assert!(matches!(
530 PayoutMode::try_from(MINER_ADDRESS),
531 Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
532 ));
533 }
534
535 #[test]
536 fn parses_legacy_address_identity_with_worker_suffix() {
537 assert!(matches!(
538 PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1").as_str()),
539 Ok(PayoutMode::LegacySolo { 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(format!("{MINER_ADDRESS}.worker1.subworker").as_str()),
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 arbitrary_pool_usernames_have_no_payout_mode() {
549 assert!(matches!(
550 PayoutMode::try_from("invalid_address.worker"),
551 Err(PayoutModeError::NoPayoutMode(_))
552 ));
553 assert!(matches!(
554 PayoutMode::try_from(""),
555 Err(PayoutModeError::NoPayoutMode(_))
556 ));
557 assert!(matches!(
558 PayoutMode::try_from("other/donate/worker"),
559 Err(PayoutModeError::NoPayoutMode(_))
560 ));
561 }
562
563 #[test]
564 fn permissive_parser_treats_address_like_typos_as_no_payout_mode() {
565 assert!(matches!(
566 PayoutMode::try_from("bc1q_typo.worker"),
567 Err(PayoutModeError::NoPayoutMode(_))
568 ));
569 }
570
571 #[test]
572 fn parses_partial_donation_identities() {
573 assert!(matches!(
574 PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}/worker").as_str()).unwrap(),
575 PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
576 ));
577
578 assert!(matches!(
579 PayoutMode::try_from(format!("sri/donate/50/{TESTNET_ADDRESS}").as_str()).unwrap(),
580 PayoutMode::Donate { percentage: 50, script, .. } if Address::from_script(script.script_pubkey().as_script(), TESTNET4.clone()).unwrap().to_string() == TESTNET_ADDRESS
581 ));
582 }
583
584 #[test]
585 fn rejects_invalid_sri_patterns() {
586 assert!(PayoutMode::try_from("sri/invalid/worker").is_err());
587 assert!(PayoutMode::try_from("sri/solo").is_err());
588 assert!(PayoutMode::try_from("sri/solo/random_thing_here/worker").is_err());
589 assert!(PayoutMode::try_from("sri/solo/").is_err());
590 assert!(matches!(
591 PayoutMode::try_from("sri/donate/abc/addr/worker"),
592 Err(PayoutModeError::InvalidDonationPercentage(_))
593 ));
594 assert!(matches!(
595 PayoutMode::try_from("sri/donate/101/addr/worker"),
596 Err(PayoutModeError::InvalidDonationPercentage(_))
597 ));
598 assert!(matches!(
599 PayoutMode::try_from("sri/"),
600 Err(PayoutModeError::InvalidUserIdentity(_))
601 ));
602 }
603
604 #[test]
605 fn builds_pool_coinbase_outputs_for_all_modes() {
606 let pool_script = script_from_address(OTHER_ADDRESS).unwrap();
607
608 let solo = PayoutMode::try_from(MINER_ADDRESS).unwrap();
609 let solo_outputs = solo.coinbase_outputs(1_000, &pool_script);
610 assert_eq!(solo_outputs.len(), 1);
611 assert_eq!(solo_outputs[0].value.to_sat(), 1_000);
612
613 let donate =
614 PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w").as_str()).unwrap();
615 let donate_outputs = donate.coinbase_outputs(1_000, &pool_script);
616 assert_eq!(donate_outputs.len(), 2);
617 assert_eq!(donate_outputs[0].value.to_sat(), 100);
618 assert_eq!(donate_outputs[1].value.to_sat(), 900);
619
620 let full_donation = PayoutMode::FullDonation;
621 let full_donation_outputs = full_donation.coinbase_outputs(1_000, &pool_script);
622 assert_eq!(full_donation_outputs.len(), 1);
623 assert_eq!(full_donation_outputs[0].value.to_sat(), 1_000);
624 }
625
626 #[test]
627 fn validates_full_solo_distribution() {
628 let expected =
629 PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap();
630
631 validate_tx_outputs(&expected, vec![tx_out(1_000, MINER_ADDRESS)]).unwrap();
632 }
633
634 #[test]
635 fn rejects_full_solo_distribution_with_other_spendable_output() {
636 let expected =
637 PayoutMode::try_from(format!("sri/solo/{MINER_ADDRESS}/w1").as_str()).unwrap();
638
639 let err = validate_tx_outputs(
640 &expected,
641 vec![tx_out(900, MINER_ADDRESS), tx_out(100, OTHER_ADDRESS)],
642 )
643 .unwrap_err();
644
645 assert!(matches!(
646 err,
647 PayoutValidationError::PayoutMismatch {
648 expected_sats: 1000,
649 actual_sats: 900,
650 ..
651 }
652 ));
653 }
654
655 #[test]
656 fn validates_legacy_solo_distribution_with_service_fee_output() {
657 let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
658
659 validate_tx_outputs(
660 &expected,
661 vec![tx_out(991, MINER_ADDRESS), tx_out(9, OTHER_ADDRESS)],
662 )
663 .unwrap();
664 }
665
666 #[test]
667 fn rejects_legacy_solo_distribution_below_minimum() {
668 let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
669
670 let err = validate_tx_outputs(
671 &expected,
672 vec![tx_out(899, MINER_ADDRESS), tx_out(101, OTHER_ADDRESS)],
673 )
674 .unwrap_err();
675
676 assert!(matches!(
677 err,
678 PayoutValidationError::PayoutMismatch {
679 expected_sats: 900,
680 expected_percentage: 90,
681 actual_sats: 899,
682 ..
683 }
684 ));
685 }
686
687 #[test]
688 fn rejects_legacy_solo_distribution_without_miner_address() {
689 let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
690
691 let err = validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap_err();
692
693 assert!(matches!(
694 err,
695 PayoutValidationError::PayoutMismatch {
696 expected_sats: 900,
697 expected_percentage: 90,
698 total_spendable_sats: 1000,
699 actual_sats: 0,
700 ..
701 }
702 ));
703 }
704
705 #[test]
706 fn validates_partial_donation_distribution() {
707 let expected =
708 PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap();
709
710 validate_tx_outputs(
711 &expected,
712 vec![tx_out(100, OTHER_ADDRESS), tx_out(900, MINER_ADDRESS)],
713 )
714 .unwrap();
715 }
716
717 #[test]
718 fn rejects_wrong_partial_donation_distribution() {
719 let expected =
720 PayoutMode::try_from(format!("sri/donate/10/{MINER_ADDRESS}/w1").as_str()).unwrap();
721
722 let err = validate_tx_outputs(
723 &expected,
724 vec![tx_out(200, OTHER_ADDRESS), tx_out(800, MINER_ADDRESS)],
725 )
726 .unwrap_err();
727
728 assert!(matches!(
729 err,
730 PayoutValidationError::PayoutMismatch {
731 expected_sats: 900,
732 actual_sats: 800,
733 ..
734 }
735 ));
736 }
737
738 #[test]
739 fn full_donation_has_no_miner_payout_to_verify() {
740 let expected = PayoutMode::FullDonation;
741
742 validate_tx_outputs(&expected, vec![tx_out(1_000, OTHER_ADDRESS)]).unwrap();
743 }
744
745 #[test]
746 fn validates_coinbase_with_remaining_scriptsig_bytes_after_extranonce() {
747 let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
748 let (prefix, suffix) = coinbase_tx_parts(
749 vec![tx_out(1_000, MINER_ADDRESS), tx_out(1, OTHER_ADDRESS)],
750 b"/NexusPool/",
751 );
752
753 expected
754 .validate_coinbase_tx_parts(&prefix, &suffix, FULL_EXTRANONCE_SIZE)
755 .unwrap();
756 }
757}