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