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}