decdn_node/slash_watcher.rs
1//! Daemon slash-detection watcher (#1032, G-NODE-05).
2//!
3//! Surfaces every still-appealable slash minted against this node's own operator
4//! so the operator can see it over the admin RPC (`admin_v1_slashes`) and file
5//! `decdn appeal slash` within the 30-day window without watching the chain
6//! directly. A counter metric (`decdn_slashes_detected_total`) mirrors it.
7//!
8//! Shape follows the `CapacityBond` registry watcher
9//! ([`crate::dht::capacity_bond_registry`]) — **enumerate at a snapshot block,
10//! follow the tail, re-read periodically as the backstop** — rather than
11//! replaying logs:
12//!
13//! - **Enumerate at boot.** The in-memory store is rebuilt on every start from
14//! the authoritative `CapacityBond.operatorSlash*` enumeration (ADR 019), not
15//! by re-scanning the `Slashed` log tail from a block floor (#1108). Walking
16//! the append-only index backwards and stopping at the first closed record
17//! reads only the still-appealable tail. Offense and evidence come straight off
18//! the record; the enforced deadline is the record's base `appealWindowClose`
19//! plus the global `pausedTotal`, all read at the snapshot block. A transient
20//! RPC failure retries on the shared boot budget, like the registry bootstrap
21//! on the same contract; a deterministic fault or an exhausted budget fails
22//! startup.
23//! - **Follow `SlashRecorded` from the snapshot block.** The route follows `SlashRecorded` on the
24//! shared multiplexed poller, seeded at the enumeration snapshot block, so there
25//! is no historical scan on any boot. `SlashRecorded` indexes `operator` as
26//! `topic2`, but the merged poller filter cannot scope `topic2`, so the route
27//! receives every operator's `SlashRecorded` and `decode_recorded`'s
28//! `operator == self` guard filters out the rest. The event carries only the
29//! `slashId`; the offense/evidence/deadline are point-read from
30//! `getSlashRecord`.
31//! - **Resync as the backstop.** A missed tail event (reorg at the unstable tip,
32//! or a tick lost to RPC backoff) never self-heals otherwise, so the store is
33//! re-enumerated every `SLASH_RESYNC_INTERVAL` at the snapshot block; this
34//! also prunes slashes whose appeal window has since closed. A slash the tail
35//! saw above that block survives the resync (`fold_resync`). The store is
36//! deduped by `slashId`, so the enumeration/tail overlap is harmless.
37
38use std::future::Future;
39use std::sync::{Arc, RwLock};
40use std::time::{Duration, Instant};
41
42use alloy::eips::BlockId;
43use alloy::primitives::{Address, B256, U256};
44use alloy::providers::Provider;
45use alloy::rpc::types::Log;
46use alloy::sol_types::SolEvent;
47use anyhow::{Context, Result};
48use decdn_incentive::capacity_bond::CapacityBond;
49use tracing::{debug, info, warn};
50
51use decdn_common::redact::sanitize_err_chain;
52
53use crate::chain_events::boot_retry::BootRetry;
54use crate::chain_events::multiplexed_poller::{Route, SinkSource};
55use crate::chain_events::resumable_watcher::{CursorStart, LogSink, clear_cadence_on_recovery};
56use crate::chain_events::shared_head::{HeadSource, snapshot_block};
57use crate::chain_events::timed;
58use crate::metrics::{Metrics, metric_hook};
59
60/// How often the store is re-enumerated from `CapacityBond` as a drift backstop.
61///
62/// Mirrors the `CapacityBond` registry watcher's cadence and rationale: a missed
63/// live-tail event never self-heals short of a restart, and one enumeration of a
64/// single operator's (tiny) slash list every quarter hour is negligible.
65/// Deliberately a constant, not a config knob — a correctness backstop, not a
66/// tuning surface.
67const SLASH_RESYNC_INTERVAL: Duration = Duration::from_mins(15);
68
69/// One slash detected against this node's operator.
70#[derive(Debug, Clone)]
71pub struct DetectedSlash {
72 /// Globally-monotonic on-chain `slashId` (`CapacityBond.slash`).
73 pub slash_id: U256,
74 /// Offense taxonomy index (ADR 014): 0=RateManipulation, 1=Blacklist.
75 pub offense_type: u8,
76 /// Bond amount slashed, in TOKEN base units.
77 pub amount: U256,
78 /// `keccak256` evidence digest, from the `CapacityBond` slash record.
79 pub evidence_hash: B256,
80 /// Block the `SlashRecorded` log was mined in — `Some` for a slash seen live
81 /// on the tail, `None` for one recovered by the boot/resync enumeration
82 /// (which reads records, not logs).
83 pub block_number: Option<u64>,
84 /// **Effective** appeal-window close the contract enforces: the record's base
85 /// `appealWindowClose` plus the global `pausedTotal` snapshot, so protocol-pause
86 /// extensions are reflected. Always `Some` because the deadline comes off the
87 /// record, not a best-effort block read.
88 pub appeal_window_close: Option<u64>,
89}
90
91/// Shared, in-memory detected-slash store: appended in detection order and
92/// deduped by `slashId`. Cloned into [`crate::admin::AdminState`] for the
93/// read-only `admin_v1_slashes` surface.
94pub type SlashStore = Arc<RwLock<Vec<DetectedSlash>>>;
95
96/// The authoritative `CapacityBond.getSlashRecord` fields the enumeration and
97/// live tail consume, decoupled from the ABI struct so the boot path is
98/// unit-testable with a scripted stub.
99#[derive(Debug, Clone)]
100struct SlashRecordView {
101 /// Offense taxonomy index (ADR 014).
102 offense_type: u8,
103 /// Bond amount slashed, in TOKEN base units.
104 amount: U256,
105 /// `keccak256` evidence digest.
106 evidence_hash: B256,
107 /// **Base** appeal-window close (`slashedAt + 30d`), fixed at mint. The
108 /// deadline the contract enforces adds the global `pausedTotal`; callers must
109 /// fold that in (see [`bootstrap_slashes`]).
110 appeal_window_close: u64,
111}
112
113/// The chain reads the boot enumeration and the live tail perform, behind a
114/// trait so the paging and still-appealable filter are unit-testable without a
115/// provider.
116///
117/// Spelled RPITIT with an explicit `+ Send` (rather than `async fn`, whose
118/// futures carry no `Send` bound) because the periodic resync calls these from
119/// inside the spawned watcher's `on_tick_complete`, whose future `tokio::spawn`
120/// requires to be `Send`. Production monomorphizes to the alloy contract impl.
121trait SlashChainReads: Send + Sync {
122 /// The block an enumeration pins its reads to
123 /// (`shared_head::snapshot_block`): the lag margin below the head.
124 fn snapshot_block(&self) -> impl Future<Output = Result<u64>> + Send;
125 /// How many slashes have ever been minted against `operator`
126 /// (`operatorSlashCount`).
127 fn operator_slash_count(
128 &self,
129 operator: Address,
130 at: BlockId,
131 ) -> impl Future<Output = Result<U256>> + Send;
132 /// The `slashId` at `index` in `operator`'s append-only slash list
133 /// (`operatorSlashIdAt`).
134 fn operator_slash_id_at(
135 &self,
136 operator: Address,
137 index: U256,
138 at: BlockId,
139 ) -> impl Future<Output = Result<U256>> + Send;
140 /// The authoritative record for `slashId` (`getSlashRecord`).
141 fn get_slash_record(
142 &self,
143 slash_id: U256,
144 at: BlockId,
145 ) -> impl Future<Output = Result<SlashRecordView>> + Send;
146 /// The global `pausedTotal` offset added to every record's base
147 /// `appealWindowClose` to get the deadline the contract enforces.
148 fn paused_total(&self, at: BlockId) -> impl Future<Output = Result<u64>> + Send;
149}
150
151/// Enumerate this operator's still-appealable slashes from the authoritative
152/// `CapacityBond` records — the boot path that replaces re-scanning the
153/// `Slashed` log tail from a block floor on every start (#1108, ADR 019).
154///
155/// Walks the append-only index backwards from newest and stops at the first
156/// record whose appeal window has already closed against `now_secs`: earlier
157/// records were minted earlier (`slashedAt` order), so their windows closed too.
158/// The list is append-only with stable indices, so — unlike the swap-and-pop
159/// enumeration views — no count re-check is required; a slash minted mid-scan
160/// lands at an index past the old count (unseen here) and is picked up by the
161/// live `SlashRecorded` tail instead.
162///
163/// Every read runs at `at`, the snapshot block, so a load-balanced provider
164/// cannot answer the count from one backend and the index or record from a
165/// lagging one: a backend behind `at` answers "header not found", which retries,
166/// rather than reverting on an index it has not seen, which is deterministic and
167/// fails boot. A failed resync keeps the current set.
168async fn bootstrap_slashes<R: SlashChainReads>(
169 reads: &R,
170 operator: Address,
171 now_secs: u64,
172 at: BlockId,
173) -> Result<Vec<DetectedSlash>> {
174 let count = reads.operator_slash_count(operator, at).await?;
175 // The enforced deadline is the record's base `appealWindowClose` plus this
176 // global offset; read once at the same snapshot. It only ever grows and
177 // applies uniformly, so base-close ordering equals effective-close ordering
178 // and the backward-walk early-stop below stays valid.
179 let paused_total = reads.paused_total(at).await?;
180 let mut out = Vec::new();
181 let mut index = count;
182 while index > U256::ZERO {
183 index -= U256::from(1u8);
184 let slash_id = reads.operator_slash_id_at(operator, index, at).await?;
185 let record = reads.get_slash_record(slash_id, at).await?;
186 // The deadline the contract enforces (`SlashEscrowLib`): base + pause.
187 let effective_close = record.appeal_window_close.saturating_add(paused_total);
188 if effective_close <= now_secs {
189 // Appended in `slashedAt` order, so every older record is closed too.
190 break;
191 }
192 out.push(DetectedSlash {
193 slash_id,
194 offense_type: record.offense_type,
195 amount: record.amount,
196 evidence_hash: record.evidence_hash,
197 // No log is read on this path; the deadline is the pause-extended one
198 // the contract enforces, not a block-timestamp derivation.
199 block_number: None,
200 appeal_window_close: Some(effective_close),
201 });
202 }
203 Ok(out)
204}
205
206/// Production [`SlashChainReads`] over the live `CapacityBond` contract.
207#[derive(Clone)]
208struct ContractReads<P: Provider + Clone> {
209 bond: CapacityBond::CapacityBondInstance<P>,
210 /// The shared head source the snapshot block derives from.
211 head: Arc<dyn HeadSource>,
212}
213
214impl<P: Provider + Clone> SlashChainReads for ContractReads<P> {
215 async fn snapshot_block(&self) -> Result<u64> {
216 snapshot_block(self.bond.provider(), &*self.head, *self.bond.address()).await
217 }
218
219 async fn operator_slash_count(&self, operator: Address, at: BlockId) -> Result<U256> {
220 timed(
221 None,
222 "operatorSlashCount",
223 self.bond.operatorSlashCount(operator).block(at).call(),
224 )
225 .await
226 .with_context(|| format!("operatorSlashCount({operator})"))
227 }
228
229 async fn operator_slash_id_at(
230 &self,
231 operator: Address,
232 index: U256,
233 at: BlockId,
234 ) -> Result<U256> {
235 timed(
236 None,
237 "operatorSlashIdAt",
238 self.bond
239 .operatorSlashIdAt(operator, index)
240 .block(at)
241 .call(),
242 )
243 .await
244 .with_context(|| format!("operatorSlashIdAt({operator}, {index})"))
245 }
246
247 async fn get_slash_record(&self, slash_id: U256, at: BlockId) -> Result<SlashRecordView> {
248 let record = timed(
249 None,
250 "getSlashRecord",
251 self.bond.getSlashRecord(slash_id).block(at).call(),
252 )
253 .await
254 .with_context(|| format!("getSlashRecord({slash_id})"))?;
255 Ok(SlashRecordView {
256 offense_type: record.offenseType,
257 amount: record.slashAmount,
258 evidence_hash: record.evidenceHash,
259 appeal_window_close: record.appealWindowClose,
260 })
261 }
262
263 async fn paused_total(&self, at: BlockId) -> Result<u64> {
264 timed(
265 None,
266 "pausedTotal",
267 self.bond.pausedTotal().block(at).call(),
268 )
269 .await
270 .context("pausedTotal")
271 }
272}
273
274/// Current wall-clock UNIX seconds, the clock the still-appealable filter tests
275/// each record's `appealWindowClose` against. Wall time tracks chain time within
276/// seconds on an L2; the filter only decides whether a slash is *shown*, so a
277/// boundary-second skew is immaterial (the authoritative deadline is stored
278/// regardless). A pre-epoch clock degrades to `0`, which shows every slash.
279fn unix_now() -> u64 {
280 std::time::SystemTime::now()
281 .duration_since(std::time::UNIX_EPOCH)
282 .map_or(0, |d| d.as_secs())
283}
284
285/// Enumerate this operator's still-appealable slashes from `CapacityBond`, then
286/// return the shared detected-slash store (for the admin surface) and the
287/// `SlashRecorded` [`Route`] the shared poller drives to keep it current.
288///
289/// The enumeration retries transient failures on `boot`'s budget, like the
290/// `CapacityBond` registry bootstrap that runs on the same contract immediately
291/// before this one; a deterministic fault or an exhausted budget fails the
292/// bootstrap. Once running, a transient tail RPC blip retries with the shared
293/// poller's backoff, and the periodic resync repairs any drift — so detection is
294/// never disabled for the daemon's lifetime.
295pub async fn bootstrap<P: Provider + Clone + 'static>(
296 provider: P,
297 capacity_bond_addr: Address,
298 self_address: Address,
299 head: Arc<dyn HeadSource>,
300 metrics: Arc<Metrics>,
301 boot: &BootRetry,
302) -> Result<(SlashStore, Route)> {
303 info!(%capacity_bond_addr, %self_address, "slash-detection watcher started");
304 let reads = ContractReads {
305 bond: CapacityBond::new(capacity_bond_addr, provider),
306 head,
307 };
308
309 // Pin every read to one snapshot block and seed the tail cursor at that same
310 // block. A slash minted at or below it is in the enumeration; one above it is
311 // on the tail, which scans from the block inclusive. A slash both see is a
312 // deduped no-op, so overlap is safe but a gap is not. The block sits
313 // `SNAPSHOT_LAG_MARGIN_BLOCKS` below the reported head so every upstream
314 // behind a load-balanced RPC can serve the pinned reads.
315 let (snapshot_block, initial) = boot
316 .run("slash enumeration snapshot", || async {
317 let snapshot_block = reads
318 .snapshot_block()
319 .await
320 .context("read the slash enumeration snapshot block")?;
321 let initial = bootstrap_slashes(
322 &reads,
323 self_address,
324 unix_now(),
325 BlockId::number(snapshot_block),
326 )
327 .await
328 .with_context(|| {
329 format!("enumerate operator slashes from CapacityBond at {capacity_bond_addr}")
330 })?;
331 Ok((snapshot_block, initial))
332 })
333 .await?;
334 info!(
335 slash_count = initial.len(),
336 snapshot_block, %self_address, "slash enumeration complete"
337 );
338
339 // Seed through `record_slash` so the boot set is logged, counted, and deduped
340 // on the same path the live tail uses.
341 let store: SlashStore = Arc::new(RwLock::new(Vec::new()));
342 for slash in initial {
343 record_slash(&store, &metrics, slash);
344 }
345
346 let sink = SlashSink {
347 reads,
348 self_address,
349 store: Arc::clone(&store),
350 metrics: Arc::clone(&metrics),
351 resync_interval: SLASH_RESYNC_INTERVAL,
352 // The bootstrap enumeration just ran, so the first backstop resync is due
353 // one interval from now rather than on the first tick.
354 last_resync: Some(Instant::now()),
355 };
356 let route = Route {
357 addresses: vec![capacity_bond_addr],
358 topic0s: slash_route_topic0s(),
359 start: slash_cursor_start(snapshot_block),
360 sink: SinkSource::Ready(Box::new(sink)),
361 label: "slash",
362 on_established: Some(metric_hook(
363 &metrics,
364 Metrics::slash_watcher_cycle_established,
365 )),
366 on_backoff: Some(metric_hook(
367 &metrics,
368 Metrics::slash_watcher_backoff_started,
369 )),
370 on_tick_success: Some(metric_hook(&metrics, Metrics::slash_watcher_tick)),
371 on_task_panic: Some(metric_hook(&metrics, Metrics::slash_watcher_task_panicked)),
372 };
373 Ok((store, route))
374}
375
376/// The slash route's demux key: `SlashRecorded` only.
377///
378/// `SlashRecorded` indexes `operator` as `topic2`, but the merged poller filter
379/// cannot scope `topic2` to only this event (it ORs many events across
380/// contracts), so this route receives EVERY operator's `SlashRecorded`.
381/// [`decode_recorded`]'s `operator == self` guard filters them, which is what
382/// makes dropping the wire-level `topic2` safe. Split out from [`bootstrap`] so
383/// the exact topic0 set is unit-testable without a provider.
384fn slash_route_topic0s() -> Vec<B256> {
385 vec![CapacityBond::SlashRecorded::SIGNATURE_HASH]
386}
387
388/// The slash route's cursor start: seed the tail at the enumeration snapshot
389/// head. The enumeration rebuilt the store from all of history, so there is no
390/// durable cursor and no historical scan. Split out from [`bootstrap`] so the
391/// cursor shape is unit-testable without a provider.
392const fn slash_cursor_start(snapshot_block: u64) -> CursorStart {
393 CursorStart::Seeded { at: snapshot_block }
394}
395
396/// Applies operator-filtered `SlashRecorded` logs to the in-memory store and
397/// re-enumerates it periodically as the drift backstop.
398///
399/// `apply` never returns `Err`: [`record_recorded_log`] decodes, skips a
400/// reorged-out/undecodable/foreign log, soft-skips on a failed record read (the
401/// resync recovers it), and dedupes by `slashId`, so a bad log neither tears
402/// down the poll cycle nor hot-loops.
403struct SlashSink<R: SlashChainReads> {
404 reads: R,
405 self_address: Address,
406 store: SlashStore,
407 metrics: Arc<Metrics>,
408 resync_interval: Duration,
409 last_resync: Option<Instant>,
410}
411
412impl<R: SlashChainReads> LogSink for SlashSink<R> {
413 async fn apply(&mut self, log: Log) -> Result<()> {
414 record_recorded_log(
415 &self.reads,
416 self.self_address,
417 &self.store,
418 &self.metrics,
419 &log,
420 )
421 .await;
422 Ok(())
423 }
424
425 /// Re-enumerate the operator's still-appealable slashes on the resync cadence
426 /// at the lagged snapshot block and fold them into the store
427 /// ([`fold_resync`]; build-then-swap: a failed read keeps the current set).
428 /// This repairs any tail event lost to a reorg or RPC backoff and prunes
429 /// slashes whose appeal window has since closed. Returns `Ok` on failure —
430 /// the event tail is the primary path and is still working — and counts it
431 /// in `decdn_slash_resync_failures_total`.
432 async fn on_tick_complete(&mut self) -> Result<()> {
433 let now = Instant::now();
434 if self
435 .last_resync
436 .is_some_and(|last| now.duration_since(last) < self.resync_interval)
437 {
438 return Ok(());
439 }
440 // Stamp before the call, not only on success, so a persistently failing
441 // read retries on the resync cadence rather than on every watcher tick.
442 self.last_resync = Some(now);
443
444 let read = async {
445 let at = self.reads.snapshot_block().await?;
446 let fresh = bootstrap_slashes(
447 &self.reads,
448 self.self_address,
449 unix_now(),
450 BlockId::number(at),
451 )
452 .await?;
453 anyhow::Ok((at, fresh))
454 };
455 let (at, fresh) = match read.await {
456 Ok(read) => read,
457 Err(err) => {
458 self.metrics.slash_resync_failure();
459 warn!(
460 error = %sanitize_err_chain(&err),
461 "slash resync failed; keeping the current detected-slash set"
462 );
463 return Ok(());
464 }
465 };
466 let mut guard = self
467 .store
468 .write()
469 .unwrap_or_else(std::sync::PoisonError::into_inner);
470 let folded = fold_resync(&guard, fresh, at);
471 let count = folded.len();
472 *guard = folded;
473 drop(guard);
474 debug!(
475 slash_count = count,
476 "detected-slash set resynced from chain"
477 );
478 Ok(())
479 }
480
481 /// Force the backstop re-read on the tick the watcher recovers, rather than
482 /// waiting out the cadence.
483 ///
484 /// [`Self::on_tick_complete`] stamps its clock before the read, so an outage
485 /// that spans a due tick defers the re-read a further `resync_interval` —
486 /// and the reconcile does not run at all while the route is errored. A slash
487 /// recorded during the outage would otherwise sit unseen for up to that long,
488 /// which eats into the appeal window.
489 fn on_recovered(&mut self) {
490 clear_cadence_on_recovery(&mut self.last_resync, self.resync_interval);
491 }
492}
493
494/// Decode one `SlashRecorded` log to its `slashId`, or `None` for a log that
495/// must not be recorded: reorged-out (`removed == true` — not expected from
496/// `eth_getLogs`, so this is defensive against a nonconforming provider),
497/// undecodable, or another operator's. The merged poller filter cannot scope
498/// `topic2` to this operator, so this route receives every operator's
499/// `SlashRecorded` and the `operator == self` check below is what filters them.
500/// Pure (no provider) so the skip policy is unit-testable.
501fn decode_recorded(self_address: Address, log: &alloy::rpc::types::Log) -> Option<U256> {
502 if log.removed {
503 debug!("skipping reorged-out (removed) SlashRecorded log");
504 return None;
505 }
506 let Ok(event) = CapacityBond::SlashRecorded::decode_log_data(&log.inner.data) else {
507 warn!(
508 block_number = ?log.block_number,
509 tx = ?log.transaction_hash,
510 "skipping undecodable SlashRecorded log"
511 );
512 return None;
513 };
514 if event.operator != self_address {
515 return None;
516 }
517 Some(event.slashId)
518}
519
520/// Decode one `SlashRecorded` log, point-read its authoritative record, and
521/// record it. Skipped logs (see [`decode_recorded`]) and a failed record read
522/// neither tear down the cycle nor record a phantom slash — a record read that
523/// fails is recovered by the next resync.
524async fn record_recorded_log<R: SlashChainReads>(
525 reads: &R,
526 self_address: Address,
527 store: &SlashStore,
528 metrics: &Arc<Metrics>,
529 log: &alloy::rpc::types::Log,
530) {
531 let Some(slash_id) = decode_recorded(self_address, log) else {
532 return;
533 };
534 // Read the record and the global pause offset together; a failure on either
535 // soft-skips (the resync recovers it) rather than recording a wrong deadline.
536 let record = match reads.get_slash_record(slash_id, BlockId::latest()).await {
537 Ok(record) => record,
538 Err(err) => {
539 warn!(
540 error = %sanitize_err_chain(&err),
541 slash_id = %slash_id,
542 "failed to read slash record for a live SlashRecorded log; the resync will recover it"
543 );
544 return;
545 }
546 };
547 let paused_total = match reads.paused_total(BlockId::latest()).await {
548 Ok(paused_total) => paused_total,
549 Err(err) => {
550 warn!(
551 error = %sanitize_err_chain(&err),
552 slash_id = %slash_id,
553 "failed to read pausedTotal for a live SlashRecorded log; the resync will recover it"
554 );
555 return;
556 }
557 };
558 record_slash(
559 store,
560 metrics,
561 DetectedSlash {
562 slash_id,
563 offense_type: record.offense_type,
564 amount: record.amount,
565 evidence_hash: record.evidence_hash,
566 block_number: log.block_number,
567 // The deadline the contract enforces: base close + global pause offset.
568 appeal_window_close: Some(record.appeal_window_close.saturating_add(paused_total)),
569 },
570 );
571}
572
573/// The store after a resync pinned at block `at`: the enumeration `fresh`, plus
574/// every stored slash the tail saw in a block above `at`.
575///
576/// The enumeration reads the lagged snapshot block, so a slash the tail already
577/// recorded above it is absent from `fresh`. Replacing the store wholesale
578/// would drop it until the next resync. A stored slash at or below `at` that
579/// `fresh` lacks is gone from the chain's view (a closed appeal window, or a
580/// reorged-out log), so it drops.
581fn fold_resync(
582 current: &[DetectedSlash],
583 mut fresh: Vec<DetectedSlash>,
584 at: u64,
585) -> Vec<DetectedSlash> {
586 for slash in current {
587 let newer = slash.block_number.is_some_and(|block| block > at);
588 if newer && !fresh.iter().any(|f| f.slash_id == slash.slash_id) {
589 fresh.push(slash.clone());
590 }
591 }
592 fresh
593}
594
595/// Append `slash` to the store if its `slashId` is not already present, bumping
596/// the detection metric only on a genuinely new slash (backfill/live overlap is
597/// deduped). Poison-tolerant so a panicked reader can't wedge the writer.
598fn record_slash(store: &SlashStore, metrics: &Arc<Metrics>, slash: DetectedSlash) {
599 let mut guard = store
600 .write()
601 .unwrap_or_else(std::sync::PoisonError::into_inner);
602 if let Some(existing) = guard.iter_mut().find(|s| s.slash_id == slash.slash_id) {
603 // Re-sighting (backfill/live overlap or a re-scanned window): don't
604 // re-record or re-count, but do backfill the best-effort deadline hint
605 // if the original sighting's block-timestamp read failed — the dedup
606 // would otherwise leave a one-off RPC error's `None` in place forever.
607 if existing.appeal_window_close.is_none() {
608 existing.appeal_window_close = slash.appeal_window_close;
609 }
610 return;
611 }
612 info!(
613 slash_id = %slash.slash_id,
614 offense_type = slash.offense_type,
615 amount = %slash.amount,
616 "slash detected against this operator; file `decdn appeal slash` within 30 days"
617 );
618 guard.push(slash);
619 drop(guard);
620 metrics.slash_detected();
621}
622
623#[cfg(test)]
624#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
625mod tests;