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}