Skip to main content

strata_sdk/
lib.rs

1//! Official Rust client for Strata markets and Sonar quotes.
2//!
3//! It provides typed requests and responses and validates compatibility, quote
4//! binding, and economic fields before returning data to the application.
5
6use 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    /// Request a short-lived Sonar quote by human market label or market ID.
94    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(&quote, 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, &quote.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", &quote.amount_in_consumed_atoms)?;
287    let output = parse_atoms("amount_out_atoms", &quote.amount_out_atoms)?;
288    let minimum = parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?;
289    parse_atoms("input_fee_atoms", &quote.input_fee_atoms)?;
290    parse_atoms("output_fee_atoms", &quote.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}