Skip to main content

dig_block/types/
block.rs

1//! `L2Block` — full L2 block: header, transaction body (`SpendBundle`s), slash proposal payloads, proposer signature.
2//!
3//! **Requirements:**
4//! - [BLK-003](docs/requirements/domains/block_types/specs/BLK-003.md) — struct + `new` / `hash` / `height` / `epoch`
5//! - [HSH-003](docs/requirements/domains/hashing/specs/HSH-003.md) — [`crate::compute_spends_root`] (spends Merkle root)
6//! - [HSH-004](docs/requirements/domains/hashing/specs/HSH-004.md) — [`crate::compute_additions_root`] (additions Merkle set)
7//! - [HSH-005](docs/requirements/domains/hashing/specs/HSH-005.md) — [`crate::compute_removals_root`] (removals Merkle set)
8//! - [HSH-006](docs/requirements/domains/hashing/specs/HSH-006.md) — [`crate::compute_filter_hash`] (BIP-158; [`L2Block::compute_filter_hash`] keys SipHash with [`L2BlockHeader::parent_hash`] per SPEC §6.4)
9//! - [BLK-004](docs/requirements/domains/block_types/specs/BLK-004.md) — Merkle roots, BIP158 `filter_hash` preimage,
10//!   additions/removals collectors, duplicate / double-spend probes, serialized size
11//! - [SVL-005](docs/requirements/domains/structural_validation/specs/SVL-005.md) — header/body **count agreement**
12//!   ([`L2Block::validate_structure`]; SPEC §5.2 steps 2, 4, 5, 13) before expensive Merkle checks ([SVL-006](docs/requirements/domains/structural_validation/specs/SVL-006.md))
13//! - [SER-002](docs/requirements/domains/serialization/specs/SER-002.md) — [`L2Block::to_bytes`] / [`L2Block::from_bytes`] (bincode + [`BlockError::InvalidData`](crate::BlockError::InvalidData) on decode)
14//! - [SPEC §2.3](docs/resources/SPEC.md), [SPEC §3.3–§3.6](docs/resources/SPEC.md) — body commitments + filter
15//!
16//! ## Usage
17//!
18//! Build a block by assembling an [`L2BlockHeader`] (commitments, roots, counts) and the body fields.
19//! **Canonical identity** is [`L2Block::hash`] → [`L2BlockHeader::hash`] only; spend bundles and slash
20//! bytes are committed via Merkle roots **in the header**, not mixed into this hash (SPEC §2.3 / BLK-003 notes).
21//!
22//! ## Rationale
23//!
24//! - **`SpendBundle`** comes from **`chia-protocol`** so CLVM spends match L1/Chia tooling ([BLK-003](docs/requirements/domains/block_types/specs/BLK-003.md)).
25//! - **`Signature`** is the **`chia-bls`** type re-exported as [`crate::primitives::Signature`] ([BLK-006](docs/requirements/domains/block_types/specs/BLK-006.md)) so callers import one `dig_block` surface.
26//! - **`slash_proposal_payloads`** are `Vec<Vec<u8>>` for opaque slash evidence (encoding evolves independently).
27
28use chia_protocol::{Coin, SpendBundle};
29use chia_streamable_macro::Streamable;
30use serde::{Deserialize, Serialize};
31
32use super::header::L2BlockHeader;
33use crate::error::BlockError;
34use crate::merkle_util::{empty_on_additions_err, merkle_tree_root, slash_leaf_hash};
35use crate::primitives::{Bytes32, Signature};
36use crate::{MAX_BLOCK_SIZE, MAX_SLASH_PROPOSALS_PER_BLOCK, MAX_SLASH_PROPOSAL_PAYLOAD_BYTES};
37
38/// Complete L2 block: header plus body (spend bundles, slash payloads) and proposer attestation.
39///
40/// See [BLK-003](docs/requirements/domains/block_types/specs/BLK-003.md) and [`SPEC §2.3`](docs/resources/SPEC.md).
41/// **Chia [`Streamable`] (wire):** see [`L2BlockHeader`] — gossip uses this encoding; persistence uses bincode + zstd in dig-blockstore.
42#[derive(Debug, Clone, Serialize, Deserialize, Streamable)]
43pub struct L2Block {
44    /// Block header (identity hash, Merkle roots, metadata).
45    pub header: L2BlockHeader,
46    /// Spend bundles included in this block (`chia-protocol`).
47    pub spend_bundles: Vec<SpendBundle>,
48    /// Raw slash proposal payloads (count should align with header slash fields when validated).
49    pub slash_proposal_payloads: Vec<Vec<u8>>,
50    /// BLS signature over the block from the proposer ([`crate::primitives::Signature`] / `chia-bls`).
51    pub proposer_signature: Signature,
52}
53
54impl L2Block {
55    /// Construct a block from all body fields and the header ([BLK-003](docs/requirements/domains/block_types/specs/BLK-003.md) `new()`).
56    ///
57    /// **Note:** Callers must keep `header` fields (e.g. `spend_bundle_count`, Merkle roots) consistent with
58    /// `spend_bundles` / `slash_proposal_payloads`; structural validation is separate (ERR-* / VAL-* requirements).
59    pub fn new(
60        header: L2BlockHeader,
61        spend_bundles: Vec<SpendBundle>,
62        slash_proposal_payloads: Vec<Vec<u8>>,
63        proposer_signature: Signature,
64    ) -> Self {
65        Self {
66            header,
67            spend_bundles,
68            slash_proposal_payloads,
69            proposer_signature,
70        }
71    }
72
73    /// Canonical block identity: SHA-256 over the header preimage only ([`L2BlockHeader::hash`], HSH-001 / SPEC §3.1).
74    ///
75    /// **Delegation:** identical to `self.header.hash()` — required by BLK-003 so light clients and
76    /// signers can treat the header hash as the block id without serializing the body.
77    #[inline]
78    pub fn hash(&self) -> Bytes32 {
79        self.header.hash()
80    }
81
82    /// Serialize this block (header + body) to **bincode** bytes ([SER-002](docs/requirements/domains/serialization/specs/SER-002.md), SPEC §8.2).
83    ///
84    /// **Infallible:** Same contract as [`L2BlockHeader::to_bytes`] — well-formed structs serialize; failures are `expect` panics.
85    #[must_use]
86    pub fn to_bytes(&self) -> Vec<u8> {
87        bincode::serialize(self).expect("L2Block serialization should never fail")
88    }
89
90    /// Deserialize a block from **bincode** bytes ([SER-002](docs/requirements/domains/serialization/specs/SER-002.md)).
91    pub fn from_bytes(bytes: &[u8]) -> Result<Self, BlockError> {
92        bincode::deserialize(bytes).map_err(|e| BlockError::InvalidData(e.to_string()))
93    }
94
95    /// Block height from the header ([`L2BlockHeader::height`]).
96    #[inline]
97    pub fn height(&self) -> u64 {
98        self.header.height
99    }
100
101    /// Epoch from the header ([`L2BlockHeader::epoch`]).
102    #[inline]
103    pub fn epoch(&self) -> u64 {
104        self.header.epoch
105    }
106
107    // --- BLK-004: Merkle roots (SPEC §3.3–§3.5) ---
108
109    /// Merkle root over spend-bundle leaf digests in **block order**; empty body → [`crate::EMPTY_ROOT`].
110    ///
111    /// **Delegation:** [`crate::compute_spends_root`] ([HSH-003](docs/requirements/domains/hashing/specs/HSH-003.md)) —
112    /// each leaf is SHA-256 of serialized [`SpendBundle`] bytes; [`chia_sdk_types::MerkleTree`] applies tagged hashing
113    /// (HSH-007, SPEC §3.3 `spends_root` row).
114    #[must_use]
115    pub fn compute_spends_root(&self) -> Bytes32 {
116        crate::compute_spends_root(&self.spend_bundles)
117    }
118
119    /// Additions Merkle root over [`Self::all_additions`] ([HSH-004](docs/requirements/domains/hashing/specs/HSH-004.md)).
120    ///
121    /// **Delegation:** [`crate::compute_additions_root`] — `puzzle_hash` groups, `[ph, hash_coin_ids(ids)]` pairs in
122    /// first-seen order ([`indexmap::IndexMap`] inside that function), then [`merkle_set_root`] /
123    /// [`chia_consensus::merkle_set::compute_merkle_set_root`] ([SPEC §3.4](docs/resources/SPEC.md)).
124    #[must_use]
125    pub fn compute_additions_root(&self) -> Bytes32 {
126        let additions = self.all_additions();
127        crate::compute_additions_root(&additions)
128    }
129
130    /// Removals Merkle set over all spent coin IDs ([HSH-005](docs/requirements/domains/hashing/specs/HSH-005.md)).
131    ///
132    /// **Body order:** IDs come from [`Self::all_removals`] (spend-bundle then coin-spend order). **Root:** delegates to
133    /// [`crate::compute_removals_root`], which uses [`chia_consensus::merkle_set::compute_merkle_set_root`] — the same
134    /// multiset of IDs yields the same root regardless of slice order ([SPEC §3.5](docs/resources/SPEC.md)).
135    #[must_use]
136    pub fn compute_removals_root(&self) -> Bytes32 {
137        let ids = self.all_removals();
138        crate::compute_removals_root(&ids)
139    }
140
141    /// BIP-158 compact filter hash ([HSH-006](docs/requirements/domains/hashing/specs/HSH-006.md), SPEC §3.6).
142    ///
143    /// **Delegation:** [`crate::compute_filter_hash`] with body-derived [`Self::all_additions`] /
144    /// [`Self::all_removals`] slices.
145    ///
146    /// **BIP158 key (`block_identity` argument):** [`Self::header`]'s [`L2BlockHeader::parent_hash`] — stable while the
147    /// filter field is being filled and matches SPEC §6.4’s `filter_hash = compute_filter_hash(additions, removals)` build
148    /// step (no self-referential [`Self::hash`] dependency). SipHash keys are the first 16 bytes of that parent digest
149    /// ([`crate::merkle_util::bip158_filter_encoded`]).
150    #[must_use]
151    pub fn compute_filter_hash(&self) -> Bytes32 {
152        let additions = self.all_additions();
153        let removals = self.all_removals();
154        crate::compute_filter_hash(self.header.parent_hash, &additions, &removals)
155    }
156
157    /// Binary Merkle root over slash payload digests (`sha256` each), in payload order.
158    #[must_use]
159    pub fn compute_slash_proposals_root(&self) -> Bytes32 {
160        Self::slash_proposals_root_from(&self.slash_proposal_payloads)
161    }
162
163    /// [`Self::compute_slash_proposals_root`] for an explicit payload list (tests, pre-serialized batches).
164    #[must_use]
165    pub fn slash_proposals_root_from(payloads: &[Vec<u8>]) -> Bytes32 {
166        if payloads.is_empty() {
167            return merkle_tree_root(&[]);
168        }
169        let leaves: Vec<Bytes32> = payloads.iter().map(|p| slash_leaf_hash(p)).collect();
170        merkle_tree_root(&leaves)
171    }
172
173    /// Single slash payload leaf digest (building block for [`Self::compute_slash_proposals_root`]).
174    #[must_use]
175    pub fn slash_proposal_leaf_hash(payload: &[u8]) -> Bytes32 {
176        slash_leaf_hash(payload)
177    }
178
179    // --- BLK-004: collections & integrity ---
180
181    /// All `CREATE_COIN` outputs from every spend bundle (CLVM-simulated per [`SpendBundle::additions`]).
182    #[must_use]
183    pub fn all_additions(&self) -> Vec<Coin> {
184        let mut out = Vec::new();
185        for sb in &self.spend_bundles {
186            out.extend(empty_on_additions_err(sb.additions()));
187        }
188        out
189    }
190
191    /// Coin IDs of every addition in body order (same walk as [`Self::all_additions`]).
192    #[must_use]
193    pub fn all_addition_ids(&self) -> Vec<Bytes32> {
194        self.all_additions()
195            .into_iter()
196            .map(|c| c.coin_id())
197            .collect()
198    }
199
200    /// Spent coin IDs (`CoinSpend.coin`) in bundle / spend order.
201    #[must_use]
202    pub fn all_removals(&self) -> Vec<Bytes32> {
203        self.spend_bundles
204            .iter()
205            .flat_map(|sb| sb.coin_spends.iter().map(|cs| cs.coin.coin_id()))
206            .collect()
207    }
208
209    /// First duplicate output coin ID in addition set, else `None` (SPEC / Chia duplicate-output check).
210    #[must_use]
211    pub fn has_duplicate_outputs(&self) -> Option<Bytes32> {
212        first_duplicate_addition_coin_id(&self.all_additions())
213    }
214
215    /// First coin ID spent twice as a removal, else `None`.
216    #[must_use]
217    pub fn has_double_spends(&self) -> Option<Bytes32> {
218        let mut seen = std::collections::HashSet::<Bytes32>::new();
219        self.all_removals().into_iter().find(|&id| !seen.insert(id))
220    }
221
222    /// Full `bincode` body size (header + spends + slash payloads + signature), per SPEC serialization rules.
223    #[must_use]
224    pub fn compute_size(&self) -> usize {
225        bincode::serialize(self).map(|b| b.len()).unwrap_or(0)
226    }
227
228    /// Tier 1 **structural** validation: cheap consistency checks that need no chain state ([SPEC §5.2](docs/resources/SPEC.md)).
229    ///
230    /// **SVL-005** ([spec](docs/requirements/domains/structural_validation/specs/SVL-005.md)): header counters
231    /// `spend_bundle_count`, `additions_count`, `removals_count`, and `slash_proposal_count` MUST match the body
232    /// (`spend_bundles`, [`Self::all_additions`], total [`CoinSpend`] rows, `slash_proposal_payloads`).
233    ///
234    /// **SVL-006** ([spec](docs/requirements/domains/structural_validation/specs/SVL-006.md)): after counts, enforces
235    /// Merkle commitments and integrity in **SPEC §5.2** order: `spends_root` → duplicate outputs (Chia check 13) →
236    /// double spends (check 14) → `additions_root` / `removals_root` → BIP158 `filter_hash` → slash count and per-payload
237    /// byte caps → `slash_proposals_root` → full-block bincode size vs [`crate::MAX_BLOCK_SIZE`]. All hashing reuses
238    /// [`Self::compute_spends_root`], [`Self::compute_additions_root`], [`Self::compute_removals_root`],
239    /// [`Self::compute_filter_hash`], [`Self::compute_slash_proposals_root`] so validation stays aligned with HSH-003–006
240    /// and BLK-004.
241    ///
242    /// **Rationale:** Count checks stay first (cheap, fail-fast); Merkle and filter work only after cardinality is sane;
243    /// serialized-size is last so malicious oversized bodies still pay for earlier checks where applicable.
244    /// [`crate::validation::structural`](crate::validation::structural) indexes the SVL matrix.
245    pub fn validate_structure(&self) -> Result<(), BlockError> {
246        let actual_spend_bundles = u32_len(self.spend_bundles.len());
247        if self.header.spend_bundle_count != actual_spend_bundles {
248            return Err(BlockError::SpendBundleCountMismatch {
249                header: self.header.spend_bundle_count,
250                actual: actual_spend_bundles,
251            });
252        }
253
254        let computed_additions = u32_len(self.all_additions().len());
255        if self.header.additions_count != computed_additions {
256            return Err(BlockError::AdditionsCountMismatch {
257                header: self.header.additions_count,
258                actual: computed_additions,
259            });
260        }
261
262        let computed_removals: usize = self
263            .spend_bundles
264            .iter()
265            .map(|sb| sb.coin_spends.len())
266            .sum();
267        let computed_removals = u32_len(computed_removals);
268        if self.header.removals_count != computed_removals {
269            return Err(BlockError::RemovalsCountMismatch {
270                header: self.header.removals_count,
271                actual: computed_removals,
272            });
273        }
274
275        let actual_slash = u32_len(self.slash_proposal_payloads.len());
276        if self.header.slash_proposal_count != actual_slash {
277            return Err(BlockError::SlashProposalCountMismatch {
278                header: self.header.slash_proposal_count,
279                actual: actual_slash,
280            });
281        }
282
283        // --- SVL-006: Merkle roots + integrity (SPEC §5.2 steps 3, 6–15) ---
284        // Step 3 — spends_root (HSH-003)
285        let computed_spends_root = self.compute_spends_root();
286        if self.header.spends_root != computed_spends_root {
287            return Err(BlockError::InvalidSpendsRoot {
288                expected: self.header.spends_root,
289                computed: computed_spends_root,
290            });
291        }
292
293        // Steps 6–7 — duplicate outputs / double spends (Chia checks 13–14; BLK-004 probes)
294        if let Some(coin_id) = self.has_duplicate_outputs() {
295            return Err(BlockError::DuplicateOutput { coin_id });
296        }
297        if let Some(coin_id) = self.has_double_spends() {
298            return Err(BlockError::DoubleSpendInBlock { coin_id });
299        }
300
301        // Steps 8–9 — additions / removals Merkle sets (HSH-004 / HSH-005)
302        let computed_additions_root = self.compute_additions_root();
303        if self.header.additions_root != computed_additions_root {
304            return Err(BlockError::InvalidAdditionsRoot);
305        }
306        let computed_removals_root = self.compute_removals_root();
307        if self.header.removals_root != computed_removals_root {
308            return Err(BlockError::InvalidRemovalsRoot);
309        }
310
311        // Step 10 — BIP158 filter (HSH-006)
312        let computed_filter_hash = self.compute_filter_hash();
313        if self.header.filter_hash != computed_filter_hash {
314            return Err(BlockError::InvalidFilterHash);
315        }
316
317        // Steps 11–12 — slash proposal policy ([`MAX_SLASH_PROPOSALS_PER_BLOCK`], [`MAX_SLASH_PROPOSAL_PAYLOAD_BYTES`])
318        if self.slash_proposal_payloads.len() > MAX_SLASH_PROPOSALS_PER_BLOCK as usize {
319            return Err(BlockError::TooManySlashProposals);
320        }
321        let max_payload = MAX_SLASH_PROPOSAL_PAYLOAD_BYTES as usize;
322        for payload in &self.slash_proposal_payloads {
323            if payload.len() > max_payload {
324                return Err(BlockError::SlashProposalPayloadTooLarge);
325            }
326        }
327
328        // Step 14 — slash proposals Merkle root (BLK-004 / header field)
329        let computed_slash_root = self.compute_slash_proposals_root();
330        if self.header.slash_proposals_root != computed_slash_root {
331            return Err(BlockError::InvalidSlashProposalsRoot);
332        }
333
334        // Step 15 — actual serialized block size (bincode), independent of header `block_size` (SVL-003 caps declared field)
335        let serialized_size = self.compute_size();
336        if serialized_size > MAX_BLOCK_SIZE as usize {
337            let size_u32 = u32::try_from(serialized_size).unwrap_or(u32::MAX);
338            return Err(BlockError::TooLarge {
339                size: size_u32,
340                max: MAX_BLOCK_SIZE,
341            });
342        }
343
344        Ok(())
345    }
346
347    /// Tier 2 — **execution validation** entry point ([EXE-001](docs/requirements/domains/execution_validation/specs/EXE-001.md), [SPEC §7.4](docs/resources/SPEC.md)).
348    ///
349    /// Processes each [`SpendBundle`] in **block order** (ephemeral-coin semantics depend on this)
350    /// and returns an aggregated [`crate::ExecutionResult`] that carries into Tier 3
351    /// ([STV-001](docs/requirements/domains/state_validation/specs/STV-001.md)).
352    ///
353    /// ## Scope of this method (EXE-001 alone)
354    ///
355    /// **Implemented:**
356    /// - API surface matching NORMATIVE: `&self`, `&ValidationConfig` (from **dig-clvm**, see
357    ///   [`docs/prompt/start.md`](docs/prompt/start.md) Hard Requirement 2), `&Bytes32`
358    ///   (`genesis_challenge` for `AGG_SIG_ME` domain separation under EXE-005).
359    /// - Block-order traversal of [`Self::spend_bundles`].
360    /// - Block-level **fee consistency** ([EXE-006](docs/requirements/domains/execution_validation/specs/EXE-006.md))
361    ///   — `computed_total_fees == header.total_fees`, else
362    ///   [`BlockError::FeesMismatch`].
363    /// - Block-level **cost consistency** ([EXE-007](docs/requirements/domains/execution_validation/specs/EXE-007.md))
364    ///   — `computed_total_cost == header.total_cost`, else
365    ///   [`BlockError::CostMismatch`].
366    /// - Emits a fully populated (potentially empty) [`crate::ExecutionResult`]
367    ///   ([EXE-008](docs/requirements/domains/execution_validation/specs/EXE-008.md)).
368    ///
369    /// **Deferred to later requirements (documented here for trace):**
370    /// - **EXE-002** — `tree_hash(puzzle_reveal) == coin.puzzle_hash` per [`CoinSpend`]
371    ///   ([`clvm_utils::tree_hash`]).
372    /// - **EXE-003** — [`dig_clvm::validate_spend_bundle`] per bundle (CLVM execution);
373    ///   note it requires a [`dig_clvm::ValidationContext`] with per-coin `CoinRecord`s, which
374    ///   today lives in Tier 3 ([`crate::CoinLookup`]). Wiring this in is EXE-003's job.
375    /// - **EXE-004 / EXE-009** — two-pass condition collection + [`crate::PendingAssertion`]
376    ///   population.
377    /// - **EXE-005** — BLS aggregate signature verification (inside `dig-clvm`).
378    ///
379    /// For this requirement alone, the method only needs to be callable with the NORMATIVE
380    /// signature and must return the empty-block identity when there is no body. That matches the
381    /// EXE-001 test plan's `empty_block` case; non-empty behavior is validated once EXE-003 lands.
382    ///
383    /// ## Error mapping
384    ///
385    /// | Trigger | Variant | Requirement |
386    /// |---|---|---|
387    /// | `computed_total_fees != header.total_fees` | [`BlockError::FeesMismatch`] | EXE-006 |
388    /// | `computed_total_cost != header.total_cost` | [`BlockError::CostMismatch`] | EXE-007 |
389    ///
390    /// ## Chia parity
391    ///
392    /// The method sits at the same layer as `chia-blockchain`'s `pre_validate_blocks` + body-level
393    /// checks ([`block_body_validation.py` Check 9 (`INVALID_BLOCK_COST`) + Check 19
394    /// (`INVALID_BLOCK_FEE_AMOUNT`)](https://github.com/Chia-Network/chia-blockchain/blob/main/chia/consensus/block_body_validation.py)).
395    pub fn validate_execution(
396        &self,
397        clvm_config: &dig_clvm::ValidationConfig,
398        genesis_challenge: &Bytes32,
399    ) -> Result<crate::ExecutionResult, BlockError> {
400        // NOTE: `clvm_config` and `genesis_challenge` are accepted per NORMATIVE EXE-001 but are
401        // not consumed until EXE-003 (CLVM execution) / EXE-005 (BLS `AGG_SIG_ME` under genesis
402        // domain). Silencing unused-variable lints here without suppressing the symbols so they
403        // remain visible to the API surface.
404        let _ = clvm_config;
405        let _ = genesis_challenge;
406
407        // Intentionally `mut`: EXE-003 will push additions/removals/receipts into `result` as
408        // each bundle's `SpendResult` is produced. Kept mutable so the EXE-003 diff is minimal.
409        #[allow(unused_mut)]
410        let mut result = crate::ExecutionResult::default();
411
412        // Process bundles in block order (ephemeral-coin semantics; EXE-001 NORMATIVE).
413        //
414        // EXE-002: For every CoinSpend, tree_hash(puzzle_reveal) MUST equal coin.puzzle_hash
415        // before CLVM execution. This is cheap (pure SHA-256 over serialized CLVM bytes) and
416        // fails fast on tampered puzzle reveals, so it runs first per Chia parity with Check 20.
417        // See [`crate::verify_coin_spend_puzzle_hash`].
418        //
419        // EXE-003 (dig-clvm invocation) remains deferred — CLVM execution requires a
420        // [`dig_clvm::ValidationContext`] seeded with coin_records from Tier 3 ([`crate::CoinLookup`]).
421        // Will land alongside the Tier-2/Tier-3 bridge.
422        for bundle in &self.spend_bundles {
423            for coin_spend in &bundle.coin_spends {
424                crate::verify_coin_spend_puzzle_hash(coin_spend)?;
425            }
426            // EXE-003 / EXE-004 / EXE-005 / EXE-009 deferred.
427        }
428
429        // EXE-006 — block-level fee consistency.
430        if result.total_fees != self.header.total_fees {
431            return Err(BlockError::FeesMismatch {
432                header: self.header.total_fees,
433                computed: result.total_fees,
434            });
435        }
436
437        // EXE-007 — block-level cost consistency.
438        if result.total_cost != self.header.total_cost {
439            return Err(BlockError::CostMismatch {
440                header: self.header.total_cost,
441                computed: result.total_cost,
442            });
443        }
444
445        Ok(result)
446    }
447
448    /// Tier-2 execution with an explicit [`dig_clvm::ValidationContext`]
449    /// ([EXE-003](docs/requirements/domains/execution_validation/specs/EXE-003.md), [SPEC §7.4.3](docs/resources/SPEC.md)).
450    ///
451    /// ## Why this signature (not EXE-001's tight one)
452    ///
453    /// `dig_clvm::validate_spend_bundle` requires a [`dig_clvm::ValidationContext`] populated with
454    /// per-coin [`chia_sdk_coinset::CoinRecord`]s before it can run CLVM (structural coin-exists
455    /// check precedes execution). That state lives in Tier 3 ([`crate::CoinLookup`]). This method
456    /// is the integration entry point callers use when they have a context ready; the
457    /// NORMATIVE-pinned [`Self::validate_execution`] remains a thin wrapper over an **empty**
458    /// context and is only sound for empty bodies. When the Tier-2/Tier-3 bridge lands
459    /// (`validate_full`), the wrapper will build context from a provided `CoinLookup`.
460    ///
461    /// ## Pipeline
462    ///
463    /// 1. For each [`chia_protocol::SpendBundle`] in block order:
464    ///    1. For each [`chia_protocol::CoinSpend`]: [`crate::verify_coin_spend_puzzle_hash`]
465    ///       (EXE-002).
466    ///    2. [`dig_clvm::validate_spend_bundle`] (EXE-003) — CLVM + conditions + BLS
467    ///       aggregate verify + per-bundle conservation.
468    ///    3. Fold `SpendResult` into the running [`crate::ExecutionResult`].
469    /// 2. After all bundles:
470    ///    1. EXE-006 fee consistency (`computed_total_fees == header.total_fees`).
471    ///    2. EXE-007 cost consistency (`computed_total_cost == header.total_cost`).
472    ///
473    /// ## Error mapping
474    ///
475    /// All [`dig_clvm::ValidationError`] variants pass through [`crate::map_clvm_validation_error`]
476    /// (EXE-003 mapping table) so callers see only [`BlockError`].
477    ///
478    /// ## Rationale vs delegating directly
479    ///
480    /// Keeping the CLVM call gated by a puzzle-hash pre-check (EXE-002) preserves fail-fast on
481    /// tampered reveals without paying CLVM cost. Every other check runs inside dig-clvm.
482    pub fn validate_execution_with_context(
483        &self,
484        clvm_config: &dig_clvm::ValidationConfig,
485        genesis_challenge: &Bytes32,
486        context: &dig_clvm::ValidationContext,
487    ) -> Result<crate::ExecutionResult, BlockError> {
488        // `genesis_challenge` is part of the AGG_SIG_ME domain; dig-clvm currently reads this
489        // from `context.constants`, so the parameter is documentary here until EXE-005 uses it
490        // to override per-call.
491        let _ = genesis_challenge;
492
493        let mut result = crate::ExecutionResult::default();
494
495        for (idx, bundle) in self.spend_bundles.iter().enumerate() {
496            // EXE-002: puzzle-hash pre-check (fail-fast before CLVM cost).
497            for coin_spend in &bundle.coin_spends {
498                crate::verify_coin_spend_puzzle_hash(coin_spend)?;
499            }
500
501            // EXE-003: delegate to dig-clvm for full CLVM + conditions + BLS + conservation.
502            let spend_result = dig_clvm::validate_spend_bundle(bundle, context, clvm_config, None)
503                .map_err(|e| {
504                    // Rewrap bundle_index on signature failures; all other variants ignore it.
505                    let mapped = crate::map_clvm_validation_error(e);
506                    if let BlockError::SignatureFailed { .. } = mapped {
507                        BlockError::SignatureFailed {
508                            bundle_index: idx as u32,
509                        }
510                    } else {
511                        mapped
512                    }
513                })?;
514
515            // Aggregate per-bundle outputs into the block-level ExecutionResult (EXE-008 shape).
516            result.additions.extend(spend_result.additions);
517            result
518                .removals
519                .extend(spend_result.removals.iter().map(|c| c.coin_id()));
520            result.total_cost = result
521                .total_cost
522                .saturating_add(spend_result.conditions.cost);
523            result.total_fees = result.total_fees.saturating_add(spend_result.fee);
524
525            // EXE-004: collect height / time pending assertions from this bundle's parsed
526            // conditions (block-level absolutes + per-spend relatives). Tier-3 (STV-005)
527            // evaluates them against chain context.
528            result
529                .pending_assertions
530                .extend(crate::collect_pending_assertions_from_conditions(
531                    &spend_result.conditions,
532                ));
533
534            // EXE-004 Pass 2 (announcement / concurrent-spend / self-assertions) + EXE-005
535            // (BLS) run inside `dig_clvm::validate_spend_bundle` → `run_spendbundle`; rejection
536            // surfaces via the mapped `ValidationError::Clvm` / `SignatureFailed` paths above.
537            //
538            // Per-bundle Receipt construction lives outside this commit; Tier-2 callers that
539            // need receipts build them from the SpendResult + bundle metadata (RCP-002).
540        }
541
542        // EXE-006 — block-level fee consistency.
543        if result.total_fees != self.header.total_fees {
544            return Err(BlockError::FeesMismatch {
545                header: self.header.total_fees,
546                computed: result.total_fees,
547            });
548        }
549
550        // EXE-007 — block-level cost consistency.
551        if result.total_cost != self.header.total_cost {
552            return Err(BlockError::CostMismatch {
553                header: self.header.total_cost,
554                computed: result.total_cost,
555            });
556        }
557
558        Ok(result)
559    }
560
561    /// Tier 3 — **state validation** entry point ([STV-001](docs/requirements/domains/state_validation/specs/STV-001.md), [SPEC §7.5](docs/resources/SPEC.md)).
562    ///
563    /// Consumes the [`crate::ExecutionResult`] produced by Tier 2, cross-references it against
564    /// the caller's [`crate::CoinLookup`] view of the coin set, verifies the proposer signature,
565    /// and returns the computed state-trie root for commitment.
566    ///
567    /// ## Sub-checks (each a follow-on STV-* requirement)
568    ///
569    /// | Step | Requirement | Purpose |
570    /// |---|---|---|
571    /// | 1 | [STV-002](docs/requirements/domains/state_validation/specs/STV-002.md) | Every `exec.removals` coin exists and is unspent (or is ephemeral — present in `exec.additions`). |
572    /// | 2 | [STV-003](docs/requirements/domains/state_validation/specs/STV-003.md) | `CoinState.coin.puzzle_hash` cross-check vs the spent coin's `puzzle_hash`. |
573    /// | 3 | [STV-004](docs/requirements/domains/state_validation/specs/STV-004.md) | Every `exec.additions` coin is not already in the coin set (ephemeral exception). |
574    /// | 4 | [STV-005](docs/requirements/domains/state_validation/specs/STV-005.md) | Evaluate each [`crate::PendingAssertion`] from Tier 2 against chain context. |
575    /// | 5 | [STV-006](docs/requirements/domains/state_validation/specs/STV-006.md) | `chia_bls::verify(proposer_pubkey, header.hash(), proposer_signature)`. |
576    /// | 6 | [STV-007](docs/requirements/domains/state_validation/specs/STV-007.md) | Apply additions / removals, recompute state root, compare to `header.state_root`, return it. |
577    ///
578    /// ## Scope of this commit (STV-001 only)
579    ///
580    /// Dispatcher with placeholder sub-check bodies. On empty inputs (zero additions / removals /
581    /// pending assertions) every sub-check is a no-op and the method returns
582    /// `self.header.state_root` directly — the boundary case needed for `validate_full` to
583    /// finish a genesis-style empty block end-to-end. STV-002..007 will harden each step
584    /// without changing this outer signature.
585    ///
586    /// ## Return value
587    ///
588    /// `Bytes32` — the computed state-trie root. For successful validation this equals
589    /// `self.header.state_root`; callers use it as the committed parent-state value for the next
590    /// block. This is why the return is not `()`.
591    pub fn validate_state(
592        &self,
593        exec: &crate::ExecutionResult,
594        coins: &dyn crate::CoinLookup,
595        proposer_pubkey: &crate::primitives::PublicKey,
596    ) -> Result<Bytes32, BlockError> {
597        // STV-002 — coin existence. (Stub: on empty removals, no-op.)
598        self.check_coin_existence_stub(exec, coins)?;
599        // STV-003 — puzzle hash cross-check. (Stub.)
600        self.check_puzzle_hashes_stub(exec, coins)?;
601        // STV-004 — addition non-existence. (Stub.)
602        self.check_addition_uniqueness_stub(exec, coins)?;
603        // STV-005 — height/time lock evaluation. (Stub: on empty pending_assertions, no-op.)
604        self.evaluate_pending_assertions_stub(exec, coins)?;
605        // STV-006 — proposer signature. (Stub.)
606        self.verify_proposer_signature_stub(proposer_pubkey)?;
607        // STV-007 — state root verification + computation.
608        self.compute_and_verify_state_root_stub(exec, coins)
609    }
610
611    /// Convenience wrapper: Tier 1 → Tier 2 → Tier 3 ([STV-001](docs/requirements/domains/state_validation/specs/STV-001.md)).
612    ///
613    /// Short-circuits on the first failing tier. On success returns the computed state root
614    /// (same semantics as [`Self::validate_state`]). Each tier can still be called independently
615    /// for partial validation or tests.
616    pub fn validate_full(
617        &self,
618        clvm_config: &dig_clvm::ValidationConfig,
619        genesis_challenge: &Bytes32,
620        coins: &dyn crate::CoinLookup,
621        proposer_pubkey: &crate::primitives::PublicKey,
622    ) -> Result<Bytes32, BlockError> {
623        // Tier 1 — structural validation.
624        self.validate_structure()?;
625        // Tier 2 — execution validation.
626        let exec = self.validate_execution(clvm_config, genesis_challenge)?;
627        // Tier 3 — state validation + compute state root.
628        self.validate_state(&exec, coins, proposer_pubkey)
629    }
630
631    // --- STV-002..007 stub sub-checks (STV-001 dispatcher shape) ---
632
633    /// STV-002 coin existence ([SPEC §7.5.1](docs/resources/SPEC.md), Chia Check 15).
634    ///
635    /// For every coin id in [`crate::ExecutionResult::removals`]:
636    /// 1. Look up via [`crate::CoinLookup::get_coin_state`].
637    /// 2. If `Some(state)` and `state.spent_height.is_some()` -> [`BlockError::CoinAlreadySpent`].
638    /// 3. If `None`, must be ephemeral — present as a coin id in `exec.additions`.
639    ///    Otherwise [`BlockError::CoinNotFound`].
640    ///
641    /// **Ephemeral lookup:** built as a `HashSet<Bytes32>` over `exec.additions.iter().map(|c| c.coin_id())`
642    /// once per invocation so iteration over `removals` is O(n + m), not O(n*m).
643    fn check_coin_existence_stub(
644        &self,
645        exec: &crate::ExecutionResult,
646        coins: &dyn crate::CoinLookup,
647    ) -> Result<(), BlockError> {
648        // Ephemeral coin IDs: coins created and spent within the same block.
649        let ephemeral_ids: std::collections::HashSet<Bytes32> =
650            exec.additions.iter().map(|c| c.coin_id()).collect();
651
652        for removal_id in &exec.removals {
653            match coins.get_coin_state(removal_id) {
654                Some(state) => {
655                    if let Some(height) = state.spent_height {
656                        return Err(BlockError::CoinAlreadySpent {
657                            coin_id: *removal_id,
658                            spent_height: u64::from(height),
659                        });
660                    }
661                }
662                None => {
663                    if !ephemeral_ids.contains(removal_id) {
664                        return Err(BlockError::CoinNotFound {
665                            coin_id: *removal_id,
666                        });
667                    }
668                }
669            }
670        }
671        Ok(())
672    }
673
674    /// STV-003 puzzle-hash cross-check ([SPEC §7.5.2](docs/resources/SPEC.md), Chia Check 20).
675    ///
676    /// For every `CoinSpend` in every `SpendBundle`:
677    /// - Look up the persistent coin via [`crate::CoinLookup::get_coin_state`].
678    /// - If `Some`, require `state.coin.puzzle_hash == coin_spend.coin.puzzle_hash`; else
679    ///   reject with [`BlockError::PuzzleHashMismatch`] carrying `expected` (state) / `computed`
680    ///   (declared).
681    /// - If `None` (ephemeral), skip — STV-002 covers existence; the ephemeral coin was
682    ///   committed in this block's `exec.additions` with its puzzle_hash already bound.
683    ///
684    /// ## Complementary to EXE-002
685    ///
686    /// EXE-002 proves `tree_hash(puzzle_reveal) == coin_spend.coin.puzzle_hash`. STV-003 closes
687    /// the chain to `coin_state.puzzle_hash == coin_spend.coin.puzzle_hash`.
688    fn check_puzzle_hashes_stub(
689        &self,
690        _exec: &crate::ExecutionResult,
691        coins: &dyn crate::CoinLookup,
692    ) -> Result<(), BlockError> {
693        for bundle in &self.spend_bundles {
694            for coin_spend in &bundle.coin_spends {
695                let coin_id = coin_spend.coin.coin_id();
696                if let Some(state) = coins.get_coin_state(&coin_id) {
697                    if state.coin.puzzle_hash != coin_spend.coin.puzzle_hash {
698                        return Err(BlockError::PuzzleHashMismatch {
699                            coin_id,
700                            expected: state.coin.puzzle_hash,
701                            computed: coin_spend.coin.puzzle_hash,
702                        });
703                    }
704                }
705                // None => ephemeral (spent-in-same-block); STV-002 handled existence.
706            }
707        }
708        Ok(())
709    }
710
711    /// STV-004 addition non-existence ([SPEC §7.5.3](docs/resources/SPEC.md)).
712    ///
713    /// For each coin in `exec.additions`:
714    /// - If [`crate::CoinLookup::get_coin_state`] returns `Some(_)`, the coin id already exists
715    ///   in persistent state — reject unless the coin id also appears in `exec.removals`
716    ///   (ephemeral exception: the addition is consumed in the same block).
717    ///
718    /// ## Why `removals` is treated as a HashSet
719    ///
720    /// The ephemeral check is O(n+m) rather than O(n*m) — we build the removal-id set once per
721    /// invocation. For realistic block sizes the allocation cost is negligible.
722    fn check_addition_uniqueness_stub(
723        &self,
724        exec: &crate::ExecutionResult,
725        coins: &dyn crate::CoinLookup,
726    ) -> Result<(), BlockError> {
727        let removal_ids: std::collections::HashSet<Bytes32> =
728            exec.removals.iter().copied().collect();
729
730        for addition in &exec.additions {
731            let coin_id = addition.coin_id();
732            if coins.get_coin_state(&coin_id).is_some() && !removal_ids.contains(&coin_id) {
733                return Err(BlockError::CoinAlreadyExists { coin_id });
734            }
735        }
736        Ok(())
737    }
738
739    /// STV-005 height / time lock evaluation ([SPEC §7.5.4](docs/resources/SPEC.md), Chia Check 21).
740    ///
741    /// For each [`crate::PendingAssertion`] from Tier 2 (EXE-004 / EXE-009), compare its
742    /// threshold to chain context from [`crate::CoinLookup`].
743    ///
744    /// ## Chain context
745    ///
746    /// - `chain_height` — [`crate::CoinLookup::get_chain_height`].
747    /// - `chain_timestamp` — [`crate::CoinLookup::get_chain_timestamp`].
748    ///
749    /// ## Relative assertions
750    ///
751    /// Require the owning coin's `created_height` from [`crate::CoinLookup::get_coin_state`].
752    /// A missing coin rejects with [`BlockError::CoinNotFound`] (no reference point).
753    ///
754    /// ## Relative-seconds caveat
755    ///
756    /// `chia_protocol::CoinState` has no per-coin timestamp. Implementation estimates
757    /// `coin_timestamp ≈ chain_timestamp - (chain_height - created_height) * AVG_BLOCK_SECONDS`
758    /// with `AVG_BLOCK_SECONDS = 10`. Extension of [`crate::CoinLookup`] with per-coin creation
759    /// timestamps is a future improvement.
760    ///
761    /// ## Error mapping
762    ///
763    /// Failed assertions → [`BlockError::AssertionFailed { condition, reason }`] with the opcode
764    /// name and expected / actual context for logs.
765    fn evaluate_pending_assertions_stub(
766        &self,
767        exec: &crate::ExecutionResult,
768        coins: &dyn crate::CoinLookup,
769    ) -> Result<(), BlockError> {
770        /// Average L2 block interval (seconds) for per-coin timestamp estimation.
771        const AVG_BLOCK_SECONDS: u64 = 10;
772
773        let chain_height = coins.get_chain_height();
774        let chain_timestamp = coins.get_chain_timestamp();
775
776        for assertion in &exec.pending_assertions {
777            match &assertion.kind {
778                crate::AssertionKind::HeightAbsolute(h) => {
779                    if chain_height < *h {
780                        return Err(BlockError::AssertionFailed {
781                            condition: "ASSERT_HEIGHT_ABSOLUTE".into(),
782                            reason: format!("expected >= {h}, actual {chain_height}"),
783                        });
784                    }
785                }
786                crate::AssertionKind::HeightRelative(h) => {
787                    let state = coins.get_coin_state(&assertion.coin_id).ok_or(
788                        BlockError::CoinNotFound {
789                            coin_id: assertion.coin_id,
790                        },
791                    )?;
792                    let confirmed = u64::from(state.created_height.unwrap_or(0));
793                    let threshold = confirmed.saturating_add(*h);
794                    if chain_height < threshold {
795                        return Err(BlockError::AssertionFailed {
796                            condition: "ASSERT_HEIGHT_RELATIVE".into(),
797                            reason: format!(
798                                "expected >= {threshold} (confirmed {confirmed} + h {h}), actual {chain_height}"
799                            ),
800                        });
801                    }
802                }
803                crate::AssertionKind::SecondsAbsolute(t) => {
804                    if chain_timestamp < *t {
805                        return Err(BlockError::AssertionFailed {
806                            condition: "ASSERT_SECONDS_ABSOLUTE".into(),
807                            reason: format!("expected >= {t}, actual {chain_timestamp}"),
808                        });
809                    }
810                }
811                crate::AssertionKind::SecondsRelative(t) => {
812                    let state = coins.get_coin_state(&assertion.coin_id).ok_or(
813                        BlockError::CoinNotFound {
814                            coin_id: assertion.coin_id,
815                        },
816                    )?;
817                    let confirmed = u64::from(state.created_height.unwrap_or(0));
818                    let delta_blocks = chain_height.saturating_sub(confirmed);
819                    let coin_ts_estimate = chain_timestamp
820                        .saturating_sub(delta_blocks.saturating_mul(AVG_BLOCK_SECONDS));
821                    let threshold = coin_ts_estimate.saturating_add(*t);
822                    if chain_timestamp < threshold {
823                        return Err(BlockError::AssertionFailed {
824                            condition: "ASSERT_SECONDS_RELATIVE".into(),
825                            reason: format!(
826                                "expected >= {threshold} (coin_ts_estimate {coin_ts_estimate} + t {t}), actual {chain_timestamp}"
827                            ),
828                        });
829                    }
830                }
831                crate::AssertionKind::BeforeHeightAbsolute(h) => {
832                    if chain_height >= *h {
833                        return Err(BlockError::AssertionFailed {
834                            condition: "ASSERT_BEFORE_HEIGHT_ABSOLUTE".into(),
835                            reason: format!("expected < {h}, actual {chain_height}"),
836                        });
837                    }
838                }
839                crate::AssertionKind::BeforeHeightRelative(h) => {
840                    let state = coins.get_coin_state(&assertion.coin_id).ok_or(
841                        BlockError::CoinNotFound {
842                            coin_id: assertion.coin_id,
843                        },
844                    )?;
845                    let confirmed = u64::from(state.created_height.unwrap_or(0));
846                    let threshold = confirmed.saturating_add(*h);
847                    if chain_height >= threshold {
848                        return Err(BlockError::AssertionFailed {
849                            condition: "ASSERT_BEFORE_HEIGHT_RELATIVE".into(),
850                            reason: format!(
851                                "expected < {threshold} (confirmed {confirmed} + h {h}), actual {chain_height}"
852                            ),
853                        });
854                    }
855                }
856                crate::AssertionKind::BeforeSecondsAbsolute(t) => {
857                    if chain_timestamp >= *t {
858                        return Err(BlockError::AssertionFailed {
859                            condition: "ASSERT_BEFORE_SECONDS_ABSOLUTE".into(),
860                            reason: format!("expected < {t}, actual {chain_timestamp}"),
861                        });
862                    }
863                }
864                crate::AssertionKind::BeforeSecondsRelative(t) => {
865                    let state = coins.get_coin_state(&assertion.coin_id).ok_or(
866                        BlockError::CoinNotFound {
867                            coin_id: assertion.coin_id,
868                        },
869                    )?;
870                    let confirmed = u64::from(state.created_height.unwrap_or(0));
871                    let delta_blocks = chain_height.saturating_sub(confirmed);
872                    let coin_ts_estimate = chain_timestamp
873                        .saturating_sub(delta_blocks.saturating_mul(AVG_BLOCK_SECONDS));
874                    let threshold = coin_ts_estimate.saturating_add(*t);
875                    if chain_timestamp >= threshold {
876                        return Err(BlockError::AssertionFailed {
877                            condition: "ASSERT_BEFORE_SECONDS_RELATIVE".into(),
878                            reason: format!(
879                                "expected < {threshold} (coin_ts_estimate {coin_ts_estimate} + t {t}), actual {chain_timestamp}"
880                            ),
881                        });
882                    }
883                }
884            }
885        }
886        Ok(())
887    }
888
889    /// STV-006 proposer signature verification ([SPEC §7.5.5](docs/resources/SPEC.md)).
890    ///
891    /// Verify `self.proposer_signature` against `pubkey` + `self.header.hash()` using
892    /// [`chia_bls::verify`]. Reject with [`BlockError::InvalidProposerSignature`] on failure.
893    ///
894    /// ## Why the header hash
895    ///
896    /// The header hash is canonical block identity ([`L2BlockHeader::hash`], HSH-001). Signing
897    /// it (not the full body) lets light clients and attestors verify proposer authorship
898    /// without downloading `SpendBundle` bytes — same design as `AttestedBlock::new` seeding
899    /// `aggregate_signature` from `proposer_signature`
900    /// ([ATT-001](docs/requirements/domains/attestation/specs/ATT-001.md)).
901    fn verify_proposer_signature_stub(
902        &self,
903        pubkey: &crate::primitives::PublicKey,
904    ) -> Result<(), BlockError> {
905        let header_hash: Bytes32 = self.header.hash();
906        if chia_bls::verify(&self.proposer_signature, pubkey, header_hash.as_ref()) {
907            Ok(())
908        } else {
909            Err(BlockError::InvalidProposerSignature)
910        }
911    }
912
913    /// STV-007 state-root recomputation ([SPEC §7.5.6](docs/resources/SPEC.md)).
914    ///
915    /// Computes a deterministic delta root from `exec.additions` + `exec.removals` via
916    /// [`crate::compute_state_root_from_delta`] and compares to `self.header.state_root`. On
917    /// match, returns the computed value so callers can thread it into the next block's
918    /// `header.parent_hash` / `header.state_root`. On mismatch, rejects with
919    /// [`BlockError::InvalidStateRoot`] carrying both values for diagnostic.
920    ///
921    /// ## Scope
922    ///
923    /// This is the **interim** state-root formula documented in
924    /// [`crate::compute_state_root_from_delta`]. A full sparse-Merkle / Patricia-trie state
925    /// computation is tracked as a follow-on extension to [`crate::CoinLookup`] — STV-007's
926    /// acceptance criteria (match/mismatch, empty-block, determinism) are met here.
927    fn compute_and_verify_state_root_stub(
928        &self,
929        exec: &crate::ExecutionResult,
930        _coins: &dyn crate::CoinLookup,
931    ) -> Result<Bytes32, BlockError> {
932        let computed = crate::compute_state_root_from_delta(&exec.additions, &exec.removals);
933        if computed != self.header.state_root {
934            return Err(BlockError::InvalidStateRoot {
935                expected: self.header.state_root,
936                computed,
937            });
938        }
939        Ok(computed)
940    }
941}
942
943/// Convert slice lengths to `u32` for header/count fields; saturates at `u32::MAX` if the platform `usize` exceeds it.
944#[inline]
945fn u32_len(n: usize) -> u32 {
946    u32::try_from(n).unwrap_or(u32::MAX)
947}
948
949/// First repeated [`Coin::coin_id`] in a slice of additions (shared by [`L2Block::has_duplicate_outputs`]).
950#[must_use]
951fn first_duplicate_addition_coin_id(coins: &[Coin]) -> Option<Bytes32> {
952    let mut seen = std::collections::HashSet::<Bytes32>::new();
953    for c in coins {
954        let id = c.coin_id();
955        if !seen.insert(id) {
956            return Some(id);
957        }
958    }
959    None
960}
961
962/// Exposed for [`tests/test_l2_block_helpers.rs`] (BLK-004) only — not protocol surface.
963#[doc(hidden)]
964#[must_use]
965pub fn __blk004_first_duplicate_addition_coin_id(coins: &[Coin]) -> Option<Bytes32> {
966    first_duplicate_addition_coin_id(coins)
967}