Skip to main content

hns_mining/
lib.rs

1#![doc = "Runtime-independent Handshake block commitments and immutable mining jobs."]
2
3use std::collections::{BTreeMap, HashSet};
4use std::sync::Arc;
5
6use blake2::Blake2bVar;
7use blake2::digest::{Update, VariableOutput};
8use hns_covenants::{Covenant, CovenantKind, MAX_COVENANT_ITEMS, MAX_RESOURCE_SIZE, hash_name};
9use hns_encoding::{Decoder, Encoder};
10use hns_header_consensus::{EXTRA_NONCE_SIZE, HEADER_SIZE, Header, Network};
11use hns_primitives::{
12    BlockHash, BlockTime, CompactTarget, Dollarydoos, Height, MerkleRoot, PowMask, ReservedRoot,
13    TreeRoot, WitnessRoot,
14};
15use hns_transaction::{Address, Input, Outpoint, Transaction, Witness};
16use thiserror::Error;
17
18pub const COIN: u64 = 1_000_000;
19pub const BASE_REWARD: u64 = 2_000 * COIN;
20pub const MAX_MONEY: u64 = 2_040_000_000 * COIN;
21pub const MAX_BLOCK_BASE_SIZE: usize = 1_000_000;
22pub const MAX_BLOCK_WEIGHT: usize = 4_000_000;
23pub const MAX_BLOCK_OPENS: u32 = 300;
24pub const MAX_BLOCK_UPDATES: u32 = 600;
25pub const MAX_BLOCK_RENEWALS: u32 = 600;
26pub const MAX_COVENANT_SIZE: usize = 585;
27pub const MAX_COINBASE_WITNESS_SIZE: usize = 1_000;
28pub const MAX_COINBASE_CLAIM_WITNESS_ITEM_SIZE: usize = 10_000;
29pub const WITNESS_SCALE_FACTOR: usize = 4;
30pub const MAX_PREPARED_JOBS: usize = 16;
31
32pub type MiningGeneration = u64;
33pub type MiningJobId = [u8; 32];
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct Block {
37    pub header: Header,
38    pub transactions: Vec<Transaction>,
39}
40
41impl Block {
42    pub fn decode(input: &[u8]) -> Result<Self, MiningError> {
43        if input.len() > MAX_BLOCK_WEIGHT {
44            return Err(MiningError::InvalidBlockBody(
45                "serialized block exceeds allocation bound",
46            ));
47        }
48        let mut decoder = Decoder::new(input);
49        let header = Header::decode(decoder.read_slice(HEADER_SIZE)?)?;
50        let count = decoder.read_compact_usize(MAX_BLOCK_BASE_SIZE, "block transactions")?;
51        let mut transactions = Vec::with_capacity(count.min(1024));
52        for _ in 0..count {
53            transactions.push(Transaction::decode_from(&mut decoder)?);
54        }
55        decoder.finish()?;
56        let block = Self {
57            header,
58            transactions,
59        };
60        validate_block_limits(&block)?;
61        Ok(block)
62    }
63
64    pub fn encode(&self) -> Result<Vec<u8>, MiningError> {
65        let metrics = validate_block_limits(self)?;
66        let mut encoder = Encoder::with_capacity(metrics.serialized_size);
67        encoder.put_bytes(&self.header.encode());
68        encoder.put_compact_size(self.transactions.len() as u64);
69        for transaction in &self.transactions {
70            encoder.put_bytes(&transaction.encode()?);
71        }
72        Ok(encoder.into_bytes())
73    }
74
75    pub fn decode_validated(input: &[u8]) -> Result<Self, MiningError> {
76        let block = Self::decode(input)?;
77        validate_block_body(&block)?;
78        Ok(block)
79    }
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub struct BlockMetrics {
84    pub base_size: usize,
85    pub witness_size: usize,
86    pub serialized_size: usize,
87    pub weight: usize,
88    pub merkle_root: MerkleRoot,
89    pub witness_root: WitnessRoot,
90}
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93pub struct MiningSnapshot {
94    pub network: Network,
95    pub generation: MiningGeneration,
96    pub tip_hash: BlockHash,
97    pub tip_height: Height,
98    pub tip_time: BlockTime,
99    pub parent_median_time: BlockTime,
100    pub next_tree_root: TreeRoot,
101    pub expected_bits: CompactTarget,
102}
103
104impl MiningSnapshot {
105    pub fn next_height(self) -> Result<Height, MiningError> {
106        self.tip_height
107            .get()
108            .checked_add(1)
109            .map(Height::new)
110            .ok_or(MiningError::ArithmeticOverflow)
111    }
112
113    fn validate(self) -> Result<(), MiningError> {
114        if self.generation == 0 || self.expected_bits.get() == 0 {
115            return Err(MiningError::InvalidSnapshot);
116        }
117        Ok(())
118    }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct MiningHeaderTemplate {
123    pub parent_hash: BlockHash,
124    pub tree_root: TreeRoot,
125    pub reserved_root: ReservedRoot,
126    pub witness_root: WitnessRoot,
127    pub merkle_root: MerkleRoot,
128    pub version: u32,
129    pub bits: CompactTarget,
130    pub minimum_time: BlockTime,
131    pub mask_hash: [u8; 32],
132}
133
134impl MiningHeaderTemplate {
135    pub fn from_transactions(
136        snapshot: MiningSnapshot,
137        reserved_root: ReservedRoot,
138        version: u32,
139        minimum_time: BlockTime,
140        mask: PowMask,
141        transactions: &[Transaction],
142    ) -> Result<Self, MiningError> {
143        snapshot.validate()?;
144        if minimum_time <= snapshot.parent_median_time || transactions.is_empty() {
145            return Err(MiningError::InvalidTemplate);
146        }
147        Ok(Self {
148            parent_hash: snapshot.tip_hash,
149            tree_root: snapshot.next_tree_root,
150            reserved_root,
151            witness_root: block_witness_root(transactions)?,
152            merkle_root: block_merkle_root(transactions)?,
153            version,
154            bits: snapshot.expected_bits,
155            minimum_time,
156            mask_hash: mask_hash(snapshot.tip_hash, mask),
157        })
158    }
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
162pub struct PreparedMiningJob {
163    job_id: MiningJobId,
164    snapshot_generation: MiningGeneration,
165    network: Network,
166    header: MiningHeaderTemplate,
167    maximum_target_time: Option<BlockTime>,
168    transactions: Arc<[Transaction]>,
169}
170
171impl PreparedMiningJob {
172    pub fn new(
173        snapshot: MiningSnapshot,
174        header: MiningHeaderTemplate,
175        transactions: Arc<[Transaction]>,
176    ) -> Result<Self, MiningError> {
177        snapshot.validate()?;
178        if header.parent_hash != snapshot.tip_hash
179            || header.tree_root != snapshot.next_tree_root
180            || header.minimum_time <= snapshot.parent_median_time
181            || header.bits != snapshot.expected_bits
182            || transactions.is_empty()
183        {
184            return Err(MiningError::InvalidJob);
185        }
186        let provisional = Block {
187            header: Header {
188                time: header.minimum_time,
189                previous_block: header.parent_hash,
190                tree_root: header.tree_root,
191                reserved_root: header.reserved_root,
192                witness_root: header.witness_root,
193                merkle_root: header.merkle_root,
194                version: header.version,
195                bits: header.bits,
196                ..Header::default()
197            },
198            transactions: transactions.to_vec(),
199        };
200        validate_block_body(&provisional)?;
201        if provisional.transactions[0].locktime != snapshot.next_height()?.get() {
202            return Err(MiningError::InvalidCoinbaseHeight);
203        }
204        let parameters = snapshot.network.parameters().pow;
205        let maximum_target_time =
206            (parameters.target_reset && header.bits != parameters.bits).then(|| {
207                BlockTime::new(
208                    snapshot
209                        .tip_time
210                        .get()
211                        .saturating_add(u64::from(parameters.target_spacing).saturating_mul(2)),
212                )
213            });
214        if maximum_target_time.is_some_and(|maximum| header.minimum_time > maximum) {
215            return Err(MiningError::InvalidJob);
216        }
217        let job_id = job_id(snapshot, &header, &transactions)?;
218        Ok(Self {
219            job_id,
220            snapshot_generation: snapshot.generation,
221            network: snapshot.network,
222            header,
223            maximum_target_time,
224            transactions,
225        })
226    }
227
228    pub fn prepare(
229        snapshot: MiningSnapshot,
230        reserved_root: ReservedRoot,
231        version: u32,
232        minimum_time: BlockTime,
233        mask: PowMask,
234        transactions: Arc<[Transaction]>,
235    ) -> Result<Self, MiningError> {
236        let header = MiningHeaderTemplate::from_transactions(
237            snapshot,
238            reserved_root,
239            version,
240            minimum_time,
241            mask,
242            &transactions,
243        )?;
244        Self::new(snapshot, header, transactions)
245    }
246
247    pub const fn job_id(&self) -> MiningJobId {
248        self.job_id
249    }
250
251    pub const fn snapshot_generation(&self) -> MiningGeneration {
252        self.snapshot_generation
253    }
254
255    pub const fn header(&self) -> &MiningHeaderTemplate {
256        &self.header
257    }
258
259    pub const fn maximum_target_time(&self) -> Option<BlockTime> {
260        self.maximum_target_time
261    }
262
263    pub fn transactions(&self) -> &[Transaction] {
264        &self.transactions
265    }
266
267    pub fn validate_for_snapshot(&self, snapshot: MiningSnapshot) -> Result<(), MiningError> {
268        snapshot.validate().map_err(|_| MiningError::StaleJob)?;
269        let parameters = snapshot.network.parameters().pow;
270        let expected_maximum = (parameters.target_reset && self.header.bits != parameters.bits)
271            .then(|| {
272                BlockTime::new(
273                    snapshot
274                        .tip_time
275                        .get()
276                        .saturating_add(u64::from(parameters.target_spacing).saturating_mul(2)),
277                )
278            });
279        if self.network != snapshot.network
280            || self.snapshot_generation != snapshot.generation
281            || self.header.parent_hash != snapshot.tip_hash
282            || self.header.tree_root != snapshot.next_tree_root
283            || self.header.bits != snapshot.expected_bits
284            || self.header.minimum_time <= snapshot.parent_median_time
285            || self.maximum_target_time != expected_maximum
286            || self.job_id != job_id(snapshot, &self.header, &self.transactions)?
287        {
288            return Err(MiningError::StaleJob);
289        }
290        Ok(())
291    }
292
293    pub fn reconstruct(
294        &self,
295        nonce: u32,
296        time: BlockTime,
297        extra_nonce: [u8; EXTRA_NONCE_SIZE],
298        mask: PowMask,
299    ) -> Result<Block, MiningError> {
300        if time < self.header.minimum_time
301            || self
302                .maximum_target_time
303                .is_some_and(|maximum| time > maximum)
304            || mask_hash(self.header.parent_hash, mask) != self.header.mask_hash
305        {
306            return Err(MiningError::InvalidReconstruction);
307        }
308        let block = Block {
309            header: Header {
310                nonce,
311                time,
312                previous_block: self.header.parent_hash,
313                tree_root: self.header.tree_root,
314                extra_nonce,
315                reserved_root: self.header.reserved_root,
316                witness_root: self.header.witness_root,
317                merkle_root: self.header.merkle_root,
318                version: self.header.version,
319                bits: self.header.bits,
320                mask,
321            },
322            transactions: self.transactions.to_vec(),
323        };
324        validate_block_body(&block).map_err(|_| MiningError::InvalidReconstruction)?;
325        if block.header.mask_hash() != self.header.mask_hash {
326            return Err(MiningError::InvalidReconstruction);
327        }
328        Ok(block)
329    }
330
331    pub fn admit_solution(
332        &self,
333        snapshot: MiningSnapshot,
334        nonce: u32,
335        time: BlockTime,
336        extra_nonce: [u8; EXTRA_NONCE_SIZE],
337        mask: PowMask,
338    ) -> Result<SolvedMiningCandidate, MiningError> {
339        self.validate_for_snapshot(snapshot)?;
340        let block = self.reconstruct(nonce, time, extra_nonce, mask)?;
341        if !block.header.verify_pow() {
342            return Err(MiningError::InsufficientProofOfWork);
343        }
344        Ok(SolvedMiningCandidate {
345            job_id: self.job_id,
346            snapshot_generation: self.snapshot_generation,
347            parent_height: snapshot.tip_height,
348            block,
349        })
350    }
351}
352
353#[derive(Clone, Debug, Eq, PartialEq)]
354pub struct SolvedMiningCandidate {
355    job_id: MiningJobId,
356    snapshot_generation: MiningGeneration,
357    parent_height: Height,
358    block: Block,
359}
360
361impl SolvedMiningCandidate {
362    pub const fn job_id(&self) -> MiningJobId {
363        self.job_id
364    }
365
366    pub const fn snapshot_generation(&self) -> MiningGeneration {
367        self.snapshot_generation
368    }
369
370    pub const fn parent_height(&self) -> Height {
371        self.parent_height
372    }
373
374    pub const fn block(&self) -> &Block {
375        &self.block
376    }
377
378    pub fn into_block(self) -> Block {
379        self.block
380    }
381}
382
383#[derive(Clone, Debug, Default)]
384pub struct PreparedJobSet {
385    jobs: BTreeMap<MiningJobId, Arc<PreparedMiningJob>>,
386}
387
388impl PreparedJobSet {
389    pub fn insert(
390        &mut self,
391        job: PreparedMiningJob,
392    ) -> Result<Arc<PreparedMiningJob>, MiningError> {
393        if let Some(existing) = self.jobs.get(&job.job_id) {
394            if existing.as_ref() == &job {
395                return Ok(Arc::clone(existing));
396            }
397            return Err(MiningError::JobConflict);
398        }
399        if self.jobs.len() >= MAX_PREPARED_JOBS {
400            return Err(MiningError::JobCapacity);
401        }
402        let job = Arc::new(job);
403        self.jobs.insert(job.job_id, Arc::clone(&job));
404        Ok(job)
405    }
406
407    pub fn activate(
408        &mut self,
409        job_id: MiningJobId,
410        snapshot: MiningSnapshot,
411    ) -> Result<Arc<PreparedMiningJob>, MiningError> {
412        let job = self
413            .jobs
414            .get(&job_id)
415            .cloned()
416            .ok_or(MiningError::UnknownJob)?;
417        job.validate_for_snapshot(snapshot)?;
418        self.jobs.retain(|_, candidate| {
419            candidate.snapshot_generation == snapshot.generation
420                && candidate.network == snapshot.network
421        });
422        Ok(job)
423    }
424
425    pub fn retain_generation(&mut self, generation: MiningGeneration) {
426        self.jobs
427            .retain(|_, candidate| candidate.snapshot_generation == generation);
428    }
429
430    pub fn clear(&mut self) {
431        self.jobs.clear();
432    }
433
434    pub fn len(&self) -> usize {
435        self.jobs.len()
436    }
437
438    pub fn is_empty(&self) -> bool {
439        self.jobs.is_empty()
440    }
441}
442
443pub fn block_subsidy(height: Height, halving_interval: u32) -> Result<Dollarydoos, MiningError> {
444    if halving_interval == 0 {
445        return Err(MiningError::InvalidHalvingInterval);
446    }
447    let halvings = height.get() / halving_interval;
448    Ok(Dollarydoos::new(if halvings >= 52 {
449        0
450    } else {
451        BASE_REWARD >> halvings
452    }))
453}
454
455pub const fn halving_interval(network: Network) -> u32 {
456    match network {
457        Network::Regtest => 2_500,
458        Network::Mainnet | Network::Testnet | Network::Simnet => 170_000,
459    }
460}
461
462pub fn create_coinbase(
463    height: Height,
464    generation: MiningGeneration,
465    subsidy: Dollarydoos,
466    fees: Dollarydoos,
467    payout_address: Address,
468    coinbase_flags: Vec<u8>,
469) -> Result<Transaction, MiningError> {
470    payout_address.validate()?;
471    let reward = subsidy.checked_add(fees)?;
472    if reward.get() > MAX_MONEY {
473        return Err(MiningError::OutputValue);
474    }
475    let generation =
476        u32::try_from(generation).map_err(|_| MiningError::GenerationOutOfRange(generation))?;
477    let transaction = Transaction {
478        version: 0,
479        inputs: vec![Input {
480            previous_output: Outpoint::NULL,
481            sequence: generation,
482            witness: Witness {
483                items: vec![coinbase_flags, vec![0; 8], vec![0; 8]],
484            },
485        }],
486        outputs: vec![hns_transaction::Output {
487            value: reward,
488            address: payout_address,
489            covenant: Covenant::default(),
490        }],
491        locktime: height.get(),
492    };
493    validate_transaction_sanity(&transaction)?;
494    Ok(transaction)
495}
496
497pub fn merkle_root(hashes: &[[u8; 32]]) -> [u8; 32] {
498    let sentinel = blake2b_256(&[]);
499    let mut nodes = hashes
500        .iter()
501        .map(|hash| blake2b_256_many(&[&[0], hash]))
502        .collect::<Vec<_>>();
503    if nodes.is_empty() {
504        return sentinel;
505    }
506    while nodes.len() > 1 {
507        let mut next = Vec::with_capacity(nodes.len().div_ceil(2));
508        for pair in nodes.chunks(2) {
509            let right = pair.get(1).unwrap_or(&sentinel);
510            next.push(blake2b_256_many(&[&[1], &pair[0], right]));
511        }
512        nodes = next;
513    }
514    nodes[0]
515}
516
517pub fn block_merkle_root(transactions: &[Transaction]) -> Result<MerkleRoot, MiningError> {
518    let hashes = transactions
519        .iter()
520        .map(|transaction| transaction.transaction_hash().map(|hash| hash.into_bytes()))
521        .collect::<Result<Vec<_>, _>>()?;
522    Ok(MerkleRoot::new(merkle_root(&hashes)))
523}
524
525pub fn block_witness_root(transactions: &[Transaction]) -> Result<WitnessRoot, MiningError> {
526    let hashes = transactions
527        .iter()
528        .map(Transaction::witness_hash)
529        .collect::<Result<Vec<_>, _>>()?;
530    Ok(WitnessRoot::new(merkle_root(&hashes)))
531}
532
533pub fn validate_block_body(block: &Block) -> Result<BlockMetrics, MiningError> {
534    let metrics = validate_block_limits(block)?;
535    if block.transactions.is_empty() {
536        return Err(MiningError::InvalidBlockBody("invalid transaction count"));
537    }
538    if metrics.merkle_root.as_bytes() == &[0; 32] {
539        return Err(MiningError::InvalidBlockBody("zero merkle root"));
540    }
541    if metrics.merkle_root != block.header.merkle_root {
542        return Err(MiningError::InvalidBlockBody("merkle root mismatch"));
543    }
544    if metrics.witness_root != block.header.witness_root {
545        return Err(MiningError::InvalidBlockBody("witness root mismatch"));
546    }
547    if !block.transactions[0].is_coinbase() {
548        return Err(MiningError::InvalidBlockBody(
549            "first transaction is not coinbase",
550        ));
551    }
552    for (index, transaction) in block.transactions.iter().enumerate() {
553        validate_transaction_sanity(transaction)?;
554        if index != 0 && transaction.is_coinbase() {
555            return Err(MiningError::InvalidBlockBody(
556                "block contains multiple coinbase transactions",
557            ));
558        }
559    }
560    validate_block_covenant_limits(block)?;
561    Ok(metrics)
562}
563
564fn validate_block_limits(block: &Block) -> Result<BlockMetrics, MiningError> {
565    if block.transactions.len() > MAX_BLOCK_BASE_SIZE {
566        return Err(MiningError::InvalidBlockBody("invalid transaction count"));
567    }
568    let metrics = block_metrics(&block.transactions)?;
569    if metrics.base_size > MAX_BLOCK_BASE_SIZE {
570        return Err(MiningError::InvalidBlockBody("base size exceeds limit"));
571    }
572    if metrics.weight > MAX_BLOCK_WEIGHT {
573        return Err(MiningError::InvalidBlockBody("weight exceeds limit"));
574    }
575    Ok(metrics)
576}
577
578pub fn block_metrics(transactions: &[Transaction]) -> Result<BlockMetrics, MiningError> {
579    let count_size = compact_size_len(transactions.len() as u64);
580    let mut base_size = HEADER_SIZE
581        .checked_add(count_size)
582        .ok_or(MiningError::ArithmeticOverflow)?;
583    let mut witness_size = 0_usize;
584    for transaction in transactions {
585        base_size = base_size
586            .checked_add(transaction.base_size()?)
587            .ok_or(MiningError::ArithmeticOverflow)?;
588        witness_size = witness_size
589            .checked_add(transaction.witness_encode()?.len())
590            .ok_or(MiningError::ArithmeticOverflow)?;
591    }
592    let serialized_size = base_size
593        .checked_add(witness_size)
594        .ok_or(MiningError::ArithmeticOverflow)?;
595    let weight = base_size
596        .checked_mul(WITNESS_SCALE_FACTOR)
597        .and_then(|base| base.checked_add(witness_size))
598        .ok_or(MiningError::ArithmeticOverflow)?;
599    Ok(BlockMetrics {
600        base_size,
601        witness_size,
602        serialized_size,
603        weight,
604        merkle_root: block_merkle_root(transactions)?,
605        witness_root: block_witness_root(transactions)?,
606    })
607}
608
609pub fn validate_transaction_sanity(transaction: &Transaction) -> Result<(), MiningError> {
610    if transaction.inputs.is_empty() {
611        return Err(MiningError::InvalidTransaction("transaction has no inputs"));
612    }
613    if transaction.outputs.is_empty() {
614        return Err(MiningError::InvalidTransaction(
615            "transaction has no outputs",
616        ));
617    }
618    if transaction.base_size()? > MAX_BLOCK_BASE_SIZE || transaction.weight()? > MAX_BLOCK_WEIGHT {
619        return Err(MiningError::InvalidTransaction(
620            "transaction exceeds consensus size",
621        ));
622    }
623    let name_operations = count_name_operations(transaction);
624    if name_operations.opens > MAX_BLOCK_OPENS {
625        return Err(MiningError::InvalidTransaction(
626            "transaction open limit exceeded",
627        ));
628    }
629    if name_operations.updates > MAX_BLOCK_UPDATES {
630        return Err(MiningError::InvalidTransaction(
631            "transaction update limit exceeded",
632        ));
633    }
634    if name_operations.renewals > MAX_BLOCK_RENEWALS {
635        return Err(MiningError::InvalidTransaction(
636            "transaction renewal limit exceeded",
637        ));
638    }
639    let mut total = 0_u64;
640    for output in &transaction.outputs {
641        output.address.validate()?;
642        total = total
643            .checked_add(output.value.get())
644            .ok_or(MiningError::OutputValue)?;
645        if total > MAX_MONEY {
646            return Err(MiningError::OutputValue);
647        }
648    }
649    if transaction.is_coinbase() {
650        if witness_size(&transaction.inputs[0].witness) > MAX_COINBASE_WITNESS_SIZE {
651            return Err(MiningError::InvalidTransaction(
652                "coinbase witness exceeds limit",
653            ));
654        }
655        for input in transaction.inputs.iter().skip(1) {
656            if !input.previous_output.is_null() {
657                return Err(MiningError::InvalidTransaction(
658                    "coinbase claim input is not null",
659                ));
660            }
661            if input.witness.items.len() != 1 {
662                return Err(MiningError::InvalidTransaction(
663                    "coinbase claim input must have one witness item",
664                ));
665            }
666            if input.witness.items[0].len() > MAX_COINBASE_CLAIM_WITNESS_ITEM_SIZE {
667                return Err(MiningError::InvalidTransaction(
668                    "coinbase claim witness item exceeds limit",
669                ));
670            }
671        }
672    } else {
673        let mut inputs = HashSet::with_capacity(transaction.inputs.len());
674        for input in &transaction.inputs {
675            if input.previous_output.is_null() {
676                return Err(MiningError::InvalidTransaction(
677                    "non-coinbase spends null outpoint",
678                ));
679            }
680            if !inputs.insert(input.previous_output) {
681                return Err(MiningError::InvalidTransaction(
682                    "transaction contains duplicate inputs",
683                ));
684            }
685        }
686    }
687    if !has_sane_covenants(transaction) {
688        return Err(MiningError::InvalidTransaction(
689            "transaction covenants are structurally invalid",
690        ));
691    }
692    Ok(())
693}
694
695fn validate_block_covenant_limits(block: &Block) -> Result<(), MiningError> {
696    let mut opens = 0_u32;
697    let mut updates = 0_u32;
698    let mut renewals = 0_u32;
699    let mut exclusive_names = HashSet::new();
700    for transaction in &block.transactions {
701        let name_operations = count_name_operations(transaction);
702        opens = opens.saturating_add(name_operations.opens);
703        updates = updates.saturating_add(name_operations.updates);
704        renewals = renewals.saturating_add(name_operations.renewals);
705
706        // HSD permits repeated exclusive covenants within one transaction but
707        // rejects the same name when it appears in a later transaction.
708        let mut transaction_exclusive_names = HashSet::new();
709        for output in &transaction.outputs {
710            if matches!(
711                output.covenant.kind,
712                CovenantKind::Claim
713                    | CovenantKind::Open
714                    | CovenantKind::Register
715                    | CovenantKind::Update
716                    | CovenantKind::Renew
717                    | CovenantKind::Transfer
718                    | CovenantKind::Finalize
719                    | CovenantKind::Revoke
720            ) {
721                let name_hash: [u8; 32] = output
722                    .covenant
723                    .item(0)
724                    .and_then(|item| item.try_into().ok())
725                    .ok_or(MiningError::InvalidBlockBody(
726                        "name covenant hash has invalid length",
727                    ))?;
728                if exclusive_names.contains(&name_hash) {
729                    return Err(MiningError::InvalidBlockBody(
730                        "block contains duplicate exclusive name updates",
731                    ));
732                }
733                transaction_exclusive_names.insert(name_hash);
734            }
735        }
736        exclusive_names.extend(transaction_exclusive_names);
737    }
738    if opens > MAX_BLOCK_OPENS {
739        return Err(MiningError::InvalidBlockBody("block open limit exceeded"));
740    }
741    if updates > MAX_BLOCK_UPDATES {
742        return Err(MiningError::InvalidBlockBody("block update limit exceeded"));
743    }
744    if renewals > MAX_BLOCK_RENEWALS {
745        return Err(MiningError::InvalidBlockBody(
746            "block renewal limit exceeded",
747        ));
748    }
749    Ok(())
750}
751
752#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
753struct NameOperationCounts {
754    opens: u32,
755    updates: u32,
756    renewals: u32,
757}
758
759fn count_name_operations(transaction: &Transaction) -> NameOperationCounts {
760    let mut counts = NameOperationCounts::default();
761    for output in &transaction.outputs {
762        match output.covenant.kind {
763            CovenantKind::Open => {
764                counts.opens = counts.opens.saturating_add(1);
765                counts.updates = counts.updates.saturating_add(1);
766            }
767            CovenantKind::Claim
768            | CovenantKind::Update
769            | CovenantKind::Transfer
770            | CovenantKind::Revoke => {
771                counts.updates = counts.updates.saturating_add(1);
772            }
773            CovenantKind::Register | CovenantKind::Renew | CovenantKind::Finalize => {
774                counts.renewals = counts.renewals.saturating_add(1);
775            }
776            _ => {}
777        }
778    }
779    counts
780}
781
782fn has_sane_covenants(transaction: &Transaction) -> bool {
783    if transaction.is_coinbase() {
784        if transaction.inputs.len() > transaction.outputs.len() {
785            return false;
786        }
787        for (index, output) in transaction.outputs.iter().enumerate() {
788            match output.covenant.kind {
789                CovenantKind::None => {
790                    if !output.covenant.items.is_empty() {
791                        return false;
792                    }
793                }
794                CovenantKind::Claim => {
795                    let items = &output.covenant.items;
796                    if index == 0
797                        || index >= transaction.inputs.len()
798                        || transaction.inputs[index].witness.items.len() != 1
799                        || !item_lengths(items, &[32, 4, usize::MAX, 1, 32, 4])
800                        || !valid_name_hash(&items[0], &items[2])
801                    {
802                        return false;
803                    }
804                }
805                _ => return false,
806            }
807        }
808        return true;
809    }
810
811    for (index, output) in transaction.outputs.iter().enumerate() {
812        let items = &output.covenant.items;
813        let linked = index < transaction.inputs.len();
814        let sane = match output.covenant.kind {
815            CovenantKind::None => items.is_empty(),
816            CovenantKind::Claim => false,
817            CovenantKind::Open => {
818                item_lengths(items, &[32, 4, usize::MAX])
819                    && item_u32(items, 1) == Some(0)
820                    && valid_name_hash(&items[0], &items[2])
821            }
822            CovenantKind::Bid => {
823                item_lengths(items, &[32, 4, usize::MAX, 32])
824                    && valid_name_hash(&items[0], &items[2])
825            }
826            CovenantKind::Reveal => linked && item_lengths(items, &[32, 4, 32]),
827            CovenantKind::Redeem => linked && item_lengths(items, &[32, 4]),
828            CovenantKind::Register => {
829                linked
830                    && item_lengths(items, &[32, 4, usize::MAX, 32])
831                    && items[2].len() <= MAX_RESOURCE_SIZE
832            }
833            CovenantKind::Update => {
834                linked
835                    && item_lengths(items, &[32, 4, usize::MAX])
836                    && items[2].len() <= MAX_RESOURCE_SIZE
837            }
838            CovenantKind::Renew => linked && item_lengths(items, &[32, 4, 32]),
839            CovenantKind::Transfer => {
840                linked
841                    && item_lengths(items, &[32, 4, 1, usize::MAX])
842                    && items[2][0] <= 31
843                    && (2..=40).contains(&items[3].len())
844            }
845            CovenantKind::Finalize => {
846                linked
847                    && item_lengths(items, &[32, 4, usize::MAX, 1, 4, 4, 32])
848                    && valid_name_hash(&items[0], &items[2])
849            }
850            CovenantKind::Revoke => linked && item_lengths(items, &[32, 4]),
851            CovenantKind::Unknown(_) => {
852                items.len() <= MAX_COVENANT_ITEMS
853                    && output
854                        .covenant
855                        .encode()
856                        .is_ok_and(|encoded| encoded.len() <= MAX_COVENANT_SIZE)
857            }
858        };
859        if !sane {
860            return false;
861        }
862    }
863    true
864}
865
866fn item_lengths(items: &[Vec<u8>], expected: &[usize]) -> bool {
867    items.len() == expected.len()
868        && items
869            .iter()
870            .zip(expected)
871            .all(|(item, length)| *length == usize::MAX || item.len() == *length)
872}
873
874fn item_u32(items: &[Vec<u8>], index: usize) -> Option<u32> {
875    Some(u32::from_le_bytes(
876        items.get(index)?.as_slice().try_into().ok()?,
877    ))
878}
879
880fn valid_name_hash(hash: &[u8], name: &[u8]) -> bool {
881    hash_name(name)
882        .map(|expected| expected.as_bytes() == hash)
883        .unwrap_or(false)
884}
885
886fn witness_size(witness: &Witness) -> usize {
887    compact_size_len(witness.items.len() as u64).saturating_add(
888        witness
889            .items
890            .iter()
891            .map(|item| compact_size_len(item.len() as u64).saturating_add(item.len()))
892            .sum::<usize>(),
893    )
894}
895
896fn compact_size_len(value: u64) -> usize {
897    match value {
898        0..=0xfc => 1,
899        0xfd..=0xffff => 3,
900        0x1_0000..=0xffff_ffff => 5,
901        _ => 9,
902    }
903}
904
905fn mask_hash(parent: BlockHash, mask: PowMask) -> [u8; 32] {
906    blake2b_256_many(&[parent.as_bytes(), mask.as_bytes()])
907}
908
909fn job_id(
910    snapshot: MiningSnapshot,
911    header: &MiningHeaderTemplate,
912    transactions: &[Transaction],
913) -> Result<MiningJobId, MiningError> {
914    let mut body = Encoder::new();
915    body.put_u64_le(transactions.len() as u64);
916    for transaction in transactions {
917        let encoded = transaction.encode()?;
918        body.put_u64_le(encoded.len() as u64);
919        body.put_bytes(&encoded);
920    }
921    Ok(blake2b_256_many(&[
922        b"hsrd/mining-job/v1",
923        &[snapshot.network.id()],
924        &snapshot.generation.to_le_bytes(),
925        header.parent_hash.as_bytes(),
926        header.tree_root.as_bytes(),
927        header.reserved_root.as_bytes(),
928        header.witness_root.as_bytes(),
929        header.merkle_root.as_bytes(),
930        &header.version.to_le_bytes(),
931        &header.bits.get().to_le_bytes(),
932        &header.minimum_time.get().to_le_bytes(),
933        &header.mask_hash,
934        &body.into_bytes(),
935    ]))
936}
937
938fn blake2b_256(input: &[u8]) -> [u8; 32] {
939    blake2b_256_many(&[input])
940}
941
942fn blake2b_256_many(parts: &[&[u8]]) -> [u8; 32] {
943    let mut hasher = Blake2bVar::new(32).expect("valid BLAKE2b output length");
944    for part in parts {
945        hasher.update(part);
946    }
947    let mut output = [0; 32];
948    hasher
949        .finalize_variable(&mut output)
950        .expect("valid BLAKE2b output buffer");
951    output
952}
953
954#[derive(Debug, Error)]
955pub enum MiningError {
956    #[error(transparent)]
957    Decode(#[from] hns_encoding::DecodeError),
958    #[error(transparent)]
959    Header(#[from] hns_header_consensus::HeaderError),
960    #[error(transparent)]
961    Transaction(#[from] hns_transaction::TransactionError),
962    #[error(transparent)]
963    Arithmetic(#[from] hns_primitives::ArithmeticError),
964    #[error("mining snapshot is zero, stale, or inconsistent")]
965    InvalidSnapshot,
966    #[error("mining template is inconsistent with its snapshot or body")]
967    InvalidTemplate,
968    #[error("prepared mining job is inconsistent with its snapshot or body")]
969    InvalidJob,
970    #[error("candidate coinbase does not commit the next height")]
971    InvalidCoinbaseHeight,
972    #[error("prepared mining job is stale")]
973    StaleJob,
974    #[error("opened-mask block reconstruction is invalid")]
975    InvalidReconstruction,
976    #[error("opened-mask mining result does not meet the network target")]
977    InsufficientProofOfWork,
978    #[error("prepared mining job ID conflicts with different bytes")]
979    JobConflict,
980    #[error("prepared mining job capacity is exhausted")]
981    JobCapacity,
982    #[error("prepared mining job is unknown")]
983    UnknownJob,
984    #[error("halving interval must be nonzero")]
985    InvalidHalvingInterval,
986    #[error("mining generation {0} cannot be encoded in the coinbase sequence")]
987    GenerationOutOfRange(MiningGeneration),
988    #[error("numeric overflow while building mining data")]
989    ArithmeticOverflow,
990    #[error("transaction output amount is invalid")]
991    OutputValue,
992    #[error("invalid transaction: {0}")]
993    InvalidTransaction(&'static str),
994    #[error("invalid block body: {0}")]
995    InvalidBlockBody(&'static str),
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    const COINBASE_RAW: &str = "00000000010000000000000000000000000000000000000000000000000000000000000000ffffffff07000000013c943577000000000014090909090909090909090909090909090909090900000b000000030468737264080000000000000000080000000000000000";
1003
1004    fn fixture_coinbase() -> Transaction {
1005        create_coinbase(
1006            Height::new(11),
1007            7,
1008            block_subsidy(Height::new(11), 2_500).unwrap(),
1009            Dollarydoos::new(60),
1010            Address::new(0, vec![9; 20]).unwrap(),
1011            b"hsrd".to_vec(),
1012        )
1013        .unwrap()
1014    }
1015
1016    fn snapshot(generation: u64, marker: u8) -> MiningSnapshot {
1017        MiningSnapshot {
1018            network: Network::Regtest,
1019            generation,
1020            tip_hash: BlockHash::new([marker; 32]),
1021            tip_height: Height::new(10),
1022            tip_time: BlockTime::new(100),
1023            parent_median_time: BlockTime::new(99),
1024            next_tree_root: TreeRoot::new([marker.wrapping_add(1); 32]),
1025            expected_bits: Network::Regtest.parameters().pow.bits,
1026        }
1027    }
1028
1029    fn prepared(snapshot: MiningSnapshot, mask: PowMask) -> PreparedMiningJob {
1030        PreparedMiningJob::prepare(
1031            snapshot,
1032            ReservedRoot::new([3; 32]),
1033            1,
1034            BlockTime::new(101),
1035            mask,
1036            Arc::from(vec![fixture_coinbase()]),
1037        )
1038        .unwrap()
1039    }
1040
1041    #[test]
1042    fn subsidy_and_coinbase_match_hsd_template_fixture() {
1043        let cases = [
1044            (0, 170_000, 2_000_000_000),
1045            (169_999, 170_000, 2_000_000_000),
1046            (170_000, 170_000, 1_000_000_000),
1047            (340_000, 170_000, 500_000_000),
1048            (8_670_000, 170_000, 0),
1049            (2_499, 2_500, 2_000_000_000),
1050            (2_500, 2_500, 1_000_000_000),
1051            (5_000, 2_500, 500_000_000),
1052            (127_500, 2_500, 0),
1053        ];
1054        for (height, interval, expected) in cases {
1055            assert_eq!(
1056                block_subsidy(Height::new(height), interval).unwrap().get(),
1057                expected
1058            );
1059        }
1060        let transaction = fixture_coinbase();
1061        assert_eq!(
1062            transaction.encode().unwrap(),
1063            hex::decode(COINBASE_RAW).unwrap()
1064        );
1065        assert_eq!(
1066            transaction.transaction_hash().unwrap().to_string(),
1067            "34108e299d22a4114526b0d191780ca77f795430b48f1951e3e318731931078a"
1068        );
1069        assert_eq!(
1070            hex::encode(transaction.witness_hash().unwrap()),
1071            "3cd20aa3dd6ede9246e5827cd0c8bd10367dd5da1eb7d29fee04edab473fec4a"
1072        );
1073        assert_eq!(transaction.base_size().unwrap(), 82);
1074        assert_eq!(transaction.witness_encode().unwrap().len(), 24);
1075        assert_eq!(transaction.weight().unwrap(), 352);
1076        assert!(matches!(
1077            create_coinbase(
1078                Height::new(11),
1079                u64::from(u32::MAX) + 1,
1080                Dollarydoos::new(1),
1081                Dollarydoos::new(0),
1082                Address::new(0, vec![9; 20]).unwrap(),
1083                Vec::new(),
1084            ),
1085            Err(MiningError::GenerationOutOfRange(_))
1086        ));
1087    }
1088
1089    #[test]
1090    fn merkle_and_witness_roots_match_hsd_domain_separation() {
1091        let transaction = fixture_coinbase();
1092        assert_eq!(
1093            block_merkle_root(std::slice::from_ref(&transaction))
1094                .unwrap()
1095                .to_string(),
1096            "5a88b64fe244a4b3adbb73e9ae7983e62f66bf154ceea5180efcb7214b11e949"
1097        );
1098        assert_eq!(
1099            block_witness_root(&[transaction]).unwrap().to_string(),
1100            "223cf6dbd896ecb22a504884606f20e0b8d70fda48b65bc8cfb9a6be5f6798b1"
1101        );
1102        assert_eq!(merkle_root(&[]), blake2b_256(&[]));
1103        let hsd_vectors = [
1104            (
1105                1,
1106                "6bf22d230bc6f17e2dc9bdce220e8696630a067ab5029fb66d91e6ecd74c7c54",
1107            ),
1108            (
1109                2,
1110                "e7ee5228698f31758aa7e13445bc54d4c4b37303a90d5ca4677fad9976d1187b",
1111            ),
1112            (
1113                3,
1114                "7511ad764e06d5f02ea56d142da5e920242a39cfc4f4631c2597760d3fc81123",
1115            ),
1116            (
1117                5,
1118                "45148643ac4aa66c59361ab95443c0bdcc49c000d7f1365413de7a9d39be8f1c",
1119            ),
1120        ];
1121        for (count, expected) in hsd_vectors {
1122            let leaves = (1..=count)
1123                .map(|marker| [marker as u8; 32])
1124                .collect::<Vec<_>>();
1125            assert_eq!(hex::encode(merkle_root(&leaves)), expected);
1126        }
1127    }
1128
1129    #[test]
1130    fn immutable_job_reconstructs_and_stale_bindings_fail() {
1131        let current = snapshot(1, 1);
1132        let mask = PowMask::new([9; 32]);
1133        let job = prepared(current, mask);
1134        let block = job
1135            .reconstruct(7, BlockTime::new(101), [8; EXTRA_NONCE_SIZE], mask)
1136            .unwrap();
1137        assert_eq!(block.header.mask_hash(), job.header().mask_hash);
1138        assert_eq!(block.transactions[0].locktime, 11);
1139        assert!(validate_block_body(&block).is_ok());
1140        assert_eq!(
1141            Block::decode_validated(&block.encode().unwrap()).unwrap(),
1142            block
1143        );
1144        assert!(matches!(
1145            job.validate_for_snapshot(snapshot(2, 1)),
1146            Err(MiningError::StaleJob)
1147        ));
1148        assert!(matches!(
1149            job.reconstruct(7, BlockTime::new(100), [8; EXTRA_NONCE_SIZE], mask),
1150            Err(MiningError::InvalidReconstruction)
1151        ));
1152        assert!(matches!(
1153            job.reconstruct(
1154                7,
1155                BlockTime::new(101),
1156                [8; EXTRA_NONCE_SIZE],
1157                PowMask::new([8; 32])
1158            ),
1159            Err(MiningError::InvalidReconstruction)
1160        ));
1161    }
1162
1163    #[test]
1164    fn solution_admission_requires_current_generation_and_pow() {
1165        let current = snapshot(1, 1);
1166        let mask = PowMask::new([9; 32]);
1167        let job = prepared(current, mask);
1168        let mut nonce = 0_u32;
1169        let solved = loop {
1170            match job.admit_solution(
1171                current,
1172                nonce,
1173                BlockTime::new(101),
1174                [7; EXTRA_NONCE_SIZE],
1175                mask,
1176            ) {
1177                Ok(candidate) => break candidate,
1178                Err(MiningError::InsufficientProofOfWork) => {
1179                    nonce = nonce.checked_add(1).expect("regtest solution")
1180                }
1181                Err(error) => panic!("unexpected solution error: {error}"),
1182            }
1183        };
1184        assert_eq!(solved.job_id(), job.job_id());
1185        assert_eq!(solved.snapshot_generation(), 1);
1186        assert!(solved.block().header.verify_pow());
1187    }
1188
1189    #[test]
1190    fn testnet_target_reset_time_bounds_prepared_work() {
1191        let mut current = snapshot(1, 1);
1192        current.network = Network::Testnet;
1193        current.expected_bits = CompactTarget::new(0x1d00_fffe);
1194        let mask = PowMask::new([7; 32]);
1195        let transactions: Arc<[Transaction]> = Arc::from(vec![fixture_coinbase()]);
1196        let header = MiningHeaderTemplate::from_transactions(
1197            current,
1198            ReservedRoot::new([3; 32]),
1199            0,
1200            BlockTime::new(101),
1201            mask,
1202            &transactions,
1203        )
1204        .unwrap();
1205        let job = PreparedMiningJob::new(current, header, transactions).unwrap();
1206        let boundary = current
1207            .tip_time
1208            .get()
1209            .saturating_add(u64::from(Network::Testnet.parameters().pow.target_spacing) * 2);
1210        assert_eq!(job.maximum_target_time(), Some(BlockTime::new(boundary)));
1211        assert!(
1212            job.reconstruct(1, BlockTime::new(boundary), [0; EXTRA_NONCE_SIZE], mask)
1213                .is_ok()
1214        );
1215        assert!(matches!(
1216            job.reconstruct(1, BlockTime::new(boundary + 1), [0; EXTRA_NONCE_SIZE], mask),
1217            Err(MiningError::InvalidReconstruction)
1218        ));
1219    }
1220
1221    #[test]
1222    fn body_and_job_identity_fail_closed() {
1223        let current = snapshot(1, 1);
1224        let mask = PowMask::new([9; 32]);
1225        let one = prepared(current, mask);
1226        let mut altered_coinbase = fixture_coinbase();
1227        altered_coinbase.outputs[0].value = Dollarydoos::new(1);
1228        let transactions: Arc<[Transaction]> = Arc::from(vec![altered_coinbase]);
1229        let header = MiningHeaderTemplate::from_transactions(
1230            current,
1231            ReservedRoot::new([3; 32]),
1232            1,
1233            BlockTime::new(101),
1234            mask,
1235            &transactions,
1236        )
1237        .unwrap();
1238        let two = PreparedMiningJob::new(current, header, transactions).unwrap();
1239        assert_ne!(one.job_id(), two.job_id());
1240
1241        let mut invalid = one
1242            .reconstruct(0, BlockTime::new(101), [0; EXTRA_NONCE_SIZE], mask)
1243            .unwrap();
1244        invalid.header.merkle_root = MerkleRoot::new([0; 32]);
1245        assert!(matches!(
1246            validate_block_body(&invalid),
1247            Err(MiningError::InvalidBlockBody("merkle root mismatch"))
1248        ));
1249
1250        let mut jobs = PreparedJobSet::default();
1251        let id = one.job_id();
1252        jobs.insert(one).unwrap();
1253        assert_eq!(jobs.activate(id, current).unwrap().job_id(), id);
1254        assert!(matches!(
1255            jobs.activate(id, snapshot(2, 1)),
1256            Err(MiningError::StaleJob)
1257        ));
1258
1259        let mut claim_coinbase = fixture_coinbase();
1260        claim_coinbase.inputs.push(Input {
1261            previous_output: Outpoint::NULL,
1262            sequence: u32::MAX,
1263            witness: Witness {
1264                items: vec![vec![1, 2, 3]],
1265            },
1266        });
1267        assert!(claim_coinbase.is_coinbase());
1268        assert!(matches!(
1269            validate_transaction_sanity(&claim_coinbase),
1270            Err(MiningError::InvalidTransaction(
1271                "transaction covenants are structurally invalid"
1272            ))
1273        ));
1274        let name = b"claimname";
1275        claim_coinbase.outputs.push(hns_transaction::Output {
1276            value: Dollarydoos::new(0),
1277            address: Address::new(0, vec![7; 20]).unwrap(),
1278            covenant: Covenant {
1279                kind: CovenantKind::Claim,
1280                items: vec![
1281                    hash_name(name).unwrap().into_bytes().to_vec(),
1282                    1_u32.to_le_bytes().to_vec(),
1283                    name.to_vec(),
1284                    vec![0],
1285                    vec![2; 32],
1286                    1_u32.to_le_bytes().to_vec(),
1287                ],
1288            },
1289        });
1290        validate_transaction_sanity(&claim_coinbase).unwrap();
1291    }
1292
1293    #[test]
1294    fn covenant_shapes_and_name_operation_limits_match_hsd() {
1295        let name = b"boundedname";
1296        let name_hash = hash_name(name).unwrap().into_bytes().to_vec();
1297        let input = Input {
1298            previous_output: Outpoint {
1299                transaction_hash: fixture_coinbase().transaction_hash().unwrap(),
1300                index: 0,
1301            },
1302            sequence: u32::MAX,
1303            witness: Witness::default(),
1304        };
1305        let open_output = hns_transaction::Output {
1306            value: Dollarydoos::new(0),
1307            address: Address::new(0, vec![4; 20]).unwrap(),
1308            covenant: Covenant {
1309                kind: CovenantKind::Open,
1310                items: vec![
1311                    name_hash.clone(),
1312                    0_u32.to_le_bytes().to_vec(),
1313                    name.to_vec(),
1314                ],
1315            },
1316        };
1317        let valid_open = Transaction {
1318            version: 0,
1319            inputs: vec![input.clone()],
1320            outputs: vec![open_output.clone()],
1321            locktime: 0,
1322        };
1323        validate_transaction_sanity(&valid_open).unwrap();
1324
1325        let mut wrong_hash = valid_open.clone();
1326        wrong_hash.outputs[0].covenant.items[0][0] ^= 1;
1327        assert!(validate_transaction_sanity(&wrong_hash).is_err());
1328
1329        let too_many_opens = Transaction {
1330            version: 0,
1331            inputs: vec![input],
1332            outputs: vec![open_output; MAX_BLOCK_OPENS as usize + 1],
1333            locktime: 0,
1334        };
1335        assert!(matches!(
1336            validate_transaction_sanity(&too_many_opens),
1337            Err(MiningError::InvalidTransaction(
1338                "transaction open limit exceeded"
1339            ))
1340        ));
1341    }
1342
1343    #[test]
1344    fn exclusive_name_updates_may_repeat_only_within_one_transaction() {
1345        let name_hash = hash_name(b"exclusive").unwrap().into_bytes().to_vec();
1346        let update = |index| Transaction {
1347            version: 0,
1348            inputs: vec![Input {
1349                previous_output: Outpoint {
1350                    transaction_hash: fixture_coinbase().transaction_hash().unwrap(),
1351                    index,
1352                },
1353                sequence: u32::MAX,
1354                witness: Witness::default(),
1355            }],
1356            outputs: vec![hns_transaction::Output {
1357                value: Dollarydoos::new(0),
1358                address: Address::new(0, vec![5; 20]).unwrap(),
1359                covenant: Covenant {
1360                    kind: CovenantKind::Update,
1361                    items: vec![name_hash.clone(), 1_u32.to_le_bytes().to_vec(), vec![0]],
1362                },
1363            }],
1364            locktime: 0,
1365        };
1366        let first = update(1);
1367        let second = update(2);
1368        validate_transaction_sanity(&first).unwrap();
1369        validate_transaction_sanity(&second).unwrap();
1370        let header = prepared(snapshot(1, 1), PowMask::new([9; 32]))
1371            .reconstruct(
1372                0,
1373                BlockTime::new(101),
1374                [0; EXTRA_NONCE_SIZE],
1375                PowMask::new([9; 32]),
1376            )
1377            .unwrap()
1378            .header;
1379        let duplicated_across_transactions = Block {
1380            header: header.clone(),
1381            transactions: vec![first.clone(), second],
1382        };
1383        assert!(matches!(
1384            validate_block_covenant_limits(&duplicated_across_transactions),
1385            Err(MiningError::InvalidBlockBody(
1386                "block contains duplicate exclusive name updates"
1387            ))
1388        ));
1389
1390        let mut repeated_within_transaction = first;
1391        repeated_within_transaction
1392            .outputs
1393            .push(repeated_within_transaction.outputs[0].clone());
1394        let permitted = Block {
1395            header,
1396            transactions: vec![repeated_within_transaction],
1397        };
1398        validate_block_covenant_limits(&permitted).unwrap();
1399    }
1400}