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}