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}