Skip to main content

scematica_protocol/
opendexter.rs

1use anyhow::{anyhow, bail, Context, Result};
2use reqwest::{Client, Method, StatusCode, Url};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use solana_sdk::signature::Keypair;
6use std::{env, time::Duration};
7
8use crate::{
9    client::{build_payment_payload, encode_payment_header},
10    middleware::{PAYMENT_HEADER, PAYMENT_RESPONSE_HEADER},
11    types::PaymentRequirements,
12};
13
14pub const SCEMATICA_X402_MARKETPLACE_URL_ENV: &str = "SCEMATICA_X402_MARKETPLACE_URL";
15pub const SCEMATICA_X402_MAX_USDC_ENV: &str = "SCEMATICA_X402_MAX_USDC";
16pub const SCEMATICA_X402_ALLOW_UNKNOWN_PRICE_ENV: &str = "SCEMATICA_X402_ALLOW_UNKNOWN_PRICE";
17pub const SVM_PRIVATE_KEY_ENV: &str = "SVM_PRIVATE_KEY";
18pub const EVM_PRIVATE_KEY_ENV: &str = "EVM_PRIVATE_KEY";
19pub const DEFAULT_X402_MAX_USDC: f64 = 0.25;
20pub const DEFAULT_MIN_QUALITY_SCORE: f64 = 75.0;
21pub const DEFAULT_SEARCH_LIMIT: usize = 8;
22
23const SOLANA_USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
24const SOLANA_WSOL_MINT: &str = "So11111111111111111111111111111111111111112";
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MarketplaceSearchRequest {
28    pub query: String,
29    pub verified_only: bool,
30    pub min_quality_score: f64,
31    pub limit: usize,
32}
33
34impl MarketplaceSearchRequest {
35    pub fn new(query: impl Into<String>) -> Self {
36        Self {
37            query: query.into(),
38            verified_only: false,
39            min_quality_score: DEFAULT_MIN_QUALITY_SCORE,
40            limit: DEFAULT_SEARCH_LIMIT,
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MarketplaceApi {
47    pub id: Option<String>,
48    pub name: String,
49    pub description: Option<String>,
50    pub endpoint: Option<String>,
51    pub seller: Option<String>,
52    pub network: Option<String>,
53    pub price_usdc: Option<f64>,
54    pub quality_score: Option<f64>,
55    pub verified: bool,
56    pub call_count: Option<u64>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PaymentOption {
61    pub scheme: String,
62    pub network: String,
63    pub asset: String,
64    pub amount_raw: u64,
65    pub pay_to: String,
66    pub max_timeout_seconds: u64,
67    pub amount_usdc: Option<f64>,
68    pub requirements: PaymentRequirements,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PriceCheck {
73    pub url: String,
74    pub method: String,
75    pub status: u16,
76    pub requires_payment: bool,
77    pub description: Option<String>,
78    pub payment_options: Vec<PaymentOption>,
79    pub max_payment_usdc: f64,
80    pub raw: Value,
81}
82
83impl PriceCheck {
84    pub fn cheapest_option(&self) -> Option<&PaymentOption> {
85        self.payment_options.iter().min_by(|a, b| {
86            let a_price = a.amount_usdc.unwrap_or(f64::INFINITY);
87            let b_price = b.amount_usdc.unwrap_or(f64::INFINITY);
88            a_price.total_cmp(&b_price)
89        })
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct WalletStatus {
95    pub svm_configured: bool,
96    pub evm_configured: bool,
97    pub active_networks: Vec<String>,
98    pub max_payment_usdc: f64,
99    pub marketplace_url_configured: bool,
100    pub warnings: Vec<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct PaidFetchResponse {
105    pub status: u16,
106    pub paid_usdc: Option<f64>,
107    pub network: Option<String>,
108    pub payment_response: Option<String>,
109    pub data: Value,
110}
111
112#[derive(Debug, Clone)]
113pub struct OpenDexterClient {
114    http: Client,
115    marketplace_url: Option<String>,
116    max_payment_usdc: f64,
117    allow_unknown_price: bool,
118}
119
120impl OpenDexterClient {
121    pub fn from_env() -> Result<Self> {
122        let max_payment_usdc = env::var(SCEMATICA_X402_MAX_USDC_ENV)
123            .ok()
124            .and_then(|v| v.parse::<f64>().ok())
125            .unwrap_or(DEFAULT_X402_MAX_USDC);
126
127        let allow_unknown_price = env_flag(SCEMATICA_X402_ALLOW_UNKNOWN_PRICE_ENV);
128        let marketplace_url = env::var(SCEMATICA_X402_MARKETPLACE_URL_ENV)
129            .ok()
130            .filter(|v| !v.trim().is_empty());
131
132        let http = Client::builder()
133            .timeout(Duration::from_secs(20))
134            .user_agent(format!("scematica/{}", env!("CARGO_PKG_VERSION")))
135            .build()
136            .context("failed to build x402 HTTP client")?;
137
138        Ok(Self {
139            http,
140            marketplace_url,
141            max_payment_usdc,
142            allow_unknown_price,
143        })
144    }
145
146    pub fn wallet_status(&self) -> WalletStatus {
147        let svm_configured = env::var(SVM_PRIVATE_KEY_ENV)
148            .map(|v| !v.trim().is_empty())
149            .unwrap_or(false);
150        let evm_configured = env::var(EVM_PRIVATE_KEY_ENV)
151            .map(|v| !v.trim().is_empty())
152            .unwrap_or(false);
153
154        let mut active_networks = Vec::new();
155        if svm_configured {
156            active_networks.push("solana".to_string());
157        }
158        if evm_configured {
159            active_networks.extend(
160                ["base", "polygon", "arbitrum", "optimism", "avalanche"]
161                    .into_iter()
162                    .map(str::to_string),
163            );
164        }
165
166        let mut warnings = Vec::new();
167        if !svm_configured && !evm_configured {
168            warnings.push(format!(
169                "No x402 payment wallet configured. Set {} for Solana payments or {} for EVM payments.",
170                SVM_PRIVATE_KEY_ENV, EVM_PRIVATE_KEY_ENV
171            ));
172        }
173        if self.marketplace_url.is_none() {
174            warnings.push(format!(
175                "Marketplace search is disabled until {} points at a marketplace search API.",
176                SCEMATICA_X402_MARKETPLACE_URL_ENV
177            ));
178        }
179        if self.max_payment_usdc <= 0.0 {
180            warnings.push(format!(
181                "{} is zero or negative; paid x402 fetches will be blocked.",
182                SCEMATICA_X402_MAX_USDC_ENV
183            ));
184        }
185
186        WalletStatus {
187            svm_configured,
188            evm_configured,
189            active_networks,
190            max_payment_usdc: self.max_payment_usdc,
191            marketplace_url_configured: self.marketplace_url.is_some(),
192            warnings,
193        }
194    }
195
196    pub async fn search(&self, request: MarketplaceSearchRequest) -> Result<Vec<MarketplaceApi>> {
197        let marketplace_url = self.marketplace_url.as_ref().ok_or_else(|| {
198            anyhow!(
199                "{} is not set. Configure it with a marketplace search endpoint before using x402_search.",
200                SCEMATICA_X402_MARKETPLACE_URL_ENV
201            )
202        })?;
203
204        let mut url = Url::parse(marketplace_url)
205            .with_context(|| format!("invalid {}", SCEMATICA_X402_MARKETPLACE_URL_ENV))?;
206        url.query_pairs_mut()
207            .append_pair("q", &request.query)
208            .append_pair("query", &request.query)
209            .append_pair("limit", &request.limit.to_string())
210            .append_pair("verified", &request.verified_only.to_string())
211            .append_pair("minQualityScore", &request.min_quality_score.to_string());
212
213        let response = self.http.get(url).send().await?;
214        let status = response.status();
215        let body = response.text().await.unwrap_or_default();
216        if !status.is_success() {
217            bail!(
218                "x402 marketplace search failed with HTTP {}: {}",
219                status.as_u16(),
220                truncate(&body, 400)
221            );
222        }
223
224        let value: Value =
225            serde_json::from_str(&body).context("marketplace response was not JSON")?;
226        let mut apis = parse_marketplace_results(&value);
227        if request.verified_only {
228            apis.retain(|api| api.verified);
229        }
230        apis.retain(|api| api.quality_score.unwrap_or(0.0) >= request.min_quality_score);
231        apis.truncate(request.limit);
232        Ok(apis)
233    }
234
235    pub async fn check(&self, url: &str, method: Option<&str>) -> Result<PriceCheck> {
236        let method = parse_method(method.unwrap_or("GET"))?;
237        let response = self.http.request(method.clone(), url).send().await?;
238        let status = response.status();
239        let body = response.text().await.unwrap_or_default();
240
241        if status.as_u16() == StatusCode::PAYMENT_REQUIRED.as_u16() {
242            let raw: Value = serde_json::from_str(&body).unwrap_or(Value::String(body));
243            let description = raw
244                .pointer("/resource/description")
245                .and_then(Value::as_str)
246                .map(str::to_string);
247            let payment_options = parse_payment_options(&raw);
248
249            return Ok(PriceCheck {
250                url: url.to_string(),
251                method: method.as_str().to_string(),
252                status: status.as_u16(),
253                requires_payment: true,
254                description,
255                payment_options,
256                max_payment_usdc: self.max_payment_usdc,
257                raw,
258            });
259        }
260
261        if status.is_success() {
262            return Ok(PriceCheck {
263                url: url.to_string(),
264                method: method.as_str().to_string(),
265                status: status.as_u16(),
266                requires_payment: false,
267                description: Some("Endpoint did not require x402 payment.".to_string()),
268                payment_options: Vec::new(),
269                max_payment_usdc: self.max_payment_usdc,
270                raw: serde_json::json!({ "status": status.as_u16(), "bodyPreview": truncate(&body, 800) }),
271            });
272        }
273
274        bail!(
275            "x402 price check failed with HTTP {}: {}",
276            status.as_u16(),
277            truncate(&body, 400)
278        );
279    }
280
281    pub async fn fetch(&self, url: &str, method: Option<&str>) -> Result<PaidFetchResponse> {
282        let method = parse_method(method.unwrap_or("GET"))?;
283        let check = self.check(url, Some(method.as_str())).await?;
284        if !check.requires_payment {
285            let response = self.http.request(method, url).send().await?;
286            return response_to_fetch(None, None, response).await;
287        }
288
289        let option = check.cheapest_option().ok_or_else(|| {
290            anyhow!("endpoint requires payment but did not return usable payment options")
291        })?;
292        self.guard_payment(option)?;
293
294        if !option.network.to_ascii_lowercase().contains("solana") {
295            bail!(
296                "x402 endpoint requires {} payment. Scematica currently has native Rust signing for Solana x402 only.",
297                option.network
298            );
299        }
300
301        let keypair = read_svm_keypair()?;
302        let decimals = token_decimals(option)?;
303        let payload = build_payment_payload(&keypair, &option.requirements, decimals)?;
304        let payment_header = encode_payment_header(&payload)?;
305
306        let response = self
307            .http
308            .request(method, url)
309            .header(PAYMENT_HEADER, payment_header)
310            .send()
311            .await?;
312
313        response_to_fetch(option.amount_usdc, Some(option.network.clone()), response).await
314    }
315
316    fn guard_payment(&self, option: &PaymentOption) -> Result<()> {
317        match option.amount_usdc {
318            Some(amount) if amount <= self.max_payment_usdc => Ok(()),
319            Some(amount) => bail!(
320                "x402 payment {:.6} USDC exceeds {}={:.6}",
321                amount,
322                SCEMATICA_X402_MAX_USDC_ENV,
323                self.max_payment_usdc
324            ),
325            None if self.allow_unknown_price => Ok(()),
326            None => bail!(
327                "x402 payment option has no USDC price. Set {}=1 to allow non-USDC or unknown-priced payments.",
328                SCEMATICA_X402_ALLOW_UNKNOWN_PRICE_ENV
329            ),
330        }
331    }
332}
333
334async fn response_to_fetch(
335    paid_usdc: Option<f64>,
336    network: Option<String>,
337    response: reqwest::Response,
338) -> Result<PaidFetchResponse> {
339    let status = response.status();
340    let payment_response = response
341        .headers()
342        .get(PAYMENT_RESPONSE_HEADER)
343        .and_then(|v| v.to_str().ok())
344        .map(str::to_string);
345    let body = response.text().await.unwrap_or_default();
346    let data = serde_json::from_str(&body).unwrap_or(Value::String(body));
347
348    if !status.is_success() {
349        bail!(
350            "x402 fetch failed with HTTP {}: {}",
351            status.as_u16(),
352            truncate(&data.to_string(), 400)
353        );
354    }
355
356    Ok(PaidFetchResponse {
357        status: status.as_u16(),
358        paid_usdc,
359        network,
360        payment_response,
361        data,
362    })
363}
364
365fn parse_method(method: &str) -> Result<Method> {
366    method
367        .parse::<Method>()
368        .with_context(|| format!("invalid HTTP method '{method}'"))
369}
370
371fn parse_marketplace_results(value: &Value) -> Vec<MarketplaceApi> {
372    let candidates = value
373        .as_array()
374        .or_else(|| value.get("results").and_then(Value::as_array))
375        .or_else(|| value.get("apis").and_then(Value::as_array))
376        .or_else(|| value.get("items").and_then(Value::as_array))
377        .or_else(|| value.get("data").and_then(Value::as_array));
378
379    candidates
380        .into_iter()
381        .flatten()
382        .filter_map(parse_marketplace_api)
383        .collect()
384}
385
386fn parse_marketplace_api(value: &Value) -> Option<MarketplaceApi> {
387    let name = string_at(value, &["name", "title"])?;
388    Some(MarketplaceApi {
389        id: string_at(value, &["id", "slug"]),
390        name,
391        description: string_at(value, &["description", "summary"]),
392        endpoint: string_at(value, &["endpoint", "url", "apiUrl", "api_url"]),
393        seller: string_at(value, &["seller", "provider", "owner"]),
394        network: string_at(value, &["network", "chain"]),
395        price_usdc: number_at(
396            value,
397            &["priceUsdc", "price_usdc", "paidUsdc", "paid_usdc", "price"],
398        ),
399        quality_score: number_at(value, &["qualityScore", "quality_score", "score"]),
400        verified: bool_at(value, &["verified", "isVerified"]).unwrap_or(false),
401        call_count: u64_at(value, &["callCount", "call_count", "calls"]),
402    })
403}
404
405fn parse_payment_options(raw: &Value) -> Vec<PaymentOption> {
406    raw.get("accepts")
407        .and_then(Value::as_array)
408        .into_iter()
409        .flatten()
410        .filter_map(parse_payment_option)
411        .collect()
412}
413
414fn parse_payment_option(value: &Value) -> Option<PaymentOption> {
415    let scheme = string_at(value, &["scheme"])?;
416    let network = string_at(value, &["network"])?;
417    let asset = string_at(value, &["asset"])?;
418    let amount_raw = u64_at(value, &["amount", "maxAmountRequired"])?;
419    let pay_to = string_at(value, &["pay_to", "payTo"])?;
420    let max_timeout_seconds =
421        u64_at(value, &["max_timeout_seconds", "maxTimeoutSeconds"]).unwrap_or(300);
422    let extra = value.get("extra").cloned().unwrap_or(Value::Null);
423
424    let amount_usdc = number_at(
425        value,
426        &["amount_usdc", "amountUsdc", "price_usdc", "priceUsdc"],
427    )
428    .or_else(|| {
429        number_at(
430            &extra,
431            &["amount_usdc", "amountUsdc", "price_usdc", "priceUsdc"],
432        )
433    })
434    .or_else(|| infer_usdc_amount(&asset, amount_raw, &extra));
435
436    let requirements = PaymentRequirements {
437        scheme: scheme.clone(),
438        network: network.clone(),
439        asset: asset.clone(),
440        amount: amount_raw,
441        pay_to: pay_to.clone(),
442        max_timeout_seconds,
443        extra,
444    };
445
446    Some(PaymentOption {
447        scheme,
448        network,
449        asset,
450        amount_raw,
451        pay_to,
452        max_timeout_seconds,
453        amount_usdc,
454        requirements,
455    })
456}
457
458fn read_svm_keypair() -> Result<Keypair> {
459    let raw = env::var(SVM_PRIVATE_KEY_ENV)
460        .with_context(|| format!("{} is not set", SVM_PRIVATE_KEY_ENV))?;
461    let trimmed = raw.trim();
462
463    if trimmed.starts_with('[') {
464        let bytes: Vec<u8> =
465            serde_json::from_str(trimmed).context("SVM_PRIVATE_KEY JSON array is invalid")?;
466        return Keypair::from_bytes(&bytes)
467            .context("SVM_PRIVATE_KEY JSON array is not a Solana keypair");
468    }
469
470    let bytes = bs58::decode(trimmed)
471        .into_vec()
472        .context("SVM_PRIVATE_KEY is not valid base58")?;
473    Keypair::from_bytes(&bytes).context("SVM_PRIVATE_KEY base58 is not a Solana keypair")
474}
475
476fn token_decimals(option: &PaymentOption) -> Result<u8> {
477    if let Some(decimals) = u64_at(&option.requirements.extra, &["decimals"]) {
478        return u8::try_from(decimals).context("token decimals exceed u8");
479    }
480
481    if option.asset == SOLANA_USDC_MINT {
482        return Ok(6);
483    }
484    if option.asset == SOLANA_WSOL_MINT {
485        return Ok(9);
486    }
487
488    bail!(
489        "payment token decimals are unknown for asset {}; include extra.decimals in the 402 response",
490        option.asset
491    )
492}
493
494fn infer_usdc_amount(asset: &str, amount_raw: u64, extra: &Value) -> Option<f64> {
495    if asset == SOLANA_USDC_MINT {
496        let decimals = u64_at(extra, &["decimals"]).unwrap_or(6) as i32;
497        return Some(amount_raw as f64 / 10_f64.powi(decimals));
498    }
499    None
500}
501
502fn env_flag(name: &str) -> bool {
503    env::var(name)
504        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
505        .unwrap_or(false)
506}
507
508fn string_at(value: &Value, keys: &[&str]) -> Option<String> {
509    keys.iter()
510        .find_map(|key| value.get(*key))
511        .and_then(Value::as_str)
512        .map(str::to_string)
513}
514
515fn number_at(value: &Value, keys: &[&str]) -> Option<f64> {
516    keys.iter().find_map(|key| {
517        let value = value.get(*key)?;
518        value
519            .as_f64()
520            .or_else(|| value.as_str().and_then(|s| s.parse::<f64>().ok()))
521    })
522}
523
524fn u64_at(value: &Value, keys: &[&str]) -> Option<u64> {
525    keys.iter().find_map(|key| {
526        let value = value.get(*key)?;
527        value
528            .as_u64()
529            .or_else(|| value.as_str().and_then(|s| s.parse::<u64>().ok()))
530    })
531}
532
533fn bool_at(value: &Value, keys: &[&str]) -> Option<bool> {
534    keys.iter().find_map(|key| {
535        let value = value.get(*key)?;
536        value.as_bool().or_else(|| {
537            value
538                .as_str()
539                .map(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes"))
540        })
541    })
542}
543
544fn truncate(input: &str, max_chars: usize) -> String {
545    let mut out = String::new();
546    for (idx, ch) in input.chars().enumerate() {
547        if idx >= max_chars {
548            out.push_str("...");
549            return out;
550        }
551        out.push(ch);
552    }
553    out
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn parses_camel_case_payment_requirements() {
562        let raw = serde_json::json!({
563            "accepts": [{
564                "scheme": "exact",
565                "network": "solana-mainnet",
566                "asset": SOLANA_USDC_MINT,
567                "maxAmountRequired": "25000",
568                "payTo": "11111111111111111111111111111111",
569                "maxTimeoutSeconds": 120,
570                "extra": { "decimals": 6 }
571            }]
572        });
573
574        let options = parse_payment_options(&raw);
575        assert_eq!(options.len(), 1);
576        assert_eq!(options[0].amount_raw, 25_000);
577        assert_eq!(options[0].amount_usdc, Some(0.025));
578        assert_eq!(
579            options[0].requirements.pay_to,
580            "11111111111111111111111111111111"
581        );
582    }
583
584    #[test]
585    fn parses_marketplace_shapes() {
586        let raw = serde_json::json!({
587            "results": [{
588                "name": "Weather API",
589                "apiUrl": "https://example.com/weather",
590                "priceUsdc": "0.01",
591                "qualityScore": 91,
592                "verified": true,
593                "callCount": 42
594            }]
595        });
596
597        let results = parse_marketplace_results(&raw);
598        assert_eq!(results.len(), 1);
599        assert_eq!(results[0].price_usdc, Some(0.01));
600        assert_eq!(results[0].quality_score, Some(91.0));
601        assert!(results[0].verified);
602    }
603}