Skip to main content

dig_slashing/appeal/
adjudicator.rs

1//! Appeal adjudicator — applies the economic consequences of a
2//! sustained or rejected `AppealVerdict`.
3//!
4//! Traces to: [SPEC.md §6.5](../../../docs/resources/SPEC.md),
5//! catalogue rows
6//! [DSL-064..073](../../../docs/requirements/domains/appeal/specs/).
7//!
8//! # Surface
9//!
10//! Each economic consequence is a free function (a "slice"):
11//!
12//!   - DSL-064: revert base slash (stake restoration)
13//!   - DSL-065: collateral revert
14//!   - DSL-066: `restore_status`
15//!   - DSL-067: reward clawback
16//!   - DSL-068: reporter-bond 50/50 split
17//!   - DSL-069: reporter penalty
18//!   - DSL-070: status transition → `Reverted`
19//!   - DSL-071: rejected → appellant bond 50/50 split
20//!   - DSL-072: rejected → `ChallengeOpen` increment
21//!   - DSL-073: clawback shortfall absorbed from bond
22//!
23//! The top-level [`adjudicate_appeal`] dispatcher composes these
24//! slices in fixed order for the sustained + rejected branches.
25
26use std::collections::BTreeMap;
27
28use dig_peer_protocol::Bytes32;
29use serde::{Deserialize, Serialize};
30
31use crate::appeal::envelope::{SlashAppeal, SlashAppealPayload};
32use crate::appeal::ground::AttesterAppealGround;
33use crate::appeal::verdict::{AppealSustainReason, AppealVerdict};
34use crate::bonds::{BondError, BondEscrow, BondTag};
35use crate::constants::{
36    APPELLANT_BOND_MOJOS, BOND_AWARD_TO_WINNER_BPS, BPS_DENOMINATOR, INVALID_BLOCK_BASE_BPS,
37    MIN_SLASHING_PENALTY_QUOTIENT, PROPOSER_REWARD_QUOTIENT, REPORTER_BOND_MOJOS,
38    WHISTLEBLOWER_REWARD_QUOTIENT,
39};
40use crate::pending::{AppealAttempt, AppealOutcome, PendingSlash, PendingSlashStatus};
41use crate::traits::{
42    CollateralSlasher, EffectiveBalanceView, RewardClawback, RewardPayout, ValidatorView,
43};
44
45/// Aggregate result of an appeal adjudication pass.
46///
47/// Traces to [DSL-164](../../../docs/requirements/domains/appeal/specs/DSL-164.md).
48/// Produced by the top-level [`adjudicate_appeal`] dispatcher that
49/// composes the per-DSL slice functions in this module. Consumed by:
50///
51///   - Audit logs — full reproduction of what happened on a sustained
52///     or rejected appeal.
53///   - RPC responses — telemetry consumers query via serde_json.
54///   - Test fixtures — the serde contract (DSL-164) lets tests
55///     construct an `AppealAdjudicationResult` directly without
56///     driving the full pipeline.
57///
58/// # Field grouping
59///
60/// - `appeal_hash`, `evidence_hash` — identity fields.
61/// - `outcome` — Won / Lost{reason_hash} / Pending. Uses
62///   `AppealOutcome` (the per-attempt lifecycle enum) rather than
63///   `AppealVerdict` because the result feeds into
64///   `AppealAttempt::outcome` on the pending slash's history.
65/// - Sustained branch: `reverted_stake_mojos`,
66///   `reverted_collateral_mojos`, `clawback_shortfall`,
67///   `reporter_bond_forfeited`, `appellant_award_mojos`,
68///   `reporter_penalty_mojos`. Populated on Won outcomes.
69/// - Rejected branch: `appellant_bond_forfeited`,
70///   `reporter_award_mojos`. Populated on Lost outcomes.
71/// - `burn_amount` — residual burn applicable to BOTH branches
72///   (sustained = shortfall not absorbed, rejected = bond split
73///   residue).
74///
75/// Fields not applicable to a given branch are `0` or empty vec
76/// by construction — the serde contract preserves this shape
77/// (DSL-164 test pins zero + empty preservation).
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct AppealAdjudicationResult {
80    /// Hash of the adjudicated appeal (DSL-159).
81    pub appeal_hash: Bytes32,
82    /// Evidence hash the appeal targeted (DSL-002).
83    pub evidence_hash: Bytes32,
84    /// Per-attempt outcome recorded into `appeal_history`.
85    pub outcome: AppealOutcome,
86    /// `(validator_index, stake_mojos_credited)` pairs for a
87    /// sustained appeal (DSL-064). Empty on rejection.
88    pub reverted_stake_mojos: Vec<(u32, u64)>,
89    /// `(validator_index, collateral_mojos_credited)` pairs for a
90    /// sustained appeal (DSL-065). Empty on rejection or when
91    /// the collateral slasher is disabled.
92    pub reverted_collateral_mojos: Vec<(u32, u64)>,
93    /// Residual debt after `adjudicate_sustained_clawback_rewards`
94    /// (DSL-067). Absorbed from `reporter_bond_forfeited` per
95    /// DSL-073.
96    pub clawback_shortfall: u64,
97    /// Reporter bond forfeited on sustained appeal (DSL-068).
98    pub reporter_bond_forfeited: u64,
99    /// Appellant award routed by DSL-068 (50% of forfeited
100    /// reporter bond).
101    pub appellant_award_mojos: u64,
102    /// Reporter penalty scheduled by DSL-069.
103    pub reporter_penalty_mojos: u64,
104    /// Appellant bond forfeited on rejected appeal (DSL-071).
105    pub appellant_bond_forfeited: u64,
106    /// Reporter award routed by DSL-071 (50% of forfeited
107    /// appellant bond).
108    pub reporter_award_mojos: u64,
109    /// Residual burn applicable to both sustained and rejected
110    /// paths (unrecovered bond residue).
111    pub burn_amount: u64,
112}
113
114/// Outcome of a reward clawback pass.
115///
116/// Traces to [SPEC §12.2](../../../docs/resources/SPEC.md). Returned
117/// by [`adjudicate_sustained_clawback_rewards`] so callers (the
118/// top-level adjudicator dispatcher + DSL-073 bond-absorption
119/// logic) can reason about the shortfall.
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
121pub struct ClawbackResult {
122    /// Recomputed whistleblower reward =
123    /// `total_eff_bal_at_slash / WHISTLEBLOWER_REWARD_QUOTIENT`.
124    pub wb_amount: u64,
125    /// Recomputed proposer inclusion reward =
126    /// `wb_amount / PROPOSER_REWARD_QUOTIENT`.
127    pub prop_amount: u64,
128    /// Mojos actually clawed back from the reporter's reward
129    /// account. May be less than `wb_amount` if the reporter
130    /// already withdrew (partial clawback — DSL-142 contract).
131    pub wb_clawed: u64,
132    /// Mojos actually clawed back from the proposer's reward
133    /// account.
134    pub prop_clawed: u64,
135    /// `(wb_amount + prop_amount) - (wb_clawed + prop_clawed)`
136    /// — the residual debt that DSL-073 absorbs from the
137    /// forfeited reporter bond.
138    pub shortfall: u64,
139}
140
141/// Revert base-slash amounts on a sustained appeal by calling
142/// `ValidatorEntry::credit_stake(amount)` per affected index.
143///
144/// Implements [DSL-064](../../../docs/requirements/domains/appeal/specs/DSL-064.md).
145/// Traces to SPEC §6.5.
146///
147/// # Verdict branching
148///
149/// - Rejected (any) → no-op, returns empty vec. Rejected appeals
150///   do not revert anything; DSL-072 bumps `appeal_count`
151///   instead.
152/// - Sustained{ValidatorNotInIntersection} → revert ONLY the
153///   named index from
154///   `AttesterAppealGround::ValidatorNotInIntersection{ validator_index }`.
155///   Other slashed validators keep their debit.
156/// - Sustained{anything else} → revert EVERY validator listed in
157///   `pending.base_slash_per_validator`. The ground was about the
158///   evidence as a whole, so every affected validator is
159///   rescued.
160///
161/// # Skip conditions
162///
163/// - Validator absent from `validator_set.get_mut(idx)` → skip
164///   (defensive tolerance, same pattern as DSL-022
165///   `submit_evidence`).
166/// - Base-slash amount of `0` → credit is still called — consensus
167///   observes the method-call pattern per SPEC §7.3.
168///
169/// # Returns
170///
171/// Vector of validator indices that were actually credited
172/// (present in `base_slash_per_validator` AND in the validator
173/// view). Callers (DSL-067 reward clawback, DSL-070 status
174/// transition) use the list to restrict downstream side effects
175/// to the same set.
176///
177/// # Determinism
178///
179/// Iteration order follows `base_slash_per_validator` which is
180/// itself built in DSL-007 sorted-intersection order (attester)
181/// or single-element (proposer/invalid-block) — already
182/// deterministic.
183#[must_use]
184pub fn adjudicate_sustained_revert_base_slash(
185    pending: &PendingSlash,
186    appeal: &SlashAppeal,
187    verdict: &AppealVerdict,
188    validator_set: &mut dyn ValidatorView,
189) -> Vec<u32> {
190    // Rejected / any non-sustained branch is a no-op.
191    let reason = match verdict {
192        AppealVerdict::Sustained { reason } => *reason,
193        AppealVerdict::Rejected { .. } => return Vec::new(),
194    };
195
196    // For the per-validator ValidatorNotInIntersection ground,
197    // restrict reverts to the named index carried on the appeal
198    // ground variant. For every other sustained ground, revert
199    // the whole slashable set.
200    let named_index = if matches!(reason, AppealSustainReason::ValidatorNotInIntersection) {
201        named_validator_from_ground(appeal)
202    } else {
203        None
204    };
205
206    let mut reverted: Vec<u32> = Vec::new();
207    for slash in &pending.base_slash_per_validator {
208        if let Some(named) = named_index
209            && slash.validator_index != named
210        {
211            continue;
212        }
213        if let Some(entry) = validator_set.get_mut(slash.validator_index) {
214            entry.credit_stake(slash.base_slash_amount);
215            reverted.push(slash.validator_index);
216        }
217    }
218    reverted
219}
220
221/// Clear the `Slashed` flag on reverted validators by calling
222/// `ValidatorEntry::restore_status()`.
223///
224/// Implements [DSL-066](../../../docs/requirements/domains/appeal/specs/DSL-066.md).
225/// Traces to SPEC §6.5.
226///
227/// # Verdict branching
228///
229/// Same scope rules as DSL-064/065:
230/// - Rejected → no-op, returns empty vec.
231/// - Sustained{ValidatorNotInIntersection} → only the named
232///   index from the attester ground.
233/// - Any other Sustained → every validator in
234///   `base_slash_per_validator`.
235///
236/// # Returns
237///
238/// Indices whose `restore_status()` call returned `true` — i.e.
239/// the validator was actually in `Slashed` state and transitioned
240/// to active. Indices that were never slashed (or were already
241/// restored) are absent from the result.
242///
243/// # Idempotence
244///
245/// `ValidatorEntry::restore_status` is idempotent (DSL-133); a
246/// repeat call on an already-active validator returns `false`
247/// and does not appear in the result.
248///
249/// # Skip conditions
250///
251/// - Validator absent from `validator_set.get_mut(idx)` → skip
252///   (defensive tolerance, same as DSL-064).
253#[must_use]
254pub fn adjudicate_sustained_restore_status(
255    pending: &PendingSlash,
256    appeal: &SlashAppeal,
257    verdict: &AppealVerdict,
258    validator_set: &mut dyn ValidatorView,
259) -> Vec<u32> {
260    let reason = match verdict {
261        AppealVerdict::Sustained { reason } => *reason,
262        AppealVerdict::Rejected { .. } => return Vec::new(),
263    };
264
265    let named_index = if matches!(reason, AppealSustainReason::ValidatorNotInIntersection) {
266        named_validator_from_ground(appeal)
267    } else {
268        None
269    };
270
271    let mut restored: Vec<u32> = Vec::new();
272    for slash in &pending.base_slash_per_validator {
273        if let Some(named) = named_index
274            && slash.validator_index != named
275        {
276            continue;
277        }
278        if let Some(entry) = validator_set.get_mut(slash.validator_index)
279            && entry.restore_status()
280        {
281            restored.push(slash.validator_index);
282        }
283    }
284    restored
285}
286
287/// Revert collateral debits on a sustained appeal by calling
288/// `CollateralSlasher::credit` per reverted validator.
289///
290/// Implements [DSL-065](../../../docs/requirements/domains/appeal/specs/DSL-065.md).
291/// Traces to SPEC §6.5.
292///
293/// # Verdict branching
294///
295/// Matches DSL-064's scope branching — `ValidatorNotInIntersection`
296/// restricts to the named index, every other sustained reason
297/// covers every slashed validator. Rejected is a no-op.
298///
299/// # Skip conditions
300///
301/// - `collateral: None` (light-client) → no calls at all, empty
302///   returned. Credit is a full-node concern.
303/// - `slash.collateral_slashed == 0` → skipped. No-op credits
304///   would be observable in consensus auditing (DSL-025 pattern);
305///   collateral revert is value-bearing only when a debit
306///   actually occurred.
307///
308/// # Returns
309///
310/// Indices that were actually credited (present in the scope AND
311/// had non-zero `collateral_slashed` AND a collateral slasher was
312/// supplied). Downstream side effects that key off collateral
313/// revert scope can use this list.
314#[must_use]
315pub fn adjudicate_sustained_revert_collateral(
316    pending: &PendingSlash,
317    appeal: &SlashAppeal,
318    verdict: &AppealVerdict,
319    collateral: Option<&mut dyn CollateralSlasher>,
320) -> Vec<u32> {
321    // Rejected branch → no-op.
322    let reason = match verdict {
323        AppealVerdict::Sustained { reason } => *reason,
324        AppealVerdict::Rejected { .. } => return Vec::new(),
325    };
326
327    // No slasher → nothing to do (light-client / bootstrap path).
328    let Some(slasher) = collateral else {
329        return Vec::new();
330    };
331
332    let named_index = if matches!(reason, AppealSustainReason::ValidatorNotInIntersection) {
333        named_validator_from_ground(appeal)
334    } else {
335        None
336    };
337
338    let mut credited: Vec<u32> = Vec::new();
339    for slash in &pending.base_slash_per_validator {
340        if let Some(named) = named_index
341            && slash.validator_index != named
342        {
343            continue;
344        }
345        if slash.collateral_slashed == 0 {
346            continue;
347        }
348        slasher.credit(slash.validator_index, slash.collateral_slashed);
349        credited.push(slash.validator_index);
350    }
351    credited
352}
353
354/// Outcome of a forfeited-bond 50/50 split.
355///
356/// Traces to [SPEC §6.5](../../../docs/resources/SPEC.md). Produced by
357/// DSL-068 (sustained → reporter's bond forfeited to appellant +
358/// burn) and will be reused by DSL-071 (rejected → appellant's
359/// bond forfeited to reporter + burn) with different field
360/// interpretations.
361#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
362pub struct BondSplitResult {
363    /// Mojos actually forfeited from escrow (return value of
364    /// `BondEscrow::forfeit`). May be less than the requested
365    /// amount if the escrow's ledger disagrees — treat as the
366    /// authoritative value for the split math.
367    pub forfeited: u64,
368    /// Award routed to the winning party's puzzle hash. For
369    /// DSL-068 sustained appeals this is the appellant's share.
370    /// Computed as `forfeited * BOND_AWARD_TO_WINNER_BPS /
371    /// BPS_DENOMINATOR` with integer division (truncation).
372    pub winner_award: u64,
373    /// Burn amount = `forfeited - winner_award`. Rounding slips
374    /// flow here so the split is always exactly equal to the
375    /// forfeited total (no mojo accounting drift).
376    pub burn: u64,
377}
378
379/// Forfeit the reporter's bond on a sustained appeal and split
380/// the proceeds 50/50 between the appellant and the burn bucket.
381///
382/// Implements [DSL-068](../../../docs/requirements/domains/appeal/specs/DSL-068.md).
383/// Traces to SPEC §6.5, §2.6.
384///
385/// # Pipeline
386///
387/// 1. `bond_escrow.forfeit(reporter_idx, REPORTER_BOND_MOJOS,
388///    Reporter(evidence_hash))` — authoritative forfeit amount.
389/// 2. `winner_award = forfeited * BOND_AWARD_TO_WINNER_BPS /
390///    BPS_DENOMINATOR` (integer division — odd mojos round toward
391///    the burn bucket).
392/// 3. `burn = forfeited - winner_award` — conservation by
393///    construction.
394/// 4. `reward_payout.pay(appellant_puzzle_hash, winner_award)` —
395///    unconditional (emit even on zero award for auditability).
396///
397/// # Rejected branch
398///
399/// No-op, returns a zero-filled `BondSplitResult`. Rejected
400/// appeals forfeit the APPELLANT's bond (DSL-071) via a mirror
401/// function, not this one.
402///
403/// # Integer-division rounding
404///
405/// - `forfeited = 1` → `award = 0, burn = 1`
406/// - `forfeited = 2` → `award = 1, burn = 1`
407/// - `forfeited = 3` → `award = 1, burn = 2`
408///
409/// Floor rounding on the winner's side; burn absorbs the
410/// remainder. Matches the SPEC §2.6 reference.
411///
412/// # Errors
413///
414/// Propagates `BondError` from the escrow's `forfeit` call. The
415/// caller (top-level adjudicator) MUST decide whether to abort
416/// or continue — adjudication is transactional at the manager
417/// boundary, so partial application on an escrow failure would
418/// leave inconsistent state.
419pub fn adjudicate_sustained_forfeit_reporter_bond(
420    pending: &PendingSlash,
421    appeal: &SlashAppeal,
422    verdict: &AppealVerdict,
423    bond_escrow: &mut dyn BondEscrow,
424    reward_payout: &mut dyn RewardPayout,
425) -> Result<BondSplitResult, BondError> {
426    if matches!(verdict, AppealVerdict::Rejected { .. }) {
427        return Ok(BondSplitResult {
428            forfeited: 0,
429            winner_award: 0,
430            burn: 0,
431        });
432    }
433
434    let forfeited = bond_escrow.forfeit(
435        pending.evidence.reporter_validator_index,
436        REPORTER_BOND_MOJOS,
437        BondTag::Reporter(pending.evidence_hash),
438    )?;
439    let winner_award = forfeited * BOND_AWARD_TO_WINNER_BPS / BPS_DENOMINATOR;
440    let burn = forfeited - winner_award;
441
442    // Audit-visible two-call shape: the pay() always fires, even
443    // on zero award — mirrors the admission-side pay() pattern
444    // from DSL-025.
445    reward_payout.pay(appeal.appellant_puzzle_hash, winner_award);
446
447    Ok(BondSplitResult {
448        forfeited,
449        winner_award,
450        burn,
451    })
452}
453
454/// Outcome of the reporter-penalty step on a sustained appeal.
455///
456/// Traces to [SPEC §6.5](../../../docs/resources/SPEC.md). Reports
457/// the index + amount debited from the reporter so the top-level
458/// adjudicator + downstream correlation-window bookkeeping (DSL-030
459/// at finalisation) have everything they need.
460#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
461pub struct ReporterPenalty {
462    /// Validator index of the reporter that filed the appealed
463    /// evidence.
464    pub reporter_index: u32,
465    /// `EffectiveBalanceView::get(idx)` captured at adjudication
466    /// time. Stored so the DSL-030 correlation-penalty formula
467    /// can read the same value later without re-reading state
468    /// (which may drift across further epochs).
469    pub effective_balance_at_slash: u64,
470    /// Mojos debited via `ValidatorEntry::slash_absolute`. Follows
471    /// the InvalidBlock base formula:
472    /// `max(eff_bal * INVALID_BLOCK_BASE_BPS / BPS_DENOMINATOR,
473    /// eff_bal / MIN_SLASHING_PENALTY_QUOTIENT)`.
474    pub penalty_mojos: u64,
475}
476
477/// Slash the reporter that filed the sustained-appeal-losing
478/// evidence.
479///
480/// Implements [DSL-069](../../../docs/requirements/domains/appeal/specs/DSL-069.md).
481/// Traces to SPEC §6.5.
482///
483/// # Formula
484///
485/// Mirrors the DSL-022 InvalidBlock base-slash branch exactly:
486///
487/// ```text
488/// penalty = max(
489///     eff_bal * INVALID_BLOCK_BASE_BPS / BPS_DENOMINATOR,
490///     eff_bal / MIN_SLASHING_PENALTY_QUOTIENT,
491/// )
492/// ```
493///
494/// The reporter staked reputation by filing evidence; a sustained
495/// appeal proves the evidence was at least partially wrong, so
496/// they absorb the protocol's standard penalty for filing a
497/// false invalid-block report.
498///
499/// # Correlation-window bookkeeping
500///
501/// Inserts `(current_epoch, reporter_index) → eff_bal` into
502/// `slashed_in_window`. At finalisation (DSL-030) that entry
503/// contributes to the `cohort_sum` used to amplify correlated
504/// slashes — i.e. if the reporter also got slashed through the
505/// normal channel in the same correlation window, the
506/// proportional-slashing multiplier activates against both.
507///
508/// # Skip conditions
509///
510/// - Rejected verdict → returns `None`, no side effects.
511/// - Reporter absent from `validator_set.get_mut` → returns
512///   `None`. Defensive tolerance for an already-exited reporter;
513///   consensus never needs to slash a validator that no longer
514///   exists.
515pub fn adjudicate_sustained_reporter_penalty(
516    pending: &PendingSlash,
517    verdict: &AppealVerdict,
518    validator_set: &mut dyn ValidatorView,
519    effective_balances: &dyn EffectiveBalanceView,
520    slashed_in_window: &mut BTreeMap<(u64, u32), u64>,
521    current_epoch: u64,
522) -> Option<ReporterPenalty> {
523    if matches!(verdict, AppealVerdict::Rejected { .. }) {
524        return None;
525    }
526    let reporter_index = pending.evidence.reporter_validator_index;
527    let eff_bal = effective_balances.get(reporter_index);
528    let bps_term = eff_bal * u64::from(INVALID_BLOCK_BASE_BPS) / BPS_DENOMINATOR;
529    let floor_term = eff_bal / MIN_SLASHING_PENALTY_QUOTIENT;
530    let penalty_mojos = std::cmp::max(bps_term, floor_term);
531
532    let entry = validator_set.get_mut(reporter_index)?;
533    entry.slash_absolute(penalty_mojos, current_epoch);
534    slashed_in_window.insert((current_epoch, reporter_index), eff_bal);
535
536    Some(ReporterPenalty {
537        reporter_index,
538        effective_balance_at_slash: eff_bal,
539        penalty_mojos,
540    })
541}
542
543/// Outcome of the clawback-shortfall-into-burn absorption step.
544///
545/// Traces to [SPEC §6.5](../../../docs/resources/SPEC.md). Produced
546/// by [`adjudicate_absorb_clawback_shortfall`] — combines DSL-067
547/// `ClawbackResult::shortfall` with DSL-068 `BondSplitResult::burn`
548/// to produce the authoritative burn leg plus a residue flag.
549#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
550pub struct ShortfallAbsorption {
551    /// Unrecovered reward debt rolled over from the DSL-067
552    /// clawback — `(wb+prop) - (wb_clawed+prop_clawed)`. Added to
553    /// the DSL-068 burn leg.
554    pub clawback_shortfall: u64,
555    /// Original burn from the DSL-068 50/50 split (`forfeited -
556    /// winner_award`). Kept separately so auditors can derive
557    /// the absorption path without recomputing.
558    pub original_burn: u64,
559    /// `original_burn + clawback_shortfall`. May exceed the
560    /// forfeited bond if the shortfall is very large — the
561    /// excess surfaces as `residue`.
562    pub final_burn: u64,
563    /// `final_burn.saturating_sub(forfeited)` — mojos the
564    /// protocol cannot burn from the bond because the bond ran
565    /// out. Adjudication proceeds; consumers SHOULD log the
566    /// residue (`tracing::warn!` or similar) for offline audit.
567    ///
568    /// Zero when the bond is sufficient (the common case).
569    pub residue: u64,
570}
571
572/// Absorb DSL-067 clawback shortfall into the DSL-068 burn leg.
573///
574/// Implements [DSL-073](../../../docs/requirements/domains/appeal/specs/DSL-073.md).
575/// Traces to SPEC §6.5.
576///
577/// # Math
578///
579/// ```text
580/// shortfall      = clawback.shortfall
581/// original_burn  = bond_split.burn
582/// final_burn     = original_burn + shortfall
583/// residue        = saturating_sub(final_burn, bond_split.forfeited)
584/// ```
585///
586/// When `residue == 0`, the forfeited bond fully covers the
587/// reward-debt shortfall + the normal burn share. When `residue >
588/// 0`, the bond is insufficient — adjudication still succeeds
589/// (per SPEC acceptance "adjudication proceeds") and the residue
590/// surfaces for consumers to emit a warn-level log.
591///
592/// # Zero-shortfall path
593///
594/// Returns `final_burn == original_burn`, `residue == 0`. No
595/// change from the DSL-068 math — consumers can treat the return
596/// as "use `final_burn` for the burn bucket; `residue` is purely
597/// telemetry".
598///
599/// # Why not `tracing::warn!` here
600///
601/// SPEC suggests logging via `tracing::warn!`, but adding a
602/// `tracing` dep just for one call inflates the crate footprint.
603/// Residue is surfaced as a struct field instead; downstream
604/// callers that already pull tracing can emit the warn
605/// themselves.
606#[must_use]
607pub fn adjudicate_absorb_clawback_shortfall(
608    clawback: &ClawbackResult,
609    bond_split: &BondSplitResult,
610) -> ShortfallAbsorption {
611    let clawback_shortfall = clawback.shortfall;
612    let original_burn = bond_split.burn;
613    let final_burn = original_burn.saturating_add(clawback_shortfall);
614    let residue = final_burn.saturating_sub(bond_split.forfeited);
615    ShortfallAbsorption {
616        clawback_shortfall,
617        original_burn,
618        final_burn,
619        residue,
620    }
621}
622
623/// Transition a rejected appeal's `PendingSlash` to / through
624/// `ChallengeOpen` and record the losing attempt in
625/// `appeal_history`.
626///
627/// Implements [DSL-072](../../../docs/requirements/domains/appeal/specs/DSL-072.md).
628/// Traces to SPEC §6.5.
629///
630/// # Status transition
631///
632/// - `Accepted` → `ChallengeOpen { first_appeal_filed_epoch:
633///   appeal.filed_epoch, appeal_count: 1 }`.
634/// - `ChallengeOpen { first, count }` →
635///   `ChallengeOpen { first, count + 1 }` (first preserved).
636/// - `Reverted` / `Finalised` → unreachable (DSL-060/061 reject
637///   terminal-state appeals); defensive no-op here.
638///
639/// # History append
640///
641/// Pushes `AppealAttempt { outcome: Lost { reason_hash }, .. }`.
642/// `reason_hash` is a caller-supplied digest of the adjudicator's
643/// reason bytes — used downstream for audit / analytics; the
644/// crate does not prescribe a specific hasher.
645///
646/// # Sustained branch
647///
648/// No-op — sustained appeals transition to `Reverted` via DSL-070
649/// instead.
650///
651/// # Preserves first-filed epoch
652///
653/// The `first_appeal_filed_epoch` on `ChallengeOpen` is the
654/// epoch of the FIRST appeal ever filed against this slash; it
655/// is never rewritten by subsequent rejections. Downstream
656/// analytics use it to reconstruct the challenge timeline.
657pub fn adjudicate_rejected_challenge_open(
658    pending: &mut PendingSlash,
659    appeal: &SlashAppeal,
660    verdict: &AppealVerdict,
661    reason_hash: Bytes32,
662) {
663    if matches!(verdict, AppealVerdict::Sustained { .. }) {
664        return;
665    }
666    let new_status = match pending.status {
667        PendingSlashStatus::Accepted => PendingSlashStatus::ChallengeOpen {
668            first_appeal_filed_epoch: appeal.filed_epoch,
669            appeal_count: 1,
670        },
671        PendingSlashStatus::ChallengeOpen {
672            first_appeal_filed_epoch,
673            appeal_count,
674        } => PendingSlashStatus::ChallengeOpen {
675            first_appeal_filed_epoch,
676            appeal_count: appeal_count.saturating_add(1),
677        },
678        // Terminal states — DSL-060/061 already rejected; keep
679        // status unchanged as a defensive no-op.
680        PendingSlashStatus::Reverted { .. } | PendingSlashStatus::Finalised { .. } => {
681            pending.status
682        }
683    };
684    pending.status = new_status;
685    pending.appeal_history.push(AppealAttempt {
686        appeal_hash: appeal.hash(),
687        appellant_index: appeal.appellant_index,
688        filed_epoch: appeal.filed_epoch,
689        outcome: AppealOutcome::Lost { reason_hash },
690        bond_mojos: APPELLANT_BOND_MOJOS,
691    });
692}
693
694/// Forfeit the appellant's bond on a rejected appeal and split
695/// the proceeds 50/50 between the reporter and the burn bucket.
696///
697/// Implements [DSL-071](../../../docs/requirements/domains/appeal/specs/DSL-071.md).
698/// Traces to SPEC §6.5. Mirror of DSL-068 with the losing party
699/// (appellant) and winning party (reporter) swapped.
700///
701/// # Pipeline
702///
703/// 1. `bond_escrow.forfeit(appellant_idx, APPELLANT_BOND_MOJOS,
704///    Appellant(appeal.hash()))` — returns the authoritative
705///    forfeit amount.
706/// 2. `winner_award = forfeited * BOND_AWARD_TO_WINNER_BPS /
707///    BPS_DENOMINATOR` (integer division; floor toward reporter,
708///    remainder to burn — identical rounding table to DSL-068).
709/// 3. `burn = forfeited - winner_award` (conservation by
710///    construction).
711/// 4. `reward_payout.pay(reporter_puzzle_hash, winner_award)` —
712///    unconditional.
713///
714/// # Sustained branch
715///
716/// No-op, returns zero-filled `BondSplitResult`. Sustained
717/// appeals forfeit the REPORTER's bond via DSL-068, not the
718/// appellant's.
719///
720/// # Result interpretation
721///
722/// Reuses [`BondSplitResult`]. The `winner_award` field is the
723/// reporter's share on this rejected-path call (vs. the
724/// appellant's share on a sustained-path DSL-068 call). The
725/// struct shape is identical so downstream serialisation
726/// (DSL-164 `AppealAdjudicationResult`) can carry either
727/// outcome without branching on the variant.
728pub fn adjudicate_rejected_forfeit_appellant_bond(
729    pending: &PendingSlash,
730    appeal: &SlashAppeal,
731    verdict: &AppealVerdict,
732    bond_escrow: &mut dyn BondEscrow,
733    reward_payout: &mut dyn RewardPayout,
734) -> Result<BondSplitResult, BondError> {
735    if matches!(verdict, AppealVerdict::Sustained { .. }) {
736        return Ok(BondSplitResult {
737            forfeited: 0,
738            winner_award: 0,
739            burn: 0,
740        });
741    }
742
743    let forfeited = bond_escrow.forfeit(
744        appeal.appellant_index,
745        APPELLANT_BOND_MOJOS,
746        BondTag::Appellant(appeal.hash()),
747    )?;
748    let winner_award = forfeited * BOND_AWARD_TO_WINNER_BPS / BPS_DENOMINATOR;
749    let burn = forfeited - winner_award;
750
751    // Mirror DSL-068's unconditional pay — emits even on zero
752    // award so the two-call shape is audit-deterministic
753    // regardless of split outcome.
754    reward_payout.pay(pending.evidence.reporter_puzzle_hash, winner_award);
755
756    Ok(BondSplitResult {
757        forfeited,
758        winner_award,
759        burn,
760    })
761}
762
763/// Claw back the whistleblower + proposer rewards paid at
764/// optimistic admission (DSL-025).
765///
766/// Implements [DSL-067](../../../docs/requirements/domains/appeal/specs/DSL-067.md).
767/// Traces to SPEC §6.5, §12.2.
768///
769/// # Scope
770///
771/// Clawback is FULL on any sustained ground — including
772/// `ValidatorNotInIntersection` (DSL-047). Rationale: the
773/// rewards were paid to the reporter + proposer in exchange for
774/// producing correct evidence. A sustained appeal, even a
775/// per-validator one, proves the evidence was at least partially
776/// wrong, so the admission-time rewards must unwind. Partial
777/// per-validator clawback would complicate reasoning without
778/// adding protection — DSL-047 is rare enough that full
779/// clawback is the simplest honest disposition.
780///
781/// Rejected → no-op, returns a zero-filled `ClawbackResult`.
782///
783/// # Formula
784///
785/// `wb_amount = total_eff_bal_at_slash / WHISTLEBLOWER_REWARD_QUOTIENT`
786/// `prop_amount = wb_amount / PROPOSER_REWARD_QUOTIENT`
787///
788/// Both amounts are RECOMPUTED from
789/// `pending.base_slash_per_validator[*].effective_balance_at_slash`
790/// — NOT read from the original `SlashingResult`. This keeps the
791/// adjudicator self-contained and lets it ignore `SlashingResult`
792/// drift. DSL-022 +DSL-025 uses the same formula, so numbers
793/// agree by construction.
794///
795/// # Shortfall
796///
797/// `RewardClawback::claw_back` returns the mojos ACTUALLY clawed
798/// back (DSL-142 contract). The principal may have already
799/// withdrawn the reward. The shortfall
800/// `(wb_amount + prop_amount) - (wb_clawed + prop_clawed)` is
801/// returned for DSL-073 bond-absorption.
802///
803/// # Call pattern
804///
805/// Two `claw_back` calls issued unconditionally, matching the
806/// admission-side two-call `RewardPayout::pay` pattern (DSL-025).
807/// Consensus auditors rely on the deterministic call shape.
808#[must_use]
809pub fn adjudicate_sustained_clawback_rewards(
810    pending: &PendingSlash,
811    verdict: &AppealVerdict,
812    reward_clawback: &mut dyn RewardClawback,
813    proposer_puzzle_hash: Bytes32,
814) -> ClawbackResult {
815    // Rejected branch → no-op. Zero-filled result signals
816    // "no clawback was attempted".
817    if matches!(verdict, AppealVerdict::Rejected { .. }) {
818        return ClawbackResult {
819            wb_amount: 0,
820            prop_amount: 0,
821            wb_clawed: 0,
822            prop_clawed: 0,
823            shortfall: 0,
824        };
825    }
826
827    let total_eff_bal: u64 = pending
828        .base_slash_per_validator
829        .iter()
830        .map(|p| p.effective_balance_at_slash)
831        .sum();
832    let wb_amount = total_eff_bal / WHISTLEBLOWER_REWARD_QUOTIENT;
833    let prop_amount = wb_amount / PROPOSER_REWARD_QUOTIENT;
834
835    let wb_clawed = reward_clawback.claw_back(pending.evidence.reporter_puzzle_hash, wb_amount);
836    let prop_clawed = reward_clawback.claw_back(proposer_puzzle_hash, prop_amount);
837
838    let expected = wb_amount + prop_amount;
839    let got = wb_clawed + prop_clawed;
840    let shortfall = expected.saturating_sub(got);
841
842    ClawbackResult {
843        wb_amount,
844        prop_amount,
845        wb_clawed,
846        prop_clawed,
847        shortfall,
848    }
849}
850
851/// Transition a `PendingSlash` to the `Reverted` terminal state
852/// and record the winning appeal in `appeal_history`.
853///
854/// Implements [DSL-070](../../../docs/requirements/domains/appeal/specs/DSL-070.md).
855/// Traces to SPEC §6.5.
856///
857/// # Mutation order
858///
859/// 1. `pending.appeal_history.push(AppealAttempt { outcome: Won,
860///    .. })` — the attempt is recorded BEFORE the status flips
861///    so observers reading status and history together see
862///    consistent state (DSL-161 serde roundtrip implicitly
863///    validates this pairing).
864/// 2. `pending.status = Reverted { winning_appeal_hash,
865///    reverted_at_epoch: current_epoch }` — terminal state;
866///    `submit_appeal` rejects subsequent attempts via DSL-060.
867///
868/// # Rejected branch
869///
870/// No-op — rejected appeals do not transition the pending slash.
871/// DSL-072 handles the rejected-path bookkeeping (appeal_count
872/// bump + ChallengeOpen).
873///
874/// # Bond amount recorded
875///
876/// The `AppealAttempt::bond_mojos` field stores
877/// `APPELLANT_BOND_MOJOS` — the amount actually locked by
878/// DSL-062. Downstream auditors (DSL-073, adjudication result
879/// serialisation) read this to route the forfeited/refunded
880/// amount correctly.
881pub fn adjudicate_sustained_status_reverted(
882    pending: &mut PendingSlash,
883    appeal: &SlashAppeal,
884    verdict: &AppealVerdict,
885    current_epoch: u64,
886) {
887    if matches!(verdict, AppealVerdict::Rejected { .. }) {
888        return;
889    }
890    let appeal_hash = appeal.hash();
891    pending.appeal_history.push(AppealAttempt {
892        appeal_hash,
893        appellant_index: appeal.appellant_index,
894        filed_epoch: appeal.filed_epoch,
895        outcome: AppealOutcome::Won,
896        bond_mojos: APPELLANT_BOND_MOJOS,
897    });
898    pending.status = PendingSlashStatus::Reverted {
899        winning_appeal_hash: appeal_hash,
900        reverted_at_epoch: current_epoch,
901    };
902}
903
904/// Top-level appeal adjudication dispatcher.
905///
906/// Implements [DSL-167](../../../docs/requirements/domains/appeal/specs/DSL-167.md).
907/// Traces to SPEC §6.5.
908///
909/// # Role
910///
911/// Composes the 10 DSL-064..073 slice functions into one
912/// end-to-end adjudication pass. Embedders call this ONCE per
913/// verdict and receive a fully-populated `AppealAdjudicationResult`
914/// (DSL-164) with economic effects already applied to the
915/// injected trait impls. `pending.status` + `appeal_history` are
916/// mutated exactly once per call (append one `AppealAttempt`).
917///
918/// # Signatures
919///
920/// All trait-object arguments are `&mut dyn` / `&dyn`; scalar
921/// arguments are small by-value. `Option<&mut dyn CollateralSlasher>`
922/// mirrors the DSL-139 default contract — light-client embedders
923/// pass `None` and the dispatcher skips collateral-side work.
924///
925/// `slashed_in_window` is exposed explicitly because the DSL-069
926/// reporter-penalty slice records into it. Embedders typically
927/// pass `manager.slashed_in_window_mut()` (a future pub accessor);
928/// tests pass a local `BTreeMap` and inspect it post-call.
929///
930/// # Sustained branch (fixed order)
931///
932/// 1. Revert base slash (DSL-064) → `reverted_stake_mojos`
933/// 2. Revert collateral (DSL-065) → `reverted_collateral_mojos`
934/// 3. Restore status (DSL-066)
935/// 4. Clawback rewards (DSL-067) → `clawback_shortfall`
936/// 5. Forfeit reporter bond + winner award (DSL-068) →
937///    `reporter_bond_forfeited`, `appellant_award_mojos`,
938///    preliminary `burn_amount`
939/// 6. Absorb clawback shortfall (DSL-073) → final `burn_amount`
940/// 7. Reporter penalty (DSL-069) → `reporter_penalty_mojos`
941/// 8. Status transition to Reverted + history append (DSL-070)
942///
943/// # Rejected branch (fixed order)
944///
945/// 1. Forfeit appellant bond + reporter award (DSL-071) →
946///    `appellant_bond_forfeited`, `reporter_award_mojos`,
947///    `burn_amount`
948/// 2. ChallengeOpen transition + history append (DSL-072)
949///
950/// # Errors
951///
952/// Propagates `BondError` from the forfeit calls. On error,
953/// earlier side effects (stake credits, status restore) have
954/// already run — the caller is responsible for transactional
955/// rollback at the manager boundary. The dispatcher does NOT
956/// attempt recovery.
957///
958/// # Outcome
959///
960/// `result.outcome == verdict.to_appeal_outcome()` (DSL-171
961/// canonical mapping). Never `Pending`.
962#[allow(clippy::too_many_arguments)]
963pub fn adjudicate_appeal(
964    verdict: AppealVerdict,
965    pending: &mut PendingSlash,
966    appeal: &SlashAppeal,
967    validator_set: &mut dyn ValidatorView,
968    effective_balances: &dyn EffectiveBalanceView,
969    collateral: Option<&mut dyn CollateralSlasher>,
970    bond_escrow: &mut dyn BondEscrow,
971    reward_payout: &mut dyn RewardPayout,
972    reward_clawback: &mut dyn RewardClawback,
973    slashed_in_window: &mut BTreeMap<(u64, u32), u64>,
974    proposer_puzzle_hash: Bytes32,
975    reason_hash: Bytes32,
976    current_epoch: u64,
977) -> Result<crate::appeal::adjudicator::AppealAdjudicationResult, BondError> {
978    let outcome = verdict.to_appeal_outcome();
979    let appeal_hash = appeal.hash();
980    let evidence_hash = pending.evidence_hash;
981
982    match verdict {
983        AppealVerdict::Sustained { .. } => {
984            // Slice 1 — revert base slash. Returns reverted indices
985            // so we can project per-index amounts without re-scanning
986            // `pending.base_slash_per_validator`.
987            let reverted_idx =
988                adjudicate_sustained_revert_base_slash(pending, appeal, &verdict, validator_set);
989            let reverted_stake_mojos: Vec<(u32, u64)> = pending
990                .base_slash_per_validator
991                .iter()
992                .filter(|p| reverted_idx.contains(&p.validator_index))
993                .map(|p| (p.validator_index, p.base_slash_amount))
994                .collect();
995
996            // Slice 2 — revert collateral (optional slasher).
997            // Pass the Option by value; slice takes
998            // `Option<&mut dyn CollateralSlasher>` directly so the
999            // dispatcher hands over the borrow verbatim rather than
1000            // reborrowing through `as_deref_mut` (which fails
1001            // lifetime inference on trait-objects).
1002            let collateral_idx =
1003                adjudicate_sustained_revert_collateral(pending, appeal, &verdict, collateral);
1004            let reverted_collateral_mojos: Vec<(u32, u64)> = pending
1005                .base_slash_per_validator
1006                .iter()
1007                .filter(|p| collateral_idx.contains(&p.validator_index))
1008                .map(|p| (p.validator_index, p.collateral_slashed))
1009                .collect();
1010
1011            // Slice 3 — restore status (result vec intentionally
1012            // discarded; presence of the call is the contract).
1013            let _restored =
1014                adjudicate_sustained_restore_status(pending, appeal, &verdict, validator_set);
1015
1016            // Slice 4 — clawback rewards.
1017            let clawback = adjudicate_sustained_clawback_rewards(
1018                pending,
1019                &verdict,
1020                reward_clawback,
1021                proposer_puzzle_hash,
1022            );
1023
1024            // Slice 5 — forfeit reporter bond (may fail).
1025            let bond_split = adjudicate_sustained_forfeit_reporter_bond(
1026                pending,
1027                appeal,
1028                &verdict,
1029                bond_escrow,
1030                reward_payout,
1031            )?;
1032
1033            // Slice 6 — absorb shortfall into burn.
1034            let absorb = adjudicate_absorb_clawback_shortfall(&clawback, &bond_split);
1035
1036            // Slice 7 — reporter penalty.
1037            let rp = adjudicate_sustained_reporter_penalty(
1038                pending,
1039                &verdict,
1040                validator_set,
1041                effective_balances,
1042                slashed_in_window,
1043                current_epoch,
1044            );
1045            let reporter_penalty_mojos = rp.map(|r| r.penalty_mojos).unwrap_or(0);
1046
1047            // Slice 8 — status transition + history append.
1048            adjudicate_sustained_status_reverted(pending, appeal, &verdict, current_epoch);
1049
1050            Ok(AppealAdjudicationResult {
1051                appeal_hash,
1052                evidence_hash,
1053                outcome,
1054                reverted_stake_mojos,
1055                reverted_collateral_mojos,
1056                clawback_shortfall: clawback.shortfall,
1057                reporter_bond_forfeited: bond_split.forfeited,
1058                appellant_award_mojos: bond_split.winner_award,
1059                reporter_penalty_mojos,
1060                appellant_bond_forfeited: 0,
1061                reporter_award_mojos: 0,
1062                burn_amount: absorb.final_burn,
1063            })
1064        }
1065        AppealVerdict::Rejected { .. } => {
1066            // Slice R1 — forfeit appellant bond (may fail).
1067            let bond_split = adjudicate_rejected_forfeit_appellant_bond(
1068                pending,
1069                appeal,
1070                &verdict,
1071                bond_escrow,
1072                reward_payout,
1073            )?;
1074
1075            // Slice R2 — ChallengeOpen transition + history append.
1076            adjudicate_rejected_challenge_open(pending, appeal, &verdict, reason_hash);
1077
1078            Ok(AppealAdjudicationResult {
1079                appeal_hash,
1080                evidence_hash,
1081                outcome,
1082                reverted_stake_mojos: Vec::new(),
1083                reverted_collateral_mojos: Vec::new(),
1084                clawback_shortfall: 0,
1085                reporter_bond_forfeited: 0,
1086                appellant_award_mojos: 0,
1087                reporter_penalty_mojos: 0,
1088                appellant_bond_forfeited: bond_split.forfeited,
1089                reporter_award_mojos: bond_split.winner_award,
1090                burn_amount: bond_split.burn,
1091            })
1092        }
1093    }
1094}
1095
1096/// Extract the named `validator_index` from an attester
1097/// `ValidatorNotInIntersection` ground. Returns `None` if the
1098/// appeal payload is not an attester ground (programmer error —
1099/// the caller has already matched on the sustain reason, so the
1100/// verdict and payload SHOULD match).
1101fn named_validator_from_ground(appeal: &SlashAppeal) -> Option<u32> {
1102    match &appeal.payload {
1103        SlashAppealPayload::Attester(a) => match a.ground {
1104            AttesterAppealGround::ValidatorNotInIntersection { validator_index } => {
1105                Some(validator_index)
1106            }
1107            _ => None,
1108        },
1109        SlashAppealPayload::Proposer(_) | SlashAppealPayload::InvalidBlock(_) => None,
1110    }
1111}