use crate::bond::{Bond, BondOutcome};
use crate::counter::{Challenge, ChallengeSettlement, CounterMarket};
use crate::error::{Result, ScemaDexError};
use crate::policy::Conviction;
use crate::primitives::Usdc;
use crate::scar::{certify_scar, ScarRecord};
use crate::settlement::{
BondState, Clock, FinalizeReason, SettlementConfig, SettlementMachine, SlashDistribution,
SystemClock,
};
#[derive(Clone, Debug)]
pub struct SettlementReport {
pub intent_digest: String,
pub outcome: BondOutcome,
pub reason: FinalizeReason,
pub slash: Option<SlashDistribution>,
pub challenge: Option<ChallengeSettlement>,
}
impl SettlementReport {
pub fn is_adversarial_slash(&self) -> bool {
self.outcome == BondOutcome::Slashed && self.reason == FinalizeReason::ChallengeUpheld
}
}
pub struct DisputeCoordinator<C: Clock = SystemClock> {
machine: SettlementMachine<C>,
counter: CounterMarket,
}
impl DisputeCoordinator<SystemClock> {
pub fn system(config: SettlementConfig) -> Self {
Self::new(config, SystemClock)
}
}
impl<C: Clock> DisputeCoordinator<C> {
pub fn new(config: SettlementConfig, clock: C) -> Self {
Self {
machine: SettlementMachine::new(config, clock),
counter: CounterMarket::new(),
}
}
pub fn machine(&self) -> &SettlementMachine<C> {
&self.machine
}
pub fn counter(&self) -> &CounterMarket {
&self.counter
}
pub fn open(&self, bond: &Bond, self_conviction: Conviction) -> Result<()> {
self.machine.open(bond)?;
let challenger_pool = self
.machine
.config()
.slash_routing
.distribute(bond.amount)
.challengers;
let listed = Bond {
amount: challenger_pool,
..bond.clone()
};
self.counter.list(&listed, self_conviction)
}
pub fn provision(&self, digest: &str, provisional: BondOutcome) -> Result<BondState> {
self.machine.provision(digest, provisional)
}
pub fn challenge(
&self,
digest: &str,
challenger_id: impl Into<String>,
stake: Usdc,
) -> Result<Challenge> {
let id = challenger_id.into();
match self.machine.state(digest) {
Some(BondState::Provisional { .. }) => {
self.machine.file_challenge(digest, id.clone())?;
}
Some(BondState::Disputed { .. }) => { }
other => {
return Err(ScemaDexError::Bond(format!(
"bond {digest} is not open to challenge (state {other:?})"
)))
}
}
self.counter.challenge(digest, id, stake)
}
pub fn resolve(&self, digest: &str, adjudicated: BondOutcome) -> Result<SettlementReport> {
let challenger_won = adjudicated == BondOutcome::Slashed;
let outcome = self.machine.resolve_dispute(digest, challenger_won)?;
self.report(digest, outcome)
}
pub fn finalize_unchallenged(&self, digest: &str) -> Result<SettlementReport> {
let outcome = self.machine.finalize(digest)?;
self.report(digest, outcome)
}
pub fn resolve_via_oracle(&self, digest: &str, outcome: BondOutcome) -> Result<SettlementReport> {
let outcome = self.machine.resolve_with_proof(digest, outcome)?;
self.report(digest, outcome)
}
pub fn sweep(&self) -> Result<Vec<SettlementReport>> {
let matured = self.machine.sweep()?;
matured
.into_iter()
.map(|(digest, outcome)| self.report(&digest, outcome))
.collect()
}
pub fn mint_scar(
&self,
digest: &str,
peer_id: impl Into<String>,
transitions: u32,
payload: Vec<u8>,
price: Usdc,
) -> Result<ScarRecord> {
match self.machine.state(digest) {
Some(BondState::Finalized {
outcome: BondOutcome::Slashed,
..
}) => {}
other => {
return Err(ScemaDexError::Bond(format!(
"scar requires a finalized-slashed bond; {digest} is {other:?}"
)))
}
}
let bond = self
.machine
.bond(digest)
.ok_or_else(|| ScemaDexError::Bond(format!("no bond tracked for {digest}")))?;
certify_scar(&bond, BondOutcome::Slashed, peer_id, transitions, payload, price)
}
fn report(&self, digest: &str, outcome: BondOutcome) -> Result<SettlementReport> {
let challenge = self.counter.settle(digest, outcome).ok();
let slash = if outcome == BondOutcome::Slashed {
self.machine.slash_distribution(digest)
} else {
None
};
let reason = self
.machine
.state(digest)
.and_then(|s| match s {
BondState::Finalized { reason, .. } => Some(reason),
_ => None,
})
.ok_or_else(|| ScemaDexError::Bond(format!("bond {digest} is not finalized")))?;
Ok(SettlementReport {
intent_digest: digest.to_string(),
outcome,
reason,
slash,
challenge,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settlement::{ManualClock, SlashRouting};
fn bond(digest: &str, amount: u64) -> Bond {
Bond {
intent_digest: digest.into(),
amount: Usdc(amount),
min_out_raw: 1_000_000,
deadline_unix: 0,
}
}
fn dueling_config() -> SettlementConfig {
SettlementConfig::optimistic(60).with_slash_routing(SlashRouting {
to_caller_bps: 5_000,
to_challengers_bps: 5_000,
to_insurance_bps: 0,
to_lineage_bps: 0,
})
}
#[test]
fn unchallenged_bond_honors_after_window() {
let coord = DisputeCoordinator::new(dueling_config(), ManualClock::new(1_000));
coord.open(&bond("d", 1_000), Conviction::clamped(0.8)).unwrap();
coord.provision("d", BondOutcome::Honored).unwrap();
assert!(matches!(
coord.machine().state("d"),
Some(BondState::Provisional { .. })
));
advance(&coord, 61);
let reports = coord.sweep().unwrap();
assert_eq!(reports.len(), 1);
let r = &reports[0];
assert_eq!(r.outcome, BondOutcome::Honored);
assert_eq!(r.reason, FinalizeReason::WindowElapsed);
assert!(r.slash.is_none());
assert!(!r.is_adversarial_slash());
}
#[test]
fn upheld_challenge_slashes_and_pays_challenger_only_the_slice() {
let coord = DisputeCoordinator::new(dueling_config(), ManualClock::new(1_000));
coord.open(&bond("d", 1_000), Conviction::clamped(0.9)).unwrap();
coord.provision("d", BondOutcome::Honored).unwrap();
coord.challenge("d", "skeptic", Usdc(200)).unwrap();
assert!(coord.counter().doubt_spread("d").unwrap() > 0.0);
let r = coord.resolve("d", BondOutcome::Slashed).unwrap();
assert_eq!(r.outcome, BondOutcome::Slashed);
assert_eq!(r.reason, FinalizeReason::ChallengeUpheld);
assert!(r.is_adversarial_slash());
let slash = r.slash.unwrap();
assert_eq!(slash.caller, Usdc(500));
assert_eq!(slash.challengers, Usdc(500));
let cs = r.challenge.unwrap();
assert_eq!(cs.challenger_payouts, vec![("skeptic".to_string(), Usdc(700))]);
let share: u64 = cs
.challenger_payouts
.iter()
.map(|(_, p)| p.0)
.sum::<u64>()
- 200;
assert_eq!(share, slash.challengers.0);
}
#[test]
fn rejected_challenge_keeps_honor_and_pays_agent_premium() {
let coord = DisputeCoordinator::new(dueling_config(), ManualClock::new(1_000));
coord.open(&bond("d", 1_000), Conviction::clamped(0.9)).unwrap();
coord.provision("d", BondOutcome::Honored).unwrap();
coord.challenge("d", "a", Usdc(150)).unwrap();
coord.challenge("d", "b", Usdc(350)).unwrap();
let r = coord.resolve("d", BondOutcome::Honored).unwrap();
assert_eq!(r.outcome, BondOutcome::Honored);
assert_eq!(r.reason, FinalizeReason::ChallengeRejected);
assert!(r.slash.is_none());
assert_eq!(r.challenge.unwrap().agent_premium, Usdc(500));
}
#[test]
fn scar_mints_only_from_an_adversarial_slash() {
let coord = DisputeCoordinator::new(dueling_config(), ManualClock::new(1_000));
coord.open(&bond("h", 1_000), Conviction::clamped(0.8)).unwrap();
coord.provision("h", BondOutcome::Honored).unwrap();
advance(&coord, 61);
coord.sweep().unwrap();
assert!(coord.mint_scar("h", "peer", 5, vec![], Usdc(100)).is_err());
coord.open(&bond("s", 1_000), Conviction::clamped(0.9)).unwrap();
coord.provision("s", BondOutcome::Honored).unwrap();
coord.challenge("s", "skeptic", Usdc(200)).unwrap();
let r = coord.resolve("s", BondOutcome::Slashed).unwrap();
assert!(r.is_adversarial_slash());
let scar = coord.mint_scar("s", "peer", 12, vec![1, 2, 3], Usdc(250)).unwrap();
assert_eq!(scar.slashed_collateral, Usdc(1_000));
assert_eq!(scar.transitions, 12);
}
#[test]
fn cannot_challenge_outside_the_window() {
let coord = DisputeCoordinator::new(dueling_config(), ManualClock::new(1_000));
coord.open(&bond("d", 1_000), Conviction::clamped(0.9)).unwrap();
coord.provision("d", BondOutcome::Honored).unwrap();
advance(&coord, 61);
assert!(coord.challenge("d", "late", Usdc(100)).is_err());
}
fn advance(coord: &DisputeCoordinator<ManualClock>, secs: u64) {
coord.machine().clock().advance(secs);
}
}