dig_slashing/evidence/attestation_data.rs
1//! `AttestationData` — the Ethereum-parity attester vote payload.
2//!
3//! Traces to: [SPEC.md §3.3](../../docs/resources/SPEC.md), catalogue row
4//! [DSL-004](../../docs/requirements/domains/evidence/specs/DSL-004.md).
5//!
6//! # Role
7//!
8//! `AttestationData` is the signable payload every attester BLS-signs. It
9//! carries:
10//!
11//! - `slot` + `index` — the committee coordinates.
12//! - `beacon_block_root` — the head vote.
13//! - `source` + `target` — the FFG vote pair ([`Checkpoint`]).
14//!
15//! `signing_root(&network_id)` hashes the payload under `DOMAIN_BEACON_ATTESTER`
16//! with the network id mixed in; the result is the BLS signing message
17//! consumed by `IndexedAttestation::verify_signature` (DSL-006) and by
18//! `classify_timeliness` (DSL-075..077) participation tracking.
19//!
20//! # Determinism + replay resistance
21//!
22//! - Identical inputs always produce identical output (verified by
23//! `test_dsl_004_signing_root_deterministic`).
24//! - The domain tag stops a signature produced here from verifying against
25//! a proposer signing message (which uses a different tag, DSL-050).
26//! - The network_id mix stops cross-network replay (testnet → mainnet).
27//! - Every field (including both `Checkpoint`s in full) participates in
28//! the hash, so mutation anywhere shifts the output.
29
30use chia_sha2::Sha256;
31use dig_protocol::Bytes32;
32use serde::{Deserialize, Serialize};
33
34use crate::constants::DOMAIN_BEACON_ATTESTER;
35use crate::evidence::checkpoint::Checkpoint;
36
37/// Attester vote payload.
38///
39/// Per [SPEC §3.3](../../docs/resources/SPEC.md). Field layout is frozen
40/// as wire protocol — see [`AttestationData::signing_root`] for the exact
41/// byte order used by BLS signing.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
43pub struct AttestationData {
44 /// L2 slot the attestation targets.
45 pub slot: u64,
46 /// Committee index within the slot.
47 pub index: u64,
48 /// Head vote: canonical beacon block root at `slot`.
49 pub beacon_block_root: Bytes32,
50 /// FFG source checkpoint.
51 pub source: Checkpoint,
52 /// FFG target checkpoint.
53 pub target: Checkpoint,
54}
55
56impl AttestationData {
57 /// Compute the BLS signing root for this attestation.
58 ///
59 /// Implements [DSL-004](../../docs/requirements/domains/evidence/specs/DSL-004.md).
60 /// Traces to SPEC §3.3.
61 ///
62 /// # Wire layout
63 ///
64 /// The hasher is fed the following bytes, in order:
65 ///
66 /// ```text
67 /// DOMAIN_BEACON_ATTESTER (22 bytes, "DIG_BEACON_ATTESTER_V1")
68 /// network_id (32 bytes)
69 /// slot (8 bytes, little-endian u64)
70 /// index (8 bytes, little-endian u64)
71 /// beacon_block_root (32 bytes)
72 /// source.epoch (8 bytes, little-endian u64)
73 /// source.root (32 bytes)
74 /// target.epoch (8 bytes, little-endian u64)
75 /// target.root (32 bytes)
76 /// ```
77 ///
78 /// Total input: 182 bytes. Output: 32-byte SHA-256 digest.
79 ///
80 /// # Invariants
81 ///
82 /// - **Deterministic:** identical `(self, network_id)` inputs always
83 /// produce bit-identical output.
84 /// - **Domain-bound:** prefixed with `DOMAIN_BEACON_ATTESTER`; the tag
85 /// is NOT a separate field of the struct.
86 /// - **Network-bound:** `network_id` mixed in after the tag; a
87 /// signing root produced for testnet does NOT verify under mainnet.
88 /// - **Field-covering:** every field (including both `Checkpoint`
89 /// components) contributes to the digest; mutating any one shifts
90 /// the output.
91 ///
92 /// All four invariants are enforced by
93 /// `tests/dsl_004_attestation_data_signing_root_test.rs`.
94 ///
95 /// # Endianness
96 ///
97 /// All integer fields (`slot`, `index`, `source.epoch`, `target.epoch`)
98 /// use little-endian encoding via `u64::to_le_bytes`. Little-endian is
99 /// the wire-level standard for DIG / Chia; the test suite guards
100 /// against accidental big-endian drift.
101 ///
102 /// # No custom hashing
103 ///
104 /// Uses `chia_sha2::Sha256` directly — do NOT introduce a generic
105 /// `sha2` crate dep for this codebase (SPEC §5 hard rule, dt-hard-rules
106 /// Rule 4).
107 pub fn signing_root(&self, network_id: &Bytes32) -> Bytes32 {
108 let mut h = Sha256::new();
109 h.update(DOMAIN_BEACON_ATTESTER);
110 h.update(network_id.as_ref());
111 h.update(self.slot.to_le_bytes());
112 h.update(self.index.to_le_bytes());
113 h.update(self.beacon_block_root.as_ref());
114 h.update(self.source.epoch.to_le_bytes());
115 h.update(self.source.root.as_ref());
116 h.update(self.target.epoch.to_le_bytes());
117 h.update(self.target.root.as_ref());
118 let out: [u8; 32] = h.finalize();
119 Bytes32::new(out)
120 }
121
122 /// Whether this attestation forms a slashable pair with `other`.
123 ///
124 /// The single source of truth for the attester-slashing predicate
125 /// (DSL-014 double-vote, DSL-015 surround-vote). Both the evidence
126 /// verifier (`verify_attester_slashing`) and the appeal ground
127 /// (`verify_attester_appeal_not_slashable_by_predicate`) decide
128 /// slashability from HERE. Keeping one definition is load-bearing for
129 /// soundness: the appeal is the exact logical inverse of the evidence
130 /// check, so a second copy could drift into an unfair slash (evidence
131 /// slashes, appeal cannot revert) or an un-slashable one.
132 ///
133 /// Returns `true` iff EITHER predicate holds:
134 /// - **Double vote (DSL-014):** same `target.epoch`, but the two
135 /// attestations are not byte-identical.
136 /// - **Surround vote (DSL-015):** one `(source.epoch, target.epoch)`
137 /// window strictly surrounds the other, checked in both directions.
138 ///
139 /// Symmetric in its two operands:
140 /// `a.is_slashable_against(b) == b.is_slashable_against(a)`.
141 #[must_use]
142 pub(crate) fn is_slashable_against(&self, other: &AttestationData) -> bool {
143 let double_vote = self.target.epoch == other.target.epoch && self != other;
144 let surround_vote = (self.source.epoch < other.source.epoch
145 && self.target.epoch > other.target.epoch)
146 || (other.source.epoch < self.source.epoch && other.target.epoch > self.target.epoch);
147 double_vote || surround_vote
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::evidence::checkpoint::Checkpoint;
155
156 /// Build an `AttestationData` from `(source_epoch, target_epoch)`; the
157 /// checkpoint roots vary with the epoch so distinct epochs give
158 /// distinct (non-byte-identical) attestations.
159 fn att(source_epoch: u64, target_epoch: u64) -> AttestationData {
160 AttestationData {
161 slot: target_epoch,
162 index: 0,
163 beacon_block_root: Bytes32::new([0u8; 32]),
164 source: Checkpoint {
165 epoch: source_epoch,
166 root: Bytes32::new([source_epoch as u8; 32]),
167 },
168 target: Checkpoint {
169 epoch: target_epoch,
170 root: Bytes32::new([target_epoch as u8; 32]),
171 },
172 }
173 }
174
175 #[test]
176 fn double_vote_same_target_different_data_is_slashable() {
177 let a = att(1, 5);
178 let mut b = att(1, 5);
179 b.beacon_block_root = Bytes32::new([9u8; 32]);
180 assert!(a.is_slashable_against(&b));
181 assert!(b.is_slashable_against(&a));
182 }
183
184 #[test]
185 fn byte_identical_attestations_are_not_slashable() {
186 let a = att(1, 5);
187 let b = att(1, 5);
188 assert!(!a.is_slashable_against(&b));
189 }
190
191 #[test]
192 fn surround_vote_is_slashable_both_directions() {
193 // a = [1, 6] strictly surrounds b = [2, 5].
194 let a = att(1, 6);
195 let b = att(2, 5);
196 assert!(a.is_slashable_against(&b));
197 assert!(b.is_slashable_against(&a));
198 }
199
200 #[test]
201 fn distinct_non_surrounding_non_double_vote_is_not_slashable() {
202 // Disjoint windows, different target epochs, neither surrounds.
203 let a = att(1, 2);
204 let b = att(3, 4);
205 assert!(!a.is_slashable_against(&b));
206 assert!(!b.is_slashable_against(&a));
207 }
208
209 #[test]
210 fn predicate_is_symmetric_across_a_matrix() {
211 for sa in 0..4u64 {
212 for ta in sa..sa + 4 {
213 for sb in 0..4u64 {
214 for tb in sb..sb + 4 {
215 let a = att(sa, ta);
216 let b = att(sb, tb);
217 assert_eq!(
218 a.is_slashable_against(&b),
219 b.is_slashable_against(&a),
220 "asymmetry at a=({sa},{ta}) b=({sb},{tb})"
221 );
222 }
223 }
224 }
225 }
226 }
227}