1use reqwest::{StatusCode, Url};
7use serde::de::DeserializeOwned;
8use std::collections::HashSet;
9use std::time::Duration;
10use strata_public_contract::{ErrorResponse, CONTRACT_MAJOR, CONTRACT_VERSION};
11use thiserror::Error;
12
13pub use strata_public_contract::{
14 CapabilityCatalog, CapabilityDescriptor, CapabilityRisk, CapabilityStability, Market,
15 MarketsResponse, McpExposure, QuoteRequest, QuoteResponse, QuoteSide,
16};
17
18pub const DEFAULT_API_BASE: &str = "https://api.stratabook.app";
19const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
20
21#[derive(Debug, Error)]
22pub enum SdkError {
23 #[error("invalid API base URL: {0}")]
24 InvalidBaseUrl(String),
25 #[error("invalid request: {0}")]
26 InvalidRequest(String),
27 #[error("market is not available: {0}")]
28 MarketNotFound(String),
29 #[error("operation is not available for market: {0}")]
30 OperationUnavailable(String),
31 #[error("Strata API error {status} ({code}): {message}")]
32 Api {
33 status: StatusCode,
34 code: String,
35 message: String,
36 retryable: bool,
37 },
38 #[error("invalid public contract response: {0}")]
39 InvalidResponse(String),
40 #[error(transparent)]
41 Transport(#[from] reqwest::Error),
42}
43
44#[derive(Clone, Debug)]
45pub struct StrataClient {
46 base_url: Url,
47 http: reqwest::Client,
48}
49
50impl StrataClient {
51 pub fn production() -> Result<Self, SdkError> {
52 Self::new(DEFAULT_API_BASE)
53 }
54
55 pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
56 Self::with_timeout(base_url, DEFAULT_TIMEOUT)
57 }
58
59 pub fn with_timeout(base_url: impl AsRef<str>, timeout: Duration) -> Result<Self, SdkError> {
60 if timeout.is_zero() {
61 return Err(SdkError::InvalidRequest(
62 "timeout must be greater than zero".to_owned(),
63 ));
64 }
65 let base_url = normalize_base_url(base_url.as_ref())?;
66 let http = reqwest::Client::builder().timeout(timeout).build()?;
67 Ok(Self { base_url, http })
68 }
69
70 pub async fn capabilities(&self) -> Result<CapabilityCatalog, SdkError> {
71 let catalog: CapabilityCatalog = self.get("sonar/capabilities", &[]).await?;
72 validate_version(catalog.schema_version, &catalog.contract_version)?;
73
74 let mut ids = HashSet::new();
75 if catalog
76 .capabilities
77 .iter()
78 .any(|capability| !ids.insert(capability.id.as_str()))
79 {
80 return Err(SdkError::InvalidResponse(
81 "capability IDs must be unique".to_owned(),
82 ));
83 }
84 Ok(catalog)
85 }
86
87 pub async fn markets(&self) -> Result<MarketsResponse, SdkError> {
88 let markets: MarketsResponse = self.get("sonar/markets", &[]).await?;
89 validate_version(markets.schema_version, &markets.contract_version)?;
90 Ok(markets)
91 }
92
93 pub async fn quote(&self, request: QuoteRequest) -> Result<QuoteResponse, SdkError> {
95 let amount_in = parse_atoms("amount_in_atoms", &request.amount_in_atoms)?;
96 if amount_in == 0 {
97 return Err(SdkError::InvalidRequest(
98 "amount_in_atoms must be greater than zero".to_owned(),
99 ));
100 }
101 if request.slippage_bps > 1_000 {
102 return Err(SdkError::InvalidRequest(
103 "slippage_bps must be between 0 and 1,000".to_owned(),
104 ));
105 }
106
107 let markets = self.markets().await?;
108 let market = markets
109 .markets
110 .iter()
111 .find(|market| {
112 market.label.eq_ignore_ascii_case(&request.market_id)
113 || market.market_pda.as_deref() == Some(request.market_id.as_str())
114 })
115 .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
116 if !market.ready {
117 return Err(SdkError::OperationUnavailable(market.label.clone()));
118 }
119 let market_pda = market
120 .market_pda
121 .as_deref()
122 .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
123 let quote_path = market
124 .quote_path
125 .as_deref()
126 .filter(|path| valid_public_operation_path(path))
127 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
128 let wire = QuoteRequest {
129 market_id: market_pda.to_owned(),
130 side: request.side,
131 amount_in_atoms: request.amount_in_atoms.clone(),
132 slippage_bps: request.slippage_bps,
133 };
134 let quote: QuoteResponse = self.post(quote_path, &wire).await?;
135 validate_quote("e, market_pda, &request, amount_in)?;
136 Ok(quote)
137 }
138
139 async fn get<T: DeserializeOwned>(
140 &self,
141 path: &str,
142 query: &[(&str, &str)],
143 ) -> Result<T, SdkError> {
144 let mut url = self.base_url.join(path).map_err(|error| {
145 SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
146 })?;
147 url.query_pairs_mut().extend_pairs(query.iter().copied());
148
149 let response = self
150 .http
151 .get(url)
152 .header(reqwest::header::ACCEPT, "application/json")
153 .send()
154 .await?;
155 let status = response.status();
156 let bytes = response.bytes().await?;
157 if !status.is_success() {
158 return match serde_json::from_slice::<ErrorResponse>(&bytes) {
159 Ok(error) => Err(SdkError::Api {
160 status,
161 code: error.error.code,
162 message: error.error.message,
163 retryable: error.error.retryable,
164 }),
165 Err(_) => Err(SdkError::Api {
166 status,
167 code: "request_failed".to_owned(),
168 message: "Strata could not complete the request.".to_owned(),
169 retryable: status.is_server_error(),
170 }),
171 };
172 }
173 serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
174 }
175
176 async fn post<T: DeserializeOwned, B: serde::Serialize>(
177 &self,
178 path: &str,
179 body: &B,
180 ) -> Result<T, SdkError> {
181 let url = self.base_url.join(path).map_err(|error| {
182 SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
183 })?;
184 let response = self
185 .http
186 .post(url)
187 .header(reqwest::header::ACCEPT, "application/json")
188 .json(body)
189 .send()
190 .await?;
191 let status = response.status();
192 let bytes = response.bytes().await?;
193 if !status.is_success() {
194 return match serde_json::from_slice::<ErrorResponse>(&bytes) {
195 Ok(error) => Err(SdkError::Api {
196 status,
197 code: error.error.code,
198 message: error.error.message,
199 retryable: error.error.retryable,
200 }),
201 Err(_) => Err(SdkError::Api {
202 status,
203 code: "request_failed".to_owned(),
204 message: "Strata could not complete the request.".to_owned(),
205 retryable: status.is_server_error(),
206 }),
207 };
208 }
209 serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
210 }
211}
212
213fn normalize_base_url(value: &str) -> Result<Url, SdkError> {
214 let mut normalized = value.trim().to_owned();
215 if !normalized.ends_with('/') {
216 normalized.push('/');
217 }
218 let url =
219 Url::parse(&normalized).map_err(|error| SdkError::InvalidBaseUrl(error.to_string()))?;
220 if !matches!(url.scheme(), "http" | "https") || url.cannot_be_a_base() {
221 return Err(SdkError::InvalidBaseUrl(
222 "URL must use http or https and include a host".to_owned(),
223 ));
224 }
225 Ok(url)
226}
227
228fn validate_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
229 if schema_version != CONTRACT_MAJOR || contract_version != CONTRACT_VERSION {
230 return Err(SdkError::InvalidResponse(format!(
231 "unsupported contract {contract_version} (schema {schema_version})"
232 )));
233 }
234 Ok(())
235}
236
237fn parse_atoms(field: &str, value: &str) -> Result<u64, SdkError> {
238 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
239 return Err(SdkError::InvalidResponse(format!(
240 "{field} must be an unsigned atomic decimal string"
241 )));
242 }
243 value
244 .parse::<u64>()
245 .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds the supported range")))
246}
247
248fn valid_public_operation_path(path: &str) -> bool {
249 let Some(market_id) = path
250 .strip_prefix("/sonar/markets/")
251 .and_then(|value| value.strip_suffix("/quote"))
252 else {
253 return false;
254 };
255 !market_id.is_empty()
256 && !market_id.starts_with('-')
257 && !market_id.ends_with('-')
258 && market_id
259 .bytes()
260 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
261}
262
263fn validate_quote(
264 quote: &QuoteResponse,
265 market_id: &str,
266 request: &QuoteRequest,
267 requested_amount: u64,
268) -> Result<(), SdkError> {
269 validate_version(quote.schema_version, "e.contract_version)?;
270 if quote.provider != "Sonar"
271 || quote.market_id != market_id
272 || quote.side != request.side
273 || quote.amount_in_atoms != request.amount_in_atoms
274 || quote.quote_id.len() != 35
275 || !quote.quote_id.starts_with("sq_")
276 || !quote.quote_id[3..]
277 .bytes()
278 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
279 || quote.expires_at_ms <= quote.server_time_ms
280 {
281 return Err(SdkError::InvalidResponse(
282 "quote binding or lifetime is invalid".to_owned(),
283 ));
284 }
285
286 let consumed = parse_atoms("amount_in_consumed_atoms", "e.amount_in_consumed_atoms)?;
287 let output = parse_atoms("amount_out_atoms", "e.amount_out_atoms)?;
288 let minimum = parse_atoms("minimum_output_atoms", "e.minimum_output_atoms)?;
289 parse_atoms("input_fee_atoms", "e.input_fee_atoms)?;
290 parse_atoms("output_fee_atoms", "e.output_fee_atoms)?;
291 if consumed > requested_amount || minimum > output {
292 return Err(SdkError::InvalidResponse(
293 "quote economics are internally inconsistent".to_owned(),
294 ));
295 }
296 quote
297 .reference_price
298 .parse::<f64>()
299 .ok()
300 .filter(|value| value.is_finite() && *value > 0.0)
301 .ok_or_else(|| SdkError::InvalidResponse("reference_price is invalid".to_owned()))?;
302 quote
303 .price_impact_pct
304 .parse::<f64>()
305 .ok()
306 .filter(|value| value.is_finite() && *value >= 0.0)
307 .ok_or_else(|| SdkError::InvalidResponse("price_impact_pct is invalid".to_owned()))?;
308 Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use wiremock::matchers::{body_json, method, path};
315 use wiremock::{Mock, MockServer, ResponseTemplate};
316
317 fn fixture(path: &str) -> serde_json::Value {
318 let raw = match path {
319 "markets" => strata_public_contract::contract_fixtures::MARKETS,
320 "quote" => strata_public_contract::contract_fixtures::QUOTE,
321 "capabilities" => strata_public_contract::contract_fixtures::CAPABILITIES,
322 _ => unreachable!(),
323 };
324 serde_json::from_str(raw).unwrap()
325 }
326
327 #[tokio::test]
328 async fn reads_capabilities_and_quotes_without_internal_metadata() {
329 let server = MockServer::start().await;
330 Mock::given(method("GET"))
331 .and(path("/sonar/capabilities"))
332 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("capabilities")))
333 .mount(&server)
334 .await;
335 Mock::given(method("GET"))
336 .and(path("/sonar/markets"))
337 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
338 .expect(1)
339 .mount(&server)
340 .await;
341 Mock::given(method("POST"))
342 .and(path("/sonar/markets/sol-usdc/quote"))
343 .and(body_json(serde_json::json!({
344 "market_id": "11111111111111111111111111111111",
345 "side": "sell",
346 "amount_in_atoms": "10000000",
347 "slippage_bps": 50
348 })))
349 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("quote")))
350 .expect(1)
351 .mount(&server)
352 .await;
353
354 let client = StrataClient::new(server.uri()).unwrap();
355 let capabilities = client.capabilities().await.unwrap();
356 assert!(capabilities
357 .capabilities
358 .iter()
359 .any(|capability| capability.id == "quotes.read"));
360
361 let quote = client
362 .quote(QuoteRequest {
363 market_id: "SOL/USDC".to_owned(),
364 side: QuoteSide::Sell,
365 amount_in_atoms: "10000000".to_owned(),
366 slippage_bps: 50,
367 })
368 .await
369 .unwrap();
370 let public = serde_json::to_value(quote).unwrap();
371 assert!(public.get("quote_id").is_some());
372 assert!(public.get("unexpected_field").is_none());
373 }
374
375 #[test]
376 fn rejects_non_http_base_urls() {
377 assert!(matches!(
378 StrataClient::new("file:///tmp/contract"),
379 Err(SdkError::InvalidBaseUrl(_))
380 ));
381 }
382
383 #[test]
384 fn accepts_only_product_level_quote_operation_paths() {
385 assert!(valid_public_operation_path("/sonar/markets/sol-usdc/quote"));
386 for unsupported_or_ambiguous in [
387 "/unsupported/build",
388 "/unsupported/quote",
389 "/sonar/markets/../quote",
390 "/sonar/markets/SOL-USDC/quote",
391 ] {
392 assert!(!valid_public_operation_path(unsupported_or_ambiguous));
393 }
394 }
395}