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