zeldhash_protocol/
protocol.rs

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/// Entry point used to process blocks according to the ZELD protocol.
13#[derive(Debug, Clone, Default)]
14pub struct ZeldProtocol {
15    config: ZeldConfig,
16}
17
18impl ZeldProtocol {
19    /// Creates a new protocol instance with the given configuration.
20    pub fn new(config: ZeldConfig) -> Self {
21        Self { config }
22    }
23
24    /// Returns a reference to the protocol configuration.
25    pub fn config(&self) -> &ZeldConfig {
26        &self.config
27    }
28
29    /// Pre-processes a Bitcoin block, extracting all ZELD-relevant data.
30    ///
31    /// This phase is **parallelizable** — you can pre-process multiple blocks concurrently.
32    /// The returned [`PreProcessedZeldBlock`] contains all transactions with their computed rewards
33    /// and distribution hints.
34    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            // Coinbase transactions can never earn ZELD, so ignore them early.
40            if tx.is_coinbase() {
41                continue;
42            }
43
44            // Track how "nice" (leading zero count) each TXID is for block-wide ranking.
45            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            // Inputs are only represented by their previous UTXO keys.
50            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            // Collect custom distribution hints from OP_RETURN if present.
61            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                        // Only keep the last valid OP_RETURN payload, per the spec.
69                        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            // Apply OP_RETURN-provided custom ZELD distribution
84            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                // Assign protocol reward tiers relative to the block-best transaction.
105                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                    // All mined rewards attach to the first non-OP_RETURN output.
113                    tx.outputs[0].reward = tx.reward;
114                }
115            }
116        }
117
118        PreProcessedZeldBlock {
119            transactions,
120            max_zero_count,
121        }
122    }
123
124    /// Processes a pre-processed block, updating ZELD balances in the store.
125    ///
126    /// This phase is **sequential** — blocks must be processed in order, one after another.
127    /// For each transaction, this method:
128    /// 1. Collects ZELD from spent inputs
129    /// 2. Applies rewards and distributions to outputs
130    /// 3. Updates the store with new balances
131    ///
132    /// Returns a [`ProcessedZeldBlock`] containing all rewards and block statistics.
133    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            // Track the nicest txid (highest zero count).
150            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            // Collect rewards for outputs with non-zero rewards.
156            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            // Initialize the ZELD values for the outputs to the reward values.
169            let mut outputs_zeld_values = tx
170                .outputs
171                .iter()
172                .map(|output| output.reward)
173                .collect::<Vec<_>>();
174
175            // Calculate the total ZELD input from the inputs.
176            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 there is a total ZELD input distribute the ZELDs.
186            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                        // Invalid request: fall back to sending everything to the first output.
196                        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            // Set the ZELD values for the outputs.
218            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            // Trailing invalid OP_RETURN must not override the last valid payload.
618            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        // Verify ProcessedZeldBlock fields
847        // input_a has 60, input_b has 0, so only 1 is counted as spent
848        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        // Verify ProcessedZeldBlock fields
936        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        // Inputs carry ZELD; custom distribution requests some for the second output,
970        // but mining rewards must stay on the first output.
971        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        // Requested 20 to the second output; remainder (30) stays with the first,
1000        // which already carried the mining reward (100).
1001        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        // Requested 70 while only 25 are available; everything (25) falls back to the first
1041        // output, which already carries the mining reward (64).
1042        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        // Input exists but holds no ZELD.
1054        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        // Verify ProcessedZeldBlock fields
1170        // zero_input has 0 balance so it doesn't count as spent, producing_input has 25 so it counts
1171        assert_eq_cov!(
1172            result.utxo_spent_count,
1173            1,
1174            "only producing_input had non-zero balance"
1175        );
1176        // reward_only_outputs has 2 outputs, empty_outputs_tx has 0 outputs
1177        assert_eq_cov!(result.new_utxo_count, reward_only_outputs.len() as u64);
1178        // Verify total_reward comes from reward_only_outputs
1179        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}