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}