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