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 async_trait::async_trait;
7use base64::Engine as _;
8use reqwest::{StatusCode, Url};
9use serde::de::DeserializeOwned;
10use std::collections::HashSet;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use strata_public_contract::{ErrorResponse, CONTRACT_MAJOR, CONTRACT_VERSION};
13use thiserror::Error;
14
15pub use strata_public_contract::{
16    CapabilityCatalog, CapabilityDescriptor, CapabilityRisk, CapabilityStability,
17    ExecutionChallengeRequest, ExecutionChallengeResponse, ExecutionPrepareRequest,
18    ExecutionPrepareResponse, ExecutionStatus, ExecutionSubmitRequest, ExecutionSubmitResponse,
19    Market, MarketsResponse, McpExposure, QuoteRequest, QuoteResponse, QuoteSide,
20    DEFAULT_SLIPPAGE_BPS,
21};
22
23pub const DEFAULT_API_BASE: &str = "https://api.stratabook.app";
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
25const PUBLIC_EXECUTION_AUTH_DOMAIN: &[u8] = b"strata-sonar-execution:v1\0";
26
27#[async_trait]
28pub trait SessionSigner: Send + Sync {
29    /// Canonical base58 Ed25519 public key registered as the Vault delegate.
30    fn public_key(&self) -> &str;
31
32    /// Sign the exact SDK-validated public execution authorization.
33    async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String>;
34
35    /// Add only the session signature to an already-verified transaction.
36    async fn sign_transaction(&self, transaction_base64: &str) -> Result<String, String>;
37}
38
39#[derive(Debug)]
40pub struct ExecutionVerificationContext<'a> {
41    pub quote: &'a QuoteResponse,
42    pub challenge: &'a ExecutionChallengeResponse,
43    pub prepared: &'a ExecutionPrepareResponse,
44    pub owner_wallet: &'a str,
45    pub session_public_key: &'a str,
46}
47
48#[async_trait]
49pub trait ExecutionVerifier: Send + Sync {
50    /// Reject unless the prepared transaction is acceptable for this exact
51    /// Vault session and public economic intent.
52    async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String>;
53}
54
55#[derive(Debug, Error)]
56pub enum SdkError {
57    #[error("invalid API base URL: {0}")]
58    InvalidBaseUrl(String),
59    #[error("invalid request: {0}")]
60    InvalidRequest(String),
61    #[error("market is not available: {0}")]
62    MarketNotFound(String),
63    #[error("operation is not available for market: {0}")]
64    OperationUnavailable(String),
65    #[error("Strata API error {status} ({code}): {message}")]
66    Api {
67        status: StatusCode,
68        code: String,
69        message: String,
70        retryable: bool,
71    },
72    #[error("invalid public contract response: {0}")]
73    InvalidResponse(String),
74    #[error("session signer rejected the operation: {0}")]
75    Signer(String),
76    #[error("prepared transaction was rejected: {0}")]
77    Verification(String),
78    #[error(transparent)]
79    Transport(#[from] reqwest::Error),
80}
81
82#[derive(Clone, Debug)]
83pub struct StrataClient {
84    base_url: Url,
85    http: reqwest::Client,
86}
87
88impl StrataClient {
89    pub fn production() -> Result<Self, SdkError> {
90        Self::new(DEFAULT_API_BASE)
91    }
92
93    pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
94        Self::with_timeout(base_url, DEFAULT_TIMEOUT)
95    }
96
97    pub fn with_timeout(base_url: impl AsRef<str>, timeout: Duration) -> Result<Self, SdkError> {
98        if timeout.is_zero() {
99            return Err(SdkError::InvalidRequest(
100                "timeout must be greater than zero".to_owned(),
101            ));
102        }
103        let base_url = normalize_base_url(base_url.as_ref())?;
104        let http = reqwest::Client::builder().timeout(timeout).build()?;
105        Ok(Self { base_url, http })
106    }
107
108    pub async fn capabilities(&self) -> Result<CapabilityCatalog, SdkError> {
109        let catalog: CapabilityCatalog = self.get("sonar/capabilities", &[]).await?;
110        validate_version(catalog.schema_version, &catalog.contract_version)?;
111
112        let mut ids = HashSet::new();
113        if catalog
114            .capabilities
115            .iter()
116            .any(|capability| !ids.insert(capability.id.as_str()))
117        {
118            return Err(SdkError::InvalidResponse(
119                "capability IDs must be unique".to_owned(),
120            ));
121        }
122        Ok(catalog)
123    }
124
125    pub async fn markets(&self) -> Result<MarketsResponse, SdkError> {
126        let markets: MarketsResponse = self.get("sonar/markets", &[]).await?;
127        validate_version(markets.schema_version, &markets.contract_version)?;
128        Ok(markets)
129    }
130
131    /// Request a short-lived Sonar quote by human market label or market ID.
132    pub async fn quote(&self, request: QuoteRequest) -> Result<QuoteResponse, SdkError> {
133        let amount_in = parse_atoms("amount_in_atoms", &request.amount_in_atoms)?;
134        if amount_in == 0 {
135            return Err(SdkError::InvalidRequest(
136                "amount_in_atoms must be greater than zero".to_owned(),
137            ));
138        }
139        if request.slippage_bps > 1_000 {
140            return Err(SdkError::InvalidRequest(
141                "slippage_bps must be between 0 and 1,000".to_owned(),
142            ));
143        }
144
145        let markets = self.markets().await?;
146        let market = markets
147            .markets
148            .iter()
149            .find(|market| {
150                market.label.eq_ignore_ascii_case(&request.market_id)
151                    || market.market_pda.as_deref() == Some(request.market_id.as_str())
152            })
153            .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
154        if !market.ready {
155            return Err(SdkError::OperationUnavailable(market.label.clone()));
156        }
157        let market_pda = market
158            .market_pda
159            .as_deref()
160            .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
161        let quote_path = market
162            .quote_path
163            .as_deref()
164            .filter(|path| valid_public_operation_path(path))
165            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
166        let wire = QuoteRequest {
167            market_id: market_pda.to_owned(),
168            side: request.side,
169            amount_in_atoms: request.amount_in_atoms.clone(),
170            slippage_bps: request.slippage_bps,
171        };
172        let quote: QuoteResponse = self.post(quote_path, &wire).await?;
173        validate_quote(&quote, market_pda, &request, amount_in)?;
174        Ok(quote)
175    }
176
177    /// Execute one short-lived Sonar quote without giving the SDK custody of a
178    /// session private key. The transaction verifier always runs before the
179    /// session adapter is allowed to sign.
180    pub async fn execute_quote<S, V>(
181        &self,
182        quote: &QuoteResponse,
183        owner_wallet: &str,
184        account_sequence: u64,
185        signer: &S,
186        verifier: &V,
187        idempotency_key: Option<&str>,
188    ) -> Result<ExecutionSubmitResponse, SdkError>
189    where
190        S: SessionSigner + ?Sized,
191        V: ExecutionVerifier + ?Sized,
192    {
193        validate_version(quote.schema_version, &quote.contract_version)?;
194        let now_ms = unix_ms()?;
195        if quote.expires_at_ms <= now_ms {
196            return Err(SdkError::InvalidRequest("quote has expired".to_owned()));
197        }
198        let owner_wallet = canonical_public_key(owner_wallet, "owner_wallet")?;
199        let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
200        let markets = self.markets().await?;
201        let market = markets
202            .markets
203            .iter()
204            .find(|market| market.market_pda.as_deref() == Some(quote.market_id.as_str()))
205            .ok_or_else(|| SdkError::MarketNotFound(quote.market_id.clone()))?;
206        let quote_path = market
207            .quote_path
208            .as_deref()
209            .filter(|path| valid_public_operation_path(path))
210            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
211        let execution_path = format!(
212            "{}/execution",
213            quote_path
214                .strip_suffix("/quote")
215                .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
216        );
217        let challenge: ExecutionChallengeResponse = self
218            .post(
219                &format!("{execution_path}/challenge"),
220                &ExecutionChallengeRequest {
221                    quote_id: quote.quote_id.clone(),
222                    owner_wallet: owner_wallet.clone(),
223                    session_public_key: session_public_key.clone(),
224                    account_sequence: account_sequence.to_string(),
225                },
226            )
227            .await?;
228        validate_execution_challenge(&challenge, quote)?;
229        let authorization = validate_execution_authorization(
230            &challenge,
231            quote,
232            &owner_wallet,
233            &session_public_key,
234            account_sequence,
235        )?;
236        let signature = signer
237            .sign_message(&authorization.bytes)
238            .await
239            .map_err(SdkError::Signer)?;
240        if signature.len() != 64 {
241            return Err(SdkError::InvalidResponse(
242                "session authorization signature must contain 64 bytes".to_owned(),
243            ));
244        }
245        let prepared: ExecutionPrepareResponse = self
246            .post(
247                &format!("{execution_path}/prepare"),
248                &ExecutionPrepareRequest {
249                    challenge_id: challenge.challenge_id.clone(),
250                    authorization_signature: bs58::encode(signature).into_string(),
251                },
252            )
253            .await?;
254        validate_execution_prepare(&prepared, quote, &challenge, &authorization)?;
255        verifier
256            .verify(&ExecutionVerificationContext {
257                quote,
258                challenge: &challenge,
259                prepared: &prepared,
260                owner_wallet: &owner_wallet,
261                session_public_key: &session_public_key,
262            })
263            .await
264            .map_err(SdkError::Verification)?;
265        let signed_transaction = signer
266            .sign_transaction(&prepared.transaction_base64)
267            .await
268            .map_err(SdkError::Signer)?;
269        base64::engine::general_purpose::STANDARD
270            .decode(signed_transaction.trim())
271            .map_err(|_| {
272                SdkError::InvalidResponse(
273                    "session signer returned an invalid base64 transaction".to_owned(),
274                )
275            })?;
276        let idempotency_key =
277            normalize_idempotency_key(idempotency_key.unwrap_or(&prepared.execution_id))?;
278        let submitted: ExecutionSubmitResponse = self
279            .post(
280                &format!("{execution_path}/submit"),
281                &ExecutionSubmitRequest {
282                    execution_id: prepared.execution_id.clone(),
283                    signed_transaction_base64: signed_transaction,
284                    idempotency_key,
285                },
286            )
287            .await?;
288        validate_version(submitted.schema_version, &submitted.contract_version)?;
289        if submitted.execution_id != prepared.execution_id
290            || submitted.status != ExecutionStatus::Submitted
291            || submitted.signature.trim().is_empty()
292        {
293            return Err(SdkError::InvalidResponse(
294                "execution receipt does not match the prepared transaction".to_owned(),
295            ));
296        }
297        Ok(submitted)
298    }
299
300    async fn get<T: DeserializeOwned>(
301        &self,
302        path: &str,
303        query: &[(&str, &str)],
304    ) -> Result<T, SdkError> {
305        let mut url = self.base_url.join(path).map_err(|error| {
306            SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
307        })?;
308        url.query_pairs_mut().extend_pairs(query.iter().copied());
309
310        let response = self
311            .http
312            .get(url)
313            .header(reqwest::header::ACCEPT, "application/json")
314            .send()
315            .await?;
316        let status = response.status();
317        let bytes = response.bytes().await?;
318        if !status.is_success() {
319            return match serde_json::from_slice::<ErrorResponse>(&bytes) {
320                Ok(error) => Err(SdkError::Api {
321                    status,
322                    code: error.error.code,
323                    message: error.error.message,
324                    retryable: error.error.retryable,
325                }),
326                Err(_) => Err(SdkError::Api {
327                    status,
328                    code: "request_failed".to_owned(),
329                    message: "Strata could not complete the request.".to_owned(),
330                    retryable: status.is_server_error(),
331                }),
332            };
333        }
334        serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
335    }
336
337    async fn post<T: DeserializeOwned, B: serde::Serialize>(
338        &self,
339        path: &str,
340        body: &B,
341    ) -> Result<T, SdkError> {
342        let url = self.base_url.join(path).map_err(|error| {
343            SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
344        })?;
345        let response = self
346            .http
347            .post(url)
348            .header(reqwest::header::ACCEPT, "application/json")
349            .json(body)
350            .send()
351            .await?;
352        let status = response.status();
353        let bytes = response.bytes().await?;
354        if !status.is_success() {
355            return match serde_json::from_slice::<ErrorResponse>(&bytes) {
356                Ok(error) => Err(SdkError::Api {
357                    status,
358                    code: error.error.code,
359                    message: error.error.message,
360                    retryable: error.error.retryable,
361                }),
362                Err(_) => Err(SdkError::Api {
363                    status,
364                    code: "request_failed".to_owned(),
365                    message: "Strata could not complete the request.".to_owned(),
366                    retryable: status.is_server_error(),
367                }),
368            };
369        }
370        serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
371    }
372}
373
374fn normalize_base_url(value: &str) -> Result<Url, SdkError> {
375    let mut normalized = value.trim().to_owned();
376    if !normalized.ends_with('/') {
377        normalized.push('/');
378    }
379    let url =
380        Url::parse(&normalized).map_err(|error| SdkError::InvalidBaseUrl(error.to_string()))?;
381    if !matches!(url.scheme(), "http" | "https") || url.cannot_be_a_base() {
382        return Err(SdkError::InvalidBaseUrl(
383            "URL must use http or https and include a host".to_owned(),
384        ));
385    }
386    Ok(url)
387}
388
389fn validate_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
390    if schema_version != CONTRACT_MAJOR || contract_version != CONTRACT_VERSION {
391        return Err(SdkError::InvalidResponse(format!(
392            "unsupported contract {contract_version} (schema {schema_version})"
393        )));
394    }
395    Ok(())
396}
397
398fn parse_atoms(field: &str, value: &str) -> Result<u64, SdkError> {
399    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
400        return Err(SdkError::InvalidResponse(format!(
401            "{field} must be an unsigned atomic decimal string"
402        )));
403    }
404    value
405        .parse::<u64>()
406        .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds the supported range")))
407}
408
409fn valid_public_operation_path(path: &str) -> bool {
410    let Some(market_id) = path
411        .strip_prefix("/sonar/markets/")
412        .and_then(|value| value.strip_suffix("/quote"))
413    else {
414        return false;
415    };
416    !market_id.is_empty()
417        && !market_id.starts_with('-')
418        && !market_id.ends_with('-')
419        && market_id
420            .bytes()
421            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
422}
423
424fn validate_quote(
425    quote: &QuoteResponse,
426    market_id: &str,
427    request: &QuoteRequest,
428    requested_amount: u64,
429) -> Result<(), SdkError> {
430    validate_version(quote.schema_version, &quote.contract_version)?;
431    if quote.provider != "Sonar"
432        || quote.market_id != market_id
433        || quote.side != request.side
434        || quote.amount_in_atoms != request.amount_in_atoms
435        || quote.quote_id.len() != 35
436        || !quote.quote_id.starts_with("sq_")
437        || !quote.quote_id[3..]
438            .bytes()
439            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
440        || quote.expires_at_ms <= quote.server_time_ms
441    {
442        return Err(SdkError::InvalidResponse(
443            "quote binding or lifetime is invalid".to_owned(),
444        ));
445    }
446
447    let consumed = parse_atoms("amount_in_consumed_atoms", &quote.amount_in_consumed_atoms)?;
448    let output = parse_atoms("amount_out_atoms", &quote.amount_out_atoms)?;
449    let minimum = parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?;
450    parse_atoms("input_fee_atoms", &quote.input_fee_atoms)?;
451    parse_atoms("output_fee_atoms", &quote.output_fee_atoms)?;
452    if consumed > requested_amount || minimum > output {
453        return Err(SdkError::InvalidResponse(
454            "quote economics are internally inconsistent".to_owned(),
455        ));
456    }
457    quote
458        .reference_price
459        .parse::<f64>()
460        .ok()
461        .filter(|value| value.is_finite() && *value > 0.0)
462        .ok_or_else(|| SdkError::InvalidResponse("reference_price is invalid".to_owned()))?;
463    quote
464        .price_impact_pct
465        .parse::<f64>()
466        .ok()
467        .filter(|value| value.is_finite() && *value >= 0.0)
468        .ok_or_else(|| SdkError::InvalidResponse("price_impact_pct is invalid".to_owned()))?;
469    Ok(())
470}
471
472struct ExecutionAuthorization {
473    bytes: Vec<u8>,
474    recent_blockhash: String,
475    last_valid_block_height: u64,
476}
477
478fn validate_execution_challenge(
479    challenge: &ExecutionChallengeResponse,
480    quote: &QuoteResponse,
481) -> Result<(), SdkError> {
482    validate_version(challenge.schema_version, &challenge.contract_version)?;
483    validate_execution_binding(
484        &challenge.quote_id,
485        &challenge.market_id,
486        challenge.side,
487        &challenge.amount_in_atoms,
488        &challenge.minimum_output_atoms,
489        quote,
490    )?;
491    if !valid_handle(&challenge.challenge_id, "sc_")
492        || challenge.expires_at_ms <= challenge.server_time_ms
493        || challenge.expires_at_ms > quote.expires_at_ms
494    {
495        return Err(SdkError::InvalidResponse(
496            "execution challenge binding or lifetime is invalid".to_owned(),
497        ));
498    }
499    Ok(())
500}
501
502fn validate_execution_prepare(
503    prepared: &ExecutionPrepareResponse,
504    quote: &QuoteResponse,
505    challenge: &ExecutionChallengeResponse,
506    authorization: &ExecutionAuthorization,
507) -> Result<(), SdkError> {
508    validate_version(prepared.schema_version, &prepared.contract_version)?;
509    validate_execution_binding(
510        &prepared.quote_id,
511        &prepared.market_id,
512        prepared.side,
513        &prepared.amount_in_atoms,
514        &prepared.minimum_output_atoms,
515        quote,
516    )?;
517    if !valid_handle(&prepared.execution_id, "se_")
518        || prepared.recent_blockhash != authorization.recent_blockhash
519        || prepared.last_valid_block_height != authorization.last_valid_block_height
520        || prepared.expires_at_ms > challenge.expires_at_ms
521        || prepared.transaction_base64.trim().is_empty()
522        || base64::engine::general_purpose::STANDARD
523            .decode(prepared.transaction_base64.trim())
524            .is_err()
525    {
526        return Err(SdkError::InvalidResponse(
527            "prepared execution changed the signed authorization".to_owned(),
528        ));
529    }
530    Ok(())
531}
532
533fn validate_execution_binding(
534    quote_id: &str,
535    market_id: &str,
536    side: QuoteSide,
537    amount_in_atoms: &str,
538    minimum_output_atoms: &str,
539    quote: &QuoteResponse,
540) -> Result<(), SdkError> {
541    if quote_id != quote.quote_id
542        || market_id != quote.market_id
543        || side != quote.side
544        || amount_in_atoms != quote.amount_in_atoms
545        || minimum_output_atoms != quote.minimum_output_atoms
546    {
547        return Err(SdkError::InvalidResponse(
548            "execution does not match the Sonar quote".to_owned(),
549        ));
550    }
551    Ok(())
552}
553
554fn validate_execution_authorization(
555    challenge: &ExecutionChallengeResponse,
556    quote: &QuoteResponse,
557    owner_wallet: &str,
558    session_public_key: &str,
559    account_sequence: u64,
560) -> Result<ExecutionAuthorization, SdkError> {
561    let bytes = base64::engine::general_purpose::STANDARD
562        .decode(challenge.authorization_payload_base64.trim())
563        .map_err(|_| SdkError::InvalidResponse("authorization payload is not base64".to_owned()))?;
564    let market = decode_public_key(&quote.market_id, "market_id")?;
565    let owner = decode_public_key(owner_wallet, "owner_wallet")?;
566    let session = decode_public_key(session_public_key, "session_public_key")?;
567    let mut cursor = 0usize;
568    take_expected(
569        &bytes,
570        &mut cursor,
571        PUBLIC_EXECUTION_AUTH_DOMAIN,
572        "authorization domain",
573    )?;
574    take_expected(&bytes, &mut cursor, &market, "authorization market")?;
575    take_expected(
576        &bytes,
577        &mut cursor,
578        quote.quote_id.as_bytes(),
579        "authorization quote",
580    )?;
581    take_expected(&bytes, &mut cursor, &owner, "authorization owner")?;
582    take_expected(&bytes, &mut cursor, &session, "authorization session")?;
583    let side = take_bytes(&bytes, &mut cursor, 1, "authorization side")?[0];
584    if side != if quote.side == QuoteSide::Buy { 0 } else { 1 } {
585        return Err(SdkError::InvalidResponse(
586            "authorization side changed".to_owned(),
587        ));
588    }
589    take_u64_eq(
590        &bytes,
591        &mut cursor,
592        parse_atoms("amount_in_atoms", &quote.amount_in_atoms)?,
593        "authorization input",
594    )?;
595    take_u64_eq(
596        &bytes,
597        &mut cursor,
598        parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?,
599        "authorization minimum output",
600    )?;
601    take_u64_eq(
602        &bytes,
603        &mut cursor,
604        account_sequence,
605        "authorization account sequence",
606    )?;
607    let _output_balance = take_u64(&bytes, &mut cursor, "authorization output balance")?;
608    let recent_blockhash = bs58::encode(take_bytes(
609        &bytes,
610        &mut cursor,
611        32,
612        "authorization blockhash",
613    )?)
614    .into_string();
615    let last_valid_block_height =
616        take_u64(&bytes, &mut cursor, "authorization last valid block height")?;
617    take_u64_eq(
618        &bytes,
619        &mut cursor,
620        challenge.expires_at_ms,
621        "authorization expiry",
622    )?;
623    let nonce = take_bytes(&bytes, &mut cursor, 16, "authorization nonce")?;
624    if hex::encode(nonce) != challenge.challenge_id[3..] {
625        return Err(SdkError::InvalidResponse(
626            "authorization challenge nonce changed".to_owned(),
627        ));
628    }
629    let _epoch = take_bytes(&bytes, &mut cursor, 16, "authorization epoch")?;
630    if cursor != bytes.len() {
631        return Err(SdkError::InvalidResponse(
632            "authorization contains unrecognized fields".to_owned(),
633        ));
634    }
635    Ok(ExecutionAuthorization {
636        bytes,
637        recent_blockhash,
638        last_valid_block_height,
639    })
640}
641
642fn take_expected(
643    source: &[u8],
644    cursor: &mut usize,
645    expected: &[u8],
646    field: &str,
647) -> Result<(), SdkError> {
648    if take_bytes(source, cursor, expected.len(), field)? != expected {
649        return Err(SdkError::InvalidResponse(format!("{field} changed")));
650    }
651    Ok(())
652}
653
654fn take_bytes<'a>(
655    source: &'a [u8],
656    cursor: &mut usize,
657    length: usize,
658    field: &str,
659) -> Result<&'a [u8], SdkError> {
660    let end = cursor
661        .checked_add(length)
662        .filter(|end| *end <= source.len())
663        .ok_or_else(|| SdkError::InvalidResponse(format!("{field} is missing")))?;
664    let value = &source[*cursor..end];
665    *cursor = end;
666    Ok(value)
667}
668
669fn take_u64(source: &[u8], cursor: &mut usize, field: &str) -> Result<u64, SdkError> {
670    let bytes: [u8; 8] = take_bytes(source, cursor, 8, field)?
671        .try_into()
672        .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
673    Ok(u64::from_le_bytes(bytes))
674}
675
676fn take_u64_eq(
677    source: &[u8],
678    cursor: &mut usize,
679    expected: u64,
680    field: &str,
681) -> Result<(), SdkError> {
682    if take_u64(source, cursor, field)? != expected {
683        return Err(SdkError::InvalidResponse(format!("{field} changed")));
684    }
685    Ok(())
686}
687
688fn decode_public_key(value: &str, field: &str) -> Result<Vec<u8>, SdkError> {
689    let bytes = bs58::decode(value.trim())
690        .into_vec()
691        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
692    if bytes.len() != 32 || bs58::encode(&bytes).into_string() != value.trim() {
693        return Err(SdkError::InvalidRequest(format!(
694            "{field} must be a canonical 32-byte public key"
695        )));
696    }
697    Ok(bytes)
698}
699
700fn canonical_public_key(value: &str, field: &str) -> Result<String, SdkError> {
701    decode_public_key(value, field)?;
702    Ok(value.trim().to_owned())
703}
704
705fn valid_handle(value: &str, prefix: &str) -> bool {
706    value.len() == prefix.len() + 32
707        && value.starts_with(prefix)
708        && value[prefix.len()..]
709            .bytes()
710            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
711}
712
713fn normalize_idempotency_key(value: &str) -> Result<String, SdkError> {
714    let value = value.trim();
715    if value.is_empty()
716        || value.len() > 64
717        || !value.bytes().all(|byte| {
718            byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
719        })
720    {
721        return Err(SdkError::InvalidRequest(
722            "idempotency key must contain 1-64 URL-safe characters".to_owned(),
723        ));
724    }
725    Ok(value.to_owned())
726}
727
728fn unix_ms() -> Result<u64, SdkError> {
729    let elapsed = SystemTime::now()
730        .duration_since(UNIX_EPOCH)
731        .map_err(|_| SdkError::InvalidRequest("system clock is before Unix epoch".to_owned()))?;
732    u64::try_from(elapsed.as_millis())
733        .map_err(|_| SdkError::InvalidRequest("system clock exceeds supported range".to_owned()))
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use wiremock::matchers::{body_json, method, path};
740    use wiremock::{Mock, MockServer, ResponseTemplate};
741
742    fn fixture(path: &str) -> serde_json::Value {
743        let raw = match path {
744            "markets" => strata_public_contract::contract_fixtures::MARKETS,
745            "quote" => strata_public_contract::contract_fixtures::QUOTE,
746            "capabilities" => strata_public_contract::contract_fixtures::CAPABILITIES,
747            _ => unreachable!(),
748        };
749        serde_json::from_str(raw).unwrap()
750    }
751
752    #[tokio::test]
753    async fn reads_capabilities_and_quotes_without_internal_metadata() {
754        let server = MockServer::start().await;
755        Mock::given(method("GET"))
756            .and(path("/sonar/capabilities"))
757            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("capabilities")))
758            .mount(&server)
759            .await;
760        Mock::given(method("GET"))
761            .and(path("/sonar/markets"))
762            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
763            .expect(1)
764            .mount(&server)
765            .await;
766        Mock::given(method("POST"))
767            .and(path("/sonar/markets/sol-usdc/quote"))
768            .and(body_json(serde_json::json!({
769                "market_id": "11111111111111111111111111111111",
770                "side": "sell",
771                "amount_in_atoms": "10000000",
772                "slippage_bps": 50
773            })))
774            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("quote")))
775            .expect(1)
776            .mount(&server)
777            .await;
778
779        let client = StrataClient::new(server.uri()).unwrap();
780        let capabilities = client.capabilities().await.unwrap();
781        assert!(capabilities
782            .capabilities
783            .iter()
784            .any(|capability| capability.id == "quotes.read"));
785
786        let quote = client
787            .quote(QuoteRequest {
788                market_id: "SOL/USDC".to_owned(),
789                side: QuoteSide::Sell,
790                amount_in_atoms: "10000000".to_owned(),
791                slippage_bps: 50,
792            })
793            .await
794            .unwrap();
795        let public = serde_json::to_value(quote).unwrap();
796        assert!(public.get("quote_id").is_some());
797        assert!(public.get("unexpected_field").is_none());
798    }
799
800    #[test]
801    fn rejects_non_http_base_urls() {
802        assert!(matches!(
803            StrataClient::new("file:///tmp/contract"),
804            Err(SdkError::InvalidBaseUrl(_))
805        ));
806    }
807
808    #[test]
809    fn accepts_only_product_level_quote_operation_paths() {
810        assert!(valid_public_operation_path("/sonar/markets/sol-usdc/quote"));
811        for unsupported_or_ambiguous in [
812            "/unsupported/build",
813            "/unsupported/quote",
814            "/sonar/markets/../quote",
815            "/sonar/markets/SOL-USDC/quote",
816        ] {
817            assert!(!valid_public_operation_path(unsupported_or_ambiguous));
818        }
819    }
820}