Skip to main content

dig_block/builder/
block_builder.rs

1//! [`BlockBuilder`] — incremental construction of signed [`crate::L2Block`] instances.
2//!
3//! ## Requirements trace
4//!
5//! - **[BLD-001](docs/requirements/domains/block_production/specs/BLD-001.md)** — struct fields and `new()` constructor.
6//! - **[BLD-002](docs/requirements/domains/block_production/specs/BLD-002.md)** — `add_spend_bundle()` with cost/size budget enforcement, `remaining_cost()`, `spend_bundle_count()`.
7//! - **[BLD-003](docs/requirements/domains/block_production/specs/BLD-003.md)** — `add_slash_proposal()` with count/size limits.
8//! - **[BLD-004](docs/requirements/domains/block_production/specs/BLD-004.md)** — `set_l1_proofs()`, `set_dfsp_roots()`, `set_extension_data()`.
9//! - **[BLD-005](docs/requirements/domains/block_production/specs/BLD-005.md)** — `build()` pipeline: compute all derived fields, sign header.
10//! - **[BLD-006](docs/requirements/domains/block_production/specs/BLD-006.md)** — [`crate::BlockSigner`] integration in `build()` (see `tests/test_bld_006_block_signer_integration.rs`).
11//! - **[BLD-007](docs/requirements/domains/block_production/specs/BLD-007.md)** — structural validity guarantee: output always passes `validate_structure()`.
12//! - **[NORMATIVE](docs/requirements/domains/block_production/NORMATIVE.md)** — full block production domain.
13//! - **[SPEC §6](docs/resources/SPEC.md)** — block production lifecycle.
14//!
15//! ## Build pipeline overview (BLD-005)
16//!
17//! ```text
18//! 1. compute spends_root        ← MerkleTree(sha256(bundle)) per HSH-003
19//! 2. compute additions_root     ← compute_merkle_set_root(grouped by puzzle_hash) per HSH-004
20//! 3. compute removals_root      ← compute_merkle_set_root(coin IDs) per HSH-005
21//! 4. compute filter_hash        ← SHA-256(BIP158 compact filter) per HSH-006
22//! 5. compute slash_proposals_root ← MerkleTree(sha256(payload)) per BLK-004
23//! 6. count all items            ← spend_bundle_count, additions_count, removals_count, slash_proposal_count
24//! 7. auto-detect version        ← protocol_version_for_height(height) per BLK-007
25//! 8. set timestamp              ← current wall-clock time
26//! 9. compute block_size         ← two-pass: assemble with size=0, measure, update
27//! 10. sign header               ← signer.sign_block(&header_hash) per BLD-006
28//! ```
29//!
30//! ## Design decisions
31//!
32//! - **Consuming `build(self)`:** Takes ownership so the builder cannot be reused after producing a block,
33//!   preventing accidental double-build or stale state.
34//! - **Budget enforcement on add, not build:** `add_spend_bundle()` rejects bundles that would exceed
35//!   `MAX_COST_PER_BLOCK` or `MAX_BLOCK_SIZE` _before_ mutating state (BLD-002). This means a rejected
36//!   bundle leaves the builder unchanged — callers can try a smaller bundle.
37//! - **State root and receipts_root are parameters to `build()`:** The builder doesn't maintain coin state,
38//!   so the caller (proposer layer) must compute these externally and pass them in.
39//!
40//! ## Status
41//!
42//! **BLD-001**–**BLD-007** are implemented: successful [`BlockBuilder::build`] / [`BlockBuilder::build_with_dfsp_activation`]
43//! outputs pass [`crate::L2Block::validate_structure`]
44//! ([BLD-007](docs/requirements/domains/block_production/specs/BLD-007.md),
45//! `tests/test_bld_007_builder_validity_guarantee.rs`).
46
47use std::time::{SystemTime, UNIX_EPOCH};
48
49use bincode;
50use chia_protocol::{Coin, SpendBundle};
51
52use crate::error::BuilderError;
53use crate::merkle_util::empty_on_additions_err;
54use crate::primitives::{Bytes32, Cost, Signature};
55use crate::traits::BlockSigner;
56use crate::types::block::L2Block;
57use crate::types::header::L2BlockHeader;
58use crate::{
59    compute_additions_root, compute_filter_hash, compute_removals_root, compute_spends_root,
60    DFSP_ACTIVATION_HEIGHT, EMPTY_ROOT, MAX_BLOCK_SIZE, MAX_COST_PER_BLOCK,
61    MAX_SLASH_PROPOSALS_PER_BLOCK, MAX_SLASH_PROPOSAL_PAYLOAD_BYTES, VERSION_V2, ZERO_HASH,
62};
63
64/// Incremental accumulator for a single L2 block body and header metadata ([SPEC §6.1–6.2](docs/resources/SPEC.md),
65/// [BLD-001](docs/requirements/domains/block_production/specs/BLD-001.md)).
66///
67/// **Usage:** Construct with [`Self::new`], optionally configure L1 proof anchors / DFSP roots / [`L2BlockHeader::extension_data`]
68/// via [`Self::set_l1_proofs`], [`Self::set_dfsp_roots`], [`Self::set_extension_data`] (BLD-004), add spend bundles via
69/// [`Self::add_spend_bundle`] (BLD-002) and slash payloads via [`Self::add_slash_proposal`] (BLD-003), then call
70/// `build(...)` (BLD-005) to obtain a signed [`crate::L2Block`].
71/// The struct exposes **public fields** so
72/// advanced callers or tests can inspect partial state without accessor boilerplate; treat them as read-mostly except
73/// through official builder methods once those exist.
74///
75/// **Rationale:** Public fields match the BLD-001 specification prose and mirror the SPEC §6.1 layout (caller context,
76/// accumulated body, running totals). [`Coin`] and [`SpendBundle`] stay on **`chia-protocol`** types per project rules
77/// ([`docs/prompt/start.md`](docs/prompt/start.md) — Chia ecosystem first).
78///
79/// **Removals type:** NORMATIVE names this `Vec<CoinId>`; `chia-protocol` does not export a separate `CoinId` newtype in
80/// the versions we pin — coin IDs are the same 32-byte values as [`Bytes32`] (see BLK-004 / [`crate::L2Block::all_removals`]).
81pub struct BlockBuilder {
82    /// Block height this builder is assembling (immutable for the lifetime of the builder).
83    pub height: u64,
84    /// Epoch index ([`crate::L2BlockHeader::epoch`] semantics).
85    pub epoch: u64,
86    /// Parent L2 block header hash (chain link).
87    pub parent_hash: Bytes32,
88    /// Anchoring L1 block height for light-client / bridge logic.
89    pub l1_height: u32,
90    /// Anchoring L1 block hash.
91    pub l1_hash: Bytes32,
92    /// Proposer slot index in the validator set for this block.
93    pub proposer_index: u32,
94    /// Spend bundles accumulated in insertion order (body).
95    pub spend_bundles: Vec<SpendBundle>,
96    /// Raw slash-proposal payloads (opaque bytes per protocol).
97    pub slash_proposal_payloads: Vec<Vec<u8>>,
98    /// Running sum of CLVM costs for bundles accepted so far ([`Cost`] / BLK-006).
99    pub total_cost: Cost,
100    /// Running sum of fees from accepted bundles.
101    pub total_fees: u64,
102    /// Flattened [`Coin`] outputs extracted from spends ([`Self::add_spend_bundle`] / BLD-002).
103    pub additions: Vec<Coin>,
104    /// Spent coin IDs (same bytes as `coin.coin_id()` / NORMATIVE `CoinId`).
105    pub removals: Vec<Bytes32>,
106
107    // --- BLD-004: optional / deferred header fields (copied into [`L2BlockHeader`] in BLD-005) ---
108    /// L1 collateral proof anchor ([`L2BlockHeader::l1_collateral_coin_id`]); `None` until [`Self::set_l1_proofs`].
109    pub l1_collateral_coin_id: Option<Bytes32>,
110    /// Network validator collateral set anchor ([`L2BlockHeader::l1_reserve_coin_id`]).
111    pub l1_reserve_coin_id: Option<Bytes32>,
112    /// Previous-epoch finalizer proof coin ([`L2BlockHeader::l1_prev_epoch_finalizer_coin_id`]).
113    pub l1_prev_epoch_finalizer_coin_id: Option<Bytes32>,
114    /// Current-epoch finalizer proof coin ([`L2BlockHeader::l1_curr_epoch_finalizer_coin_id`]).
115    pub l1_curr_epoch_finalizer_coin_id: Option<Bytes32>,
116    /// Network singleton proof coin ([`L2BlockHeader::l1_network_coin_id`]).
117    pub l1_network_coin_id: Option<Bytes32>,
118
119    /// DFSP collateral registry root ([`L2BlockHeader::collateral_registry_root`]); defaults [`EMPTY_ROOT`] per SVL-002.
120    pub collateral_registry_root: Bytes32,
121    /// DFSP CID state root ([`L2BlockHeader::cid_state_root`]).
122    pub cid_state_root: Bytes32,
123    /// DFSP node registry root ([`L2BlockHeader::node_registry_root`]).
124    pub node_registry_root: Bytes32,
125    /// Namespace update delta root ([`L2BlockHeader::namespace_update_root`]).
126    pub namespace_update_root: Bytes32,
127    /// DFSP finalize commitment root ([`L2BlockHeader::dfsp_finalize_commitment_root`]).
128    pub dfsp_finalize_commitment_root: Bytes32,
129
130    /// Header extension slot ([`L2BlockHeader::extension_data`]); default [`ZERO_HASH`] matches [`L2BlockHeader::new`].
131    pub extension_data: Bytes32,
132}
133
134impl BlockBuilder {
135    /// Create an empty builder anchored at the given chain / L1 context ([BLD-001](docs/requirements/domains/block_production/specs/BLD-001.md)).
136    ///
137    /// **Contract:** All accumulation fields start empty or zero; identity arguments are copied into the struct so the
138    /// caller may reuse their locals afterward without aliasing the builder’s internal state.
139    #[must_use]
140    pub fn new(
141        height: u64,
142        epoch: u64,
143        parent_hash: Bytes32,
144        l1_height: u32,
145        l1_hash: Bytes32,
146        proposer_index: u32,
147    ) -> Self {
148        Self {
149            height,
150            epoch,
151            parent_hash,
152            l1_height,
153            l1_hash,
154            proposer_index,
155            spend_bundles: Vec::new(),
156            slash_proposal_payloads: Vec::new(),
157            total_cost: 0,
158            total_fees: 0,
159            additions: Vec::new(),
160            removals: Vec::new(),
161            l1_collateral_coin_id: None,
162            l1_reserve_coin_id: None,
163            l1_prev_epoch_finalizer_coin_id: None,
164            l1_curr_epoch_finalizer_coin_id: None,
165            l1_network_coin_id: None,
166            collateral_registry_root: EMPTY_ROOT,
167            cid_state_root: EMPTY_ROOT,
168            node_registry_root: EMPTY_ROOT,
169            namespace_update_root: EMPTY_ROOT,
170            dfsp_finalize_commitment_root: EMPTY_ROOT,
171            extension_data: ZERO_HASH,
172        }
173    }
174
175    /// Build a probe [`L2BlockHeader`] sharing this builder’s identity, L1 anchor, **optional L1 proofs**, **DFSP roots**,
176    /// and **extension** fields ([BLD-004](docs/requirements/domains/block_production/specs/BLD-004.md)).
177    ///
178    /// **Rationale:** `bincode` encodes `Option<Bytes32>` with a discriminant — `Some(_)` rows are larger than `None`.
179    /// After BLD-004, [`Self::serialized_l2_block_probe_len`] must fold these fields in so [`Self::add_spend_bundle`]
180    /// (BLD-002) cannot underestimate wire size when proofs are present.
181    fn probe_header_stub(&self) -> L2BlockHeader {
182        let mut h = L2BlockHeader::new(
183            self.height,
184            self.epoch,
185            self.parent_hash,
186            EMPTY_ROOT,
187            EMPTY_ROOT,
188            EMPTY_ROOT,
189            EMPTY_ROOT,
190            EMPTY_ROOT,
191            self.l1_height,
192            self.l1_hash,
193            self.proposer_index,
194            0,
195            0,
196            0,
197            0,
198            0,
199            0,
200            EMPTY_ROOT,
201        );
202        h.l1_collateral_coin_id = self.l1_collateral_coin_id;
203        h.l1_reserve_coin_id = self.l1_reserve_coin_id;
204        h.l1_prev_epoch_finalizer_coin_id = self.l1_prev_epoch_finalizer_coin_id;
205        h.l1_curr_epoch_finalizer_coin_id = self.l1_curr_epoch_finalizer_coin_id;
206        h.l1_network_coin_id = self.l1_network_coin_id;
207        h.collateral_registry_root = self.collateral_registry_root;
208        h.cid_state_root = self.cid_state_root;
209        h.node_registry_root = self.node_registry_root;
210        h.namespace_update_root = self.namespace_update_root;
211        h.dfsp_finalize_commitment_root = self.dfsp_finalize_commitment_root;
212        h.extension_data = self.extension_data;
213        h
214    }
215
216    /// Serialized [`L2Block`] byte length if the body were `spend_bundles` plus this builder’s slash payloads.
217    ///
218    /// **Rationale (BLD-002):** [`crate::L2Block::validate_structure`] and SVL-003 compare **full** `bincode(L2Block)`
219    /// against [`MAX_BLOCK_SIZE`](crate::MAX_BLOCK_SIZE). The builder does not yet have a final header (BLD-005), so we
220    /// synthesize a probe header via [`Self::probe_header_stub`] (counts/ Merkle fields still placeholders), while the
221    /// variable body (`Vec<SpendBundle>`, `Vec<Vec<u8>>` slash payloads) matches this builder — so the estimate tracks
222    /// growth in spend + slash bytes **and** optional header encodings from BLD-004.
223    ///
224    /// **Related:** [`L2Block::compute_size`](crate::L2Block::compute_size) (BLK-004) uses the same `bincode` schema.
225    fn serialized_l2_block_probe_len(&self, spend_bundles: &[SpendBundle]) -> usize {
226        let header = self.probe_header_stub();
227        let block = L2Block::new(
228            header,
229            spend_bundles.to_vec(),
230            self.slash_proposal_payloads.clone(),
231            Signature::default(),
232        );
233        bincode::serialize(&block)
234            .map(|bytes| bytes.len())
235            .unwrap_or(usize::MAX)
236    }
237
238    /// Append a [`SpendBundle`] after validating CLVM cost and serialized block-size budgets ([BLD-002](docs/requirements/domains/block_production/specs/BLD-002.md)).
239    ///
240    /// **Parameters:** `cost` / `fee` are **caller-supplied** aggregates for this bundle (typically from
241    /// `dig_clvm::validate_spend_bundle` / execution preview). The builder trusts these numbers for budgeting only;
242    /// [`L2BlockHeader::total_cost`] consistency is enforced later at execution tier (EXE-007) and in `build()` (BLD-005).
243    ///
244    /// **Budget order:** cost is checked first (cheap, no cloning). Size uses a bincode probe that temporarily
245    /// [`clone`]s the candidate bundle — if the probe fails, `bundle` is still owned by the caller on `Err`.
246    ///
247    /// **Mutation contract:** On `Err`, **no** field of `self` changes. On `Ok`, additions/removals/totals/spend_bundles
248    /// advance together so partial state is impossible.
249    ///
250    /// **Additions / removals:** Mirrors [`crate::L2Block::all_additions`] / [`crate::L2Block::all_removals`] —
251    /// [`SpendBundle::additions`] (CLVM-simulated `CREATE_COIN`s) plus one removal [`Bytes32`] per [`CoinSpend`].
252    pub fn add_spend_bundle(
253        &mut self,
254        bundle: SpendBundle,
255        cost: Cost,
256        fee: u64,
257    ) -> Result<(), BuilderError> {
258        let next_cost = self.total_cost.saturating_add(cost);
259        if next_cost > MAX_COST_PER_BLOCK {
260            return Err(BuilderError::CostBudgetExceeded {
261                current: self.total_cost,
262                addition: cost,
263                max: MAX_COST_PER_BLOCK,
264            });
265        }
266
267        let base_bytes = self.serialized_l2_block_probe_len(&self.spend_bundles);
268        let mut probe_bundles = self.spend_bundles.clone();
269        probe_bundles.push(bundle.clone());
270        let with_bytes = self.serialized_l2_block_probe_len(&probe_bundles);
271
272        if with_bytes > MAX_BLOCK_SIZE as usize {
273            return Err(BuilderError::SizeBudgetExceeded {
274                current: u32::try_from(base_bytes).unwrap_or(u32::MAX),
275                addition: u32::try_from(with_bytes.saturating_sub(base_bytes)).unwrap_or(u32::MAX),
276                max: MAX_BLOCK_SIZE,
277            });
278        }
279
280        self.additions
281            .extend(empty_on_additions_err(bundle.additions()));
282        for cs in &bundle.coin_spends {
283            self.removals.push(cs.coin.coin_id());
284        }
285        self.total_cost += cost;
286        self.total_fees += fee;
287        self.spend_bundles.push(bundle);
288        Ok(())
289    }
290
291    /// Remaining CLVM cost budget before hitting [`MAX_COST_PER_BLOCK`](crate::MAX_COST_PER_BLOCK) ([BLD-002](docs/requirements/domains/block_production/specs/BLD-002.md)).
292    ///
293    /// **Usage:** Proposers can gate bundle selection without duplicating protocol constants. Saturates at zero if
294    /// `total_cost` ever overshoots (should not happen if only [`Self::add_spend_bundle`] mutates cost).
295    #[must_use]
296    pub fn remaining_cost(&self) -> Cost {
297        MAX_COST_PER_BLOCK.saturating_sub(self.total_cost)
298    }
299
300    /// Number of spend bundles accepted so far (same as `spend_bundles.len()`).
301    #[must_use]
302    pub fn spend_bundle_count(&self) -> usize {
303        self.spend_bundles.len()
304    }
305
306    /// Append an opaque slash-proposal payload after enforcing protocol caps ([BLD-003](docs/requirements/domains/block_production/specs/BLD-003.md)).
307    ///
308    /// **Count:** Rejects when [`Self::slash_proposal_payloads`] already holds [`MAX_SLASH_PROPOSALS_PER_BLOCK`](crate::MAX_SLASH_PROPOSALS_PER_BLOCK)
309    /// rows — the guard uses `>=` so the **next** push would exceed the cap ([`BuilderError::TooManySlashProposals`]).
310    ///
311    /// **Size:** Rejects when `payload.len() > `[`MAX_SLASH_PROPOSAL_PAYLOAD_BYTES`](crate::MAX_SLASH_PROPOSAL_PAYLOAD_BYTES)
312    /// ([`BuilderError::SlashProposalTooLarge`]). A payload whose length **equals** the limit is accepted (strict `>`).
313    ///
314    /// **Check order (spec):** Count is validated **before** size so that a builder already at the count cap surfaces
315    /// [`BuilderError::TooManySlashProposals`] even if the candidate payload is also oversized — callers get a stable
316    /// primary failure mode ([BLD-003 implementation notes](docs/requirements/domains/block_production/specs/BLD-003.md#implementation-notes)).
317    ///
318    /// **Mutation contract:** On `Err`, `slash_proposal_payloads` and all other fields are unchanged. On `Ok`, only
319    /// `slash_proposal_payloads` grows; spend-bundle state is untouched (slash bytes still participate in the BLD-002
320    /// `bincode(L2Block)` size probe on later [`Self::add_spend_bundle`] calls).
321    pub fn add_slash_proposal(&mut self, payload: Vec<u8>) -> Result<(), BuilderError> {
322        if self.slash_proposal_payloads.len() >= MAX_SLASH_PROPOSALS_PER_BLOCK as usize {
323            return Err(BuilderError::TooManySlashProposals {
324                max: MAX_SLASH_PROPOSALS_PER_BLOCK,
325            });
326        }
327        let len = payload.len();
328        if len > MAX_SLASH_PROPOSAL_PAYLOAD_BYTES as usize {
329            return Err(BuilderError::SlashProposalTooLarge {
330                size: u32::try_from(len).unwrap_or(u32::MAX),
331                max: MAX_SLASH_PROPOSAL_PAYLOAD_BYTES,
332            });
333        }
334        self.slash_proposal_payloads.push(payload);
335        Ok(())
336    }
337
338    /// Set all five L1 proof anchor coin IDs at once ([BLD-004](docs/requirements/domains/block_production/specs/BLD-004.md)).
339    ///
340    /// **Semantics:** Each argument becomes `Some(hash)` on the builder, matching [`L2BlockHeader`]’s optional L1 proof
341    /// fields ([`L2BlockHeader::l1_collateral_coin_id`] … [`L2BlockHeader::l1_network_coin_id`]). Callers omitting L1
342    /// proofs should leave these as `None` (default from [`Self::new`]).
343    ///
344    /// **Overwrite:** Later calls replace the entire quintuple — there is no partial merge.
345    pub fn set_l1_proofs(
346        &mut self,
347        collateral: Bytes32,
348        reserve: Bytes32,
349        prev_finalizer: Bytes32,
350        curr_finalizer: Bytes32,
351        network_coin: Bytes32,
352    ) {
353        self.l1_collateral_coin_id = Some(collateral);
354        self.l1_reserve_coin_id = Some(reserve);
355        self.l1_prev_epoch_finalizer_coin_id = Some(prev_finalizer);
356        self.l1_curr_epoch_finalizer_coin_id = Some(curr_finalizer);
357        self.l1_network_coin_id = Some(network_coin);
358    }
359
360    /// Set all five DFSP data-layer Merkle roots ([BLD-004](docs/requirements/domains/block_production/specs/BLD-004.md)).
361    ///
362    /// **Semantics:** Mirrors [`L2BlockHeader`]’s DFSP root block ([`L2BlockHeader::collateral_registry_root`] …
363    /// [`L2BlockHeader::dfsp_finalize_commitment_root`]). Pre-activation callers typically keep [`EMPTY_ROOT`] values
364    /// (SVL-002); post-activation, pass real roots before `build()` (BLD-005 validates against version).
365    pub fn set_dfsp_roots(
366        &mut self,
367        collateral_registry_root: Bytes32,
368        cid_state_root: Bytes32,
369        node_registry_root: Bytes32,
370        namespace_update_root: Bytes32,
371        dfsp_finalize_commitment_root: Bytes32,
372    ) {
373        self.collateral_registry_root = collateral_registry_root;
374        self.cid_state_root = cid_state_root;
375        self.node_registry_root = node_registry_root;
376        self.namespace_update_root = namespace_update_root;
377        self.dfsp_finalize_commitment_root = dfsp_finalize_commitment_root;
378    }
379
380    /// Set the header extension hash ([BLD-004](docs/requirements/domains/block_production/specs/BLD-004.md)).
381    ///
382    /// **Semantics:** Stored as [`L2BlockHeader::extension_data`]; [`Self::new`] initializes to [`ZERO_HASH`] like
383    /// [`L2BlockHeader::new`].
384    pub fn set_extension_data(&mut self, extension_data: Bytes32) {
385        self.extension_data = extension_data;
386    }
387
388    /// Finalize this builder into a signed [`L2Block`] ([BLD-005](docs/requirements/domains/block_production/specs/BLD-005.md)).
389    ///
390    /// **Parameters:** `state_root` / `receipts_root` come from the execution + receipt pipeline outside this crate
391    /// (see module-level **Rationale**). `signer` produces the BLS attestation over [`L2BlockHeader::hash`] (HSH-001).
392    ///
393    /// **Pipeline:** Computes Merkle roots and counts using the same public functions as [`L2Block::validate_structure`]
394    /// (`compute_spends_root`, `compute_additions_root`, `compute_removals_root`, [`L2Block::slash_proposals_root_from`],
395    /// [`compute_filter_hash`]), sets wall-clock [`L2BlockHeader::timestamp`], runs the two-pass `block_size` fill
396    /// (assemble with `block_size == 0`, measure [`L2Block::compute_size`], write), then signs.
397    ///
398    /// **Errors:** [`BuilderError::EmptyBlock`] if no spend bundles ([ERR-004](docs/requirements/domains/error_types/specs/ERR-004.md));
399    /// [`BuilderError::MissingDfspRoots`] when [`VERSION_V2`](crate::VERSION_V2) applies but all DFSP roots are still
400    /// [`EMPTY_ROOT`]; [`BuilderError::SigningFailed`] wraps [`crate::traits::SignerError`].
401    ///
402    /// **DFSP activation:** Uses [`crate::DFSP_ACTIVATION_HEIGHT`]. For tests or fork simulation with a different
403    /// activation height, call [`Self::build_with_dfsp_activation`] instead.
404    pub fn build(
405        self,
406        state_root: Bytes32,
407        receipts_root: Bytes32,
408        signer: &dyn BlockSigner,
409    ) -> Result<L2Block, BuilderError> {
410        self.build_with_dfsp_activation(state_root, receipts_root, signer, DFSP_ACTIVATION_HEIGHT)
411    }
412
413    /// Like [`Self::build`], but supplies an explicit `dfsp_activation_height` for BLK-007 / SVL-001 version selection and
414    /// the BLD-005 DFSP-root precondition ([`BuilderError::MissingDfspRoots`]).
415    ///
416    /// **Rationale:** Crate tests keep [`DFSP_ACTIVATION_HEIGHT`] at `u64::MAX` (DFSP off) so normal `build()` always
417    /// selects [`crate::VERSION_V1`]. Passing a finite `dfsp_activation_height` **≤** [`Self::height`] forces V2 in
418    /// integration tests without recompiling constants.
419    pub fn build_with_dfsp_activation(
420        self,
421        state_root: Bytes32,
422        receipts_root: Bytes32,
423        signer: &dyn BlockSigner,
424        dfsp_activation_height: u64,
425    ) -> Result<L2Block, BuilderError> {
426        if self.spend_bundles.is_empty() {
427            return Err(BuilderError::EmptyBlock);
428        }
429
430        let spends_root = compute_spends_root(&self.spend_bundles);
431        let additions_root = compute_additions_root(&self.additions);
432        let removals_root = compute_removals_root(&self.removals);
433        let slash_proposals_root =
434            L2Block::slash_proposals_root_from(&self.slash_proposal_payloads);
435        let filter_hash = compute_filter_hash(self.parent_hash, &self.additions, &self.removals);
436
437        let version = L2BlockHeader::protocol_version_for_height_with_activation(
438            self.height,
439            dfsp_activation_height,
440        );
441        if version == VERSION_V2 {
442            let dfsp = [
443                self.collateral_registry_root,
444                self.cid_state_root,
445                self.node_registry_root,
446                self.namespace_update_root,
447                self.dfsp_finalize_commitment_root,
448            ];
449            if dfsp.iter().all(|r| *r == EMPTY_ROOT) {
450                return Err(BuilderError::MissingDfspRoots);
451            }
452        }
453
454        let spend_bundle_count = usize_to_u32_count(self.spend_bundles.len());
455        let additions_count = usize_to_u32_count(self.additions.len());
456        let removals_rows: usize = self
457            .spend_bundles
458            .iter()
459            .map(|sb| sb.coin_spends.len())
460            .sum();
461        let removals_count = usize_to_u32_count(removals_rows);
462        let slash_proposal_count = usize_to_u32_count(self.slash_proposal_payloads.len());
463
464        let timestamp = SystemTime::now()
465            .duration_since(UNIX_EPOCH)
466            .map(|d| d.as_secs())
467            .unwrap_or(0);
468
469        let mut header = L2BlockHeader::new(
470            self.height,
471            self.epoch,
472            self.parent_hash,
473            state_root,
474            spends_root,
475            additions_root,
476            removals_root,
477            receipts_root,
478            self.l1_height,
479            self.l1_hash,
480            self.proposer_index,
481            spend_bundle_count,
482            self.total_cost,
483            self.total_fees,
484            additions_count,
485            removals_count,
486            0,
487            filter_hash,
488        );
489        header.version = version;
490        header.timestamp = timestamp;
491        header.slash_proposal_count = slash_proposal_count;
492        header.slash_proposals_root = slash_proposals_root;
493        header.extension_data = self.extension_data;
494        header.l1_collateral_coin_id = self.l1_collateral_coin_id;
495        header.l1_reserve_coin_id = self.l1_reserve_coin_id;
496        header.l1_prev_epoch_finalizer_coin_id = self.l1_prev_epoch_finalizer_coin_id;
497        header.l1_curr_epoch_finalizer_coin_id = self.l1_curr_epoch_finalizer_coin_id;
498        header.l1_network_coin_id = self.l1_network_coin_id;
499        header.collateral_registry_root = self.collateral_registry_root;
500        header.cid_state_root = self.cid_state_root;
501        header.node_registry_root = self.node_registry_root;
502        header.namespace_update_root = self.namespace_update_root;
503        header.dfsp_finalize_commitment_root = self.dfsp_finalize_commitment_root;
504
505        let spend_bundles = self.spend_bundles;
506        let slash_proposal_payloads = self.slash_proposal_payloads;
507
508        let mut block = L2Block::new(
509            header,
510            spend_bundles,
511            slash_proposal_payloads,
512            Signature::default(),
513        );
514
515        // Two-pass `block_size` (BLD-005): measure with placeholder zero, then store the full `bincode(L2Block)` length.
516        let measured = block.compute_size();
517        block.header.block_size = usize_to_u32_count(measured);
518
519        // BLD-006: sign the **final** header digest (includes two-pass `block_size`; HSH-001 preimage omits BLS sig —
520        // see BLK-003 `L2Block::proposer_signature`). ERR-004 stores `SignerError` as `String` via `Display` so
521        // [`crate::BuilderError`] stays `Clone` without wrapping a nested `thiserror` source type.
522        let header_hash = block.header.hash();
523        let sig = signer
524            .sign_block(&header_hash)
525            .map_err(|e| BuilderError::SigningFailed(e.to_string()))?;
526        block.proposer_signature = sig;
527
528        Ok(block)
529    }
530}
531
532/// Header/body count fields are `u32` on wire; saturate when `usize` exceeds `u32::MAX` (same helper pattern as BLK-004).
533#[inline]
534fn usize_to_u32_count(n: usize) -> u32 {
535    u32::try_from(n).unwrap_or(u32::MAX)
536}