1use std::fmt;
10
11use crate::{
12 config_helpers::CoinbaseRewardScript,
13 stratum_core::bitcoin::{consensus::Decodable, Amount, ScriptBuf, TxOut},
14};
15
16#[derive(Debug, Clone)]
26pub enum PayoutMode {
27 Solo {
29 address: String,
31 script: CoinbaseRewardScript,
33 },
34 Donate {
36 percentage: u8,
38 address: String,
40 script: CoinbaseRewardScript,
42 },
43 FullDonation,
45}
46
47impl PayoutMode {
48 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum PayoutModeError {
273 NoPayoutMode(String),
275 InvalidUserIdentity(String),
277 InvalidPayoutAddress { address: String, error: String },
279 InvalidDonationPercentage(String),
281 MissingMinerPayout {
283 user_identity: String,
284 mode: MissingMinerPayoutMode,
285 },
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum MissingMinerPayoutMode {
291 FullDonation,
293 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#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum PayoutValidationError {
338 NoSpendableOutputs,
340 PayoutMismatch {
342 address: String,
344 expected_sats: u64,
346 expected_percentage: u8,
348 total_spendable_sats: u64,
350 actual_sats: u64,
352 },
353 CoinbaseTxSuffixTooShort,
355 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}