use std::collections::HashMap;
use std::sync::Mutex;
use crate::bond::{Bond, BondOutcome};
use crate::coordinator::{DisputeCoordinator, SettlementReport};
use crate::error::{Result, ScemaDexError};
use crate::policy::Conviction;
use crate::primitives::Usdc;
use crate::promise::{Measurement, Outcome, Promise};
use crate::scar::ScarRecord;
use crate::settlement::{BondState, Clock, SettlementConfig, SystemClock};
pub struct ForecastVenue<C: Clock = SystemClock> {
coord: DisputeCoordinator<C>,
promises: Mutex<HashMap<String, Promise>>,
}
impl ForecastVenue<SystemClock> {
pub fn system(config: SettlementConfig) -> Self {
Self::new(config, SystemClock)
}
}
impl<C: Clock> ForecastVenue<C> {
pub fn new(config: SettlementConfig, clock: C) -> Self {
Self {
coord: DisputeCoordinator::new(config, clock),
promises: Mutex::new(HashMap::new()),
}
}
pub fn coordinator(&self) -> &DisputeCoordinator<C> {
&self.coord
}
fn promises(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Promise>>> {
self.promises
.lock()
.map_err(|_| ScemaDexError::Bond("forecast promise map poisoned".into()))
}
pub fn submit(&self, bond: &Bond, promise: Promise, conviction: Conviction) -> Result<()> {
self.coord.open(bond, conviction)?;
self.coord.provision(&bond.intent_digest, BondOutcome::Honored)?;
self.promises()?
.insert(bond.intent_digest.clone(), promise);
Ok(())
}
pub fn challenge(
&self,
digest: &str,
challenger_id: impl Into<String>,
stake: Usdc,
) -> Result<()> {
self.coord.challenge(digest, challenger_id, stake)?;
Ok(())
}
pub fn resolve(&self, digest: &str, truth: Measurement) -> Result<SettlementReport> {
let promise = self
.promises()?
.get(digest)
.cloned()
.ok_or_else(|| ScemaDexError::Bond(format!("no open forecast for {digest}")))?;
let adjudicated = truth.evaluate(&promise);
let report = match self.coord.machine().state(digest) {
Some(BondState::Disputed { .. }) => self.coord.resolve(digest, adjudicated)?,
Some(BondState::Provisional { .. }) => self.coord.resolve_via_oracle(digest, adjudicated)?,
other => {
return Err(ScemaDexError::Bond(format!(
"forecast {digest} is not resolvable (state {other:?})"
)))
}
};
self.promises()?.remove(digest);
Ok(report)
}
pub fn state(&self, digest: &str) -> Option<BondState> {
self.coord.machine().state(digest)
}
pub fn mint_scar(
&self,
digest: &str,
peer_id: impl Into<String>,
transitions: u32,
payload: Vec<u8>,
price: Usdc,
) -> Result<ScarRecord> {
self.coord.mint_scar(digest, peer_id, transitions, payload, price)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settlement::{ManualClock, SlashRouting};
fn forecast_bond(digest: &str, amount: u64) -> Bond {
Bond {
intent_digest: digest.into(),
amount: Usdc(amount),
min_out_raw: 0, deadline_unix: 0,
}
}
fn venue() -> ForecastVenue<ManualClock> {
let config = SettlementConfig::optimistic(3_600).with_slash_routing(SlashRouting {
to_caller_bps: 5_000,
to_challengers_bps: 5_000,
to_insurance_bps: 0,
to_lineage_bps: 0,
});
ForecastVenue::new(config, ManualClock::new(1_700_000_000))
}
fn bullish(strike: f64) -> Promise {
Promise::Forecast { strike, expect_above: true }
}
#[test]
fn correct_forecast_honors_without_a_challenge() {
let v = venue();
v.submit(&forecast_bond("f", 1_000), bullish(150.0), Conviction::clamped(0.8))
.unwrap();
assert!(matches!(v.state("f"), Some(BondState::Provisional { .. })));
let r = v.resolve("f", Measurement::new(162.0, 1_700_000_500)).unwrap();
assert_eq!(r.outcome, BondOutcome::Honored);
assert!(r.slash.is_none());
assert!(v.mint_scar("f", "peer", 1, vec![], Usdc(1)).is_err());
}
#[test]
fn wrong_forecast_slashes_and_mints_a_scar() {
let v = venue();
v.submit(&forecast_bond("f", 1_000), bullish(200.0), Conviction::clamped(0.6))
.unwrap();
let r = v.resolve("f", Measurement::new(162.0, 1_700_000_500)).unwrap();
assert_eq!(r.outcome, BondOutcome::Slashed);
let slash = r.slash.expect("a slash distributes collateral");
assert_eq!(slash.caller.0 + slash.challengers.0, 1_000);
let scar = v.mint_scar("f", "forecaster", 3, vec![1, 2], Usdc(250)).unwrap();
assert_eq!(scar.slashed_collateral, Usdc(1_000));
}
#[test]
fn ground_truth_vindicates_the_agent_against_a_challenge() {
let v = venue();
v.submit(&forecast_bond("f", 1_000), bullish(150.0), Conviction::clamped(0.9))
.unwrap();
v.challenge("f", "skeptic", Usdc(300)).unwrap();
assert!(matches!(v.state("f"), Some(BondState::Disputed { .. })));
let r = v.resolve("f", Measurement::new(162.0, 1_700_000_500)).unwrap();
assert_eq!(r.outcome, BondOutcome::Honored);
assert_eq!(r.challenge.unwrap().agent_premium, Usdc(300));
}
#[test]
fn ground_truth_upholds_a_correct_challenge() {
let v = venue();
v.submit(&forecast_bond("f", 1_000), bullish(200.0), Conviction::clamped(0.7))
.unwrap();
v.challenge("f", "skeptic", Usdc(300)).unwrap();
let r = v.resolve("f", Measurement::new(162.0, 1_700_000_500)).unwrap();
assert_eq!(r.outcome, BondOutcome::Slashed);
assert!(r.is_adversarial_slash());
let cs = r.challenge.unwrap();
assert_eq!(cs.challenger_payouts, vec![("skeptic".to_string(), Usdc(800))]);
}
#[test]
fn resolving_an_unknown_forecast_errors() {
let v = venue();
assert!(v.resolve("ghost", Measurement::new(1.0, 0)).is_err());
}
}