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
42use std::sync::{Arc, Mutex};
43
44use async_trait::async_trait;
45use solana_client::nonblocking::rpc_client::RpcClient;
46use solana_sdk::commitment_config::CommitmentConfig;
47use solana_sdk::pubkey::Pubkey;
48use solana_sdk::signature::{Keypair, Signature, Signer};
49use solana_sdk::transaction::Transaction;
50use spl_associated_token_account::get_associated_token_address;
51
52use scemadex_sdk::{
53    Bond, BondConfig, BondEngine, BondLedger, BondOutcome, Conviction, EscrowBondEngine, Fill,
54    Result, ScemaDexError, Solution, Usdc,
55};
56
57/// Public Solana devnet RPC endpoint.
58pub const DEVNET_RPC: &str = "https://api.devnet.solana.com";
59
60/// USDC has 6 decimals on Solana; bonds are denominated in micro-USDC.
61pub const USDC_DECIMALS: u8 = 6;
62
63/// A devnet settler: the [`EscrowBondEngine`] state machine plus a real SPL-USDC
64/// transfer on slash. See the crate docs for the devnet-only caveat.
65pub struct DevnetUsdcSettler {
66    inner: EscrowBondEngine,
67    rpc: RpcClient,
68    agent: Arc<Keypair>,
69    usdc_mint: Pubkey,
70    /// The caller's USDC token account — receives the bond when it is slashed.
71    beneficiary_token_account: Pubkey,
72    last_signature: Mutex<Option<Signature>>,
73}
74
75impl DevnetUsdcSettler {
76    /// Construct against an explicit RPC endpoint and bond configuration.
77    pub fn new(
78        rpc_url: impl Into<String>,
79        agent: Arc<Keypair>,
80        usdc_mint: Pubkey,
81        beneficiary_token_account: Pubkey,
82        config: BondConfig,
83    ) -> Self {
84        Self {
85            inner: EscrowBondEngine::new(config),
86            rpc: RpcClient::new_with_commitment(rpc_url.into(), CommitmentConfig::confirmed()),
87            agent,
88            usdc_mint,
89            beneficiary_token_account,
90            last_signature: Mutex::new(None),
91        }
92    }
93
94    /// Convenience: the public devnet RPC with the default [`BondConfig`].
95    pub fn devnet(
96        agent: Arc<Keypair>,
97        usdc_mint: Pubkey,
98        beneficiary_token_account: Pubkey,
99    ) -> Self {
100        Self::new(
101            DEVNET_RPC,
102            agent,
103            usdc_mint,
104            beneficiary_token_account,
105            BondConfig::default(),
106        )
107    }
108
109    /// The agent's USDC associated-token account (the bond's funding source).
110    pub fn agent_usdc_account(&self) -> Pubkey {
111        get_associated_token_address(&self.agent.pubkey(), &self.usdc_mint)
112    }
113
114    /// The inference fee for a given conviction (delegates to the inner engine).
115    pub fn quote_fee(&self, conviction: Conviction) -> Usdc {
116        self.inner.quote_fee(conviction)
117    }
118
119    /// Snapshot of the honored/slashed ledger.
120    pub fn ledger(&self) -> BondLedger {
121        self.inner.ledger()
122    }
123
124    /// Number of bonds currently escrowed (awaiting settlement).
125    pub fn open_bonds(&self) -> usize {
126        self.inner.open_bonds()
127    }
128
129    /// The signature of the most recent on-chain slash transfer, if any.
130    pub fn last_signature(&self) -> Option<Signature> {
131        self.last_signature.lock().ok().and_then(|s| *s)
132    }
133
134    /// Like [`BondEngine::settle`] but also returns the on-chain transfer
135    /// signature when the bond was slashed (`None` when honored).
136    pub async fn settle_onchain(
137        &self,
138        bond: &Bond,
139        fill: &Fill,
140    ) -> Result<(BondOutcome, Option<Signature>)> {
141        let outcome = self.inner.settle(bond, fill).await?;
142        let sig = match outcome {
143            BondOutcome::Slashed => Some(self.transfer_bond(bond.amount).await?),
144            BondOutcome::Honored => None,
145        };
146        if let Some(sig) = sig {
147            if let Ok(mut slot) = self.last_signature.lock() {
148                *slot = Some(sig);
149            }
150            tracing::info!(
151                amount_micro_usdc = bond.amount.0,
152                signature = %sig,
153                "bond slashed — devnet USDC transferred to caller"
154            );
155        }
156        Ok((outcome, sig))
157    }
158
159    /// Transfer `amount` micro-USDC from the agent's USDC account to the
160    /// beneficiary on devnet, signed by the agent.
161    async fn transfer_bond(&self, amount: Usdc) -> Result<Signature> {
162        let source = self.agent_usdc_account();
163        let ix = spl_token::instruction::transfer_checked(
164            &spl_token::id(),
165            &source,
166            &self.usdc_mint,
167            &self.beneficiary_token_account,
168            &self.agent.pubkey(),
169            &[],
170            amount.0,
171            USDC_DECIMALS,
172        )
173        .map_err(|e| ScemaDexError::Bond(format!("build transfer ix: {e}")))?;
174
175        let blockhash = self
176            .rpc
177            .get_latest_blockhash()
178            .await
179            .map_err(|e| ScemaDexError::Bond(format!("get blockhash: {e}")))?;
180        let tx = Transaction::new_signed_with_payer(
181            &[ix],
182            Some(&self.agent.pubkey()),
183            &[self.agent.as_ref()],
184            blockhash,
185        );
186        self.rpc
187            .send_and_confirm_transaction(&tx)
188            .await
189            .map_err(|e| ScemaDexError::Bond(format!("submit slash transfer: {e}")))
190    }
191}
192
193#[async_trait]
194impl BondEngine for DevnetUsdcSettler {
195    async fn escrow(&self, solution: &Solution) -> Result<Bond> {
196        self.inner.escrow(solution).await
197    }
198
199    /// Settles the bond and, on slash, performs the devnet USDC transfer. Use
200    /// [`DevnetUsdcSettler::settle_onchain`] if you need the transfer signature.
201    async fn settle(&self, bond: &Bond, fill: &Fill) -> Result<BondOutcome> {
202        self.settle_onchain(bond, fill).await.map(|(o, _)| o)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::str::FromStr;
210
211    fn settler() -> DevnetUsdcSettler {
212        DevnetUsdcSettler::devnet(
213            Arc::new(Keypair::new()),
214            Pubkey::from_str("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU").unwrap(),
215            Pubkey::new_unique(),
216        )
217    }
218
219    #[test]
220    fn agent_usdc_account_is_deterministic() {
221        let s = settler();
222        assert_eq!(s.agent_usdc_account(), s.agent_usdc_account());
223    }
224
225    #[tokio::test]
226    async fn honored_settlement_moves_nothing_offline() {
227        use scemadex_sdk::RoutePolicy;
228        // A fill that meets the guarantee settles Honored with no RPC call.
229        let s = settler();
230        let sol = scemadex_sdk::ReferenceRoutePolicy
231            .solve(&scemadex_sdk::demo_intent())
232            .await
233            .unwrap();
234        let bond = s.escrow(&sol).await.unwrap();
235        let fill = Fill {
236            amount_out: scemadex_sdk::Amount::new(bond.min_out_raw, USDC_DECIMALS),
237            executed_unix: 0,
238        };
239        let (outcome, sig) = s.settle_onchain(&bond, &fill).await.unwrap();
240        assert_eq!(outcome, BondOutcome::Honored);
241        assert!(sig.is_none(), "honored settlement must not touch the chain");
242    }
243}