Skip to main content

scemadex_settle/
lib.rs

1//! # scemadex-settle — open devnet reference settler
2//!
3//! The published [`scemadex_sdk`] ships the Conviction-Routing **settlement state
4//! machine** ([`scemadex_sdk::EscrowBondEngine`]) but deliberately moves no money
5//! — it carries no `solana-sdk` dependency. This crate closes that loop on
6//! **devnet**: it wraps the state machine and, when a bond is **slashed**, makes a
7//! real on-chain SPL-USDC transfer of the bond amount to the caller.
8//!
9//! It exists so external developers can run the *entire* Conviction-Routing loop —
10//! quote → bond → execute → settle on-chain — for free, with no proprietary stack
11//! and no funds at risk. The production mainnet rail (x402 metering, fee
12//! abstraction, the trust/relay network) is a separate, closed component.
13//!
14//! ## ⚠️ Devnet / test only
15//!
16//! This is a *reference* implementation. It performs a plain SPL transfer of the
17//! bond on slash; it does **not** implement escrow custody, fee collection, x402
18//! metering, dispute windows, or any mainnet safety. Do not point it at mainnet.
19//!
20//! ## What moves, and when
21//!
22//! - `escrow` — delegates to the inner [`scemadex_sdk::EscrowBondEngine`]: sizes a
23//!   conviction-weighted bond and a guaranteed-minimum output. No transfer.
24//! - `settle` — runs the honored/slashed decision. On **`Slashed`**, transfers
25//!   `bond.amount` micro-USDC from the agent's USDC account to the caller's. On
26//!   **`Honored`**, nothing moves (the agent keeps its collateral).
27//!
28//! ```no_run
29//! use std::sync::Arc;
30//! use scemadex_settle::DevnetUsdcSettler;
31//! use solana_sdk::{pubkey::Pubkey, signature::Keypair};
32//! # use std::str::FromStr;
33//! # async fn run() -> anyhow::Result<()> {
34//! let agent = Arc::new(Keypair::new());            // funded with devnet USDC + SOL
35//! let usdc_mint = Pubkey::from_str("...")?;        // your devnet SPL mint
36//! let beneficiary = Pubkey::from_str("...")?;      // caller's USDC token account
37//! let settler = DevnetUsdcSettler::devnet(agent, usdc_mint, beneficiary);
38//! # let _ = settler;
39//! # Ok(()) }
40//! ```
41
42pub mod optimistic;
43pub use optimistic::{Beneficiaries, OptimisticUsdcSettler, SlashTransfer};
44
45use std::sync::{Arc, Mutex};
46
47use async_trait::async_trait;
48use solana_client::nonblocking::rpc_client::RpcClient;
49use solana_sdk::commitment_config::CommitmentConfig;
50use solana_sdk::pubkey::Pubkey;
51use solana_sdk::signature::{Keypair, Signature, Signer};
52use solana_sdk::transaction::Transaction;
53use spl_associated_token_account::get_associated_token_address;
54
55use scemadex_sdk::{
56    Bond, BondConfig, BondEngine, BondLedger, BondOutcome, Conviction, EscrowBondEngine, Fill,
57    Result, ScemaDexError, Solution, Usdc,
58};
59
60/// Public Solana devnet RPC endpoint.
61pub const DEVNET_RPC: &str = "https://api.devnet.solana.com";
62
63/// USDC has 6 decimals on Solana; bonds are denominated in micro-USDC.
64pub const USDC_DECIMALS: u8 = 6;
65
66/// A devnet settler: the [`EscrowBondEngine`] state machine plus a real SPL-USDC
67/// transfer on slash. See the crate docs for the devnet-only caveat.
68pub struct DevnetUsdcSettler {
69    inner: EscrowBondEngine,
70    rpc: RpcClient,
71    agent: Arc<Keypair>,
72    usdc_mint: Pubkey,
73    /// The caller's USDC token account — receives the bond when it is slashed.
74    beneficiary_token_account: Pubkey,
75    last_signature: Mutex<Option<Signature>>,
76}
77
78impl DevnetUsdcSettler {
79    /// Construct against an explicit RPC endpoint and bond configuration.
80    pub fn new(
81        rpc_url: impl Into<String>,
82        agent: Arc<Keypair>,
83        usdc_mint: Pubkey,
84        beneficiary_token_account: Pubkey,
85        config: BondConfig,
86    ) -> Self {
87        Self {
88            inner: EscrowBondEngine::new(config),
89            rpc: RpcClient::new_with_commitment(rpc_url.into(), CommitmentConfig::confirmed()),
90            agent,
91            usdc_mint,
92            beneficiary_token_account,
93            last_signature: Mutex::new(None),
94        }
95    }
96
97    /// Convenience: the public devnet RPC with the default [`BondConfig`].
98    pub fn devnet(
99        agent: Arc<Keypair>,
100        usdc_mint: Pubkey,
101        beneficiary_token_account: Pubkey,
102    ) -> Self {
103        Self::new(
104            DEVNET_RPC,
105            agent,
106            usdc_mint,
107            beneficiary_token_account,
108            BondConfig::default(),
109        )
110    }
111
112    /// The agent's USDC associated-token account (the bond's funding source).
113    pub fn agent_usdc_account(&self) -> Pubkey {
114        get_associated_token_address(&self.agent.pubkey(), &self.usdc_mint)
115    }
116
117    /// The inference fee for a given conviction (delegates to the inner engine).
118    pub fn quote_fee(&self, conviction: Conviction) -> Usdc {
119        self.inner.quote_fee(conviction)
120    }
121
122    /// Snapshot of the honored/slashed ledger.
123    pub fn ledger(&self) -> BondLedger {
124        self.inner.ledger()
125    }
126
127    /// Number of bonds currently escrowed (awaiting settlement).
128    pub fn open_bonds(&self) -> usize {
129        self.inner.open_bonds()
130    }
131
132    /// The signature of the most recent on-chain slash transfer, if any.
133    pub fn last_signature(&self) -> Option<Signature> {
134        self.last_signature.lock().ok().and_then(|s| *s)
135    }
136
137    /// Like [`BondEngine::settle`] but also returns the on-chain transfer
138    /// signature when the bond was slashed (`None` when honored).
139    pub async fn settle_onchain(
140        &self,
141        bond: &Bond,
142        fill: &Fill,
143    ) -> Result<(BondOutcome, Option<Signature>)> {
144        let outcome = self.inner.settle(bond, fill).await?;
145        let sig = match outcome {
146            BondOutcome::Slashed => Some(self.transfer_bond(bond.amount).await?),
147            BondOutcome::Honored => None,
148        };
149        if let Some(sig) = sig {
150            if let Ok(mut slot) = self.last_signature.lock() {
151                *slot = Some(sig);
152            }
153            tracing::info!(
154                amount_micro_usdc = bond.amount.0,
155                signature = %sig,
156                "bond slashed — devnet USDC transferred to caller"
157            );
158        }
159        Ok((outcome, sig))
160    }
161
162    /// Transfer `amount` micro-USDC from the agent's USDC account to the
163    /// beneficiary on devnet, signed by the agent.
164    async fn transfer_bond(&self, amount: Usdc) -> Result<Signature> {
165        let source = self.agent_usdc_account();
166        let ix = spl_token::instruction::transfer_checked(
167            &spl_token::id(),
168            &source,
169            &self.usdc_mint,
170            &self.beneficiary_token_account,
171            &self.agent.pubkey(),
172            &[],
173            amount.0,
174            USDC_DECIMALS,
175        )
176        .map_err(|e| ScemaDexError::Bond(format!("build transfer ix: {e}")))?;
177
178        let blockhash = self
179            .rpc
180            .get_latest_blockhash()
181            .await
182            .map_err(|e| ScemaDexError::Bond(format!("get blockhash: {e}")))?;
183        let tx = Transaction::new_signed_with_payer(
184            &[ix],
185            Some(&self.agent.pubkey()),
186            &[self.agent.as_ref()],
187            blockhash,
188        );
189        self.rpc
190            .send_and_confirm_transaction(&tx)
191            .await
192            .map_err(|e| ScemaDexError::Bond(format!("submit slash transfer: {e}")))
193    }
194}
195
196#[async_trait]
197impl BondEngine for DevnetUsdcSettler {
198    async fn escrow(&self, solution: &Solution) -> Result<Bond> {
199        self.inner.escrow(solution).await
200    }
201
202    /// Settles the bond and, on slash, performs the devnet USDC transfer. Use
203    /// [`DevnetUsdcSettler::settle_onchain`] if you need the transfer signature.
204    async fn settle(&self, bond: &Bond, fill: &Fill) -> Result<BondOutcome> {
205        self.settle_onchain(bond, fill).await.map(|(o, _)| o)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::str::FromStr;
213
214    fn settler() -> DevnetUsdcSettler {
215        DevnetUsdcSettler::devnet(
216            Arc::new(Keypair::new()),
217            Pubkey::from_str("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU").unwrap(),
218            Pubkey::new_unique(),
219        )
220    }
221
222    #[test]
223    fn agent_usdc_account_is_deterministic() {
224        let s = settler();
225        assert_eq!(s.agent_usdc_account(), s.agent_usdc_account());
226    }
227
228    #[tokio::test]
229    async fn honored_settlement_moves_nothing_offline() {
230        use scemadex_sdk::RoutePolicy;
231        // A fill that meets the guarantee settles Honored with no RPC call.
232        let s = settler();
233        let sol = scemadex_sdk::ReferenceRoutePolicy
234            .solve(&scemadex_sdk::demo_intent())
235            .await
236            .unwrap();
237        let bond = s.escrow(&sol).await.unwrap();
238        let fill = Fill {
239            amount_out: scemadex_sdk::Amount::new(bond.min_out_raw, USDC_DECIMALS),
240            executed_unix: 0,
241        };
242        let (outcome, sig) = s.settle_onchain(&bond, &fill).await.unwrap();
243        assert_eq!(outcome, BondOutcome::Honored);
244        assert!(sig.is_none(), "honored settlement must not touch the chain");
245    }
246}