use std::collections::HashMap;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::bond::{Bond, BondOutcome};
use crate::error::{Result, ScemaDexError};
use crate::policy::Conviction;
use crate::primitives::Usdc;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Challenge {
pub challenger_id: String,
pub stake: Usdc,
pub opened_unix: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChallengeSettlement {
pub intent_digest: String,
pub outcome: BondOutcome,
pub challenger_payouts: Vec<(String, Usdc)>,
pub agent_premium: Usdc,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
pub struct CounterStats {
pub settled: u32,
pub challenger_wins: u32,
pub agent_wins: u32,
pub agent_premiums: u64,
pub challenger_winnings: u64,
}
struct OpenChallenge {
bond: Bond,
self_conviction: Conviction,
challenges: Vec<Challenge>,
}
#[derive(Default)]
pub struct CounterMarket {
book: Mutex<HashMap<String, OpenChallenge>>,
stats: Mutex<CounterStats>,
}
impl CounterMarket {
pub fn new() -> Self {
Self::default()
}
pub fn list(&self, bond: &Bond, self_conviction: Conviction) -> Result<()> {
self.book
.lock()
.map_err(|_| ScemaDexError::Mesh("counter book lock poisoned".into()))?
.insert(
bond.intent_digest.clone(),
OpenChallenge {
bond: bond.clone(),
self_conviction,
challenges: Vec::new(),
},
);
Ok(())
}
pub fn challenge(
&self,
intent_digest: &str,
challenger_id: impl Into<String>,
stake: Usdc,
) -> Result<Challenge> {
if stake.0 == 0 {
return Err(ScemaDexError::Mesh("challenge stake must be non-zero".into()));
}
let mut book = self
.book
.lock()
.map_err(|_| ScemaDexError::Mesh("counter book lock poisoned".into()))?;
let entry = book.get_mut(intent_digest).ok_or_else(|| {
ScemaDexError::Mesh(format!("no listed bond for intent {intent_digest}"))
})?;
let challenge = Challenge {
challenger_id: challenger_id.into(),
stake,
opened_unix: 0,
};
entry.challenges.push(challenge.clone());
Ok(challenge)
}
pub fn total_stake(&self, intent_digest: &str) -> Usdc {
self.book
.lock()
.ok()
.and_then(|b| {
b.get(intent_digest)
.map(|e| Usdc(e.challenges.iter().map(|c| c.stake.0).sum()))
})
.unwrap_or(Usdc(0))
}
pub fn market_conviction(&self, intent_digest: &str) -> Option<f64> {
let book = self.book.lock().ok()?;
let entry = book.get(intent_digest)?;
let bond = entry.bond.amount.0 as f64;
let stake: u64 = entry.challenges.iter().map(|c| c.stake.0).sum();
if bond == 0.0 && stake == 0 {
return Some(1.0);
}
Some(bond / (bond + stake as f64))
}
pub fn doubt_spread(&self, intent_digest: &str) -> Option<f64> {
let market = self.market_conviction(intent_digest)?;
let own = self
.book
.lock()
.ok()?
.get(intent_digest)?
.self_conviction
.0;
Some(own - market)
}
pub fn settle(&self, intent_digest: &str, outcome: BondOutcome) -> Result<ChallengeSettlement> {
let entry = self
.book
.lock()
.map_err(|_| ScemaDexError::Mesh("counter book lock poisoned".into()))?
.remove(intent_digest)
.ok_or_else(|| {
ScemaDexError::Mesh(format!("no listed bond for intent {intent_digest}"))
})?;
let total_stake: u64 = entry.challenges.iter().map(|c| c.stake.0).sum();
let mut settlement = ChallengeSettlement {
intent_digest: intent_digest.to_string(),
outcome,
challenger_payouts: Vec::new(),
agent_premium: Usdc(0),
};
match outcome {
BondOutcome::Honored => {
settlement.agent_premium = Usdc(total_stake);
}
BondOutcome::Slashed => {
let bond = entry.bond.amount.0;
let n = entry.challenges.len() as u64;
let mut distributed = 0u64;
for (i, c) in entry.challenges.iter().enumerate() {
let share = if i as u64 + 1 == n {
bond - distributed
} else {
bond.saturating_mul(c.stake.0)
.checked_div(total_stake)
.unwrap_or(0)
};
distributed += share;
settlement
.challenger_payouts
.push((c.challenger_id.clone(), Usdc(c.stake.0 + share)));
}
}
}
if !entry.challenges.is_empty() {
let mut stats = self
.stats
.lock()
.map_err(|_| ScemaDexError::Mesh("counter stats lock poisoned".into()))?;
stats.settled += 1;
match outcome {
BondOutcome::Honored => {
stats.agent_wins += 1;
stats.agent_premiums += settlement.agent_premium.0;
}
BondOutcome::Slashed => {
stats.challenger_wins += 1;
stats.challenger_winnings += settlement
.challenger_payouts
.iter()
.map(|(_, p)| p.0)
.sum::<u64>();
}
}
}
Ok(settlement)
}
pub fn stats(&self) -> CounterStats {
self.stats.lock().map(|s| *s).unwrap_or_default()
}
pub fn open_listings(&self) -> usize {
self.book.lock().map(|b| b.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bond(digest: &str, amount: u64) -> Bond {
Bond {
intent_digest: digest.into(),
amount: Usdc(amount),
min_out_raw: 1_000_000,
deadline_unix: 0,
}
}
#[test]
fn challenge_requires_listed_bond() {
let market = CounterMarket::new();
assert!(market.challenge("missing", "doubter", Usdc(100)).is_err());
market.list(&bond("d1", 1_000), Conviction::clamped(0.8)).unwrap();
assert!(market.challenge("d1", "doubter", Usdc(0)).is_err());
assert!(market.challenge("d1", "doubter", Usdc(100)).is_ok());
}
#[test]
fn unchallenged_bond_implies_full_market_conviction() {
let market = CounterMarket::new();
market.list(&bond("d1", 1_000), Conviction::clamped(0.6)).unwrap();
assert_eq!(market.market_conviction("d1"), Some(1.0));
assert!(market.doubt_spread("d1").unwrap() < 0.0);
}
#[test]
fn stake_drives_market_conviction_down() {
let market = CounterMarket::new();
market.list(&bond("d1", 1_000), Conviction::clamped(0.9)).unwrap();
market.challenge("d1", "a", Usdc(1_000)).unwrap();
assert_eq!(market.market_conviction("d1"), Some(0.5));
assert!((market.doubt_spread("d1").unwrap() - 0.4).abs() < 1e-9);
market.challenge("d1", "b", Usdc(3_000)).unwrap();
assert_eq!(market.market_conviction("d1"), Some(0.2));
}
#[test]
fn honored_pays_agent_the_forfeited_stakes() {
let market = CounterMarket::new();
market.list(&bond("d1", 1_000), Conviction::clamped(0.9)).unwrap();
market.challenge("d1", "a", Usdc(300)).unwrap();
market.challenge("d1", "b", Usdc(700)).unwrap();
let s = market.settle("d1", BondOutcome::Honored).unwrap();
assert_eq!(s.agent_premium, Usdc(1_000));
assert!(s.challenger_payouts.is_empty());
assert_eq!(market.stats().agent_wins, 1);
assert_eq!(market.open_listings(), 0);
}
#[test]
fn slashed_pays_challengers_stake_plus_prorata_bond() {
let market = CounterMarket::new();
market.list(&bond("d1", 1_000), Conviction::clamped(0.9)).unwrap();
market.challenge("d1", "a", Usdc(250)).unwrap();
market.challenge("d1", "b", Usdc(750)).unwrap();
let s = market.settle("d1", BondOutcome::Slashed).unwrap();
assert_eq!(s.agent_premium, Usdc(0));
assert_eq!(s.challenger_payouts[0], ("a".into(), Usdc(500)));
assert_eq!(s.challenger_payouts[1], ("b".into(), Usdc(1_500)));
let total: u64 = s.challenger_payouts.iter().map(|(_, p)| p.0).sum();
assert_eq!(total, 2_000);
assert_eq!(market.stats().challenger_wins, 1);
}
#[test]
fn dust_from_integer_division_goes_to_last_challenger() {
let market = CounterMarket::new();
market.list(&bond("d1", 1_000), Conviction::clamped(0.5)).unwrap();
market.challenge("d1", "a", Usdc(1)).unwrap();
market.challenge("d1", "b", Usdc(1)).unwrap();
market.challenge("d1", "c", Usdc(1)).unwrap();
let s = market.settle("d1", BondOutcome::Slashed).unwrap();
let total: u64 = s.challenger_payouts.iter().map(|(_, p)| p.0).sum();
assert_eq!(total, 1_003);
}
#[test]
fn settling_unchallenged_bond_records_no_stats() {
let market = CounterMarket::new();
market.list(&bond("d1", 1_000), Conviction::clamped(0.5)).unwrap();
let s = market.settle("d1", BondOutcome::Honored).unwrap();
assert_eq!(s.agent_premium, Usdc(0));
assert_eq!(market.stats().settled, 0);
}
}