zeldhash_protocol/
protocol.rs

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