use std::collections::HashMap;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::bond::{Bond, BondOutcome};
use crate::error::{Result, ScemaDexError};
use crate::primitives::Usdc;
pub trait Clock: Send + Sync {
fn unix_now(&self) -> u64;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SystemClock;
impl Clock for SystemClock {
fn unix_now(&self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
}
#[derive(Debug, Default)]
pub struct ManualClock(std::sync::atomic::AtomicU64);
impl ManualClock {
pub fn new(start_unix: u64) -> Self {
Self(std::sync::atomic::AtomicU64::new(start_unix))
}
pub fn advance(&self, secs: u64) -> u64 {
self.0
.fetch_add(secs, std::sync::atomic::Ordering::SeqCst)
.saturating_add(secs)
}
pub fn set(&self, unix: u64) {
self.0.store(unix, std::sync::atomic::Ordering::SeqCst);
}
}
impl Clock for ManualClock {
fn unix_now(&self) -> u64 {
self.0.load(std::sync::atomic::Ordering::SeqCst)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FinalizeReason {
WindowElapsed,
DeadlineMissed,
ChallengeUpheld,
ChallengeRejected,
ProofVerified,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BondState {
Escrowed,
Provisional {
outcome: BondOutcome,
window_closes_unix: u64,
},
Disputed {
provisional: BondOutcome,
challenger: String,
},
Finalized {
outcome: BondOutcome,
reason: FinalizeReason,
},
}
impl BondState {
pub fn final_outcome(&self) -> Option<BondOutcome> {
match self {
BondState::Finalized { outcome, .. } => Some(*outcome),
_ => None,
}
}
pub fn is_final(&self) -> bool {
matches!(self, BondState::Finalized { .. })
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct SlashRouting {
pub to_caller_bps: u32,
pub to_challengers_bps: u32,
pub to_insurance_bps: u32,
pub to_lineage_bps: u32,
}
impl Default for SlashRouting {
fn default() -> Self {
Self {
to_caller_bps: 10_000,
to_challengers_bps: 0,
to_insurance_bps: 0,
to_lineage_bps: 0,
}
}
}
impl SlashRouting {
fn total_bps(&self) -> u32 {
self.to_caller_bps + self.to_challengers_bps + self.to_insurance_bps + self.to_lineage_bps
}
pub fn is_valid(&self) -> bool {
self.total_bps() == 10_000
}
pub fn distribute(&self, bond: Usdc) -> SlashDistribution {
let amount = bond.0;
let share = |bps: u32| amount.saturating_mul(bps as u64) / 10_000;
let challengers = share(self.to_challengers_bps);
let insurance = share(self.to_insurance_bps);
let lineage = share(self.to_lineage_bps);
let caller = amount - challengers - insurance - lineage; SlashDistribution {
caller: Usdc(caller),
challengers: Usdc(challengers),
insurance: Usdc(insurance),
lineage: Usdc(lineage),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlashDistribution {
pub caller: Usdc,
pub challengers: Usdc,
pub insurance: Usdc,
pub lineage: Usdc,
}
#[derive(Clone, Copy, Debug)]
pub struct SettlementConfig {
pub dispute_window_secs: u64,
pub challenge_bond_bps: u32,
pub slash_routing: SlashRouting,
}
impl Default for SettlementConfig {
fn default() -> Self {
Self::atomic()
}
}
impl SettlementConfig {
pub fn atomic() -> Self {
Self {
dispute_window_secs: 0,
challenge_bond_bps: 0,
slash_routing: SlashRouting::default(),
}
}
pub fn optimistic(window_secs: u64) -> Self {
Self {
dispute_window_secs: window_secs,
challenge_bond_bps: 1_000, slash_routing: SlashRouting::default(),
}
}
pub fn with_slash_routing(mut self, routing: SlashRouting) -> Self {
self.slash_routing = routing;
self
}
}
struct Entry {
bond: Bond,
state: BondState,
}
pub struct SettlementMachine<C: Clock = SystemClock> {
config: SettlementConfig,
clock: C,
bonds: Mutex<HashMap<String, Entry>>,
}
impl SettlementMachine<SystemClock> {
pub fn system(config: SettlementConfig) -> Self {
Self::new(config, SystemClock)
}
}
impl<C: Clock> SettlementMachine<C> {
pub fn new(config: SettlementConfig, clock: C) -> Self {
Self {
config,
clock,
bonds: Mutex::new(HashMap::new()),
}
}
pub fn config(&self) -> &SettlementConfig {
&self.config
}
pub fn clock(&self) -> &C {
&self.clock
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Entry>>> {
self.bonds
.lock()
.map_err(|_| ScemaDexError::Bond("settlement lock poisoned".into()))
}
pub fn open(&self, bond: &Bond) -> Result<()> {
self.lock()?.insert(
bond.intent_digest.clone(),
Entry {
bond: bond.clone(),
state: BondState::Escrowed,
},
);
Ok(())
}
pub fn provision(&self, digest: &str, provisional: BondOutcome) -> Result<BondState> {
let now = self.clock.unix_now();
let mut bonds = self.lock()?;
let entry = bonds
.get_mut(digest)
.ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
if !matches!(entry.state, BondState::Escrowed) {
return Err(ScemaDexError::Bond(format!(
"bond {digest} cannot be provisioned from {:?}",
entry.state
)));
}
entry.state = if self.config.dispute_window_secs == 0 {
BondState::Finalized {
outcome: provisional,
reason: FinalizeReason::WindowElapsed,
}
} else {
BondState::Provisional {
outcome: provisional,
window_closes_unix: now.saturating_add(self.config.dispute_window_secs),
}
};
Ok(entry.state.clone())
}
pub fn file_challenge(&self, digest: &str, challenger: impl Into<String>) -> Result<()> {
let now = self.clock.unix_now();
let mut bonds = self.lock()?;
let entry = bonds
.get_mut(digest)
.ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
match &entry.state {
BondState::Provisional {
outcome,
window_closes_unix,
} => {
if now >= *window_closes_unix {
return Err(ScemaDexError::Bond(format!(
"dispute window for {digest} has closed"
)));
}
if *outcome == BondOutcome::Slashed {
return Err(ScemaDexError::Bond(format!(
"bond {digest} is already provisionally slashed; nothing to challenge"
)));
}
entry.state = BondState::Disputed {
provisional: *outcome,
challenger: challenger.into(),
};
Ok(())
}
other => Err(ScemaDexError::Bond(format!(
"bond {digest} cannot be challenged from {other:?}"
))),
}
}
pub fn resolve_dispute(&self, digest: &str, challenger_won: bool) -> Result<BondOutcome> {
let mut bonds = self.lock()?;
let entry = bonds
.get_mut(digest)
.ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
if !matches!(entry.state, BondState::Disputed { .. }) {
return Err(ScemaDexError::Bond(format!(
"bond {digest} is not disputed (state {:?})",
entry.state
)));
}
let (outcome, reason) = if challenger_won {
(BondOutcome::Slashed, FinalizeReason::ChallengeUpheld)
} else {
(BondOutcome::Honored, FinalizeReason::ChallengeRejected)
};
entry.state = BondState::Finalized { outcome, reason };
Ok(outcome)
}
pub fn resolve_with_proof(&self, digest: &str, outcome: BondOutcome) -> Result<BondOutcome> {
let mut bonds = self.lock()?;
let entry = bonds
.get_mut(digest)
.ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
match entry.state {
BondState::Provisional { .. } | BondState::Disputed { .. } => {
entry.state = BondState::Finalized {
outcome,
reason: FinalizeReason::ProofVerified,
};
Ok(outcome)
}
_ => Err(ScemaDexError::Bond(format!(
"bond {digest} cannot be proof-resolved from {:?}",
entry.state
))),
}
}
pub fn finalize(&self, digest: &str) -> Result<BondOutcome> {
let now = self.clock.unix_now();
let mut bonds = self.lock()?;
let entry = bonds
.get_mut(digest)
.ok_or_else(|| ScemaDexError::Bond(format!("no open bond for intent {digest}")))?;
match &entry.state {
BondState::Finalized { outcome, .. } => Ok(*outcome),
BondState::Provisional {
outcome,
window_closes_unix,
} => {
if now < *window_closes_unix {
return Err(ScemaDexError::Bond(format!(
"dispute window for {digest} is still open"
)));
}
let outcome = *outcome;
entry.state = BondState::Finalized {
outcome,
reason: FinalizeReason::WindowElapsed,
};
Ok(outcome)
}
BondState::Escrowed => {
let deadline = entry.bond.deadline_unix;
if deadline == 0 || now < deadline {
return Err(ScemaDexError::Bond(format!(
"bond {digest} has no fill and its deadline has not passed"
)));
}
entry.state = BondState::Finalized {
outcome: BondOutcome::Slashed,
reason: FinalizeReason::DeadlineMissed,
};
Ok(BondOutcome::Slashed)
}
BondState::Disputed { .. } => Err(ScemaDexError::Bond(format!(
"bond {digest} is disputed; call resolve_dispute"
))),
}
}
pub fn sweep(&self) -> Result<Vec<(String, BondOutcome)>> {
let now = self.clock.unix_now();
let mut bonds = self.lock()?;
let mut settled = Vec::new();
for (digest, entry) in bonds.iter_mut() {
let matured = match &entry.state {
BondState::Provisional {
window_closes_unix, ..
} => now >= *window_closes_unix,
BondState::Escrowed => entry.bond.deadline_unix != 0 && now >= entry.bond.deadline_unix,
_ => false,
};
if !matured {
continue;
}
let (outcome, reason) = match &entry.state {
BondState::Provisional { outcome, .. } => (*outcome, FinalizeReason::WindowElapsed),
BondState::Escrowed => (BondOutcome::Slashed, FinalizeReason::DeadlineMissed),
_ => unreachable!("filtered by `matured`"),
};
entry.state = BondState::Finalized { outcome, reason };
settled.push((digest.clone(), outcome));
}
Ok(settled)
}
pub fn state(&self, digest: &str) -> Option<BondState> {
self.lock().ok()?.get(digest).map(|e| e.state.clone())
}
pub fn bond(&self, digest: &str) -> Option<Bond> {
self.lock().ok()?.get(digest).map(|e| e.bond.clone())
}
pub fn slash_distribution(&self, digest: &str) -> Option<SlashDistribution> {
let bonds = self.lock().ok()?;
let entry = bonds.get(digest)?;
match entry.state {
BondState::Finalized {
outcome: BondOutcome::Slashed,
..
} => Some(self.config.slash_routing.distribute(entry.bond.amount)),
_ => None,
}
}
pub fn open_count(&self) -> usize {
self.lock()
.map(|b| b.values().filter(|e| !e.state.is_final()).count())
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bond(digest: &str, amount: u64, deadline_unix: u64) -> Bond {
Bond {
intent_digest: digest.into(),
amount: Usdc(amount),
min_out_raw: 1_000_000,
deadline_unix,
}
}
#[test]
fn atomic_config_finalizes_on_provision() {
let m = SettlementMachine::new(SettlementConfig::atomic(), ManualClock::new(1_000));
m.open(&bond("h", 500, 0)).unwrap();
let state = m.provision("h", BondOutcome::Honored).unwrap();
assert_eq!(state.final_outcome(), Some(BondOutcome::Honored));
assert_eq!(m.finalize("h").unwrap(), BondOutcome::Honored);
m.open(&bond("s", 500, 0)).unwrap();
let state = m.provision("s", BondOutcome::Slashed).unwrap();
assert_eq!(state.final_outcome(), Some(BondOutcome::Slashed));
assert_eq!(m.open_count(), 0);
}
#[test]
fn optimistic_honors_after_window() {
let clock = ManualClock::new(1_000);
let m = SettlementMachine::new(SettlementConfig::optimistic(60), clock);
m.open(&bond("d", 500, 0)).unwrap();
m.provision("d", BondOutcome::Honored).unwrap();
assert!(matches!(
m.state("d"),
Some(BondState::Provisional { .. })
));
assert!(m.finalize("d").is_err());
}
#[test]
fn window_elapse_finalizes_via_sweep() {
let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
m.open(&bond("d", 500, 0)).unwrap();
m.provision("d", BondOutcome::Honored).unwrap();
assert!(m.sweep().unwrap().is_empty());
m.clock.advance(61);
let settled = m.sweep().unwrap();
assert_eq!(settled, vec![("d".to_string(), BondOutcome::Honored)]);
assert_eq!(m.finalize("d").unwrap(), BondOutcome::Honored);
assert_eq!(m.open_count(), 0);
}
#[test]
fn challenge_upheld_flips_to_slashed() {
let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
m.open(&bond("d", 500, 0)).unwrap();
m.provision("d", BondOutcome::Honored).unwrap();
m.file_challenge("d", "skeptic").unwrap();
assert!(matches!(m.state("d"), Some(BondState::Disputed { .. })));
assert_eq!(m.resolve_dispute("d", true).unwrap(), BondOutcome::Slashed);
assert!(matches!(
m.state("d"),
Some(BondState::Finalized {
reason: FinalizeReason::ChallengeUpheld,
..
})
));
}
#[test]
fn challenge_rejected_keeps_honor() {
let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
m.open(&bond("d", 500, 0)).unwrap();
m.provision("d", BondOutcome::Honored).unwrap();
m.file_challenge("d", "skeptic").unwrap();
assert_eq!(m.resolve_dispute("d", false).unwrap(), BondOutcome::Honored);
}
#[test]
fn cannot_challenge_after_window_or_a_slash() {
let clock = ManualClock::new(1_000);
let m = SettlementMachine::new(SettlementConfig::optimistic(60), clock);
m.open(&bond("late", 500, 0)).unwrap();
m.provision("late", BondOutcome::Honored).unwrap();
m.clock.advance(61);
assert!(m.file_challenge("late", "x").is_err());
let m2 = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
m2.open(&bond("slash", 500, 0)).unwrap();
m2.provision("slash", BondOutcome::Slashed).unwrap();
assert!(m2.file_challenge("slash", "x").is_err());
}
#[test]
fn deadline_missed_slashes_undelivered_bond() {
let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
m.open(&bond("dead", 500, 1_050)).unwrap();
assert!(m.finalize("dead").is_err());
m.clock.set(1_100);
assert_eq!(m.finalize("dead").unwrap(), BondOutcome::Slashed);
assert!(matches!(
m.state("dead"),
Some(BondState::Finalized {
reason: FinalizeReason::DeadlineMissed,
..
})
));
}
#[test]
fn proof_resolves_deterministically() {
let m = SettlementMachine::new(SettlementConfig::optimistic(3600), ManualClock::new(1_000));
m.open(&bond("p", 500, 0)).unwrap();
m.provision("p", BondOutcome::Honored).unwrap();
assert_eq!(
m.resolve_with_proof("p", BondOutcome::Slashed).unwrap(),
BondOutcome::Slashed
);
assert!(matches!(
m.state("p"),
Some(BondState::Finalized {
reason: FinalizeReason::ProofVerified,
..
})
));
}
#[test]
fn slash_routing_conserves_value() {
let routing = SlashRouting {
to_caller_bps: 4_000,
to_challengers_bps: 3_000,
to_insurance_bps: 2_000,
to_lineage_bps: 1_000,
};
assert!(routing.is_valid());
let d = routing.distribute(Usdc(1_003)); let total = d.caller.0 + d.challengers.0 + d.insurance.0 + d.lineage.0;
assert_eq!(total, 1_003, "no lamport lost to rounding");
assert_eq!(d.challengers.0, 300);
assert_eq!(d.insurance.0, 200);
assert_eq!(d.lineage.0, 100);
assert_eq!(d.caller.0, 403, "caller absorbs the dust");
}
#[test]
fn default_routing_sends_slash_to_caller() {
let m = SettlementMachine::new(SettlementConfig::optimistic(60), ManualClock::new(1_000));
m.open(&bond("d", 500, 0)).unwrap();
m.provision("d", BondOutcome::Honored).unwrap();
m.file_challenge("d", "x").unwrap();
m.resolve_dispute("d", true).unwrap();
let dist = m.slash_distribution("d").unwrap();
assert_eq!(dist.caller, Usdc(500));
assert_eq!(dist.challengers, Usdc(0));
}
}