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