use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::{Result, ScemaDexError};
use crate::policy::{Conviction, Solution};
use crate::primitives::Usdc;
use crate::route::Fill;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bond {
pub intent_digest: String,
pub amount: Usdc,
pub min_out_raw: u64,
pub deadline_unix: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BondOutcome {
Honored,
Slashed,
}
#[async_trait]
pub trait BondEngine: Send + Sync {
async fn escrow(&self, solution: &Solution) -> Result<Bond>;
async fn settle(&self, bond: &Bond, fill: &Fill) -> Result<BondOutcome>;
}
pub struct NoBondEngine;
#[async_trait]
impl BondEngine for NoBondEngine {
async fn escrow(&self, solution: &Solution) -> Result<Bond> {
Ok(Bond {
intent_digest: solution.intent_digest.clone(),
amount: Usdc(0),
min_out_raw: solution.route.expected_out.raw,
deadline_unix: 0,
})
}
async fn settle(&self, bond: &Bond, fill: &Fill) -> Result<BondOutcome> {
Ok(if fill.amount_out.raw >= bond.min_out_raw {
BondOutcome::Honored
} else {
BondOutcome::Slashed
})
}
}
#[derive(Clone, Debug)]
pub struct BondConfig {
pub max_bond: Usdc,
pub max_fee: Usdc,
pub guarantee_haircut_bps: u32,
}
impl Default for BondConfig {
fn default() -> Self {
Self {
max_bond: Usdc::from_usdc(5.0),
max_fee: Usdc::from_usdc(0.05),
guarantee_haircut_bps: 100,
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
pub struct BondLedger {
pub honored: u32,
pub slashed: u32,
}
impl BondLedger {
pub fn total(&self) -> u32 {
self.honored + self.slashed
}
pub fn honor_rate(&self) -> f64 {
if self.total() == 0 {
1.0
} else {
self.honored as f64 / self.total() as f64
}
}
}
pub struct EscrowBondEngine {
config: BondConfig,
escrowed: Mutex<HashMap<String, Bond>>,
ledger: Mutex<BondLedger>,
}
impl EscrowBondEngine {
pub fn new(config: BondConfig) -> Self {
Self {
config,
escrowed: Mutex::new(HashMap::new()),
ledger: Mutex::new(BondLedger::default()),
}
}
pub fn with_defaults() -> Self {
Self::new(BondConfig::default())
}
pub fn quote_fee(&self, conviction: Conviction) -> Usdc {
Usdc((self.config.max_fee.0 as f64 * conviction.0.clamp(0.0, 1.0)) as u64)
}
pub fn ledger(&self) -> BondLedger {
self.ledger.lock().map(|l| *l).unwrap_or_default()
}
pub fn open_bonds(&self) -> usize {
self.escrowed.lock().map(|e| e.len()).unwrap_or(0)
}
}
#[async_trait]
impl BondEngine for EscrowBondEngine {
async fn escrow(&self, solution: &Solution) -> Result<Bond> {
let conv = solution.conviction.0.clamp(0.0, 1.0);
let amount = Usdc((self.config.max_bond.0 as f64 * conv) as u64);
let expected = solution.route.expected_out.raw;
let keep_bps = 10_000u64.saturating_sub(self.config.guarantee_haircut_bps as u64);
let min_out_raw = expected.saturating_mul(keep_bps) / 10_000;
let bond = Bond {
intent_digest: solution.intent_digest.clone(),
amount,
min_out_raw,
deadline_unix: 0,
};
self.escrowed
.lock()
.map_err(|_| ScemaDexError::Bond("escrow lock poisoned".into()))?
.insert(bond.intent_digest.clone(), bond.clone());
Ok(bond)
}
async fn settle(&self, bond: &Bond, fill: &Fill) -> Result<BondOutcome> {
self.escrowed
.lock()
.map_err(|_| ScemaDexError::Bond("escrow lock poisoned".into()))?
.remove(&bond.intent_digest);
let outcome = if fill.amount_out.raw >= bond.min_out_raw {
BondOutcome::Honored
} else {
BondOutcome::Slashed
};
let mut ledger = self
.ledger
.lock()
.map_err(|_| ScemaDexError::Bond("ledger lock poisoned".into()))?;
match outcome {
BondOutcome::Honored => ledger.honored += 1,
BondOutcome::Slashed => ledger.slashed += 1,
}
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::Conviction;
use crate::primitives::Amount;
use crate::route::{Route, RouteLeg, Venue};
fn solution(expected_raw: u64, conviction: f64) -> Solution {
let leg = RouteLeg {
venue: Venue::Jupiter,
input_mint: crate::primitives::Address::new(
"So11111111111111111111111111111111111111112",
)
.unwrap(),
output_mint: crate::primitives::Address::new(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
)
.unwrap(),
split_bps: 10_000,
expected_out: Amount::new(expected_raw, 6),
};
Solution {
intent_digest: "deadbeef".into(),
route: Route {
legs: vec![leg],
expected_out: Amount::new(expected_raw, 6),
},
conviction: Conviction::clamped(conviction),
rationale: "test".into(),
}
}
#[tokio::test]
async fn bond_scales_with_conviction() {
let engine = EscrowBondEngine::with_defaults();
let low = engine.escrow(&solution(1_000_000, 0.2)).await.unwrap();
let high = engine.escrow(&solution(1_000_000, 0.9)).await.unwrap();
assert!(high.amount.0 > low.amount.0);
assert_eq!(engine.open_bonds(), 1); }
#[tokio::test]
async fn honored_when_fill_meets_guarantee() {
let engine = EscrowBondEngine::with_defaults();
let sol = solution(1_000_000, 0.8);
let bond = engine.escrow(&sol).await.unwrap();
let fill = Fill {
amount_out: Amount::new(1_000_000, 6),
executed_unix: 0,
};
assert_eq!(
engine.settle(&bond, &fill).await.unwrap(),
BondOutcome::Honored
);
assert_eq!(engine.ledger().honored, 1);
assert_eq!(engine.open_bonds(), 0);
}
#[tokio::test]
async fn slashed_when_fill_misses_guarantee() {
let engine = EscrowBondEngine::with_defaults();
let sol = solution(1_000_000, 0.8);
let bond = engine.escrow(&sol).await.unwrap();
let fill = Fill {
amount_out: Amount::new(500_000, 6),
executed_unix: 0,
};
assert_eq!(
engine.settle(&bond, &fill).await.unwrap(),
BondOutcome::Slashed
);
assert_eq!(engine.ledger().slashed, 1);
assert_eq!(engine.ledger().honor_rate(), 0.0);
}
}