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}