Skip to main content

scemadex_settle/
optimistic.rs

1//! **Optimistic settler** — the dispute-window state machine wired to real USDC.
2//!
3//! [`crate::DevnetUsdcSettler`] closes the loop *atomically*: a slash pays the caller
4//! in one transfer, with no window and no split. This module promotes that to the
5//! full **Settlement v2** shape: it drives [`scemadex_sdk::SettlementMachine`] with a
6//! dispute window and, at **Finalized(Slashed)**, disburses the bond across the
7//! four-way [`scemadex_sdk::SlashRouting`] (caller / challengers / insurance /
8//! lineage) as real on-chain SPL-USDC transfers. Honored bonds move nothing.
9//!
10//! It is the facilitator-side *driver* of the money loop. The **trustless custody**
11//! upgrade — where the program, not the agent, holds the collateral until finality —
12//! is the `scemadex-escrow` Anchor program (`programs/scemadex-escrow`); this settler
13//! and that program are the two halves of "close the loop on mainnet." Point the
14//! settler at devnet to exercise the whole optimistic lifecycle for free.
15//!
16//! Generic over a [`Clock`] so the window/deadline logic is deterministic in tests
17//! (inject a [`scemadex_sdk::ManualClock`]); defaults to the wall clock.
18
19use std::sync::{Arc, Mutex};
20
21use solana_client::nonblocking::rpc_client::RpcClient;
22use solana_sdk::commitment_config::CommitmentConfig;
23use solana_sdk::pubkey::Pubkey;
24use solana_sdk::signature::{Keypair, Signature, Signer};
25use solana_sdk::transaction::Transaction;
26use spl_associated_token_account::get_associated_token_address;
27
28use scemadex_sdk::{
29    Bond, BondOutcome, Clock, Result, ScemaDexError, SettlementConfig, SettlementMachine,
30    SlashDistribution, SystemClock, Usdc,
31};
32
33use crate::{DEVNET_RPC, USDC_DECIMALS};
34
35/// The four token accounts a slashed bond is split across, per [`scemadex_sdk::SlashRouting`].
36/// Any share whose routed bps is zero simply receives nothing.
37#[derive(Clone, Copy, Debug)]
38pub struct Beneficiaries {
39    /// The wronged caller (absorbs routing dust).
40    pub caller: Pubkey,
41    /// Counter-Market winners' payout account (Primitive E).
42    pub challengers: Pubkey,
43    /// Reinsurance pool token account (Primitive J).
44    pub insurance: Pubkey,
45    /// Upstream experience-royalty account (Primitive G).
46    pub lineage: Pubkey,
47}
48
49/// One on-chain transfer performed while settling a slash.
50#[derive(Clone, Copy, Debug)]
51pub struct SlashTransfer {
52    pub destination: Pubkey,
53    pub amount: Usdc,
54    pub signature: Signature,
55}
56
57/// An optimistic USDC settler: a dispute-windowed [`SettlementMachine`] plus real
58/// four-way slash disbursement on finality.
59pub struct OptimisticUsdcSettler<C: Clock = SystemClock> {
60    machine: SettlementMachine<C>,
61    rpc: RpcClient,
62    agent: Arc<Keypair>,
63    usdc_mint: Pubkey,
64    beneficiaries: Beneficiaries,
65    last_transfers: Mutex<Vec<SlashTransfer>>,
66}
67
68impl OptimisticUsdcSettler<SystemClock> {
69    /// A wall-clock settler against the public devnet RPC with an optimistic window.
70    pub fn devnet(
71        agent: Arc<Keypair>,
72        usdc_mint: Pubkey,
73        beneficiaries: Beneficiaries,
74        config: SettlementConfig,
75    ) -> Self {
76        Self::new(DEVNET_RPC, agent, usdc_mint, beneficiaries, config, SystemClock)
77    }
78}
79
80impl<C: Clock> OptimisticUsdcSettler<C> {
81    /// Construct against an explicit RPC endpoint, settlement config, and clock.
82    pub fn new(
83        rpc_url: impl Into<String>,
84        agent: Arc<Keypair>,
85        usdc_mint: Pubkey,
86        beneficiaries: Beneficiaries,
87        config: SettlementConfig,
88        clock: C,
89    ) -> Self {
90        Self {
91            machine: SettlementMachine::new(config, clock),
92            rpc: RpcClient::new_with_commitment(rpc_url.into(), CommitmentConfig::confirmed()),
93            agent,
94            usdc_mint,
95            beneficiaries,
96            last_transfers: Mutex::new(Vec::new()),
97        }
98    }
99
100    /// The underlying state machine — register bonds, provision fills, drive the
101    /// clock, inspect slash distributions.
102    pub fn machine(&self) -> &SettlementMachine<C> {
103        &self.machine
104    }
105
106    /// The agent's USDC associated-token account (the disbursement source).
107    pub fn agent_usdc_account(&self) -> Pubkey {
108        get_associated_token_address(&self.agent.pubkey(), &self.usdc_mint)
109    }
110
111    /// The on-chain transfers performed by the most recent slash settlement.
112    pub fn last_transfers(&self) -> Vec<SlashTransfer> {
113        self.last_transfers.lock().map(|t| t.clone()).unwrap_or_default()
114    }
115
116    /// Register a freshly escrowed bond as `Escrowed`.
117    pub fn open(&self, bond: &Bond) -> Result<()> {
118        self.machine.open(bond)
119    }
120
121    /// Record a fill's provisional outcome and open the dispute window.
122    pub fn provision(&self, digest: &str, outcome: BondOutcome) -> Result<()> {
123        self.machine.provision(digest, outcome).map(|_| ())
124    }
125
126    /// Finalize a single matured bond and, on **Slashed**, disburse the four-way
127    /// split on-chain. Returns the outcome and every transfer performed (empty on an
128    /// honor — the agent keeps its collateral, nothing moves).
129    pub async fn finalize_and_settle(&self, digest: &str) -> Result<(BondOutcome, Vec<SlashTransfer>)> {
130        let outcome = self.machine.finalize(digest)?;
131        self.disburse(digest, outcome).await
132    }
133
134    /// Finalize every matured, undisputed bond and settle each on-chain. Disputed
135    /// bonds are left for an explicit resolution path. Returns `(digest, outcome)`
136    /// per settled bond.
137    pub async fn sweep_and_settle(&self) -> Result<Vec<(String, BondOutcome)>> {
138        let matured = self.machine.sweep()?;
139        let mut out = Vec::with_capacity(matured.len());
140        for (digest, outcome) in matured {
141            self.disburse(&digest, outcome).await?;
142            out.push((digest, outcome));
143        }
144        Ok(out)
145    }
146
147    /// Disburse a just-finalized bond. Honored → nothing; Slashed → the four-way
148    /// split, one transfer per non-zero share.
149    async fn disburse(&self, digest: &str, outcome: BondOutcome) -> Result<(BondOutcome, Vec<SlashTransfer>)> {
150        let transfers = match outcome {
151            BondOutcome::Honored => Vec::new(),
152            BondOutcome::Slashed => {
153                let dist = self
154                    .machine
155                    .slash_distribution(digest)
156                    .ok_or_else(|| ScemaDexError::Bond(format!("no slash distribution for {digest}")))?;
157                self.settle_split(dist).await?
158            }
159        };
160        if let Ok(mut slot) = self.last_transfers.lock() {
161            slot.clone_from(&transfers);
162        }
163        Ok((outcome, transfers))
164    }
165
166    /// Execute one on-chain transfer per non-zero routed share.
167    async fn settle_split(&self, dist: SlashDistribution) -> Result<Vec<SlashTransfer>> {
168        let legs = [
169            (self.beneficiaries.caller, dist.caller),
170            (self.beneficiaries.challengers, dist.challengers),
171            (self.beneficiaries.insurance, dist.insurance),
172            (self.beneficiaries.lineage, dist.lineage),
173        ];
174        let mut transfers = Vec::new();
175        for (destination, amount) in legs {
176            if amount.0 == 0 {
177                continue;
178            }
179            let signature = self.transfer(destination, amount).await?;
180            transfers.push(SlashTransfer { destination, amount, signature });
181            tracing::info!(
182                %destination,
183                amount_micro_usdc = amount.0,
184                %signature,
185                "slash slice transferred"
186            );
187        }
188        Ok(transfers)
189    }
190
191    /// Transfer `amount` micro-USDC from the agent's USDC account to `destination`,
192    /// signed by the agent.
193    async fn transfer(&self, destination: Pubkey, amount: Usdc) -> Result<Signature> {
194        let source = self.agent_usdc_account();
195        let ix = spl_token::instruction::transfer_checked(
196            &spl_token::id(),
197            &source,
198            &self.usdc_mint,
199            &destination,
200            &self.agent.pubkey(),
201            &[],
202            amount.0,
203            USDC_DECIMALS,
204        )
205        .map_err(|e| ScemaDexError::Bond(format!("build transfer ix: {e}")))?;
206
207        let blockhash = self
208            .rpc
209            .get_latest_blockhash()
210            .await
211            .map_err(|e| ScemaDexError::Bond(format!("get blockhash: {e}")))?;
212        let tx = Transaction::new_signed_with_payer(
213            &[ix],
214            Some(&self.agent.pubkey()),
215            &[self.agent.as_ref()],
216            blockhash,
217        );
218        self.rpc
219            .send_and_confirm_transaction(&tx)
220            .await
221            .map_err(|e| ScemaDexError::Bond(format!("submit slash transfer: {e}")))
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use scemadex_sdk::{ManualClock, SlashRouting};
229    use std::str::FromStr;
230
231    fn beneficiaries() -> Beneficiaries {
232        Beneficiaries {
233            caller: Pubkey::new_unique(),
234            challengers: Pubkey::new_unique(),
235            insurance: Pubkey::new_unique(),
236            lineage: Pubkey::new_unique(),
237        }
238    }
239
240    fn settler(config: SettlementConfig) -> OptimisticUsdcSettler<ManualClock> {
241        OptimisticUsdcSettler::new(
242            DEVNET_RPC,
243            Arc::new(Keypair::new()),
244            Pubkey::from_str("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU").unwrap(),
245            beneficiaries(),
246            config,
247            ManualClock::new(1_000),
248        )
249    }
250
251    fn bond(digest: &str, amount: u64) -> Bond {
252        Bond {
253            intent_digest: digest.into(),
254            amount: Usdc(amount),
255            min_out_raw: 1_000_000,
256            deadline_unix: 0,
257        }
258    }
259
260    #[tokio::test]
261    async fn honored_bond_moves_nothing_offline() {
262        // A provisional honor that elapses its window settles with zero transfers —
263        // no RPC is touched, so this runs fully offline.
264        let s = settler(SettlementConfig::optimistic(60));
265        s.open(&bond("h", 1_000)).unwrap();
266        s.provision("h", BondOutcome::Honored).unwrap();
267        s.machine().clock().advance(61);
268        let (outcome, transfers) = s.finalize_and_settle("h").await.unwrap();
269        assert_eq!(outcome, BondOutcome::Honored);
270        assert!(transfers.is_empty(), "an honored bond disburses nothing");
271    }
272
273    #[test]
274    fn window_gates_finalization() {
275        // While the window is open, there is nothing to settle yet.
276        let s = settler(SettlementConfig::optimistic(60));
277        s.open(&bond("d", 1_000)).unwrap();
278        s.provision("d", BondOutcome::Honored).unwrap();
279        assert!(s.machine().sweep().unwrap().is_empty(), "window still open");
280    }
281
282    #[test]
283    fn slash_distribution_uses_the_configured_routing() {
284        // The split the settler would disburse is exactly the machine's four-way
285        // distribution — assert the routing math offline before any transfer.
286        let routing = SlashRouting {
287            to_caller_bps: 4_000,
288            to_challengers_bps: 3_000,
289            to_insurance_bps: 2_000,
290            to_lineage_bps: 1_000,
291        };
292        let s = settler(SettlementConfig::optimistic(0).with_slash_routing(routing));
293        s.open(&bond("s", 1_003)).unwrap();
294        // Zero window → provision finalizes immediately as Slashed.
295        s.provision("s", BondOutcome::Slashed).unwrap();
296        let d = s.machine().slash_distribution("s").unwrap();
297        assert_eq!(d.caller.0 + d.challengers.0 + d.insurance.0 + d.lineage.0, 1_003);
298        assert_eq!(d.challengers, Usdc(300));
299        assert_eq!(d.insurance, Usdc(200));
300        assert_eq!(d.lineage, Usdc(100));
301        assert_eq!(d.caller, Usdc(403), "caller absorbs the dust");
302    }
303}