-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpayload_matching.rs
More file actions
255 lines (230 loc) · 9.08 KB
/
Copy pathpayload_matching.rs
File metadata and controls
255 lines (230 loc) · 9.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
//! Cross-references relay delivered payloads with on-chain beacon blocks.
use std::collections::BTreeMap;
use alloy_primitives::B256;
use futures::StreamExt;
use tracing::warn;
use crate::beacon::BeaconClient;
use crate::checks::CheckResult;
use crate::relay::RelayClient;
/// Compare relay delivered payload hashes against on-chain beacon block hashes.
///
/// Collects the block_hash EACH relay reported per slot (NOT deduped/first-wins),
/// fetches the on-chain hash per slot, then classifies. Splitting IO from the
/// verdict lets `classify_payload_matches` be unit-tested (Law 4).
pub async fn check_payload_hash_match(
relays: &[RelayClient],
beacon: &BeaconClient,
start_slot: u64,
end_slot: u64,
) -> CheckResult {
// Per slot, every (relay, block_hash) reported. Keeping all of them (instead
// of first-wins `or_insert`) is what lets us DETECT cross-relay disagreement.
let mut by_slot: BTreeMap<u64, Vec<(String, B256)>> = BTreeMap::new();
for relay in relays {
match relay.get_payloads_delivered(start_slot, end_slot).await {
Ok(payloads) => {
for p in payloads {
by_slot
.entry(p.slot)
.or_default()
.push((relay.base_url().to_string(), p.block_hash));
}
}
Err(e) => {
warn!("Failed to fetch payloads from {}: {e}", relay.base_url());
}
}
}
if by_slot.is_empty() {
return CheckResult::skip(
"payload_hash_match",
1,
"No delivered payloads to compare (upstream check owns this signal)",
);
}
// Fetch the on-chain hash for each observed slot (None = missing/error),
// concurrently but bounded. The result map is keyed by slot, so out-of-order
// completion cannot change it; the Err -> warn+None mapping is preserved.
let slots: Vec<u64> = by_slot.keys().copied().collect();
let fetched: Vec<_> = futures::stream::iter(slots)
.map(|slot| async move { (slot, beacon.get_block_hash(slot).await) })
.buffer_unordered(16)
.collect()
.await;
let mut chain: BTreeMap<u64, Option<B256>> = BTreeMap::new();
for (slot, res) in fetched {
let h = match res {
Ok(v) => v,
Err(e) => {
warn!("Failed to get block for slot {slot}: {e}");
None
}
};
chain.insert(slot, h);
}
classify_payload_matches(&by_slot, &chain)
}
/// Pure verdict logic (Law 4 seam). Generic over the hash type so tests use a
/// trivial `H`. WARNs when a slot has divergent relay hashes (relay equivocation)
/// OR when no relay hash matches the on-chain hash — the first-wins union used to
/// silently drop the cross-relay disagreement and could PASS order-dependently.
pub fn classify_payload_matches<H>(
by_slot: &BTreeMap<u64, Vec<(String, H)>>,
chain: &BTreeMap<u64, Option<H>>,
) -> CheckResult
where
H: Copy + Eq + std::hash::Hash + std::fmt::LowerHex,
{
if by_slot.is_empty() {
return CheckResult::skip(
"payload_hash_match",
1,
"No delivered payloads to compare (upstream check owns this signal)",
);
}
let mut matched = 0u64;
let mut mismatched = 0u64;
let mut missed = 0u64;
let mut mismatches = Vec::new();
let mut conflicts = Vec::new();
for (&slot, relay_hashes) in by_slot {
// Cross-relay disagreement: >1 distinct block_hash reported for one slot.
let distinct: std::collections::HashSet<H> = relay_hashes.iter().map(|(_, h)| *h).collect();
if distinct.len() > 1 {
conflicts.push(serde_json::json!({
"slot": slot,
"relays": relay_hashes
.iter()
.map(|(r, h)| serde_json::json!({ "relay": r, "block_hash": format!("{h:#x}") }))
.collect::<Vec<_>>(),
}));
warn!(
"Payload hash conflict at slot {slot}: {} distinct block hashes reported \
(relay equivocation or bug)",
distinct.len()
);
}
match chain.get(&slot) {
Some(Some(chain_hash)) => {
if distinct.iter().any(|h| h == chain_hash) {
matched += 1;
} else {
mismatched += 1;
mismatches.push(serde_json::json!({
"slot": slot,
"relay_hashes": distinct.iter().map(|h| format!("{h:#x}")).collect::<Vec<_>>(),
"chain_hash": format!("{chain_hash:#x}"),
}));
warn!(
"Hash mismatch at slot {slot}: no relay hash matched chain {chain_hash:#x} \
(possible reorg)"
);
}
}
_ => missed += 1,
}
}
let total = by_slot.len();
let conflict_count = conflicts.len();
let detail = format!(
"{matched} matched, {mismatched} mismatched, {conflict_count} cross-relay conflict(s), \
{missed} missed out of {total} delivered"
);
let data = serde_json::json!({
"matched": matched,
"mismatched": mismatched,
"missed": missed,
"cross_relay_conflicts": conflict_count,
"conflicts": conflicts,
"mismatches": mismatches,
});
if mismatched > 0 || conflict_count > 0 {
CheckResult::warn("payload_hash_match", 1, detail).with_data(data)
} else {
CheckResult::pass("payload_hash_match", 1, detail).with_data(data)
}
}
/// Run all payload matching checks.
pub async fn run_payload_checks(
relays: &[RelayClient],
beacon: &BeaconClient,
start_slot: u64,
end_slot: u64,
) -> Vec<CheckResult> {
vec![check_payload_hash_match(relays, beacon, start_slot, end_slot).await]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::checks::CheckStatus;
// --- classify_payload_matches verdict tests (u64 stands in for B256) ------
fn by_slot(pairs: &[(u64, &[(&str, u64)])]) -> BTreeMap<u64, Vec<(String, u64)>> {
pairs
.iter()
.map(|(slot, relays)| {
(
*slot,
relays.iter().map(|(r, h)| (r.to_string(), *h)).collect(),
)
})
.collect()
}
fn chain(pairs: &[(u64, Option<u64>)]) -> BTreeMap<u64, Option<u64>> {
pairs.iter().copied().collect()
}
#[test]
fn payload_pass_on_clean_single_relay_match() {
let bs = by_slot(&[(5, &[("relay-a", 0xaa)])]);
let ch = chain(&[(5, Some(0xaa))]);
let r = classify_payload_matches(&bs, &ch);
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.data["matched"], 1);
}
#[test]
fn payload_warn_on_cross_relay_conflict() {
// The false-green: two relays report DIFFERENT hashes for slot 5. The old
// first-wins union dropped one and could PASS; now it's detected → WARN.
let bs = by_slot(&[(5, &[("relay-a", 0xaa), ("relay-b", 0xbb)])]);
let ch = chain(&[(5, Some(0xaa))]); // one relay even matches chain
let r = classify_payload_matches(&bs, &ch);
assert_eq!(r.status, CheckStatus::Warn, "conflict must not pass");
assert_eq!(r.data["cross_relay_conflicts"], 1);
}
#[test]
fn payload_warn_when_no_relay_matches_chain() {
let bs = by_slot(&[(5, &[("relay-a", 0xaa)])]);
let ch = chain(&[(5, Some(0xbb))]);
let r = classify_payload_matches(&bs, &ch);
assert_eq!(r.status, CheckStatus::Warn);
assert_eq!(r.data["mismatched"], 1);
}
#[test]
fn payload_missed_does_not_downgrade_verdict() {
// A delivered slot with no on-chain block is 'missed', informational only.
let bs = by_slot(&[(5, &[("relay-a", 0xaa)])]);
let ch = chain(&[(5, None)]);
let r = classify_payload_matches(&bs, &ch);
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.data["missed"], 1);
}
#[test]
fn payload_empty_skips() {
let bs: BTreeMap<u64, Vec<(String, u64)>> = BTreeMap::new();
let ch: BTreeMap<u64, Option<u64>> = BTreeMap::new();
assert_eq!(classify_payload_matches(&bs, &ch).status, CheckStatus::Skip);
}
// Contract: with no relays there are no delivered payloads to cross-check,
// so the check must SKIP (it explicitly defers this signal to an upstream
// check) rather than PASS on zero comparisons. With an empty relay slice the
// collection loop never runs and the beacon is never queried, so this is a
// pure, network-free assertion of the empty-input contract.
#[tokio::test]
async fn no_relays_skips_not_passes() {
let beacon = BeaconClient::new("http://127.0.0.1:0");
let relays: [RelayClient; 0] = [];
let r = check_payload_hash_match(&relays, &beacon, 0, 10).await;
assert_eq!(r.status, CheckStatus::Skip);
assert_eq!(r.id, "payload_hash_match");
assert_eq!(r.tier, 1);
}
}