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