zeldhash_protocol/
protocol.rs

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