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