1use 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
47pub trait Clock: Send + Sync {
51 fn unix_now(&self) -> u64;
52}
53
54#[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#[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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub enum FinalizeReason {
98 WindowElapsed,
100 DeadlineMissed,
102 ChallengeUpheld,
104 ChallengeRejected,
106 ProofVerified,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub enum BondState {
113 Escrowed,
115 Provisional {
118 outcome: BondOutcome,
119 window_closes_unix: u64,
120 },
121 Disputed {
123 provisional: BondOutcome,
124 challenger: String,
125 },
126 Finalized {
128 outcome: BondOutcome,
129 reason: FinalizeReason,
130 },
131}
132
133impl BondState {
134 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#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
152pub struct SlashRouting {
153 pub to_caller_bps: u32,
155 pub to_challengers_bps: u32,
157 pub to_insurance_bps: u32,
159 pub to_lineage_bps: u32,
161}
162
163impl Default for SlashRouting {
164 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 pub fn is_valid(&self) -> bool {
182 self.total_bps() == 10_000
183 }
184
185 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; SlashDistribution {
195 caller: Usdc(caller),
196 challengers: Usdc(challengers),
197 insurance: Usdc(insurance),
198 lineage: Usdc(lineage),
199 }
200 }
201}
202
203#[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#[derive(Clone, Copy, Debug)]
215pub struct SettlementConfig {
216 pub dispute_window_secs: u64,
219 pub challenge_bond_bps: u32,
222 pub slash_routing: SlashRouting,
224}
225
226impl Default for SettlementConfig {
227 fn default() -> Self {
228 Self::atomic()
229 }
230}
231
232impl SettlementConfig {
233 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 pub fn optimistic(window_secs: u64) -> Self {
245 Self {
246 dispute_window_secs: window_secs,
247 challenge_bond_bps: 1_000, slash_routing: SlashRouting::default(),
249 }
250 }
251
252 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
265pub struct SettlementMachine<C: Clock = SystemClock> {
277 config: SettlementConfig,
278 clock: C,
279 bonds: Mutex<HashMap<String, Entry>>,
280}
281
282impl SettlementMachine<SystemClock> {
283 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 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 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 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 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 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 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 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 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 pub fn state(&self, digest: &str) -> Option<BondState> {
519 self.lock().ok()?.get(digest).map(|e| e.state.clone())
520 }
521
522 pub fn bond(&self, digest: &str) -> Option<Bond> {
524 self.lock().ok()?.get(digest).map(|e| e.bond.clone())
525 }
526
527 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 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 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 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 assert!(m.sweep().unwrap().is_empty());
598 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 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 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)); 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 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}