Skip to main content

dig_slashing/evidence/
verify.rs

1//! Evidence verification dispatcher.
2//!
3//! Traces to: [SPEC.md §5.1](../../docs/resources/SPEC.md), catalogue rows
4//! [DSL-011..021](../../docs/requirements/domains/evidence/specs/).
5//!
6//! # Role
7//!
8//! `verify_evidence` is the sole entry point every `SlashingEvidence`
9//! flows through on its way to:
10//!
11//! - `SlashingManager::submit_evidence` (DSL-022) — state-mutating.
12//! - Block-admission / mempool pipelines — via
13//!   `verify_evidence_for_inclusion` (DSL-021), which must be identical
14//!   minus state mutation.
15//!
16//! The function runs per-envelope preconditions in a fixed order, then
17//! dispatches per payload variant. Preconditions are split into
18//! "cheap filters" (epoch lookback, reporter registration / self-accuse)
19//! and "crypto-heavy" (BLS verify, oracle re-execution) — cheap first.
20//!
21//! # Implementation status
22//!
23//! This module currently implements the OffenseTooOld precondition
24//! (DSL-011) and emits a placeholder success result for every other
25//! path. The remaining preconditions (DSL-012 reporter self-accuse,
26//! DSL-013..020 per-payload dispatch) land in subsequent commits. Each
27//! DSL row adds one conditional, never mutating the structure of the
28//! function.
29
30use chia_bls::{PublicKey, Signature};
31use dig_protocol::Bytes32;
32
33use crate::constants::{
34    BLS_SIGNATURE_SIZE, DOMAIN_BEACON_PROPOSER, MAX_SLASH_PROPOSAL_PAYLOAD_BYTES,
35};
36use crate::error::SlashingError;
37use crate::evidence::attester_slashing::AttesterSlashing;
38use crate::evidence::envelope::{SlashingEvidence, SlashingEvidencePayload};
39use crate::evidence::invalid_block::InvalidBlockProof;
40use crate::evidence::offense::OffenseType;
41use crate::evidence::proposer_slashing::{ProposerSlashing, SignedBlockHeader};
42use crate::traits::{InvalidBlockOracle, PublicKeyLookup, ValidatorView};
43
44/// Successful-verification return shape.
45///
46/// Traces to [SPEC §3.9](../../docs/resources/SPEC.md).
47///
48/// # Invariants
49///
50/// - `offense_type == evidence.offense_type` (verifier never reclassifies).
51/// - `slashable_validator_indices == evidence.slashable_validators()`
52///   (same ordering + cardinality; ascending for Attester per DSL-010).
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
54pub struct VerifiedEvidence {
55    /// Classification of the confirmed offense. Drives base-penalty
56    /// lookup (DSL-001) and downstream reward routing.
57    pub offense_type: OffenseType,
58    /// Validator indices the manager will debit. For Proposer /
59    /// InvalidBlock this is a single-element vec; for Attester it is
60    /// the sorted intersection (DSL-007).
61    pub slashable_validator_indices: Vec<u32>,
62}
63
64/// Verify a `SlashingEvidence` envelope against the current validator
65/// set + epoch context.
66///
67/// Implements [DSL-011](../../docs/requirements/domains/evidence/specs/DSL-011.md)
68/// (OffenseTooOld precondition). Subsequent DSLs extend this function
69/// rather than replace it — verifier ordering is protocol.
70///
71/// # Current precondition order
72///
73/// 1. **OffenseTooOld** (DSL-011): `evidence.epoch + SLASH_LOOKBACK_EPOCHS
74///    >= current_epoch`. Addition on the LHS avoids underflow at network
75///    boot (`current_epoch < SLASH_LOOKBACK_EPOCHS`).
76/// 2. **ReporterIsAccused** (DSL-012):
77///    `evidence.reporter_validator_index ∉ evidence.slashable_validators()`.
78///    Blocks a validator from self-slashing to collect the whistleblower
79///    reward.
80///
81/// 3. **Per-payload dispatch**:
82///    - Proposer → [`verify_proposer_slashing`] (DSL-013).
83///    - Attester / InvalidBlock → placeholder accept (DSL-014..020 land
84///      in subsequent commits).
85///
86/// # Not yet enforced (placeholder accept)
87///
88/// - DSL-014/015: attester double-vote / surround-vote predicates.
89/// - DSL-016/017: attester intersection / predicate failure.
90/// - DSL-018/019/020: invalid-block signature / epoch / oracle.
91///
92/// Until those land, envelopes passing the lookback check return a
93/// placeholder `VerifiedEvidence` — consumers MUST NOT treat this
94/// function as fully soundness-complete yet. The placeholder is
95/// observable only in test fixtures (DSL-011 test only exercises the
96/// boundary + error path).
97///
98/// # Parameters
99///
100/// - `evidence`: the envelope to verify.
101/// - `_validator_view`: validator set handle. Consumed by DSL-012+
102///   (currently unused but locked into the signature per SPEC §5.1).
103/// - `_network_id`: chain id for BLS signing-root derivation. Consumed
104///   by DSL-013/018 (currently unused).
105/// - `current_epoch`: epoch the verifier is running in. ONLY required
106///   right now for the OffenseTooOld check.
107pub fn verify_evidence(
108    evidence: &SlashingEvidence,
109    validator_view: &dyn ValidatorView,
110    network_id: &Bytes32,
111    current_epoch: u64,
112) -> Result<VerifiedEvidence, SlashingError> {
113    // DSL-011: OffenseTooOld. Phrased with `evidence.epoch + LOOKBACK`
114    // on the LHS so `current_epoch = 0` cannot underflow the RHS.
115    // `u64::saturating_add` is the defensive belt — `evidence.epoch`
116    // arriving as `u64::MAX - LOOKBACK` would overflow a naïve `+`.
117    let lookback_sum = evidence
118        .epoch
119        .saturating_add(dig_epoch::SLASH_LOOKBACK_EPOCHS);
120    if lookback_sum < current_epoch {
121        return Err(SlashingError::OffenseTooOld {
122            offense_epoch: evidence.epoch,
123            current_epoch,
124        });
125    }
126
127    // DSL-012: ReporterIsAccused. Compute slashable validators once and
128    // reuse for both this check and the `VerifiedEvidence` return.
129    // Intentional binding: a validator cannot whistleblow on itself and
130    // collect the reward — that would turn slashing into a profitable
131    // self-report.
132    let slashable = evidence.slashable_validators();
133    if slashable.contains(&evidence.reporter_validator_index) {
134        return Err(SlashingError::ReporterIsAccused(
135            evidence.reporter_validator_index,
136        ));
137    }
138
139    // DSL-013+: per-payload dispatch. Each variant drives a dedicated
140    // verifier that enforces payload-specific preconditions + BLS math.
141    // The dispatcher never reclassifies offense_type — it either
142    // returns the same `VerifiedEvidence { offense_type, slashable }`
143    // or a payload-specific error variant.
144    let _ = slashable;
145    match &evidence.payload {
146        SlashingEvidencePayload::Proposer(p) => {
147            verify_proposer_slashing(evidence, p, validator_view, network_id)
148        }
149        SlashingEvidencePayload::Attester(a) => {
150            verify_attester_slashing(evidence, a, validator_view, network_id)
151        }
152        // DSL-018..020: invalid-block. Dispatcher passes `None` for the
153        // oracle — bootstrap semantics; callers needing full re-execution
154        // call `verify_invalid_block` directly with `Some(oracle)`.
155        SlashingEvidencePayload::InvalidBlock(i) => {
156            verify_invalid_block(evidence, i, validator_view, network_id, None)
157        }
158    }
159}
160
161/// Mempool-admission wrapper around [`verify_evidence`].
162///
163/// Implements [DSL-021](../../docs/requirements/domains/evidence/specs/DSL-021.md).
164/// Traces to SPEC §5.5.
165///
166/// # Role
167///
168/// `dig-mempool` (REMARK admission, DSL-106) calls this to screen
169/// evidence envelopes BEFORE a block containing them is accepted. It
170/// runs the full per-offense verifier chain and produces a byte-equal
171/// verdict to [`verify_evidence`] on the same inputs.
172///
173/// # Parity with `verify_evidence`
174///
175/// Today the two functions are identical — `verify_evidence` itself
176/// performs no state mutation (it walks `&dyn ValidatorView`, never
177/// `&mut`). Keeping the separation is about API contract: the mempool
178/// reads through `&dyn ValidatorView` by type, and future additions
179/// to `verify_evidence` (processed-map dedup, state-mutating side
180/// effects at the manager layer) MUST NOT leak into the mempool path.
181///
182/// The DSL-021 test suite enforces byte-equal verdicts on every
183/// branch: accept, OffenseTooOld, ReporterIsAccused, per-payload
184/// reject. Any future divergence requires an explicit SPEC update.
185pub fn verify_evidence_for_inclusion(
186    evidence: &SlashingEvidence,
187    validator_view: &dyn ValidatorView,
188    network_id: &Bytes32,
189    current_epoch: u64,
190) -> Result<VerifiedEvidence, SlashingError> {
191    verify_evidence(evidence, validator_view, network_id, current_epoch)
192}
193
194/// Proposer-equivocation verifier.
195///
196/// Implements [DSL-013](../../docs/requirements/domains/evidence/specs/DSL-013.md).
197/// Traces to SPEC §5.2.
198///
199/// # Preconditions (checked in order)
200///
201/// 1. `header_a.slot == header_b.slot` — equivocation requires the
202///    proposer signed at the SAME slot.
203/// 2. `header_a.proposer_index == header_b.proposer_index` — both
204///    signatures must claim the same proposer.
205/// 3. `header_a.hash() != header_b.hash()` — different content; two
206///    byte-equal headers are not equivocation (DSL-034 appeal ground
207///    `HeadersIdentical`).
208/// 4. Both `signature.len() == BLS_SIGNATURE_SIZE` and decode as a
209///    valid G2 element.
210/// 5. Validator at `proposer_index` exists in the view, is not already
211///    slashed (short-circuit — DSL-026 dedup will reject at manager
212///    level but we reject here too to keep mempool admission honest),
213///    and `is_active_at_epoch(header_a.message.epoch)`.
214/// 6. Both signatures BLS-verify under the validator's pubkey against
215///    [`block_signing_message`] for their respective header.
216///
217/// # Returns
218///
219/// `Ok(VerifiedEvidence)` with `slashable_validator_indices ==
220/// [proposer_index]` (cardinality 1 per DSL-010).
221///
222/// Every precondition failure returns
223/// `SlashingError::InvalidProposerSlashing(reason)` carrying a
224/// human-readable diagnostic — appeals (DSL-034..040) distinguish the
225/// same categories via structured variants at their own layer.
226pub fn verify_proposer_slashing(
227    evidence: &SlashingEvidence,
228    payload: &ProposerSlashing,
229    validator_view: &dyn ValidatorView,
230    network_id: &Bytes32,
231) -> Result<VerifiedEvidence, SlashingError> {
232    let header_a = &payload.signed_header_a.message;
233    let header_b = &payload.signed_header_b.message;
234
235    // 1. Same slot.
236    if header_a.height != header_b.height {
237        return Err(SlashingError::InvalidProposerSlashing(format!(
238            "slot mismatch: header_a.height={}, header_b.height={}",
239            header_a.height, header_b.height,
240        )));
241    }
242
243    // 2. Same proposer.
244    if header_a.proposer_index != header_b.proposer_index {
245        return Err(SlashingError::InvalidProposerSlashing(format!(
246            "proposer mismatch: header_a.proposer_index={}, header_b.proposer_index={}",
247            header_a.proposer_index, header_b.proposer_index,
248        )));
249    }
250
251    // 3. Different content. Using `hash()` is cheaper than full
252    // `header_a == header_b` byte compare on the multi-KB preimage
253    // because `L2BlockHeader::hash` is a single SHA-256.
254    let hash_a = header_a.hash();
255    let hash_b = header_b.hash();
256    if hash_a == hash_b {
257        return Err(SlashingError::InvalidProposerSlashing(
258            "headers are identical (no equivocation)".into(),
259        ));
260    }
261
262    // 4. Decode both signatures. Width + parse failures collapse to
263    // InvalidProposerSlashing with a reason naming which side failed.
264    let sig_a = decode_sig(&payload.signed_header_a, "a")?;
265    let sig_b = decode_sig(&payload.signed_header_b, "b")?;
266
267    // 5. Validator lookup + active check. `is_active_at_epoch` is
268    // activation-inclusive, exit-exclusive (DSL-134).
269    let proposer_index = header_a.proposer_index;
270    let entry = validator_view
271        .get(proposer_index)
272        .ok_or(SlashingError::ValidatorNotRegistered(proposer_index))?;
273    if entry.is_slashed() {
274        return Err(SlashingError::InvalidProposerSlashing(format!(
275            "proposer {proposer_index} is already slashed",
276        )));
277    }
278    if !entry.is_active_at_epoch(header_a.epoch) {
279        return Err(SlashingError::InvalidProposerSlashing(format!(
280            "proposer {proposer_index} not active at epoch {}",
281            header_a.epoch,
282        )));
283    }
284
285    // 6. BLS verify both signatures against the respective signing
286    // messages. The augmented scheme (pk || msg) is applied by
287    // `chia_bls::verify` internally — same convention as DSL-006.
288    let pk = entry.public_key();
289    let msg_a = block_signing_message(network_id, header_a.epoch, &hash_a, proposer_index);
290    let msg_b = block_signing_message(network_id, header_b.epoch, &hash_b, proposer_index);
291    if !chia_bls::verify(&sig_a, pk, &msg_a) {
292        return Err(SlashingError::InvalidProposerSlashing(
293            "signature A BLS verify failed".into(),
294        ));
295    }
296    if !chia_bls::verify(&sig_b, pk, &msg_b) {
297        return Err(SlashingError::InvalidProposerSlashing(
298            "signature B BLS verify failed".into(),
299        ));
300    }
301
302    Ok(VerifiedEvidence {
303        offense_type: evidence.offense_type,
304        slashable_validator_indices: vec![proposer_index],
305    })
306}
307
308/// Attester-slashing verifier.
309///
310/// Implements [DSL-014](../../docs/requirements/domains/evidence/specs/DSL-014.md)
311/// (double-vote predicate + acceptance path). Also enforces the sibling
312/// preconditions that share the same control flow:
313/// [DSL-015](../../docs/requirements/domains/evidence/specs/DSL-015.md)
314/// (surround-vote), [DSL-016](../../docs/requirements/domains/evidence/specs/DSL-016.md)
315/// (empty-intersection rejection), and
316/// [DSL-017](../../docs/requirements/domains/evidence/specs/DSL-017.md)
317/// (neither-predicate rejection).
318///
319/// Traces to SPEC §5.3.
320///
321/// # Preconditions (checked in order)
322///
323/// 1. `attestation_a.validate_structure()` AND
324///    `attestation_b.validate_structure()` (DSL-005).
325/// 2. `attestation_a != attestation_b` (byte-wise) — byte-identical
326///    pairs are `InvalidAttesterSlashing("identical")`; they are NOT a
327///    slashable offense and the appeal ground `AttestationsIdentical`
328///    (DSL-041) mirrors this.
329/// 3. Double-vote OR surround-vote predicate holds (DSL-014 /
330///    DSL-015). If neither →
331///    [`SlashingError::AttesterSlashingNotSlashable`] (DSL-017).
332/// 4. `slashable = payload.slashable_indices()` non-empty (DSL-016).
333///    If empty → [`SlashingError::EmptySlashableIntersection`].
334/// 5. Both `IndexedAttestation::verify_signature` succeed (DSL-006) —
335///    aggregate BLS verify against each `AttestationData::signing_root`.
336///    Pubkeys are looked up through `validator_view`.
337///
338/// # Ordering rationale
339///
340/// Structure + identical + predicate + intersection are all byte
341/// comparisons — cheapest first. BLS verify is last because a
342/// failed aggregate pairing is the most expensive check. This
343/// ordering is protocol (appeal adjudication in DSL-042..048 walks
344/// the same sequence) and MUST NOT be reordered.
345///
346/// # Returns
347///
348/// `Ok(VerifiedEvidence { slashable_validator_indices: intersection })`
349/// where the intersection is the sorted set `{i : i ∈ a.indices ∧ i ∈
350/// b.indices}` (DSL-007).
351pub fn verify_attester_slashing(
352    evidence: &SlashingEvidence,
353    payload: &AttesterSlashing,
354    validator_view: &dyn ValidatorView,
355    network_id: &Bytes32,
356) -> Result<VerifiedEvidence, SlashingError> {
357    // 1. Structure. `validate_structure` returns a reason-bearing
358    // `InvalidIndexedAttestation`; bubble it up. Consumers (e.g.
359    // DSL-046 appeal ground) need the sub-variant intact.
360    payload.attestation_a.validate_structure()?;
361    payload.attestation_b.validate_structure()?;
362
363    // 2. Byte-identical pair → not equivocation.
364    if payload.attestation_a == payload.attestation_b {
365        return Err(SlashingError::InvalidAttesterSlashing(
366            "attestations are byte-identical (no offense)".into(),
367        ));
368    }
369
370    // 3. Predicate decision. A slashing is valid iff EITHER predicate
371    // holds. DSL-014: same target epoch + different data. DSL-015:
372    // one window strictly surrounds the other (checked both ways).
373    let a_data = &payload.attestation_a.data;
374    let b_data = &payload.attestation_b.data;
375    let is_double_vote = a_data.target.epoch == b_data.target.epoch && a_data != b_data;
376    let is_surround_vote = (a_data.source.epoch < b_data.source.epoch
377        && a_data.target.epoch > b_data.target.epoch)
378        || (b_data.source.epoch < a_data.source.epoch && b_data.target.epoch > a_data.target.epoch);
379    if !(is_double_vote || is_surround_vote) {
380        return Err(SlashingError::AttesterSlashingNotSlashable);
381    }
382
383    // 4. Intersection must be non-empty (DSL-016). Run BEFORE the BLS
384    // verify so honest nodes don't pay pairing cost on adversarial
385    // disjoint-committee evidence.
386    let slashable = payload.slashable_indices();
387    if slashable.is_empty() {
388        return Err(SlashingError::EmptySlashableIntersection);
389    }
390
391    // 5. BLS aggregate verify on BOTH attestations (DSL-006). Pubkeys
392    // come from the validator view via the `PublicKeyLookup` adapter.
393    // A missing index for any committee member collapses to
394    // `BlsVerifyFailed` — same coarse channel as DSL-006.
395    let pks = ValidatorViewPubkeys(validator_view);
396    payload.attestation_a.verify_signature(&pks, network_id)?;
397    payload.attestation_b.verify_signature(&pks, network_id)?;
398
399    // Classification: the verifier does NOT reclassify offense_type.
400    // The envelope already declares AttesterDoubleVote or AttesterSurroundVote;
401    // the predicate test above only confirms that at least one predicate
402    // holds. An honest reporter MAY file a double-vote evidence under
403    // the AttesterDoubleVote offense_type; correlation-penalty math
404    // (DSL-030) treats both variants identically.
405    Ok(VerifiedEvidence {
406        offense_type: evidence.offense_type,
407        slashable_validator_indices: slashable,
408    })
409}
410
411/// Invalid-block verifier.
412///
413/// Implements [DSL-018](../../docs/requirements/domains/evidence/specs/DSL-018.md)
414/// (BLS over `block_signing_message`). Also enforces the sibling
415/// preconditions that share the same control flow:
416/// [DSL-019](../../docs/requirements/domains/evidence/specs/DSL-019.md)
417/// (`evidence.epoch == header.epoch`) and
418/// [DSL-020](../../docs/requirements/domains/evidence/specs/DSL-020.md)
419/// (optional `InvalidBlockOracle::verify_failure` call).
420///
421/// Traces to SPEC §5.4.
422///
423/// # Preconditions (checked in order)
424///
425/// 1. `header.epoch == evidence.epoch` (DSL-019) — cheap filter before
426///    any BLS work.
427/// 2. `failure_witness.len() ∈ [1, MAX_SLASH_PROPOSAL_PAYLOAD_BYTES]`
428///    (SPEC §5.4 step 4).
429/// 3. Signature decodes as a valid 96-byte G2 element.
430/// 4. Validator exists in the view, is not already slashed, and is
431///    active at `header.epoch`.
432/// 5. BLS verify via `chia_bls::verify(sig, pk, block_signing_message(...))`
433///    using the SAME helper as honest block production (DSL-018).
434/// 6. Optional `oracle.verify_failure(header, witness, reason)` —
435///    bootstrap mode (`oracle = None`) accepts; full-node mode
436///    re-executes and rejects on disagreement (DSL-020).
437///
438/// # Ordering rationale
439///
440/// Cheap scalar compare → size check → sig parse → validator lookup →
441/// BLS pairing → oracle re-execution. Each stage is stricty more
442/// expensive than the previous; honest nodes reject adversarial
443/// evidence at the earliest possible stage.
444///
445/// # Returns
446///
447/// `Ok(VerifiedEvidence)` with
448/// `slashable_validator_indices = [proposer_index]` (cardinality 1
449/// per DSL-010).
450pub fn verify_invalid_block(
451    evidence: &SlashingEvidence,
452    payload: &InvalidBlockProof,
453    validator_view: &dyn ValidatorView,
454    network_id: &Bytes32,
455    oracle: Option<&dyn InvalidBlockOracle>,
456) -> Result<VerifiedEvidence, SlashingError> {
457    let header = &payload.signed_header.message;
458
459    // 1. Epoch match (DSL-019). Cheap + first — a mismatched envelope
460    // epoch is either a reporter bug or a replay attempt.
461    if header.epoch != evidence.epoch {
462        return Err(SlashingError::InvalidSlashingEvidence(format!(
463            "epoch mismatch: header={} envelope={}",
464            header.epoch, evidence.epoch,
465        )));
466    }
467
468    // 2. Witness size bound. Zero-length witnesses are trivially
469    // useless (nothing to re-execute); oversized witnesses are a
470    // payload-bloat attack.
471    let witness_len = payload.failure_witness.len();
472    if witness_len == 0 {
473        return Err(SlashingError::InvalidSlashingEvidence(
474            "failure_witness is empty".into(),
475        ));
476    }
477    if witness_len > MAX_SLASH_PROPOSAL_PAYLOAD_BYTES {
478        return Err(SlashingError::InvalidSlashingEvidence(format!(
479            "failure_witness length {witness_len} exceeds MAX_SLASH_PROPOSAL_PAYLOAD_BYTES ({MAX_SLASH_PROPOSAL_PAYLOAD_BYTES})",
480        )));
481    }
482
483    // 3. Signature decode.
484    let sig_bytes: &[u8; BLS_SIGNATURE_SIZE] = payload
485        .signed_header
486        .signature
487        .as_slice()
488        .try_into()
489        .map_err(|_| {
490            SlashingError::InvalidSlashingEvidence(format!(
491                "signature width {} != {BLS_SIGNATURE_SIZE}",
492                payload.signed_header.signature.len(),
493            ))
494        })?;
495    let sig = Signature::from_bytes(sig_bytes).map_err(|_| {
496        SlashingError::InvalidSlashingEvidence("signature failed to decode as BLS G2".into())
497    })?;
498
499    // 4. Validator lookup + state checks.
500    let proposer_index = header.proposer_index;
501    let entry = validator_view
502        .get(proposer_index)
503        .ok_or(SlashingError::ValidatorNotRegistered(proposer_index))?;
504    if entry.is_slashed() {
505        return Err(SlashingError::InvalidSlashingEvidence(format!(
506            "proposer {proposer_index} is already slashed",
507        )));
508    }
509    if !entry.is_active_at_epoch(header.epoch) {
510        return Err(SlashingError::InvalidSlashingEvidence(format!(
511            "proposer {proposer_index} not active at epoch {}",
512            header.epoch,
513        )));
514    }
515
516    // 5. BLS verify over the canonical block-signing message (DSL-018).
517    // SAME helper as honest block production → domain binding prevents
518    // cross-network replay + cross-context (attester) replay.
519    let msg = block_signing_message(network_id, header.epoch, &header.hash(), proposer_index);
520    let pk = entry.public_key();
521    if !chia_bls::verify(&sig, pk, &msg) {
522        return Err(SlashingError::InvalidSlashingEvidence(
523            "bad invalid-block signature".into(),
524        ));
525    }
526
527    // 6. Optional oracle (DSL-020). `None` → bootstrap mode. Full-node
528    // impls re-execute the block and validate the claimed failure
529    // reason. Any oracle error propagates.
530    if let Some(oracle) = oracle {
531        oracle.verify_failure(header, &payload.failure_witness, payload.failure_reason)?;
532    }
533
534    Ok(VerifiedEvidence {
535        offense_type: evidence.offense_type,
536        slashable_validator_indices: vec![proposer_index],
537    })
538}
539
540/// Zero-cost adapter that lets `verify_attester_slashing` reuse
541/// `IndexedAttestation::verify_signature` (DSL-006) against a
542/// `ValidatorView`.
543///
544/// `ValidatorView` and `PublicKeyLookup` are separate traits by design
545/// (SPEC §15) — the view owns mutating state, the lookup is read-only.
546/// Bridging inline here avoids forcing downstream callers to implement
547/// both traits on the same struct.
548struct ValidatorViewPubkeys<'a>(&'a dyn ValidatorView);
549
550impl<'a> PublicKeyLookup for ValidatorViewPubkeys<'a> {
551    fn pubkey_of(&self, index: u32) -> Option<&PublicKey> {
552        self.0.get(index).map(|e| e.public_key())
553    }
554}
555
556/// Parse a 96-byte BLS G2 signature from a `SignedBlockHeader`.
557fn decode_sig(signed: &SignedBlockHeader, label: &str) -> Result<Signature, SlashingError> {
558    let sig_bytes: &[u8; BLS_SIGNATURE_SIZE] =
559        signed.signature.as_slice().try_into().map_err(|_| {
560            SlashingError::InvalidProposerSlashing(format!(
561                "signature {label} has width {}, expected {BLS_SIGNATURE_SIZE}",
562                signed.signature.len(),
563            ))
564        })?;
565    Signature::from_bytes(sig_bytes).map_err(|_| {
566        SlashingError::InvalidProposerSlashing(format!(
567            "signature {label} failed to decode as BLS G2 element",
568        ))
569    })
570}
571
572/// Build the canonical BLS signing message for an L2 block header.
573///
574/// Traces to [SPEC §5.2 step 6](../../docs/resources/SPEC.md) + §2.10.
575///
576/// # Wire layout
577///
578/// ```text
579/// DOMAIN_BEACON_PROPOSER    ( 22 bytes, "DIG_BEACON_PROPOSER_V1")
580/// network_id                ( 32 bytes)
581/// epoch                     (  8 bytes, little-endian u64)
582/// header_hash               ( 32 bytes)
583/// proposer_index            (  4 bytes, little-endian u32)
584/// ```
585///
586/// Total: 98 bytes. Output: returned as `Vec<u8>` for direct use with
587/// `chia_bls::sign` / `chia_bls::verify`.
588///
589/// # Parity
590///
591/// SPEC names this function `dig_block::block_signing_message`, but
592/// `dig-block = 0.1` does not yet export it — the helper lives here
593/// pending upstream landing. The layout is frozen protocol; any future
594/// dig-block addition MUST produce byte-identical output.
595///
596/// Layout mirrors [`crate::AttestationData::signing_root`] (DSL-004):
597/// domain-tag || network_id || LE-encoded scalars || 32-byte hash. The
598/// endianness + field-ordering choices are the same.
599pub fn block_signing_message(
600    network_id: &Bytes32,
601    epoch: u64,
602    header_hash: &Bytes32,
603    proposer_index: u32,
604) -> Vec<u8> {
605    // Domain-tag + network + LE(epoch) + header hash + LE(proposer_idx)
606    let mut out = Vec::with_capacity(DOMAIN_BEACON_PROPOSER.len() + 32 + 8 + 32 + 4);
607    out.extend_from_slice(DOMAIN_BEACON_PROPOSER);
608    out.extend_from_slice(network_id.as_ref());
609    out.extend_from_slice(&epoch.to_le_bytes());
610    out.extend_from_slice(header_hash.as_ref());
611    out.extend_from_slice(&proposer_index.to_le_bytes());
612    out
613}