Skip to main content

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;