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