Skip to main content

scemadex_sdk/
settlement.rs

1//! **Settlement v2** — the optimistic conviction-settlement state machine.
2//!
3//! Conviction Routing (Primitive D) today settles a bond *atomically*: the moment
4//! a fill lands, [`crate::bond::EscrowBondEngine::settle`] decides
5//! honored-or-slashed and the outcome is final. That is fast, but it has no room
6//! for the two things the adversarial primitives need: **time** and
7//! **contestability**. A challenger (Primitive E) can only bet on an outcome that
8//! was already decided; a zkML proof (Primitive I) has nowhere to land; a slash
9//! has no window in which to be disputed before money moves.
10//!
11//! This module turns that function into a lifecycle:
12//!
13//! ```text
14//!                    ┌──────────── challenge upheld ───────────┐
15//!                    │                                         ▼
16//! Escrowed ─fill─▶ Provisional ─window elapses, unchallenged─▶ Finalized(Honored)
17//!    │               │
18//!    │               └─challenge filed─▶ Disputed ─resolve─▶ Finalized(Honored|Slashed)
19//!    │
20//!    └─deadline passes, no fill─▶ Finalized(Slashed)   // failure to deliver
21//! ```
22//!
23//! A fill provisionally honors immediately (the happy path stays fast), but funds
24//! are not considered settled until the bond reaches **Finalized** — either the
25//! dispute window elapses unchallenged, or a challenge / proof resolves it.
26//!
27//! **Backward compatibility.** [`SettlementConfig::atomic`] sets a zero-length
28//! window, so [`SettlementMachine::provision`] finalizes in the same call — exactly
29//! today's `EscrowBondEngine::settle` behavior. The state machine is a superset,
30//! not a replacement.
31//!
32//! The machine is deliberately outcome-agnostic: [`provision`](SettlementMachine::provision)
33//! takes a [`BondOutcome`], not a swap [`crate::route::Fill`]. Deciding *whether the
34//! promise held* stays the caller's job (the swap predicate lives in the bond
35//! engine), which is what lets a future `Outcome` trait generalize the rail past
36//! swaps without touching this file.
37
38use std::collections::HashMap;
39use std::sync::Mutex;
40
41use serde::{Deserialize, Serialize};
42
43use crate::bond::{Bond, BondOutcome};
44use crate::error::{Result, ScemaDexError};
45use crate::primitives::Usdc;
46
47/// A monotonic-ish source of unix-seconds, injected so the state machine stays
48/// deterministic and testable. The lean core ships [`ManualClock`] (advanceable,
49/// for tests and simulation) and [`SystemClock`] (wall-clock, for live use).
50pub trait Clock: Send + Sync {
51    fn unix_now(&self) -> u64;
52}
53
54/// A wall-clock [`Clock`] backed by the system time.
55#[derive(Clone, Copy, Debug, Default)]
56pub struct SystemClock;
57
58impl Clock for SystemClock {
59    fn unix_now(&self) -> u64 {
60        std::time::SystemTime::now()
61            .duration_since(std::time::UNIX_EPOCH)
62            .map(|d| d.as_secs())
63            .unwrap_or(0)
64    }
65}
66
67/// A deterministic [`Clock`] whose value only changes when the test advances it.
68#[derive(Debug, Default)]
69pub struct ManualClock(std::sync::atomic::AtomicU64);
70
71impl ManualClock {
72    pub fn new(start_unix: u64) -> Self {
73        Self(std::sync::atomic::AtomicU64::new(start_unix))
74    }
75
76    /// Jump forward by `secs` and return the new value.
77    pub fn advance(&self, secs: u64) -> u64 {
78        self.0
79            .fetch_add(secs, std::sync::atomic::Ordering::SeqCst)
80            .saturating_add(secs)
81    }
82
83    /// Set the absolute time.
84    pub fn set(&self, unix: u64) {
85        self.0.store(unix, std::sync::atomic::Ordering::SeqCst);
86    }
87}
88
89impl Clock for ManualClock {
90    fn unix_now(&self) -> u64 {
91        self.0.load(std::sync::atomic::Ordering::SeqCst)
92    }
93}
94
95/// Why a bond reached its terminal outcome — the audit trail behind a settlement.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub enum FinalizeReason {
98    /// The dispute window closed with no successful challenge — optimistic honor.
99    WindowElapsed,
100    /// The bond's deadline passed with no fill — failure to deliver.
101    DeadlineMissed,
102    /// A challenge (Primitive E) was upheld and flipped the provisional outcome.
103    ChallengeUpheld,
104    /// A challenge was filed but rejected — the provisional outcome stands.
105    ChallengeRejected,
106    /// A zkML / re-execution proof (Primitive I) resolved the outcome deterministically.
107    ProofVerified,
108}
109
110/// Where a bond is in its lifecycle. Only [`BondState::Finalized`] moves money.
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub enum BondState {
113    /// Collateral locked, awaiting a fill (or the deadline).
114    Escrowed,
115    /// A fill landed; `outcome` is provisional and the dispute window is open
116    /// until `window_closes_unix`.
117    Provisional {
118        outcome: BondOutcome,
119        window_closes_unix: u64,
120    },
121    /// A challenge was filed during the window; awaiting resolution.
122    Disputed {
123        provisional: BondOutcome,
124        challenger: String,
125    },
126    /// Terminal. Funds settle here per [`SlashRouting`].
127    Finalized {
128        outcome: BondOutcome,
129        reason: FinalizeReason,
130    },
131}
132
133impl BondState {
134    /// The terminal outcome, if this bond has finalized.
135    pub fn final_outcome(&self) -> Option<BondOutcome> {
136        match self {
137            BondState::Finalized { outcome, .. } => Some(*outcome),
138            _ => None,
139        }
140    }
141
142    pub fn is_final(&self) -> bool {
143        matches!(self, BondState::Finalized { .. })
144    }
145}
146
147/// How slashed collateral is split. This is the economic knob that makes the
148/// adversarial primitives load-bearing: challengers (E), the reinsurance pool (J),
149/// and the experience lineage (G) are all paid from a slash. The four shares are
150/// in basis points and must sum to `10_000`.
151#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
152pub struct SlashRouting {
153    /// Compensation to the wronged caller. Absorbs rounding dust.
154    pub to_caller_bps: u32,
155    /// Counter-Market winners (Primitive E).
156    pub to_challengers_bps: u32,
157    /// Reinsurance pool (Primitive J).
158    pub to_insurance_bps: u32,
159    /// Upstream experience-royalty holders (Primitive G).
160    pub to_lineage_bps: u32,
161}
162
163impl Default for SlashRouting {
164    /// The legacy behavior: a slash compensates the caller in full.
165    fn default() -> Self {
166        Self {
167            to_caller_bps: 10_000,
168            to_challengers_bps: 0,
169            to_insurance_bps: 0,
170            to_lineage_bps: 0,
171        }
172    }
173}
174
175impl SlashRouting {
176    fn total_bps(&self) -> u32 {
177        self.to_caller_bps + self.to_challengers_bps + self.to_insurance_bps + self.to_lineage_bps
178    }
179
180    /// True when the four shares sum to a full `10_000` bps.
181    pub fn is_valid(&self) -> bool {
182        self.total_bps() == 10_000
183    }
184
185    /// Split a slashed bond into its four destinations. Conserves value exactly —
186    /// the caller share absorbs integer-division dust so the full bond is paid out.
187    pub fn distribute(&self, bond: Usdc) -> SlashDistribution {
188        let amount = bond.0;
189        let share = |bps: u32| amount.saturating_mul(bps as u64) / 10_000;
190        let challengers = share(self.to_challengers_bps);
191        let insurance = share(self.to_insurance_bps);
192        let lineage = share(self.to_lineage_bps);
193        let caller = amount - challengers - insurance - lineage; // dust → caller
194        SlashDistribution {
195            caller: Usdc(caller),
196            challengers: Usdc(challengers),
197            insurance: Usdc(insurance),
198            lineage: Usdc(lineage),
199        }
200    }
201}
202
203/// The resolved split of a slashed bond. `caller + challengers + insurance +
204/// lineage` always equals the original bond.
205#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
206pub struct SlashDistribution {
207    pub caller: Usdc,
208    pub challengers: Usdc,
209    pub insurance: Usdc,
210    pub lineage: Usdc,
211}
212
213/// Tunables for the settlement lifecycle.
214#[derive(Clone, Copy, Debug)]
215pub struct SettlementConfig {
216    /// Length of the dispute window in seconds. `0` collapses the machine to the
217    /// legacy atomic settle (provision finalizes immediately).
218    pub dispute_window_secs: u64,
219    /// Stake a challenger must post to open a dispute, as bps of the bond. Makes
220    /// frivolous challenges costly. (Enforced by the challenge book, not here.)
221    pub challenge_bond_bps: u32,
222    /// How a slashed bond is distributed.
223    pub slash_routing: SlashRouting,
224}
225
226impl Default for SettlementConfig {
227    fn default() -> Self {
228        Self::atomic()
229    }
230}
231
232impl SettlementConfig {
233    /// Zero-window config — reproduces today's atomic `settle`. This is the
234    /// backward-compatibility default.
235    pub fn atomic() -> Self {
236        Self {
237            dispute_window_secs: 0,
238            challenge_bond_bps: 0,
239            slash_routing: SlashRouting::default(),
240        }
241    }
242
243    /// Optimistic config with a dispute window of `window_secs`.
244    pub fn optimistic(window_secs: u64) -> Self {
245        Self {
246            dispute_window_secs: window_secs,
247            challenge_bond_bps: 1_000, // 10% of the bond to challenge
248            slash_routing: SlashRouting::default(),
249        }
250    }
251
252    /// Builder: set how a slashed bond is distributed. A meaningful counter-market
253    /// (Primitive E) needs `to_challengers_bps > 0` so doubting is +EV when right.
254    pub fn with_slash_routing(mut self, routing: SlashRouting) -> Self {
255        self.slash_routing = routing;
256        self
257    }
258}
259
260struct Entry {
261    bond: Bond,
262    state: BondState,
263}
264
265/// An in-process settlement state machine over open bonds.
266///
267/// Generic over a [`Clock`] so time-based transitions (window expiry, deadline
268/// misses) are deterministic in tests. Construct with [`SettlementMachine::system`]
269/// for live use or [`SettlementMachine::new`] with a [`ManualClock`] for tests and
270/// simulation.
271///
272/// Lifecycle:
273/// [`open`](Self::open) → [`provision`](Self::provision) →
274/// (optionally [`file_challenge`](Self::file_challenge) →
275/// [`resolve_dispute`](Self::resolve_dispute)) → [`finalize`](Self::finalize).
276pub struct SettlementMachine<C: Clock = SystemClock> {
277    config: SettlementConfig,
278    clock: C,
279    bonds: Mutex<HashMap<String, Entry>>,
280}
281
282impl SettlementMachine<SystemClock> {
283    /// A machine driven by the wall clock.
284    pub fn system(config: SettlementConfig) -> Self {
285        Self::new(config, SystemClock)
286    }
287}
288
289impl<C: Clock> SettlementMachine<C> {
290    pub fn new(config: SettlementConfig, clock: C) -> Self {
291        Self {
292            config,
293            clock,
294            bonds: Mutex::new(HashMap::new()),
295        }
296    }
297
298    pub fn config(&self) -> &SettlementConfig {
299        &self.config
300    }
301
302    /// The injected clock. Handy for driving time in tests/simulation (e.g.
303    /// advancing a [`ManualClock`]) or reading "now".
304    pub fn clock(&self) -> &C {
305        &self.clock
306    }
307
308    fn lock(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Entry>>> {
309        self.bonds
310            .lock()
311            .map_err(|_| ScemaDexError::Bond("settlement lock poisoned".into()))
312    }
313
314    /// Register a freshly escrowed bond as `Escrowed`.
315    pub fn open(&self, bond: &Bond) -> Result<()> {
316        self.lock()?.insert(
317            bond.intent_digest.clone(),
318            Entry {
319                bond: bond.clone(),
320                state: BondState::Escrowed,
321            },
322        );
323        Ok(())
324    }
325
326    /// Record a fill's provisional outcome and open the dispute window.
327    ///
328    /// With a zero-length window ([`SettlementConfig::atomic`]) the bond finalizes
329    /// in this same call with reason [`FinalizeReason::WindowElapsed`] — the legacy
330    /// atomic path. Otherwise it enters `Provisional` until the window closes.
331    /// Returns the resulting state.
332    pub fn provision(&self, digest: &str, provisional: BondOutcome) -> Result<BondState> {
333        let now = self.clock.unix_now();
334        let mut bonds = self.lock()?;
335        let entry = bonds
336            .get_mut(digest)
337            .ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
338        if !matches!(entry.state, BondState::Escrowed) {
339            return Err(ScemaDexError::Bond(format!(
340                "bond {digest} cannot be provisioned from {:?}",
341                entry.state
342            )));
343        }
344        entry.state = if self.config.dispute_window_secs == 0 {
345            BondState::Finalized {
346                outcome: provisional,
347                reason: FinalizeReason::WindowElapsed,
348            }
349        } else {
350            BondState::Provisional {
351                outcome: provisional,
352                window_closes_unix: now.saturating_add(self.config.dispute_window_secs),
353            }
354        };
355        Ok(entry.state.clone())
356    }
357
358    /// File a challenge against a provisionally-**honored** bond, moving it to
359    /// `Disputed`. Fails if the bond isn't provisional, the window has closed, or
360    /// the provisional outcome is already `Slashed` (nothing to contest).
361    pub fn file_challenge(&self, digest: &str, challenger: impl Into<String>) -> Result<()> {
362        let now = self.clock.unix_now();
363        let mut bonds = self.lock()?;
364        let entry = bonds
365            .get_mut(digest)
366            .ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
367        match &entry.state {
368            BondState::Provisional {
369                outcome,
370                window_closes_unix,
371            } => {
372                if now >= *window_closes_unix {
373                    return Err(ScemaDexError::Bond(format!(
374                        "dispute window for {digest} has closed"
375                    )));
376                }
377                if *outcome == BondOutcome::Slashed {
378                    return Err(ScemaDexError::Bond(format!(
379                        "bond {digest} is already provisionally slashed; nothing to challenge"
380                    )));
381                }
382                entry.state = BondState::Disputed {
383                    provisional: *outcome,
384                    challenger: challenger.into(),
385                };
386                Ok(())
387            }
388            other => Err(ScemaDexError::Bond(format!(
389                "bond {digest} cannot be challenged from {other:?}"
390            ))),
391        }
392    }
393
394    /// Resolve an open dispute. `challenger_won == true` slashes the bond
395    /// ([`FinalizeReason::ChallengeUpheld`]); otherwise the provisional honor
396    /// stands ([`FinalizeReason::ChallengeRejected`]).
397    pub fn resolve_dispute(&self, digest: &str, challenger_won: bool) -> Result<BondOutcome> {
398        let mut bonds = self.lock()?;
399        let entry = bonds
400            .get_mut(digest)
401            .ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
402        if !matches!(entry.state, BondState::Disputed { .. }) {
403            return Err(ScemaDexError::Bond(format!(
404                "bond {digest} is not disputed (state {:?})",
405                entry.state
406            )));
407        }
408        let (outcome, reason) = if challenger_won {
409            (BondOutcome::Slashed, FinalizeReason::ChallengeUpheld)
410        } else {
411            (BondOutcome::Honored, FinalizeReason::ChallengeRejected)
412        };
413        entry.state = BondState::Finalized { outcome, reason };
414        Ok(outcome)
415    }
416
417    /// Deterministically resolve a bond from a verified proof (Primitive I),
418    /// short-circuiting the window from either `Provisional` or `Disputed`.
419    pub fn resolve_with_proof(&self, digest: &str, outcome: BondOutcome) -> Result<BondOutcome> {
420        let mut bonds = self.lock()?;
421        let entry = bonds
422            .get_mut(digest)
423            .ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
424        match entry.state {
425            BondState::Provisional { .. } | BondState::Disputed { .. } => {
426                entry.state = BondState::Finalized {
427                    outcome,
428                    reason: FinalizeReason::ProofVerified,
429                };
430                Ok(outcome)
431            }
432            _ => Err(ScemaDexError::Bond(format!(
433                "bond {digest} cannot be proof-resolved from {:?}",
434                entry.state
435            ))),
436        }
437    }
438
439    /// Advance a single bond's time-based transition and return its outcome once
440    /// terminal. Honors a `Provisional` bond whose window has elapsed; slashes an
441    /// `Escrowed` bond whose deadline has passed with no fill. Idempotent on an
442    /// already-`Finalized` bond. Errors while the window is still open or a dispute
443    /// is unresolved.
444    pub fn finalize(&self, digest: &str) -> Result<BondOutcome> {
445        let now = self.clock.unix_now();
446        let mut bonds = self.lock()?;
447        let entry = bonds
448            .get_mut(digest)
449            .ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
450        match &entry.state {
451            BondState::Finalized { outcome, .. } => Ok(*outcome),
452            BondState::Provisional {
453                outcome,
454                window_closes_unix,
455            } => {
456                if now < *window_closes_unix {
457                    return Err(ScemaDexError::Bond(format!(
458                        "dispute window for {digest} is still open"
459                    )));
460                }
461                let outcome = *outcome;
462                entry.state = BondState::Finalized {
463                    outcome,
464                    reason: FinalizeReason::WindowElapsed,
465                };
466                Ok(outcome)
467            }
468            BondState::Escrowed => {
469                let deadline = entry.bond.deadline_unix;
470                if deadline == 0 || now < deadline {
471                    return Err(ScemaDexError::Bond(format!(
472                        "bond {digest} has no fill and its deadline has not passed"
473                    )));
474                }
475                entry.state = BondState::Finalized {
476                    outcome: BondOutcome::Slashed,
477                    reason: FinalizeReason::DeadlineMissed,
478                };
479                Ok(BondOutcome::Slashed)
480            }
481            BondState::Disputed { .. } => Err(ScemaDexError::Bond(format!(
482                "bond {digest} is disputed; call resolve_dispute"
483            ))),
484        }
485    }
486
487    /// Finalize every bond whose window has elapsed or whose deadline has passed,
488    /// returning the `(digest, outcome)` of each transition. A driver loop calls
489    /// this on a tick to settle matured bonds in bulk. Disputed bonds are left
490    /// untouched.
491    pub fn sweep(&self) -> Result<Vec<(String, BondOutcome)>> {
492        let now = self.clock.unix_now();
493        let mut bonds = self.lock()?;
494        let mut settled = Vec::new();
495        for (digest, entry) in bonds.iter_mut() {
496            let matured = match &entry.state {
497                BondState::Provisional {
498                    window_closes_unix, ..
499                } => now >= *window_closes_unix,
500                BondState::Escrowed => entry.bond.deadline_unix != 0 && now >= entry.bond.deadline_unix,
501                _ => false,
502            };
503            if !matured {
504                continue;
505            }
506            let (outcome, reason) = match &entry.state {
507                BondState::Provisional { outcome, .. } => (*outcome, FinalizeReason::WindowElapsed),
508                BondState::Escrowed => (BondOutcome::Slashed, FinalizeReason::DeadlineMissed),
509                _ => unreachable!("filtered by `matured`"),
510            };
511            entry.state = BondState::Finalized { outcome, reason };
512            settled.push((digest.clone(), outcome));
513        }
514        Ok(settled)
515    }
516
517    /// Current state of a tracked bond.
518    pub fn state(&self, digest: &str) -> Option<BondState> {
519        self.lock().ok()?.get(digest).map(|e| e.state.clone())
520    }
521
522    /// The original escrowed bond (full collateral) for a tracked digest.
523    pub fn bond(&self, digest: &str) -> Option<Bond> {
524        self.lock().ok()?.get(digest).map(|e| e.bond.clone())
525    }
526
527    /// Slash distribution for a finalized-slashed bond, per the configured routing.
528    /// Returns `None` unless the bond has finalized as `Slashed`.
529    pub fn slash_distribution(&self, digest: &str) -> Option<SlashDistribution> {
530        let bonds = self.lock().ok()?;
531        let entry = bonds.get(digest)?;
532        match entry.state {
533            BondState::Finalized {
534                outcome: BondOutcome::Slashed,
535                ..
536            } => Some(self.config.slash_routing.distribute(entry.bond.amount)),
537            _ => None,
538        }
539    }
540
541    /// Bonds that have not yet reached a terminal state.
542    pub fn open_count(&self) -> usize {
543        self.lock()
544            .map(|b| b.values().filter(|e| !e.state.is_final()).count())
545            .unwrap_or(0)
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn bond(digest: &str, amount: u64, deadline_unix: u64) -> Bond {
554        Bond {
555            intent_digest: digest.into(),
556            amount: Usdc(amount),
557            min_out_raw: 1_000_000,
558            deadline_unix,
559        }
560    }
561
562    #[test]
563    fn atomic_config_finalizes_on_provision() {
564        // Zero window == today's atomic settle, for both outcomes.
565        let m = SettlementMachine::new(SettlementConfig::atomic(), ManualClock::new(1_000));
566        m.open(&bond("h", 500, 0)).unwrap();
567        let state = m.provision("h", BondOutcome::Honored).unwrap();
568        assert_eq!(state.final_outcome(), Some(BondOutcome::Honored));
569        assert_eq!(m.finalize("h").unwrap(), BondOutcome::Honored);
570
571        m.open(&bond("s", 500, 0)).unwrap();
572        let state = m.provision("s", BondOutcome::Slashed).unwrap();
573        assert_eq!(state.final_outcome(), Some(BondOutcome::Slashed));
574        assert_eq!(m.open_count(), 0);
575    }
576
577    #[test]
578    fn optimistic_honors_after_window() {
579        let clock = ManualClock::new(1_000);
580        let m = SettlementMachine::new(SettlementConfig::optimistic(60), clock);
581        m.open(&bond("d", 500, 0)).unwrap();
582        m.provision("d", BondOutcome::Honored).unwrap();
583        assert!(matches!(
584            m.state("d"),
585            Some(BondState::Provisional { .. })
586        ));
587        // Window still open → finalize refuses.
588        assert!(m.finalize("d").is_err());
589    }
590
591    #[test]
592    fn window_elapse_finalizes_via_sweep() {
593        let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
594        m.open(&bond("d", 500, 0)).unwrap();
595        m.provision("d", BondOutcome::Honored).unwrap();
596        // Nothing matured yet.
597        assert!(m.sweep().unwrap().is_empty());
598        // Advance past the window (tests share the module, so `clock` is reachable).
599        m.clock.advance(61);
600        let settled = m.sweep().unwrap();
601        assert_eq!(settled, vec![("d".to_string(), BondOutcome::Honored)]);
602        assert_eq!(m.finalize("d").unwrap(), BondOutcome::Honored);
603        assert_eq!(m.open_count(), 0);
604    }
605
606    #[test]
607    fn challenge_upheld_flips_to_slashed() {
608        let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
609        m.open(&bond("d", 500, 0)).unwrap();
610        m.provision("d", BondOutcome::Honored).unwrap();
611        m.file_challenge("d", "skeptic").unwrap();
612        assert!(matches!(m.state("d"), Some(BondState::Disputed { .. })));
613        assert_eq!(m.resolve_dispute("d", true).unwrap(), BondOutcome::Slashed);
614        assert!(matches!(
615            m.state("d"),
616            Some(BondState::Finalized {
617                reason: FinalizeReason::ChallengeUpheld,
618                ..
619            })
620        ));
621    }
622
623    #[test]
624    fn challenge_rejected_keeps_honor() {
625        let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
626        m.open(&bond("d", 500, 0)).unwrap();
627        m.provision("d", BondOutcome::Honored).unwrap();
628        m.file_challenge("d", "skeptic").unwrap();
629        assert_eq!(m.resolve_dispute("d", false).unwrap(), BondOutcome::Honored);
630    }
631
632    #[test]
633    fn cannot_challenge_after_window_or_a_slash() {
634        let clock = ManualClock::new(1_000);
635        let m = SettlementMachine::new(SettlementConfig::optimistic(60), clock);
636        m.open(&bond("late", 500, 0)).unwrap();
637        m.provision("late", BondOutcome::Honored).unwrap();
638        m.clock.advance(61);
639        assert!(m.file_challenge("late", "x").is_err());
640
641        let m2 = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
642        m2.open(&bond("slash", 500, 0)).unwrap();
643        m2.provision("slash", BondOutcome::Slashed).unwrap();
644        assert!(m2.file_challenge("slash", "x").is_err());
645    }
646
647    #[test]
648    fn deadline_missed_slashes_undelivered_bond() {
649        let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
650        m.open(&bond("dead", 500, 1_050)).unwrap();
651        // Before the deadline, an unfilled bond can't be finalized.
652        assert!(m.finalize("dead").is_err());
653        m.clock.set(1_100);
654        assert_eq!(m.finalize("dead").unwrap(), BondOutcome::Slashed);
655        assert!(matches!(
656            m.state("dead"),
657            Some(BondState::Finalized {
658                reason: FinalizeReason::DeadlineMissed,
659                ..
660            })
661        ));
662    }
663
664    #[test]
665    fn proof_resolves_deterministically() {
666        let m = SettlementMachine::new(SettlementConfig::optimistic(3600), ManualClock::new(1_000));
667        m.open(&bond("p", 500, 0)).unwrap();
668        m.provision("p", BondOutcome::Honored).unwrap();
669        // A proof collapses the (possibly long) window immediately.
670        assert_eq!(
671            m.resolve_with_proof("p", BondOutcome::Slashed).unwrap(),
672            BondOutcome::Slashed
673        );
674        assert!(matches!(
675            m.state("p"),
676            Some(BondState::Finalized {
677                reason: FinalizeReason::ProofVerified,
678                ..
679            })
680        ));
681    }
682
683    #[test]
684    fn slash_routing_conserves_value() {
685        let routing = SlashRouting {
686            to_caller_bps: 4_000,
687            to_challengers_bps: 3_000,
688            to_insurance_bps: 2_000,
689            to_lineage_bps: 1_000,
690        };
691        assert!(routing.is_valid());
692        let d = routing.distribute(Usdc(1_003)); // odd amount to force dust
693        let total = d.caller.0 + d.challengers.0 + d.insurance.0 + d.lineage.0;
694        assert_eq!(total, 1_003, "no lamport lost to rounding");
695        assert_eq!(d.challengers.0, 300);
696        assert_eq!(d.insurance.0, 200);
697        assert_eq!(d.lineage.0, 100);
698        assert_eq!(d.caller.0, 403, "caller absorbs the dust");
699    }
700
701    #[test]
702    fn default_routing_sends_slash_to_caller() {
703        // Backcompat: default routing == legacy "slash compensates the caller".
704        let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
705        m.open(&bond("d", 500, 0)).unwrap();
706        m.provision("d", BondOutcome::Honored).unwrap();
707        m.file_challenge("d", "x").unwrap();
708        m.resolve_dispute("d", true).unwrap();
709        let dist = m.slash_distribution("d").unwrap();
710        assert_eq!(dist.caller, Usdc(500));
711        assert_eq!(dist.challengers, Usdc(0));
712    }
713}