1use bitcoin::Block;
2
3use crate::{
4 config::ZeldConfig,
5 helpers::{
6 calculate_proportional_distribution, calculate_reward, compute_utxo_key,
7 leading_zero_count, parse_op_return,
8 },
9 store::ZeldStore,
10 types::{
11 PreProcessedZeldBlock, ProcessedZeldBlock, Reward, ZeldInput, ZeldOutput, ZeldTransaction,
12 },
13};
14
15#[derive(Debug, Clone, Default)]
17pub struct ZeldProtocol {
18 config: ZeldConfig,
19}
20
21impl ZeldProtocol {
22 pub fn new(config: ZeldConfig) -> Self {
24 Self { config }
25 }
26
27 pub fn config(&self) -> &ZeldConfig {
29 &self.config
30 }
31
32 pub fn pre_process_block(&self, block: &Block) -> PreProcessedZeldBlock {
38 let mut transactions = Vec::with_capacity(block.txdata.len());
39 let mut max_zero_count: u8 = 0;
40
41 for tx in &block.txdata {
42 if tx.is_coinbase() {
44 continue;
45 }
46
47 let txid = tx.compute_txid();
49 let zero_count = leading_zero_count(&txid);
50 max_zero_count = max_zero_count.max(zero_count);
51
52 let mut inputs = Vec::with_capacity(tx.input.len());
54 for input in &tx.input {
55 inputs.push(ZeldInput {
56 utxo_key: compute_utxo_key(
57 &input.previous_output.txid,
58 input.previous_output.vout,
59 ),
60 });
61 }
62
63 let mut distributions: Option<Vec<u64>> = None;
65 let mut outputs = Vec::with_capacity(tx.output.len());
66 for (vout, out) in tx.output.iter().enumerate() {
67 if out.script_pubkey.is_op_return() {
68 distributions = parse_op_return(&out.script_pubkey, self.config.zeld_prefix);
69 continue;
70 }
71 let value = out.value.to_sat();
72 outputs.push(ZeldOutput {
73 utxo_key: compute_utxo_key(&txid, vout as u32),
74 value,
75 reward: 0,
76 distribution: 0,
77 vout: vout as u32,
78 });
79 }
80
81 let mut has_op_return_distribution = false;
83 if let Some(values) = distributions {
84 for (i, output) in outputs.iter_mut().enumerate() {
85 output.distribution = *values.get(i).unwrap_or(&0);
86 }
87 has_op_return_distribution = true;
88 }
89
90 transactions.push(ZeldTransaction {
91 txid,
92 inputs,
93 outputs,
94 zero_count,
95 reward: 0,
96 has_op_return_distribution,
97 });
98 }
99
100 if max_zero_count >= self.config.min_zero_count {
101 for tx in &mut transactions {
102 tx.reward = calculate_reward(
104 tx.zero_count,
105 max_zero_count,
106 self.config.min_zero_count,
107 self.config.base_reward,
108 );
109 if tx.reward > 0 {
110 let shares = calculate_proportional_distribution(tx.reward, &tx.outputs);
112 for (i, output) in tx.outputs.iter_mut().enumerate() {
113 output.reward = shares[i];
114 }
115 }
116 }
117 }
118
119 PreProcessedZeldBlock {
120 transactions,
121 max_zero_count,
122 }
123 }
124
125 pub fn process_block<S>(
135 &self,
136 block: &PreProcessedZeldBlock,
137 store: &mut S,
138 ) -> ProcessedZeldBlock
139 where
140 S: ZeldStore,
141 {
142 let mut rewards = Vec::new();
143 let mut total_reward: u64 = 0;
144 let mut max_zero_count: u8 = 0;
145 let mut nicest_txid = None;
146 let mut utxo_spent_count = 0;
147 let mut new_utxo_count = 0;
148
149 for tx in &block.transactions {
150 if tx.zero_count > max_zero_count || nicest_txid.is_none() {
152 max_zero_count = tx.zero_count;
153 nicest_txid = Some(tx.txid);
154 }
155
156 for output in &tx.outputs {
158 if output.reward > 0 {
159 rewards.push(Reward {
160 txid: tx.txid,
161 vout: output.vout,
162 reward: output.reward,
163 zero_count: tx.zero_count,
164 });
165 total_reward += output.reward;
166 }
167 }
168
169 let mut outputs_zeld_values = tx
171 .outputs
172 .iter()
173 .map(|output| output.reward)
174 .collect::<Vec<_>>();
175
176 let mut total_zeld_input = 0;
178 for input in &tx.inputs {
179 let zeld_input = store.pop(&input.utxo_key);
180 total_zeld_input += zeld_input;
181 if zeld_input > 0 {
182 utxo_spent_count += 1;
183 }
184 }
185
186 if total_zeld_input > 0 && !tx.outputs.is_empty() {
188 let shares = if tx.has_op_return_distribution {
189 let mut requested: Vec<u64> = tx
190 .outputs
191 .iter()
192 .map(|output| output.distribution)
193 .collect();
194 let requested_total: u64 = requested.iter().copied().sum();
195 if requested_total > total_zeld_input {
196 calculate_proportional_distribution(total_zeld_input, &tx.outputs)
197 } else {
198 if requested_total < total_zeld_input {
199 requested[0] =
200 requested[0].saturating_add(total_zeld_input - requested_total);
201 }
202 requested
203 }
204 } else {
205 calculate_proportional_distribution(total_zeld_input, &tx.outputs)
206 };
207
208 for (i, value) in shares.into_iter().enumerate() {
209 outputs_zeld_values[i] = outputs_zeld_values[i].saturating_add(value);
210 }
211 }
212
213 outputs_zeld_values
215 .iter()
216 .enumerate()
217 .for_each(|(i, value)| {
218 if *value > 0 {
219 store.set(tx.outputs[i].utxo_key, *value);
220 new_utxo_count += 1;
221 }
222 });
223 }
224
225 ProcessedZeldBlock {
226 rewards,
227 total_reward,
228 max_zero_count,
229 nicest_txid,
230 utxo_spent_count,
231 new_utxo_count,
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 #![allow(unexpected_cfgs)]
239
240 use super::*;
241 use crate::types::{Amount, UtxoKey};
242 use bitcoin::{
243 absolute::LockTime,
244 block::{Block as BitcoinBlock, Header as BlockHeader, Version as BlockVersion},
245 hashes::Hash,
246 opcodes,
247 pow::CompactTarget,
248 script::PushBytesBuf,
249 transaction::Version,
250 Amount as BtcAmount, BlockHash, OutPoint, ScriptBuf, Sequence, Transaction, TxIn,
251 TxMerkleNode, TxOut, Txid, Witness,
252 };
253 use ciborium::ser::into_writer;
254 use std::collections::HashMap;
255
256 #[cfg(coverage)]
257 macro_rules! assert_cov {
258 ($cond:expr $(, $($msg:tt)+)? ) => {
259 assert!($cond);
260 };
261 }
262
263 #[cfg(not(coverage))]
264 macro_rules! assert_cov {
265 ($($tt:tt)+) => {
266 assert!($($tt)+);
267 };
268 }
269
270 #[cfg(coverage)]
271 macro_rules! assert_eq_cov {
272 ($left:expr, $right:expr $(, $($msg:tt)+)? ) => {
273 assert_eq!($left, $right);
274 };
275 }
276
277 #[cfg(not(coverage))]
278 macro_rules! assert_eq_cov {
279 ($($tt:tt)+) => {
280 assert_eq!($($tt)+);
281 };
282 }
283
284 fn encode_cbor(values: &[u64]) -> Vec<u8> {
285 let mut encoded = Vec::new();
286 into_writer(values, &mut encoded).expect("failed to encode cbor");
287 encoded
288 }
289
290 fn op_return_output_from_payload(payload: Vec<u8>) -> TxOut {
291 let push = PushBytesBuf::try_from(payload).expect("invalid op_return payload");
292 let script = ScriptBuf::builder()
293 .push_opcode(opcodes::all::OP_RETURN)
294 .push_slice(push)
295 .into_script();
296 TxOut {
297 value: BtcAmount::from_sat(0),
298 script_pubkey: script,
299 }
300 }
301
302 fn op_return_with_prefix(prefix: &[u8], values: &[u64]) -> TxOut {
303 let mut payload = prefix.to_vec();
304 payload.extend(encode_cbor(values));
305 op_return_output_from_payload(payload)
306 }
307
308 fn standard_output(value: u64) -> TxOut {
309 TxOut {
310 value: BtcAmount::from_sat(value),
311 script_pubkey: ScriptBuf::builder()
312 .push_opcode(opcodes::all::OP_CHECKSIG)
313 .into_script(),
314 }
315 }
316
317 fn previous_outpoint(byte: u8, vout: u32) -> OutPoint {
318 let txid = Txid::from_slice(&[byte; 32]).expect("invalid txid bytes");
319 OutPoint { txid, vout }
320 }
321
322 fn make_inputs(outpoints: Vec<OutPoint>) -> Vec<TxIn> {
323 outpoints
324 .into_iter()
325 .map(|previous_output| TxIn {
326 previous_output,
327 script_sig: ScriptBuf::new(),
328 sequence: Sequence::MAX,
329 witness: Witness::new(),
330 })
331 .collect()
332 }
333
334 fn make_transaction(outpoints: Vec<OutPoint>, outputs: Vec<TxOut>) -> Transaction {
335 Transaction {
336 version: Version::TWO,
337 lock_time: LockTime::ZERO,
338 input: make_inputs(outpoints),
339 output: outputs,
340 }
341 }
342
343 fn make_coinbase_tx() -> Transaction {
344 Transaction {
345 version: Version::TWO,
346 lock_time: LockTime::ZERO,
347 input: vec![TxIn {
348 previous_output: OutPoint::null(),
349 script_sig: ScriptBuf::new(),
350 sequence: Sequence::MAX,
351 witness: Witness::new(),
352 }],
353 output: vec![standard_output(50)],
354 }
355 }
356
357 fn build_block(txdata: Vec<Transaction>) -> BitcoinBlock {
358 let header = BlockHeader {
359 version: BlockVersion::TWO,
360 prev_blockhash: BlockHash::from_slice(&[0u8; 32]).expect("valid block hash"),
361 merkle_root: TxMerkleNode::from_slice(&[0u8; 32]).expect("valid merkle root"),
362 time: 0,
363 bits: CompactTarget::default(),
364 nonce: 0,
365 };
366 BitcoinBlock { header, txdata }
367 }
368
369 fn deterministic_txid(byte: u8) -> Txid {
370 Txid::from_slice(&[byte; 32]).expect("valid txid bytes")
371 }
372
373 fn fixed_utxo_key(byte: u8) -> UtxoKey {
374 [byte; 12]
375 }
376
377 fn make_zeld_output(
378 utxo_key: UtxoKey,
379 value: Amount,
380 reward: Amount,
381 distribution: Amount,
382 vout: u32,
383 ) -> ZeldOutput {
384 ZeldOutput {
385 utxo_key,
386 value,
387 reward,
388 distribution,
389 vout,
390 }
391 }
392
393 #[derive(Default)]
394 struct MockStore {
395 balances: HashMap<UtxoKey, Amount>,
396 }
397
398 impl MockStore {
399 fn with_entries(entries: &[(UtxoKey, Amount)]) -> Self {
400 let mut balances = HashMap::new();
401 for (key, value) in entries {
402 balances.insert(*key, *value);
403 }
404 Self { balances }
405 }
406
407 fn balance(&self, key: &UtxoKey) -> Amount {
408 *self.balances.get(key).unwrap_or(&0)
409 }
410 }
411
412 impl ZeldStore for MockStore {
413 fn get(&mut self, key: &UtxoKey) -> Amount {
414 *self.balances.get(key).unwrap_or(&0)
415 }
416
417 fn pop(&mut self, key: &UtxoKey) -> Amount {
418 self.balances.remove(key).unwrap_or(0)
419 }
420
421 fn set(&mut self, key: UtxoKey, value: Amount) {
422 self.balances.insert(key, value);
423 }
424 }
425
426 #[test]
427 fn pre_process_block_ignores_coinbase_and_applies_defaults() {
428 let config = ZeldConfig {
429 min_zero_count: 65,
430 base_reward: 500,
431 zeld_prefix: b"ZELD",
432 };
433 let protocol = ZeldProtocol::new(config);
434
435 let prev_outs = vec![previous_outpoint(0xAA, 1), previous_outpoint(0xBB, 0)];
436 let mut invalid_payload = b"BADP".to_vec();
437 invalid_payload.extend(encode_cbor(&[1, 2]));
438
439 let tx_outputs = vec![
440 standard_output(1_000),
441 op_return_output_from_payload(invalid_payload),
442 standard_output(2_000),
443 ];
444
445 let tx_inputs_clone = prev_outs.clone();
446 let non_coinbase = make_transaction(prev_outs.clone(), tx_outputs);
447 let block = build_block(vec![make_coinbase_tx(), non_coinbase.clone()]);
448
449 let processed = protocol.pre_process_block(&block);
450 assert_eq_cov!(processed.transactions.len(), 1, "coinbase must be skipped");
451
452 let processed_tx = &processed.transactions[0];
453 let below_threshold = processed.max_zero_count < protocol.config().min_zero_count;
454 assert_cov!(
455 below_threshold,
456 "zero count threshold should prevent rewards"
457 );
458 assert_eq!(processed_tx.reward, 0);
459 assert!(processed_tx.outputs.iter().all(|o| o.reward == 0));
460 assert_eq!(processed_tx.inputs.len(), tx_inputs_clone.len());
461
462 for (input, expected_outpoint) in processed_tx.inputs.iter().zip(prev_outs.iter()) {
463 let expected = compute_utxo_key(&expected_outpoint.txid, expected_outpoint.vout);
464 assert_eq!(input.utxo_key, expected);
465 }
466
467 let txid = non_coinbase.compute_txid();
468 assert_eq_cov!(processed_tx.outputs.len(), 2, "op_return outputs removed");
469 assert_eq!(processed_tx.outputs[0].vout, 0);
470 assert_eq!(processed_tx.outputs[1].vout, 2);
471 assert_eq!(processed_tx.outputs[0].utxo_key, compute_utxo_key(&txid, 0));
472 assert_eq!(processed_tx.outputs[1].utxo_key, compute_utxo_key(&txid, 2));
473 assert!(processed_tx
474 .outputs
475 .iter()
476 .all(|output| output.distribution == 0));
477 }
478
479 #[test]
480 fn pre_process_block_returns_empty_when_block_only_has_coinbase() {
481 let config = ZeldConfig {
482 min_zero_count: 32,
483 base_reward: 777,
484 zeld_prefix: b"ZELD",
485 };
486 let protocol = ZeldProtocol::new(config);
487
488 let block = build_block(vec![make_coinbase_tx()]);
489 let processed = protocol.pre_process_block(&block);
490
491 let only_coinbase = processed.transactions.is_empty();
492 assert_cov!(
493 only_coinbase,
494 "no non-coinbase transactions must yield zero ZELD entries"
495 );
496 let max_zero_is_zero = processed.max_zero_count == 0;
497 assert_cov!(
498 max_zero_is_zero,
499 "with no contenders the block-wide maximum stays at zero"
500 );
501 }
502
503 #[test]
504 fn pre_process_block_assigns_rewards_and_custom_distribution() {
505 let prefix = b"ZELD";
506 let config = ZeldConfig {
507 min_zero_count: 0,
508 base_reward: 1_024,
509 zeld_prefix: prefix,
510 };
511 let protocol = ZeldProtocol::new(config);
512
513 let prev_outs = vec![previous_outpoint(0xCC, 0)];
514 let tx_outputs = vec![
515 standard_output(4_000),
516 standard_output(1_000),
517 standard_output(0),
518 op_return_with_prefix(prefix, &[7, 8]),
519 ];
520 let rewarding_tx = make_transaction(prev_outs.clone(), tx_outputs);
521 let block = build_block(vec![make_coinbase_tx(), rewarding_tx.clone()]);
522
523 let processed = protocol.pre_process_block(&block);
524 assert_eq!(processed.transactions.len(), 1);
525 let tx = &processed.transactions[0];
526
527 let single_tx_defines_block_max = processed.max_zero_count == tx.zero_count;
528 assert_cov!(
529 single_tx_defines_block_max,
530 "single tx must define block max"
531 );
532 let rewarded = tx.reward > 0;
533 assert_cov!(
534 rewarded,
535 "reward must be granted when min_zero_count is zero"
536 );
537
538 let expected_reward = calculate_reward(
539 tx.zero_count,
540 processed.max_zero_count,
541 protocol.config().min_zero_count,
542 protocol.config().base_reward,
543 );
544 assert_eq!(tx.reward, expected_reward);
545
546 let expected_shares = calculate_proportional_distribution(tx.reward, &tx.outputs);
547 for (output, expected) in tx.outputs.iter().zip(expected_shares.iter()) {
548 assert_eq!(output.reward, *expected);
549 }
550
551 let op_return_distributions: Vec<_> = tx.outputs.iter().map(|o| o.distribution).collect();
552 let matches_hints = op_return_distributions == vec![7, 8, 0];
553 assert_cov!(
554 matches_hints,
555 "distribution hints must map to outputs with defaults"
556 );
557
558 let txid = rewarding_tx.compute_txid();
559 for output in &tx.outputs {
560 assert_eq!(output.utxo_key, compute_utxo_key(&txid, output.vout));
561 }
562 }
563
564 #[test]
565 fn pre_process_block_ignores_op_return_with_wrong_prefix() {
566 let config = ZeldConfig {
567 min_zero_count: 0,
568 base_reward: 512,
569 zeld_prefix: b"ZELD",
570 };
571 let protocol = ZeldProtocol::new(config);
572
573 let prev_outs = vec![previous_outpoint(0xAB, 0)];
574 let tx_outputs = vec![
575 standard_output(3_000),
576 op_return_with_prefix(b"ALT", &[5, 6, 7]),
577 standard_output(1_500),
578 ];
579 let block = build_block(vec![
580 make_coinbase_tx(),
581 make_transaction(prev_outs, tx_outputs),
582 ]);
583
584 let processed = protocol.pre_process_block(&block);
585 assert_eq!(processed.transactions.len(), 1);
586 let tx = &processed.transactions[0];
587
588 let ignored_prefix = !tx.has_op_return_distribution;
589 assert_cov!(
590 ignored_prefix,
591 "non-matching OP_RETURN prefixes must be ignored"
592 );
593 let default_distributions = tx.outputs.iter().all(|output| output.distribution == 0);
594 assert_cov!(
595 default_distributions,
596 "mismatched hints must leave outputs at default distributions"
597 );
598 }
599
600 #[test]
601 fn pre_process_block_handles_transactions_with_only_op_return_outputs() {
602 let prefix = b"ZELD";
603 let config = ZeldConfig {
604 min_zero_count: 0,
605 base_reward: 2_048,
606 zeld_prefix: prefix,
607 };
608 let protocol = ZeldProtocol::new(config);
609
610 let prev_outs = vec![previous_outpoint(0xEF, 1)];
611 let op_return_only_tx = make_transaction(
612 prev_outs,
613 vec![op_return_with_prefix(prefix, &[42, 43, 44])],
614 );
615 let block = build_block(vec![make_coinbase_tx(), op_return_only_tx]);
616
617 let processed = protocol.pre_process_block(&block);
618 assert_eq!(processed.transactions.len(), 1);
619 let tx = &processed.transactions[0];
620
621 let op_return_only = tx.outputs.is_empty();
622 assert_cov!(
623 op_return_only,
624 "OP_RETURN-only transactions should not produce spendable outputs"
625 );
626 let defines_block_max = processed.max_zero_count == tx.zero_count;
627 assert_cov!(
628 defines_block_max,
629 "single ZELD candidate defines the block-wide zero count"
630 );
631 let matches_base_reward = tx.reward == protocol.config().base_reward;
632 assert_cov!(
633 matches_base_reward,
634 "eligible OP_RETURN-only transactions still earn ZELD"
635 );
636 let inputs_tracked = tx.inputs.iter().all(|input| input.utxo_key != [0; 12]);
637 assert_cov!(
638 inputs_tracked,
639 "inputs must still be tracked even without spendable outputs"
640 );
641 }
642
643 #[test]
644 fn pre_process_block_runs_reward_loop_without_payouts_when_base_is_zero() {
645 let config = ZeldConfig {
646 min_zero_count: 0,
647 base_reward: 0,
648 zeld_prefix: b"ZELD",
649 };
650 let protocol = ZeldProtocol::new(config);
651
652 let prev_outs = vec![previous_outpoint(0xDD, 0)];
653 let tx_outputs = vec![standard_output(10_000), standard_output(5_000)];
654 let block = build_block(vec![
655 make_coinbase_tx(),
656 make_transaction(prev_outs, tx_outputs),
657 ]);
658
659 let processed = protocol.pre_process_block(&block);
660 assert_eq!(processed.transactions.len(), 1);
661 let tx = &processed.transactions[0];
662 assert_cov!(
663 processed.max_zero_count >= protocol.config().min_zero_count,
664 "block max should respect the configured threshold"
665 );
666 assert_eq_cov!(tx.reward, 0, "zero base reward must lead to zero payouts");
667 assert!(tx.outputs.iter().all(|o| o.reward == 0));
668 let default_distribution = tx.outputs.iter().all(|o| o.distribution == 0);
669 assert_cov!(
670 default_distribution,
671 "no OP_RETURN hints means default distribution"
672 );
673 }
674
675 #[test]
676 fn pre_process_block_only_rewards_transactions_meeting_threshold() {
677 let mut best: Option<(Transaction, u8)> = None;
678 let mut worst: Option<(Transaction, u8)> = None;
679
680 for byte in 0u8..=200 {
681 for vout in 0..=2 {
682 let prev = previous_outpoint(byte, vout);
683 let tx = make_transaction(
684 vec![prev],
685 vec![standard_output(12_500), standard_output(7_500)],
686 );
687 let zero_count = leading_zero_count(&tx.compute_txid());
688
689 if best
690 .as_ref()
691 .map(|(_, current)| zero_count > *current)
692 .unwrap_or(true)
693 {
694 best = Some((tx.clone(), zero_count));
695 }
696
697 if worst
698 .as_ref()
699 .map(|(_, current)| zero_count < *current)
700 .unwrap_or(true)
701 {
702 worst = Some((tx.clone(), zero_count));
703 }
704 }
705 }
706
707 let (best_tx, best_zeroes) = best.expect("search must yield at least one candidate");
708 let (worst_tx, worst_zeroes) = worst.expect("search must yield at least one candidate");
709 let zero_counts_differ = best_zeroes > worst_zeroes;
710 assert_cov!(
711 zero_counts_differ,
712 "search must uncover distinct zero counts"
713 );
714
715 let config = ZeldConfig {
716 min_zero_count: best_zeroes,
717 base_reward: 4_096,
718 zeld_prefix: b"ZELD",
719 };
720 let protocol = ZeldProtocol::new(config);
721
722 let best_txid = best_tx.compute_txid();
723 let worst_txid = worst_tx.compute_txid();
724 let block = build_block(vec![make_coinbase_tx(), best_tx, worst_tx]);
725
726 let processed = protocol.pre_process_block(&block);
727 assert_eq!(processed.transactions.len(), 2);
728 let block_max_matches_best = processed.max_zero_count == best_zeroes;
729 assert_cov!(
730 block_max_matches_best,
731 "block-wide max must reflect top contender"
732 );
733
734 let best_entry = processed
735 .transactions
736 .iter()
737 .find(|tx| tx.txid == best_txid)
738 .expect("best transaction must be present");
739 let worst_entry = processed
740 .transactions
741 .iter()
742 .find(|tx| tx.txid == worst_txid)
743 .expect("worst transaction must be present");
744
745 assert_eq!(best_entry.zero_count, best_zeroes);
746 assert_eq!(worst_entry.zero_count, worst_zeroes);
747
748 let best_rewarded = best_entry.reward > 0;
749 assert_cov!(
750 best_rewarded,
751 "threshold-satisfying transaction must get a reward"
752 );
753 let worst_has_zero_reward = worst_entry.reward == 0;
754 assert_cov!(
755 worst_has_zero_reward,
756 "transactions below the threshold should not earn ZELD"
757 );
758 let worst_outputs_unrewarded = worst_entry.outputs.iter().all(|out| out.reward == 0);
759 assert_cov!(
760 worst_outputs_unrewarded,
761 "zero-reward transactions must not distribute rewards to outputs"
762 );
763 }
764
765 #[test]
766 fn process_block_distributes_inputs_without_custom_shares() {
767 let protocol = ZeldProtocol::new(ZeldConfig::default());
768
769 let input_a = fixed_utxo_key(0x01);
770 let input_b = fixed_utxo_key(0x02);
771 let mut store = MockStore::with_entries(&[(input_a, 60), (input_b, 0)]);
772
773 let output_a = fixed_utxo_key(0x10);
774 let output_b = fixed_utxo_key(0x11);
775 let outputs = vec![
776 make_zeld_output(output_a, 4_000, 10, 0, 0),
777 make_zeld_output(output_b, 1_000, 5, 0, 1),
778 ];
779 let expected_shares = calculate_proportional_distribution(60, &outputs);
780
781 let tx = ZeldTransaction {
782 txid: deterministic_txid(0xAA),
783 inputs: vec![
784 ZeldInput { utxo_key: input_a },
785 ZeldInput { utxo_key: input_b },
786 ],
787 outputs: outputs.clone(),
788 zero_count: 0,
789 reward: outputs.iter().map(|o| o.reward).sum(),
790 has_op_return_distribution: false,
791 };
792
793 let block = PreProcessedZeldBlock {
794 transactions: vec![tx],
795 max_zero_count: 0,
796 };
797
798 let result = protocol.process_block(&block, &mut store);
799
800 assert_eq!(store.balance(&input_a), 0);
801 assert_eq!(store.balance(&input_b), 0);
802
803 for (idx, output) in outputs.iter().enumerate() {
804 let expected = output.reward + expected_shares[idx];
805 assert_eq!(store.get(&output.utxo_key), expected);
806 }
807
808 assert_eq!(result.utxo_spent_count, 1);
811 assert_eq!(result.new_utxo_count, outputs.len() as u64);
812 assert_eq!(
813 result.total_reward,
814 outputs.iter().map(|o| o.reward).sum::<u64>()
815 );
816 assert_eq!(result.max_zero_count, 0);
817 assert!(result.nicest_txid.is_some());
818 }
819
820 #[test]
821 fn process_block_respects_custom_distribution_requests() {
822 let protocol = ZeldProtocol::new(ZeldConfig::default());
823
824 let capped_input = fixed_utxo_key(0x80);
825 let exact_input = fixed_utxo_key(0x81);
826 let remainder_input = fixed_utxo_key(0x82);
827 let mut store = MockStore::with_entries(&[
828 (capped_input, 50),
829 (exact_input, 25),
830 (remainder_input, 50),
831 ]);
832
833 let capped_output_a = fixed_utxo_key(0x20);
834 let capped_output_b = fixed_utxo_key(0x21);
835 let capped_outputs = vec![
836 make_zeld_output(capped_output_a, 4_000, 2, 40, 0),
837 make_zeld_output(capped_output_b, 1_000, 3, 30, 1),
838 ];
839 let capped_expected = calculate_proportional_distribution(50, &capped_outputs);
840
841 let exact_output_a = fixed_utxo_key(0x22);
842 let exact_output_b = fixed_utxo_key(0x23);
843 let exact_outputs = vec![
844 make_zeld_output(exact_output_a, 2_000, 5, 10, 0),
845 make_zeld_output(exact_output_b, 3_000, 1, 15, 1),
846 ];
847 let exact_requested: Vec<_> = exact_outputs.iter().map(|o| o.distribution).collect();
848
849 let remainder_output_a = fixed_utxo_key(0x24);
850 let remainder_output_b = fixed_utxo_key(0x25);
851 let remainder_outputs = vec![
852 make_zeld_output(remainder_output_a, 5_000, 7, 20, 0),
853 make_zeld_output(remainder_output_b, 1_000, 0, 10, 1),
854 ];
855 let mut remainder_expected: Vec<_> =
856 remainder_outputs.iter().map(|o| o.distribution).collect();
857 let remainder_total: Amount = remainder_expected.iter().sum();
858 let shortfall = 50u64.saturating_sub(remainder_total);
859 remainder_expected[0] = remainder_expected[0].saturating_add(shortfall);
860
861 let capped_tx = ZeldTransaction {
862 txid: deterministic_txid(0x01),
863 inputs: vec![ZeldInput {
864 utxo_key: capped_input,
865 }],
866 outputs: capped_outputs.clone(),
867 zero_count: 0,
868 reward: 0,
869 has_op_return_distribution: true,
870 };
871 let exact_tx = ZeldTransaction {
872 txid: deterministic_txid(0x02),
873 inputs: vec![ZeldInput {
874 utxo_key: exact_input,
875 }],
876 outputs: exact_outputs.clone(),
877 zero_count: 0,
878 reward: 0,
879 has_op_return_distribution: true,
880 };
881 let remainder_tx = ZeldTransaction {
882 txid: deterministic_txid(0x03),
883 inputs: vec![ZeldInput {
884 utxo_key: remainder_input,
885 }],
886 outputs: remainder_outputs.clone(),
887 zero_count: 0,
888 reward: 0,
889 has_op_return_distribution: true,
890 };
891
892 let block = PreProcessedZeldBlock {
893 transactions: vec![capped_tx, exact_tx, remainder_tx],
894 max_zero_count: 0,
895 };
896
897 let result = protocol.process_block(&block, &mut store);
898
899 for key in [capped_input, exact_input, remainder_input] {
900 assert_eq_cov!(store.balance(&key), 0, "inputs must be burned after use");
901 }
902
903 assert_eq_cov!(
905 result.utxo_spent_count,
906 3,
907 "all 3 inputs had non-zero balances"
908 );
909 let expected_new_utxos =
910 capped_outputs.len() + exact_outputs.len() + remainder_outputs.len();
911 assert_eq_cov!(result.new_utxo_count, expected_new_utxos as u64);
912
913 for (idx, output) in capped_outputs.iter().enumerate() {
914 let expected = output.reward + capped_expected[idx];
915 let balance = store.balance(&output.utxo_key);
916 assert_eq_cov!(
917 balance,
918 expected,
919 "overages fall back to proportional distribution"
920 );
921 }
922
923 for (output, requested) in exact_outputs.iter().zip(exact_requested.iter()) {
924 let balance = store.balance(&output.utxo_key);
925 assert_eq_cov!(
926 balance,
927 output.reward + requested,
928 "exact requests must be honored"
929 );
930 }
931
932 for (output, expected_share) in remainder_outputs.iter().zip(remainder_expected.iter()) {
933 let balance = store.balance(&output.utxo_key);
934 assert_eq_cov!(
935 balance,
936 output.reward + expected_share,
937 "unused amounts roll into the first request"
938 );
939 }
940 }
941
942 #[test]
943 fn process_block_handles_zero_inputs_and_missing_outputs() {
944 let protocol = ZeldProtocol::new(ZeldConfig::default());
945
946 let zero_input = fixed_utxo_key(0x90);
947 let producing_input = fixed_utxo_key(0x91);
948 let mut store = MockStore::with_entries(&[(zero_input, 0), (producing_input, 25)]);
949
950 let reward_output_a = fixed_utxo_key(0x30);
951 let reward_output_b = fixed_utxo_key(0x31);
952 let reward_only_outputs = vec![
953 make_zeld_output(reward_output_a, 1_000, 11, 0, 0),
954 make_zeld_output(reward_output_b, 2_000, 22, 0, 1),
955 ];
956
957 let zero_input_tx = ZeldTransaction {
958 txid: deterministic_txid(0x10),
959 inputs: vec![ZeldInput {
960 utxo_key: zero_input,
961 }],
962 outputs: reward_only_outputs.clone(),
963 zero_count: 0,
964 reward: 0,
965 has_op_return_distribution: false,
966 };
967 let empty_outputs_tx = ZeldTransaction {
968 txid: deterministic_txid(0x11),
969 inputs: vec![ZeldInput {
970 utxo_key: producing_input,
971 }],
972 outputs: Vec::new(),
973 zero_count: 0,
974 reward: 0,
975 has_op_return_distribution: true,
976 };
977
978 let block = PreProcessedZeldBlock {
979 transactions: vec![zero_input_tx, empty_outputs_tx],
980 max_zero_count: 0,
981 };
982
983 let result = protocol.process_block(&block, &mut store);
984
985 for output in &reward_only_outputs {
986 let balance = store.balance(&output.utxo_key);
987 assert_eq_cov!(balance, output.reward, "no input keeps rewards untouched");
988 }
989
990 assert_eq!(store.balance(&zero_input), 0);
991 assert_eq!(store.balance(&producing_input), 0);
992 let store_entries = store.balances.len();
993 assert_eq_cov!(
994 store_entries,
995 reward_only_outputs.len(),
996 "inputs without outputs must fully leave the store"
997 );
998
999 assert_eq_cov!(
1002 result.utxo_spent_count,
1003 1,
1004 "only producing_input had non-zero balance"
1005 );
1006 assert_eq_cov!(result.new_utxo_count, reward_only_outputs.len() as u64);
1008 let expected_total_reward: u64 = reward_only_outputs.iter().map(|o| o.reward).sum();
1010 assert_eq_cov!(result.total_reward, expected_total_reward);
1011 }
1012}