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 sha2::{Digest, Sha256};
11use std::collections::HashSet;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use strata_public_contract::{ErrorResponse, CONTRACT_MAJOR, CONTRACT_VERSION};
14use thiserror::Error;
15
16pub use strata_public_contract::platform::{
17    PlatformOrderAction, PlatformOrderBatchOperation, PlatformOrderChallengeRequest,
18    PlatformOrderChallengeResponse, PlatformOrderControlStatus, PlatformOrderPrepareRequest,
19    PlatformOrderPrepareResponse, PlatformOrderStatusRequest, PlatformOrderStatusResponse,
20    PlatformOrderSubmissionStatus, PlatformOrderSubmitRequest, PlatformOrderSubmitResponse,
21    PlatformOrderType, PlatformTradeSide,
22};
23pub use strata_public_contract::{
24    ActionAuthorityModel, ActionEdge, ActionGraph, ActionNode, ActionNodeKind, ActionOperation,
25    CapabilityCatalog, CapabilityDescriptor, CapabilityRisk, CapabilityStability,
26    ExecutionChallengeRequest, ExecutionChallengeResponse, ExecutionPrepareRequest,
27    ExecutionPrepareResponse, ExecutionStatus, ExecutionSubmitRequest, ExecutionSubmitResponse,
28    Market, MarketsResponse, McpExposure, QuoteRequest, QuoteResponse, QuoteSide,
29    DEFAULT_SLIPPAGE_BPS,
30};
31
32pub const DEFAULT_API_BASE: &str = "https://api.stratabook.app";
33const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
34const PUBLIC_EXECUTION_AUTH_DOMAIN: &[u8] = b"strata-sonar-execution:v1\0";
35const PUBLIC_ORDER_AUTH_DOMAIN: &[u8] = b"strata-platform-order-control:v1\0";
36
37#[async_trait]
38pub trait SessionSigner: Send + Sync {
39    /// Canonical base58 Ed25519 public key registered as the Vault delegate.
40    fn public_key(&self) -> &str;
41
42    /// Sign the exact SDK-validated public operation authorization.
43    async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String>;
44
45    /// Add only the session signature to an already-verified transaction.
46    async fn sign_transaction(&self, transaction_base64: &str) -> Result<String, String>;
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum OrderExecuteOperation {
51    Place {
52        owner_wallet: String,
53        account_sequence: String,
54        client_order_id: String,
55        side: PlatformTradeSide,
56        order_type: PlatformOrderType,
57        limit_price_atoms: String,
58        size_atoms: String,
59    },
60    Cancel {
61        owner_wallet: String,
62        order_id: String,
63    },
64    CancelAll {
65        owner_wallet: String,
66    },
67    Replace {
68        owner_wallet: String,
69        order_id: String,
70        account_sequence: String,
71        client_order_id: String,
72        side: PlatformTradeSide,
73        order_type: PlatformOrderType,
74        limit_price_atoms: String,
75        size_atoms: String,
76    },
77    Batch {
78        owner_wallet: String,
79        operations: Vec<PlatformOrderBatchOperation>,
80    },
81}
82
83impl OrderExecuteOperation {
84    fn challenge_request(&self, session_public_key: String) -> PlatformOrderChallengeRequest {
85        match self {
86            Self::Place {
87                owner_wallet,
88                account_sequence,
89                client_order_id,
90                side,
91                order_type,
92                limit_price_atoms,
93                size_atoms,
94            } => PlatformOrderChallengeRequest::Place {
95                owner_wallet: owner_wallet.clone(),
96                session_public_key,
97                account_sequence: account_sequence.clone(),
98                client_order_id: client_order_id.clone(),
99                side: *side,
100                order_type: *order_type,
101                limit_price_atoms: limit_price_atoms.clone(),
102                size_atoms: size_atoms.clone(),
103            },
104            Self::Cancel {
105                owner_wallet,
106                order_id,
107            } => PlatformOrderChallengeRequest::Cancel {
108                owner_wallet: owner_wallet.clone(),
109                session_public_key,
110                order_id: order_id.clone(),
111            },
112            Self::CancelAll { owner_wallet } => PlatformOrderChallengeRequest::CancelAll {
113                owner_wallet: owner_wallet.clone(),
114                session_public_key,
115            },
116            Self::Replace {
117                owner_wallet,
118                order_id,
119                account_sequence,
120                client_order_id,
121                side,
122                order_type,
123                limit_price_atoms,
124                size_atoms,
125            } => PlatformOrderChallengeRequest::Replace {
126                owner_wallet: owner_wallet.clone(),
127                session_public_key,
128                order_id: order_id.clone(),
129                account_sequence: account_sequence.clone(),
130                client_order_id: client_order_id.clone(),
131                side: *side,
132                order_type: *order_type,
133                limit_price_atoms: limit_price_atoms.clone(),
134                size_atoms: size_atoms.clone(),
135            },
136            Self::Batch {
137                owner_wallet,
138                operations,
139            } => PlatformOrderChallengeRequest::Batch {
140                owner_wallet: owner_wallet.clone(),
141                session_public_key,
142                operations: operations.clone(),
143            },
144        }
145    }
146}
147
148#[derive(Debug)]
149pub struct OrderVerificationContext<'a> {
150    pub challenge: &'a PlatformOrderChallengeResponse,
151    pub prepared: &'a PlatformOrderPrepareResponse,
152    pub owner_wallet: &'a str,
153    pub session_public_key: &'a str,
154}
155
156#[async_trait]
157pub trait OrderVerifier: Send + Sync {
158    /// Reject unless the prepared transaction implements the exact signed
159    /// order operation for this Vault session.
160    async fn verify(&self, context: &OrderVerificationContext<'_>) -> Result<(), String>;
161}
162
163#[derive(Debug)]
164pub struct ExecutionVerificationContext<'a> {
165    pub quote: &'a QuoteResponse,
166    pub challenge: &'a ExecutionChallengeResponse,
167    pub prepared: &'a ExecutionPrepareResponse,
168    pub owner_wallet: &'a str,
169    pub session_public_key: &'a str,
170}
171
172#[async_trait]
173pub trait ExecutionVerifier: Send + Sync {
174    /// Reject unless the prepared transaction is acceptable for this exact
175    /// Vault session and public economic intent.
176    async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String>;
177}
178
179#[derive(Debug, Error)]
180pub enum SdkError {
181    #[error("invalid API base URL: {0}")]
182    InvalidBaseUrl(String),
183    #[error("invalid request: {0}")]
184    InvalidRequest(String),
185    #[error("market is not available: {0}")]
186    MarketNotFound(String),
187    #[error("operation is not available for market: {0}")]
188    OperationUnavailable(String),
189    #[error("Strata API error {status} ({code}): {message}")]
190    Api {
191        status: StatusCode,
192        code: String,
193        message: String,
194        retryable: bool,
195    },
196    #[error("invalid public contract response: {0}")]
197    InvalidResponse(String),
198    #[error("session signer rejected the operation: {0}")]
199    Signer(String),
200    #[error("prepared transaction was rejected: {0}")]
201    Verification(String),
202    #[error(transparent)]
203    Transport(#[from] reqwest::Error),
204}
205
206#[derive(Clone, Debug)]
207pub struct StrataClient {
208    base_url: Url,
209    http: reqwest::Client,
210}
211
212impl StrataClient {
213    pub fn production() -> Result<Self, SdkError> {
214        Self::new(DEFAULT_API_BASE)
215    }
216
217    pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
218        Self::with_timeout(base_url, DEFAULT_TIMEOUT)
219    }
220
221    pub fn with_timeout(base_url: impl AsRef<str>, timeout: Duration) -> Result<Self, SdkError> {
222        if timeout.is_zero() {
223            return Err(SdkError::InvalidRequest(
224                "timeout must be greater than zero".to_owned(),
225            ));
226        }
227        let base_url = normalize_base_url(base_url.as_ref())?;
228        let http = reqwest::Client::builder().timeout(timeout).build()?;
229        Ok(Self { base_url, http })
230    }
231
232    pub async fn capabilities(&self) -> Result<CapabilityCatalog, SdkError> {
233        let catalog: CapabilityCatalog = self.get("sonar/capabilities", &[]).await?;
234        validate_version(catalog.schema_version, &catalog.contract_version)?;
235
236        let mut ids = HashSet::new();
237        if catalog
238            .capabilities
239            .iter()
240            .any(|capability| !ids.insert(capability.id.as_str()))
241        {
242            return Err(SdkError::InvalidResponse(
243                "capability IDs must be unique".to_owned(),
244            ));
245        }
246        Ok(catalog)
247    }
248
249    /// Return the live operation topology, including capability-gated nodes and
250    /// the points where the agent owner's signer acts outside Strata.
251    pub async fn action_graph(&self) -> Result<ActionGraph, SdkError> {
252        let graph: ActionGraph = self.get("sonar/action-graph", &[]).await?;
253        validate_action_graph(&graph)?;
254        Ok(graph)
255    }
256
257    pub async fn markets(&self) -> Result<MarketsResponse, SdkError> {
258        let markets: MarketsResponse = self.get("sonar/markets", &[]).await?;
259        validate_version(markets.schema_version, &markets.contract_version)?;
260        Ok(markets)
261    }
262
263    /// Request a short-lived Sonar quote by human market label or market ID.
264    pub async fn quote(&self, request: QuoteRequest) -> Result<QuoteResponse, SdkError> {
265        let amount_in = parse_atoms("amount_in_atoms", &request.amount_in_atoms)?;
266        if amount_in == 0 {
267            return Err(SdkError::InvalidRequest(
268                "amount_in_atoms must be greater than zero".to_owned(),
269            ));
270        }
271        if request.slippage_bps > 1_000 {
272            return Err(SdkError::InvalidRequest(
273                "slippage_bps must be between 0 and 1,000".to_owned(),
274            ));
275        }
276
277        let markets = self.markets().await?;
278        let market = markets
279            .markets
280            .iter()
281            .find(|market| {
282                market.label.eq_ignore_ascii_case(&request.market_id)
283                    || market.market_pda.as_deref() == Some(request.market_id.as_str())
284            })
285            .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
286        if !market.ready {
287            return Err(SdkError::OperationUnavailable(market.label.clone()));
288        }
289        let market_pda = market
290            .market_pda
291            .as_deref()
292            .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
293        let quote_path = market
294            .quote_path
295            .as_deref()
296            .filter(|path| valid_public_operation_path(path))
297            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
298        let wire = QuoteRequest {
299            market_id: market_pda.to_owned(),
300            side: request.side,
301            amount_in_atoms: request.amount_in_atoms.clone(),
302            slippage_bps: request.slippage_bps,
303        };
304        let quote: QuoteResponse = self.post(quote_path, &wire).await?;
305        validate_quote(&quote, market_pda, &request, amount_in)?;
306        Ok(quote)
307    }
308
309    /// Request canonical authorization bytes for an external signer. This
310    /// operation accepts public identity only; signing material stays external.
311    pub async fn execution_challenge(
312        &self,
313        market: &str,
314        request: ExecutionChallengeRequest,
315    ) -> Result<ExecutionChallengeResponse, SdkError> {
316        if !valid_handle(&request.quote_id, "sq_") {
317            return Err(SdkError::InvalidRequest("quote_id is invalid".to_owned()));
318        }
319        let request = ExecutionChallengeRequest {
320            quote_id: request.quote_id,
321            owner_wallet: canonical_public_key(&request.owner_wallet, "owner_wallet")?,
322            session_public_key: canonical_public_key(
323                &request.session_public_key,
324                "session_public_key",
325            )?,
326            account_sequence: parse_atoms("account_sequence", &request.account_sequence)?
327                .to_string(),
328        };
329        let execution_path = self.execution_path(market).await?;
330        let challenge: ExecutionChallengeResponse = self
331            .post(&format!("{execution_path}/challenge"), &request)
332            .await?;
333        validate_version(challenge.schema_version, &challenge.contract_version)?;
334        if !valid_handle(&challenge.challenge_id, "sc_") || challenge.quote_id != request.quote_id {
335            return Err(SdkError::InvalidResponse(
336                "execution challenge does not match the requested quote".to_owned(),
337            ));
338        }
339        Ok(challenge)
340    }
341
342    /// Exchange an external authorization signature for a quote-bound,
343    /// partially signed transaction.
344    pub async fn execution_prepare(
345        &self,
346        market: &str,
347        request: ExecutionPrepareRequest,
348    ) -> Result<ExecutionPrepareResponse, SdkError> {
349        if !valid_handle(&request.challenge_id, "sc_") {
350            return Err(SdkError::InvalidRequest(
351                "challenge_id is invalid".to_owned(),
352            ));
353        }
354        let signature = bs58::decode(request.authorization_signature.trim())
355            .into_vec()
356            .map_err(|_| {
357                SdkError::InvalidRequest("authorization_signature must be base58".to_owned())
358            })?;
359        if signature.len() != 64
360            || bs58::encode(&signature).into_string() != request.authorization_signature.trim()
361        {
362            return Err(SdkError::InvalidRequest(
363                "authorization_signature must be a canonical Ed25519 signature".to_owned(),
364            ));
365        }
366        let request = ExecutionPrepareRequest {
367            challenge_id: request.challenge_id,
368            authorization_signature: bs58::encode(signature).into_string(),
369        };
370        let execution_path = self.execution_path(market).await?;
371        let prepared: ExecutionPrepareResponse = self
372            .post(&format!("{execution_path}/prepare"), &request)
373            .await?;
374        validate_version(prepared.schema_version, &prepared.contract_version)?;
375        if !valid_handle(&prepared.execution_id, "se_") {
376            return Err(SdkError::InvalidResponse(
377                "prepared execution ID is invalid".to_owned(),
378            ));
379        }
380        Ok(prepared)
381    }
382
383    /// Submit an externally signed transaction. Reusing the same idempotency
384    /// key cannot create a second execution.
385    pub async fn execution_submit(
386        &self,
387        market: &str,
388        request: ExecutionSubmitRequest,
389    ) -> Result<ExecutionSubmitResponse, SdkError> {
390        if !valid_handle(&request.execution_id, "se_") {
391            return Err(SdkError::InvalidRequest(
392                "execution_id is invalid".to_owned(),
393            ));
394        }
395        let transaction = request.signed_transaction_base64.trim();
396        let decoded = base64::engine::general_purpose::STANDARD
397            .decode(transaction)
398            .map_err(|_| {
399                SdkError::InvalidRequest(
400                    "signed_transaction_base64 must be canonical base64".to_owned(),
401                )
402            })?;
403        if decoded.is_empty()
404            || base64::engine::general_purpose::STANDARD.encode(&decoded) != transaction
405        {
406            return Err(SdkError::InvalidRequest(
407                "signed_transaction_base64 must be canonical base64".to_owned(),
408            ));
409        }
410        let request = ExecutionSubmitRequest {
411            execution_id: request.execution_id,
412            signed_transaction_base64: transaction.to_owned(),
413            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
414        };
415        let execution_path = self.execution_path(market).await?;
416        let submitted: ExecutionSubmitResponse = self
417            .post(&format!("{execution_path}/submit"), &request)
418            .await?;
419        validate_version(submitted.schema_version, &submitted.contract_version)?;
420        if submitted.execution_id != request.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 submitted transaction".to_owned(),
426            ));
427        }
428        Ok(submitted)
429    }
430
431    /// Request exact authorization bytes for one product-level resting-order
432    /// operation. Private key material never enters this client or Strata.
433    pub async fn order_challenge(
434        &self,
435        market_id: &str,
436        request: PlatformOrderChallengeRequest,
437    ) -> Result<PlatformOrderChallengeResponse, SdkError> {
438        let market_id = validate_platform_market_id(market_id)?;
439        let request = normalize_order_challenge_request(request)?;
440        let expected_action = order_request_action(&request);
441        let challenge: PlatformOrderChallengeResponse = self
442            .post(
443                &format!("v2/markets/{market_id}/orders/challenge"),
444                &request,
445            )
446            .await?;
447        validate_platform_version(challenge.schema_version, &challenge.contract_version)?;
448        if challenge.market_id != market_id
449            || challenge.action != expected_action
450            || !valid_handle(&challenge.challenge_id, "oc_")
451            || challenge.order_ids.is_empty()
452            || challenge.order_ids.len() > 12
453            || challenge.expires_at_ms <= challenge.server_time_ms
454            || challenge
455                .order_ids
456                .iter()
457                .any(|order_id| !valid_handle(order_id, "order_"))
458        {
459            return Err(SdkError::InvalidResponse(
460                "order challenge bindings are invalid".to_owned(),
461            ));
462        }
463        canonical_base64(
464            &challenge.authorization_payload_base64,
465            "authorization_payload_base64",
466        )?;
467        Ok(challenge)
468    }
469
470    /// Exchange a detached external authorization signature for a backend-
471    /// partially-signed v0 transaction.
472    pub async fn order_prepare(
473        &self,
474        market_id: &str,
475        request: PlatformOrderPrepareRequest,
476    ) -> Result<PlatformOrderPrepareResponse, SdkError> {
477        let market_id = validate_platform_market_id(market_id)?;
478        if !valid_handle(&request.challenge_id, "oc_") {
479            return Err(SdkError::InvalidRequest(
480                "order challenge_id is invalid".to_owned(),
481            ));
482        }
483        let signature =
484            canonical_signature(&request.authorization_signature, "authorization_signature")?;
485        let prepared: PlatformOrderPrepareResponse = self
486            .post(
487                &format!("v2/markets/{market_id}/orders/prepare"),
488                &PlatformOrderPrepareRequest {
489                    challenge_id: request.challenge_id,
490                    authorization_signature: signature,
491                },
492            )
493            .await?;
494        validate_platform_version(prepared.schema_version, &prepared.contract_version)?;
495        if prepared.market_id != market_id
496            || !valid_handle(&prepared.order_control_id, "or_")
497            || prepared.order_ids.is_empty()
498            || prepared.order_ids.len() > 12
499            || prepared.transaction_base64.trim().is_empty()
500            || prepared.expires_at_ms == 0
501        {
502            return Err(SdkError::InvalidResponse(
503                "prepared order control is invalid".to_owned(),
504            ));
505        }
506        canonical_base64(&prepared.transaction_base64, "transaction_base64")?;
507        canonical_base58_32(&prepared.recent_blockhash, "recent_blockhash")?;
508        Ok(prepared)
509    }
510
511    /// Submit an externally signed order-control transaction. The same
512    /// control ID and idempotency key return the same receipt.
513    pub async fn order_submit(
514        &self,
515        market_id: &str,
516        request: PlatformOrderSubmitRequest,
517    ) -> Result<PlatformOrderSubmitResponse, SdkError> {
518        let market_id = validate_platform_market_id(market_id)?;
519        if !valid_handle(&request.order_control_id, "or_") {
520            return Err(SdkError::InvalidRequest(
521                "order_control_id is invalid".to_owned(),
522            ));
523        }
524        let transaction = canonical_base64(
525            &request.signed_transaction_base64,
526            "signed_transaction_base64",
527        )?;
528        let request = PlatformOrderSubmitRequest {
529            order_control_id: request.order_control_id,
530            signed_transaction_base64: transaction,
531            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
532        };
533        let submitted: PlatformOrderSubmitResponse = self
534            .post(&format!("v2/markets/{market_id}/orders/submit"), &request)
535            .await?;
536        validate_platform_version(submitted.schema_version, &submitted.contract_version)?;
537        if submitted.market_id != market_id
538            || submitted.order_control_id != request.order_control_id
539            || submitted.status != PlatformOrderSubmissionStatus::Submitted
540            || submitted.signature.trim().is_empty()
541        {
542            return Err(SdkError::InvalidResponse(
543                "order control receipt is invalid".to_owned(),
544            ));
545        }
546        canonical_signature(&submitted.signature, "signature")?;
547        Ok(submitted)
548    }
549
550    /// Recover the durable result for a prior submission. The same opaque
551    /// control ID and idempotency key are required, so status polling never
552    /// broadens authority beyond the original external submission.
553    pub async fn order_status(
554        &self,
555        market_id: &str,
556        request: PlatformOrderStatusRequest,
557    ) -> Result<PlatformOrderStatusResponse, SdkError> {
558        let market_id = validate_platform_market_id(market_id)?;
559        if !valid_handle(&request.order_control_id, "or_") {
560            return Err(SdkError::InvalidRequest(
561                "order_control_id is invalid".to_owned(),
562            ));
563        }
564        let request = PlatformOrderStatusRequest {
565            order_control_id: request.order_control_id,
566            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
567        };
568        let status: PlatformOrderStatusResponse = self
569            .post(&format!("v2/markets/{market_id}/orders/status"), &request)
570            .await?;
571        validate_platform_version(status.schema_version, &status.contract_version)?;
572        if status.market_id != market_id
573            || status.order_control_id != request.order_control_id
574            || status.order_ids.is_empty()
575            || status.order_ids.len() > 12
576            || status
577                .order_ids
578                .iter()
579                .any(|order_id| !valid_handle(order_id, "order_"))
580            || (status.status == PlatformOrderControlStatus::Failed
581                && status.failure_code.as_deref().is_none_or(str::is_empty))
582            || (status.status != PlatformOrderControlStatus::Failed
583                && status.failure_code.is_some())
584        {
585            return Err(SdkError::InvalidResponse(
586                "order control status is invalid".to_owned(),
587            ));
588        }
589        canonical_signature(&status.signature, "signature")?;
590        Ok(status)
591    }
592
593    /// Execute one resting-order operation while all private keys and signing
594    /// policy remain in the caller's signer adapter. Authorization bytes are
595    /// parsed before message signing, and the mandatory verifier runs before
596    /// the transaction signature is requested.
597    pub async fn execute_order<S, V>(
598        &self,
599        market_id: &str,
600        operation: &OrderExecuteOperation,
601        signer: &S,
602        verifier: &V,
603        idempotency_key: Option<&str>,
604    ) -> Result<PlatformOrderSubmitResponse, SdkError>
605    where
606        S: SessionSigner + ?Sized,
607        V: OrderVerifier + ?Sized,
608    {
609        let market_id = validate_platform_market_id(market_id)?;
610        let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
611        let request = normalize_order_challenge_request(
612            operation.challenge_request(session_public_key.clone()),
613        )?;
614        let owner_wallet = order_request_owner(&request).to_owned();
615        if owner_wallet == session_public_key {
616            return Err(SdkError::InvalidRequest(
617                "session_public_key must be distinct from owner_wallet".to_owned(),
618            ));
619        }
620        let challenge = self.order_challenge(&market_id, request.clone()).await?;
621        if challenge.action != order_request_action(&request) {
622            return Err(SdkError::InvalidResponse(
623                "order challenge action changed".to_owned(),
624            ));
625        }
626        let authorization = validate_order_authorization(&challenge, &request)?;
627        let signature = signer
628            .sign_message(&authorization.bytes)
629            .await
630            .map_err(SdkError::Signer)?;
631        if signature.len() != 64 {
632            return Err(SdkError::InvalidResponse(
633                "order authorization signature must contain 64 bytes".to_owned(),
634            ));
635        }
636        let prepared = self
637            .order_prepare(
638                &market_id,
639                PlatformOrderPrepareRequest {
640                    challenge_id: challenge.challenge_id.clone(),
641                    authorization_signature: bs58::encode(signature).into_string(),
642                },
643            )
644            .await?;
645        validate_order_prepare_binding(&prepared, &challenge, &authorization)?;
646        verifier
647            .verify(&OrderVerificationContext {
648                challenge: &challenge,
649                prepared: &prepared,
650                owner_wallet: &owner_wallet,
651                session_public_key: &session_public_key,
652            })
653            .await
654            .map_err(SdkError::Verification)?;
655        let signed_transaction = signer
656            .sign_transaction(&prepared.transaction_base64)
657            .await
658            .map_err(SdkError::Signer)?;
659        let signed_transaction =
660            canonical_base64(&signed_transaction, "signed_transaction_base64")?;
661        self.order_submit(
662            &market_id,
663            PlatformOrderSubmitRequest {
664                order_control_id: prepared.order_control_id.clone(),
665                signed_transaction_base64: signed_transaction,
666                idempotency_key: normalize_idempotency_key(
667                    idempotency_key.unwrap_or(&prepared.order_control_id),
668                )?,
669            },
670        )
671        .await
672    }
673
674    /// Execute one short-lived Sonar quote without giving the SDK custody of a
675    /// session private key. The transaction verifier always runs before the
676    /// session adapter is allowed to sign.
677    pub async fn execute_quote<S, V>(
678        &self,
679        quote: &QuoteResponse,
680        owner_wallet: &str,
681        account_sequence: u64,
682        signer: &S,
683        verifier: &V,
684        idempotency_key: Option<&str>,
685    ) -> Result<ExecutionSubmitResponse, SdkError>
686    where
687        S: SessionSigner + ?Sized,
688        V: ExecutionVerifier + ?Sized,
689    {
690        validate_version(quote.schema_version, &quote.contract_version)?;
691        let now_ms = unix_ms()?;
692        if quote.expires_at_ms <= now_ms {
693            return Err(SdkError::InvalidRequest("quote has expired".to_owned()));
694        }
695        let owner_wallet = canonical_public_key(owner_wallet, "owner_wallet")?;
696        let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
697        let markets = self.markets().await?;
698        let market = markets
699            .markets
700            .iter()
701            .find(|market| market.market_pda.as_deref() == Some(quote.market_id.as_str()))
702            .ok_or_else(|| SdkError::MarketNotFound(quote.market_id.clone()))?;
703        let quote_path = market
704            .quote_path
705            .as_deref()
706            .filter(|path| valid_public_operation_path(path))
707            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
708        let execution_path = format!(
709            "{}/execution",
710            quote_path
711                .strip_suffix("/quote")
712                .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
713        );
714        let challenge: ExecutionChallengeResponse = self
715            .post(
716                &format!("{execution_path}/challenge"),
717                &ExecutionChallengeRequest {
718                    quote_id: quote.quote_id.clone(),
719                    owner_wallet: owner_wallet.clone(),
720                    session_public_key: session_public_key.clone(),
721                    account_sequence: account_sequence.to_string(),
722                },
723            )
724            .await?;
725        validate_execution_challenge(&challenge, quote)?;
726        let authorization = validate_execution_authorization(
727            &challenge,
728            quote,
729            &owner_wallet,
730            &session_public_key,
731            account_sequence,
732        )?;
733        let signature = signer
734            .sign_message(&authorization.bytes)
735            .await
736            .map_err(SdkError::Signer)?;
737        if signature.len() != 64 {
738            return Err(SdkError::InvalidResponse(
739                "session authorization signature must contain 64 bytes".to_owned(),
740            ));
741        }
742        let prepared: ExecutionPrepareResponse = self
743            .post(
744                &format!("{execution_path}/prepare"),
745                &ExecutionPrepareRequest {
746                    challenge_id: challenge.challenge_id.clone(),
747                    authorization_signature: bs58::encode(signature).into_string(),
748                },
749            )
750            .await?;
751        validate_execution_prepare(&prepared, quote, &challenge, &authorization)?;
752        verifier
753            .verify(&ExecutionVerificationContext {
754                quote,
755                challenge: &challenge,
756                prepared: &prepared,
757                owner_wallet: &owner_wallet,
758                session_public_key: &session_public_key,
759            })
760            .await
761            .map_err(SdkError::Verification)?;
762        let signed_transaction = signer
763            .sign_transaction(&prepared.transaction_base64)
764            .await
765            .map_err(SdkError::Signer)?;
766        base64::engine::general_purpose::STANDARD
767            .decode(signed_transaction.trim())
768            .map_err(|_| {
769                SdkError::InvalidResponse(
770                    "session signer returned an invalid base64 transaction".to_owned(),
771                )
772            })?;
773        let idempotency_key =
774            normalize_idempotency_key(idempotency_key.unwrap_or(&prepared.execution_id))?;
775        let submitted: ExecutionSubmitResponse = self
776            .post(
777                &format!("{execution_path}/submit"),
778                &ExecutionSubmitRequest {
779                    execution_id: prepared.execution_id.clone(),
780                    signed_transaction_base64: signed_transaction,
781                    idempotency_key,
782                },
783            )
784            .await?;
785        validate_version(submitted.schema_version, &submitted.contract_version)?;
786        if submitted.execution_id != prepared.execution_id
787            || submitted.status != ExecutionStatus::Submitted
788            || submitted.signature.trim().is_empty()
789        {
790            return Err(SdkError::InvalidResponse(
791                "execution receipt does not match the prepared transaction".to_owned(),
792            ));
793        }
794        Ok(submitted)
795    }
796
797    async fn get<T: DeserializeOwned>(
798        &self,
799        path: &str,
800        query: &[(&str, &str)],
801    ) -> Result<T, SdkError> {
802        let mut url = self.base_url.join(path).map_err(|error| {
803            SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
804        })?;
805        url.query_pairs_mut().extend_pairs(query.iter().copied());
806
807        let response = self
808            .http
809            .get(url)
810            .header(reqwest::header::ACCEPT, "application/json")
811            .send()
812            .await?;
813        let status = response.status();
814        let bytes = response.bytes().await?;
815        if !status.is_success() {
816            return match serde_json::from_slice::<ErrorResponse>(&bytes) {
817                Ok(error) => Err(SdkError::Api {
818                    status,
819                    code: error.error.code,
820                    message: error.error.message,
821                    retryable: error.error.retryable,
822                }),
823                Err(_) => Err(SdkError::Api {
824                    status,
825                    code: "request_failed".to_owned(),
826                    message: "Strata could not complete the request.".to_owned(),
827                    retryable: status.is_server_error(),
828                }),
829            };
830        }
831        serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
832    }
833
834    async fn post<T: DeserializeOwned, B: serde::Serialize>(
835        &self,
836        path: &str,
837        body: &B,
838    ) -> Result<T, SdkError> {
839        let url = self.base_url.join(path).map_err(|error| {
840            SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
841        })?;
842        let response = self
843            .http
844            .post(url)
845            .header(reqwest::header::ACCEPT, "application/json")
846            .json(body)
847            .send()
848            .await?;
849        let status = response.status();
850        let bytes = response.bytes().await?;
851        if !status.is_success() {
852            return match serde_json::from_slice::<ErrorResponse>(&bytes) {
853                Ok(error) => Err(SdkError::Api {
854                    status,
855                    code: error.error.code,
856                    message: error.error.message,
857                    retryable: error.error.retryable,
858                }),
859                Err(_) => Err(SdkError::Api {
860                    status,
861                    code: "request_failed".to_owned(),
862                    message: "Strata could not complete the request.".to_owned(),
863                    retryable: status.is_server_error(),
864                }),
865            };
866        }
867        serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
868    }
869
870    async fn execution_path(&self, requested_market: &str) -> Result<String, SdkError> {
871        let markets = self.markets().await?;
872        let market = markets
873            .markets
874            .iter()
875            .find(|market| {
876                market.label.eq_ignore_ascii_case(requested_market.trim())
877                    || market.market_pda.as_deref() == Some(requested_market.trim())
878            })
879            .ok_or_else(|| SdkError::MarketNotFound(requested_market.to_owned()))?;
880        if !market.ready {
881            return Err(SdkError::OperationUnavailable(market.label.clone()));
882        }
883        let quote_path = market
884            .quote_path
885            .as_deref()
886            .filter(|path| valid_public_operation_path(path))
887            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
888        Ok(format!(
889            "{}/execution",
890            quote_path
891                .strip_suffix("/quote")
892                .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
893        ))
894    }
895}
896
897fn normalize_base_url(value: &str) -> Result<Url, SdkError> {
898    let mut normalized = value.trim().to_owned();
899    if !normalized.ends_with('/') {
900        normalized.push('/');
901    }
902    let url =
903        Url::parse(&normalized).map_err(|error| SdkError::InvalidBaseUrl(error.to_string()))?;
904    if !matches!(url.scheme(), "http" | "https") || url.cannot_be_a_base() {
905        return Err(SdkError::InvalidBaseUrl(
906            "URL must use http or https and include a host".to_owned(),
907        ));
908    }
909    Ok(url)
910}
911
912fn validate_action_graph(graph: &ActionGraph) -> Result<(), SdkError> {
913    validate_version(graph.schema_version, &graph.contract_version)?;
914    if graph.graph_version != "1.0"
915        || graph.authority.permission_source != "external_agent_owner"
916        || graph.authority.signing_location != "external"
917        || graph.authority.accepts_private_keys
918    {
919        return Err(SdkError::InvalidResponse(
920            "unsupported action graph authority model".to_owned(),
921        ));
922    }
923    let ids = graph
924        .nodes
925        .iter()
926        .map(|node| node.id.as_str())
927        .collect::<HashSet<_>>();
928    if ids.len() != graph.nodes.len() || !ids.contains(graph.entry_node.as_str()) {
929        return Err(SdkError::InvalidResponse(
930            "action graph node IDs are invalid".to_owned(),
931        ));
932    }
933    if graph.edges.iter().any(|edge| {
934        !ids.contains(edge.from.as_str())
935            || !ids.contains(edge.to.as_str())
936            || edge.condition.trim().is_empty()
937    }) {
938        return Err(SdkError::InvalidResponse(
939            "action graph contains an invalid edge".to_owned(),
940        ));
941    }
942    Ok(())
943}
944
945fn validate_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
946    if schema_version != CONTRACT_MAJOR || contract_version != CONTRACT_VERSION {
947        return Err(SdkError::InvalidResponse(format!(
948            "unsupported contract {contract_version} (schema {schema_version})"
949        )));
950    }
951    Ok(())
952}
953
954fn validate_platform_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
955    if schema_version != strata_public_contract::platform::PLATFORM_SCHEMA_VERSION
956        || contract_version != strata_public_contract::platform::PLATFORM_CONTRACT_VERSION
957    {
958        return Err(SdkError::InvalidResponse(format!(
959            "unsupported platform contract {contract_version} (schema {schema_version})"
960        )));
961    }
962    Ok(())
963}
964
965fn validate_platform_market_id(value: &str) -> Result<String, SdkError> {
966    let value = value.trim();
967    if !valid_handle(value, "market_") {
968        return Err(SdkError::InvalidRequest(
969            "market_id must be an opaque Strata market ID".to_owned(),
970        ));
971    }
972    Ok(value.to_owned())
973}
974
975fn canonical_request_atoms(value: &str, field: &str, allow_zero: bool) -> Result<String, SdkError> {
976    if value.is_empty()
977        || !value.bytes().all(|byte| byte.is_ascii_digit())
978        || (value.len() > 1 && value.starts_with('0'))
979    {
980        return Err(SdkError::InvalidRequest(format!(
981            "{field} must be a canonical unsigned atomic decimal string"
982        )));
983    }
984    let parsed = value
985        .parse::<u64>()
986        .map_err(|_| SdkError::InvalidRequest(format!("{field} exceeds u64")))?;
987    if !allow_zero && parsed == 0 {
988        return Err(SdkError::InvalidRequest(format!(
989            "{field} must be greater than zero"
990        )));
991    }
992    Ok(parsed.to_string())
993}
994
995fn canonical_signature(value: &str, field: &str) -> Result<String, SdkError> {
996    let value = value.trim();
997    let decoded = bs58::decode(value)
998        .into_vec()
999        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
1000    if decoded.len() != 64 || bs58::encode(&decoded).into_string() != value {
1001        return Err(SdkError::InvalidRequest(format!(
1002            "{field} must be a canonical Ed25519 signature"
1003        )));
1004    }
1005    Ok(value.to_owned())
1006}
1007
1008fn canonical_base58_32(value: &str, field: &str) -> Result<String, SdkError> {
1009    let value = value.trim();
1010    let decoded = bs58::decode(value)
1011        .into_vec()
1012        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
1013    if decoded.len() != 32 || bs58::encode(&decoded).into_string() != value {
1014        return Err(SdkError::InvalidRequest(format!(
1015            "{field} must be a canonical 32-byte base58 value"
1016        )));
1017    }
1018    Ok(value.to_owned())
1019}
1020
1021fn canonical_base64(value: &str, field: &str) -> Result<String, SdkError> {
1022    let value = value.trim();
1023    let decoded = base64::engine::general_purpose::STANDARD
1024        .decode(value)
1025        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base64")))?;
1026    if decoded.is_empty() || base64::engine::general_purpose::STANDARD.encode(decoded) != value {
1027        return Err(SdkError::InvalidRequest(format!(
1028            "{field} must be canonical base64"
1029        )));
1030    }
1031    Ok(value.to_owned())
1032}
1033
1034fn normalize_order_challenge_request(
1035    request: PlatformOrderChallengeRequest,
1036) -> Result<PlatformOrderChallengeRequest, SdkError> {
1037    let normalized = match request {
1038        PlatformOrderChallengeRequest::Place {
1039            owner_wallet,
1040            session_public_key,
1041            account_sequence,
1042            client_order_id,
1043            side,
1044            order_type,
1045            limit_price_atoms,
1046            size_atoms,
1047        } => {
1048            let client_order_id = client_order_id.trim().to_owned();
1049            if client_order_id.is_empty()
1050                || client_order_id.len() > 64
1051                || !client_order_id
1052                    .bytes()
1053                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1054                || !matches!(
1055                    order_type,
1056                    PlatformOrderType::GoodUntilCancelled | PlatformOrderType::PostOnly
1057                )
1058            {
1059                return Err(SdkError::InvalidRequest(
1060                    "resting order client ID or type is invalid".to_owned(),
1061                ));
1062            }
1063            PlatformOrderChallengeRequest::Place {
1064                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1065                session_public_key: canonical_public_key(
1066                    &session_public_key,
1067                    "session_public_key",
1068                )?,
1069                account_sequence: canonical_request_atoms(
1070                    &account_sequence,
1071                    "account_sequence",
1072                    true,
1073                )?,
1074                client_order_id,
1075                side,
1076                order_type,
1077                limit_price_atoms: canonical_request_atoms(
1078                    &limit_price_atoms,
1079                    "limit_price_atoms",
1080                    false,
1081                )?,
1082                size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
1083            }
1084        }
1085        PlatformOrderChallengeRequest::Cancel {
1086            owner_wallet,
1087            session_public_key,
1088            order_id,
1089        } => {
1090            if !valid_handle(order_id.trim(), "order_") {
1091                return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
1092            }
1093            PlatformOrderChallengeRequest::Cancel {
1094                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1095                session_public_key: canonical_public_key(
1096                    &session_public_key,
1097                    "session_public_key",
1098                )?,
1099                order_id: order_id.trim().to_owned(),
1100            }
1101        }
1102        PlatformOrderChallengeRequest::CancelAll {
1103            owner_wallet,
1104            session_public_key,
1105        } => PlatformOrderChallengeRequest::CancelAll {
1106            owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1107            session_public_key: canonical_public_key(&session_public_key, "session_public_key")?,
1108        },
1109        PlatformOrderChallengeRequest::Replace {
1110            owner_wallet,
1111            session_public_key,
1112            order_id,
1113            account_sequence,
1114            client_order_id,
1115            side,
1116            order_type,
1117            limit_price_atoms,
1118            size_atoms,
1119        } => {
1120            let PlatformOrderBatchOperation::Replace {
1121                order_id,
1122                account_sequence,
1123                client_order_id,
1124                side,
1125                order_type,
1126                limit_price_atoms,
1127                size_atoms,
1128            } = normalize_order_batch_operation(PlatformOrderBatchOperation::Replace {
1129                order_id,
1130                account_sequence,
1131                client_order_id,
1132                side,
1133                order_type,
1134                limit_price_atoms,
1135                size_atoms,
1136            })?
1137            else {
1138                unreachable!()
1139            };
1140            PlatformOrderChallengeRequest::Replace {
1141                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1142                session_public_key: canonical_public_key(
1143                    &session_public_key,
1144                    "session_public_key",
1145                )?,
1146                order_id,
1147                account_sequence,
1148                client_order_id,
1149                side,
1150                order_type,
1151                limit_price_atoms,
1152                size_atoms,
1153            }
1154        }
1155        PlatformOrderChallengeRequest::Batch {
1156            owner_wallet,
1157            session_public_key,
1158            operations,
1159        } => {
1160            if operations.is_empty() || operations.len() > 6 {
1161                return Err(SdkError::InvalidRequest(
1162                    "order batch must contain between one and six operations".to_owned(),
1163                ));
1164            }
1165            PlatformOrderChallengeRequest::Batch {
1166                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1167                session_public_key: canonical_public_key(
1168                    &session_public_key,
1169                    "session_public_key",
1170                )?,
1171                operations: operations
1172                    .into_iter()
1173                    .map(normalize_order_batch_operation)
1174                    .collect::<Result<_, _>>()?,
1175            }
1176        }
1177    };
1178    if order_request_owner(&normalized) == order_request_session(&normalized) {
1179        return Err(SdkError::InvalidRequest(
1180            "session_public_key must be distinct from owner_wallet".to_owned(),
1181        ));
1182    }
1183    Ok(normalized)
1184}
1185
1186fn normalize_order_batch_operation(
1187    operation: PlatformOrderBatchOperation,
1188) -> Result<PlatformOrderBatchOperation, SdkError> {
1189    match operation {
1190        PlatformOrderBatchOperation::Place {
1191            account_sequence,
1192            client_order_id,
1193            side,
1194            order_type,
1195            limit_price_atoms,
1196            size_atoms,
1197        } => {
1198            let client_order_id = normalize_order_client_id(client_order_id, order_type)?;
1199            Ok(PlatformOrderBatchOperation::Place {
1200                account_sequence: canonical_request_atoms(
1201                    &account_sequence,
1202                    "account_sequence",
1203                    true,
1204                )?,
1205                client_order_id,
1206                side,
1207                order_type,
1208                limit_price_atoms: canonical_request_atoms(
1209                    &limit_price_atoms,
1210                    "limit_price_atoms",
1211                    false,
1212                )?,
1213                size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
1214            })
1215        }
1216        PlatformOrderBatchOperation::Cancel { order_id } => {
1217            if !valid_handle(order_id.trim(), "order_") {
1218                return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
1219            }
1220            Ok(PlatformOrderBatchOperation::Cancel {
1221                order_id: order_id.trim().to_owned(),
1222            })
1223        }
1224        PlatformOrderBatchOperation::Replace {
1225            order_id,
1226            account_sequence,
1227            client_order_id,
1228            side,
1229            order_type,
1230            limit_price_atoms,
1231            size_atoms,
1232        } => {
1233            if !valid_handle(order_id.trim(), "order_") {
1234                return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
1235            }
1236            let client_order_id = normalize_order_client_id(client_order_id, order_type)?;
1237            Ok(PlatformOrderBatchOperation::Replace {
1238                order_id: order_id.trim().to_owned(),
1239                account_sequence: canonical_request_atoms(
1240                    &account_sequence,
1241                    "account_sequence",
1242                    true,
1243                )?,
1244                client_order_id,
1245                side,
1246                order_type,
1247                limit_price_atoms: canonical_request_atoms(
1248                    &limit_price_atoms,
1249                    "limit_price_atoms",
1250                    false,
1251                )?,
1252                size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
1253            })
1254        }
1255    }
1256}
1257
1258fn normalize_order_client_id(
1259    client_order_id: String,
1260    order_type: PlatformOrderType,
1261) -> Result<String, SdkError> {
1262    let client_order_id = client_order_id.trim().to_owned();
1263    if client_order_id.is_empty()
1264        || client_order_id.len() > 64
1265        || !client_order_id
1266            .bytes()
1267            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1268        || !matches!(
1269            order_type,
1270            PlatformOrderType::GoodUntilCancelled | PlatformOrderType::PostOnly
1271        )
1272    {
1273        return Err(SdkError::InvalidRequest(
1274            "resting order client ID or type is invalid".to_owned(),
1275        ));
1276    }
1277    Ok(client_order_id)
1278}
1279
1280fn order_request_action(request: &PlatformOrderChallengeRequest) -> PlatformOrderAction {
1281    match request {
1282        PlatformOrderChallengeRequest::Place { .. } => PlatformOrderAction::Place,
1283        PlatformOrderChallengeRequest::Cancel { .. } => PlatformOrderAction::Cancel,
1284        PlatformOrderChallengeRequest::CancelAll { .. } => PlatformOrderAction::CancelAll,
1285        PlatformOrderChallengeRequest::Replace { .. } => PlatformOrderAction::Replace,
1286        PlatformOrderChallengeRequest::Batch { .. } => PlatformOrderAction::Batch,
1287    }
1288}
1289
1290fn order_request_owner(request: &PlatformOrderChallengeRequest) -> &str {
1291    match request {
1292        PlatformOrderChallengeRequest::Place { owner_wallet, .. }
1293        | PlatformOrderChallengeRequest::Cancel { owner_wallet, .. }
1294        | PlatformOrderChallengeRequest::CancelAll { owner_wallet, .. }
1295        | PlatformOrderChallengeRequest::Replace { owner_wallet, .. }
1296        | PlatformOrderChallengeRequest::Batch { owner_wallet, .. } => owner_wallet,
1297    }
1298}
1299
1300fn order_request_session(request: &PlatformOrderChallengeRequest) -> &str {
1301    match request {
1302        PlatformOrderChallengeRequest::Place {
1303            session_public_key, ..
1304        }
1305        | PlatformOrderChallengeRequest::Cancel {
1306            session_public_key, ..
1307        }
1308        | PlatformOrderChallengeRequest::CancelAll {
1309            session_public_key, ..
1310        }
1311        | PlatformOrderChallengeRequest::Replace {
1312            session_public_key, ..
1313        }
1314        | PlatformOrderChallengeRequest::Batch {
1315            session_public_key, ..
1316        } => session_public_key,
1317    }
1318}
1319
1320struct OrderAuthorization {
1321    bytes: Vec<u8>,
1322    recent_blockhash: String,
1323    last_valid_block_height: u64,
1324}
1325
1326#[allow(clippy::too_many_arguments)]
1327fn validate_order_place_authorization(
1328    bytes: &[u8],
1329    cursor: &mut usize,
1330    challenge: &PlatformOrderChallengeResponse,
1331    account_sequence: &str,
1332    client_order_id: &str,
1333    side: PlatformTradeSide,
1334    order_type: PlatformOrderType,
1335    limit_price_atoms: &str,
1336    size_atoms: &str,
1337) -> Result<String, SdkError> {
1338    take_u64_eq(
1339        bytes,
1340        cursor,
1341        parse_request_u64(account_sequence, "account_sequence")?,
1342        "order account sequence",
1343    )?;
1344    let client_length = take_u16(bytes, cursor, "client order ID length")? as usize;
1345    if client_length != client_order_id.len() {
1346        return Err(SdkError::InvalidResponse(
1347            "client order ID length changed".to_owned(),
1348        ));
1349    }
1350    take_expected(bytes, cursor, client_order_id.as_bytes(), "client order ID")?;
1351    let actual_side = take_bytes(bytes, cursor, 1, "order side")?[0];
1352    let expected_side = if side == PlatformTradeSide::Buy { 0 } else { 1 };
1353    if actual_side != expected_side {
1354        return Err(SdkError::InvalidResponse("order side changed".to_owned()));
1355    }
1356    let actual_type = take_bytes(bytes, cursor, 1, "order type")?[0];
1357    let expected_type = match order_type {
1358        PlatformOrderType::GoodUntilCancelled => 0,
1359        PlatformOrderType::PostOnly => 3,
1360        PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
1361            return Err(SdkError::InvalidRequest(
1362                "order type is not a resting order".to_owned(),
1363            ));
1364        }
1365    };
1366    if actual_type != expected_type {
1367        return Err(SdkError::InvalidResponse("order type changed".to_owned()));
1368    }
1369    take_u64_eq(
1370        bytes,
1371        cursor,
1372        parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
1373        "order limit price",
1374    )?;
1375    take_u64_eq(
1376        bytes,
1377        cursor,
1378        parse_request_u64(size_atoms, "size_atoms")?,
1379        "order size",
1380    )?;
1381    let order = take_bytes(bytes, cursor, 32, "order identity")?;
1382    Ok(opaque_order_id(&challenge.market_id, order))
1383}
1384
1385fn validate_order_cancel_authorization(
1386    bytes: &[u8],
1387    cursor: &mut usize,
1388    challenge: &PlatformOrderChallengeResponse,
1389    expected_order_id: &str,
1390) -> Result<String, SdkError> {
1391    let order = take_bytes(bytes, cursor, 32, "cancel order identity")?;
1392    let rent_source = take_bytes(bytes, cursor, 1, "cancel rent source")?[0];
1393    if rent_source > 1 {
1394        return Err(SdkError::InvalidResponse(
1395            "cancel rent source is invalid".to_owned(),
1396        ));
1397    }
1398    let order_id = opaque_order_id(&challenge.market_id, order);
1399    if order_id != expected_order_id {
1400        return Err(SdkError::InvalidResponse(
1401            "cancel order identity changed".to_owned(),
1402        ));
1403    }
1404    Ok(order_id)
1405}
1406
1407fn validate_order_authorization(
1408    challenge: &PlatformOrderChallengeResponse,
1409    request: &PlatformOrderChallengeRequest,
1410) -> Result<OrderAuthorization, SdkError> {
1411    let bytes = base64::engine::general_purpose::STANDARD
1412        .decode(challenge.authorization_payload_base64.trim())
1413        .map_err(|_| SdkError::InvalidResponse("order authorization is not base64".to_owned()))?;
1414    let owner = decode_public_key(order_request_owner(request), "owner_wallet")?;
1415    let session = decode_public_key(order_request_session(request), "session_public_key")?;
1416    let mut cursor = 0usize;
1417    take_expected(
1418        &bytes,
1419        &mut cursor,
1420        PUBLIC_ORDER_AUTH_DOMAIN,
1421        "order authorization domain",
1422    )?;
1423    let _market = take_bytes(&bytes, &mut cursor, 32, "order authorization market")?;
1424    take_expected(&bytes, &mut cursor, &owner, "order authorization owner")?;
1425    take_expected(&bytes, &mut cursor, &session, "order authorization session")?;
1426    let action = take_bytes(&bytes, &mut cursor, 1, "order authorization action")?[0];
1427    let expected_action = match order_request_action(request) {
1428        PlatformOrderAction::Place => 0,
1429        PlatformOrderAction::Cancel => 1,
1430        PlatformOrderAction::CancelAll => 2,
1431        PlatformOrderAction::Replace => 3,
1432        PlatformOrderAction::Batch => 4,
1433    };
1434    if action != expected_action || challenge.action != order_request_action(request) {
1435        return Err(SdkError::InvalidResponse(
1436            "order authorization action changed".to_owned(),
1437        ));
1438    }
1439    let mut derived_order_ids = Vec::new();
1440    match request {
1441        PlatformOrderChallengeRequest::Place {
1442            account_sequence,
1443            client_order_id,
1444            side,
1445            order_type,
1446            limit_price_atoms,
1447            size_atoms,
1448            ..
1449        } => {
1450            take_u64_eq(
1451                &bytes,
1452                &mut cursor,
1453                parse_request_u64(account_sequence, "account_sequence")?,
1454                "order account sequence",
1455            )?;
1456            let client_length = take_u16(&bytes, &mut cursor, "client order ID length")? as usize;
1457            if client_length != client_order_id.len() {
1458                return Err(SdkError::InvalidResponse(
1459                    "client order ID length changed".to_owned(),
1460                ));
1461            }
1462            take_expected(
1463                &bytes,
1464                &mut cursor,
1465                client_order_id.as_bytes(),
1466                "client order ID",
1467            )?;
1468            let actual_side = take_bytes(&bytes, &mut cursor, 1, "order side")?[0];
1469            let expected_side = if *side == PlatformTradeSide::Buy {
1470                0
1471            } else {
1472                1
1473            };
1474            if actual_side != expected_side {
1475                return Err(SdkError::InvalidResponse("order side changed".to_owned()));
1476            }
1477            let actual_type = take_bytes(&bytes, &mut cursor, 1, "order type")?[0];
1478            let expected_type = match order_type {
1479                PlatformOrderType::GoodUntilCancelled => 0,
1480                PlatformOrderType::PostOnly => 3,
1481                PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
1482                    return Err(SdkError::InvalidRequest(
1483                        "order type is not a resting order".to_owned(),
1484                    ));
1485                }
1486            };
1487            if actual_type != expected_type {
1488                return Err(SdkError::InvalidResponse("order type changed".to_owned()));
1489            }
1490            take_u64_eq(
1491                &bytes,
1492                &mut cursor,
1493                parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
1494                "order limit price",
1495            )?;
1496            take_u64_eq(
1497                &bytes,
1498                &mut cursor,
1499                parse_request_u64(size_atoms, "size_atoms")?,
1500                "order size",
1501            )?;
1502            let order = take_bytes(&bytes, &mut cursor, 32, "order identity")?;
1503            derived_order_ids.push(opaque_order_id(&challenge.market_id, order));
1504        }
1505        PlatformOrderChallengeRequest::Cancel { .. }
1506        | PlatformOrderChallengeRequest::CancelAll { .. } => {
1507            let count = usize::from(take_bytes(&bytes, &mut cursor, 1, "cancel order count")?[0]);
1508            if count == 0
1509                || count > 6
1510                || (matches!(request, PlatformOrderChallengeRequest::Cancel { .. }) && count != 1)
1511            {
1512                return Err(SdkError::InvalidResponse(
1513                    "cancel order count changed".to_owned(),
1514                ));
1515            }
1516            for index in 0..count {
1517                let order = take_bytes(&bytes, &mut cursor, 32, &format!("cancel order {index}"))?;
1518                let rent_source = take_bytes(
1519                    &bytes,
1520                    &mut cursor,
1521                    1,
1522                    &format!("cancel rent source {index}"),
1523                )?[0];
1524                if rent_source > 1 {
1525                    return Err(SdkError::InvalidResponse(
1526                        "cancel rent source is invalid".to_owned(),
1527                    ));
1528                }
1529                derived_order_ids.push(opaque_order_id(&challenge.market_id, order));
1530            }
1531            if let PlatformOrderChallengeRequest::Cancel { order_id, .. } = request {
1532                if derived_order_ids.first() != Some(order_id) {
1533                    return Err(SdkError::InvalidResponse(
1534                        "cancel order identity changed".to_owned(),
1535                    ));
1536                }
1537            }
1538        }
1539        PlatformOrderChallengeRequest::Replace {
1540            order_id,
1541            account_sequence,
1542            client_order_id,
1543            side,
1544            order_type,
1545            limit_price_atoms,
1546            size_atoms,
1547            ..
1548        } => {
1549            derived_order_ids.push(validate_order_cancel_authorization(
1550                &bytes,
1551                &mut cursor,
1552                challenge,
1553                order_id,
1554            )?);
1555            derived_order_ids.push(validate_order_place_authorization(
1556                &bytes,
1557                &mut cursor,
1558                challenge,
1559                account_sequence,
1560                client_order_id,
1561                *side,
1562                *order_type,
1563                limit_price_atoms,
1564                size_atoms,
1565            )?);
1566        }
1567        PlatformOrderChallengeRequest::Batch { operations, .. } => {
1568            let count = usize::from(take_bytes(&bytes, &mut cursor, 1, "batch count")?[0]);
1569            if count == 0 || count > 6 || count != operations.len() {
1570                return Err(SdkError::InvalidResponse(
1571                    "order batch count changed".to_owned(),
1572                ));
1573            }
1574            for operation in operations {
1575                let tag = take_bytes(&bytes, &mut cursor, 1, "batch action")?[0];
1576                match operation {
1577                    PlatformOrderBatchOperation::Place {
1578                        account_sequence,
1579                        client_order_id,
1580                        side,
1581                        order_type,
1582                        limit_price_atoms,
1583                        size_atoms,
1584                    } if tag == 0 => derived_order_ids.push(validate_order_place_authorization(
1585                        &bytes,
1586                        &mut cursor,
1587                        challenge,
1588                        account_sequence,
1589                        client_order_id,
1590                        *side,
1591                        *order_type,
1592                        limit_price_atoms,
1593                        size_atoms,
1594                    )?),
1595                    PlatformOrderBatchOperation::Cancel { order_id } if tag == 1 => {
1596                        derived_order_ids.push(validate_order_cancel_authorization(
1597                            &bytes,
1598                            &mut cursor,
1599                            challenge,
1600                            order_id,
1601                        )?)
1602                    }
1603                    PlatformOrderBatchOperation::Replace {
1604                        order_id,
1605                        account_sequence,
1606                        client_order_id,
1607                        side,
1608                        order_type,
1609                        limit_price_atoms,
1610                        size_atoms,
1611                    } if tag == 3 => {
1612                        derived_order_ids.push(validate_order_cancel_authorization(
1613                            &bytes,
1614                            &mut cursor,
1615                            challenge,
1616                            order_id,
1617                        )?);
1618                        derived_order_ids.push(validate_order_place_authorization(
1619                            &bytes,
1620                            &mut cursor,
1621                            challenge,
1622                            account_sequence,
1623                            client_order_id,
1624                            *side,
1625                            *order_type,
1626                            limit_price_atoms,
1627                            size_atoms,
1628                        )?);
1629                    }
1630                    _ => {
1631                        return Err(SdkError::InvalidResponse(
1632                            "order batch action changed".to_owned(),
1633                        ))
1634                    }
1635                }
1636            }
1637        }
1638    }
1639    if derived_order_ids != challenge.order_ids {
1640        return Err(SdkError::InvalidResponse(
1641            "order authorization opaque identities changed".to_owned(),
1642        ));
1643    }
1644    let recent_blockhash = bs58::encode(take_bytes(
1645        &bytes,
1646        &mut cursor,
1647        32,
1648        "order authorization blockhash",
1649    )?)
1650    .into_string();
1651    let last_valid_block_height = take_u64(
1652        &bytes,
1653        &mut cursor,
1654        "order authorization last valid block height",
1655    )?;
1656    take_u64_eq(
1657        &bytes,
1658        &mut cursor,
1659        challenge.expires_at_ms,
1660        "order authorization expiry",
1661    )?;
1662    let nonce = take_bytes(&bytes, &mut cursor, 16, "order authorization nonce")?;
1663    if hex::encode(nonce) != challenge.challenge_id[3..] {
1664        return Err(SdkError::InvalidResponse(
1665            "order challenge nonce changed".to_owned(),
1666        ));
1667    }
1668    let _epoch = take_bytes(&bytes, &mut cursor, 16, "order authorization epoch")?;
1669    if cursor != bytes.len() {
1670        return Err(SdkError::InvalidResponse(
1671            "order authorization contains unrecognized fields".to_owned(),
1672        ));
1673    }
1674    Ok(OrderAuthorization {
1675        bytes,
1676        recent_blockhash,
1677        last_valid_block_height,
1678    })
1679}
1680
1681fn validate_order_prepare_binding(
1682    prepared: &PlatformOrderPrepareResponse,
1683    challenge: &PlatformOrderChallengeResponse,
1684    authorization: &OrderAuthorization,
1685) -> Result<(), SdkError> {
1686    if prepared.market_id != challenge.market_id
1687        || prepared.action != challenge.action
1688        || prepared.order_ids != challenge.order_ids
1689        || prepared.recent_blockhash != authorization.recent_blockhash
1690        || prepared.last_valid_block_height != authorization.last_valid_block_height
1691        || prepared.expires_at_ms != challenge.expires_at_ms
1692    {
1693        return Err(SdkError::InvalidResponse(
1694            "prepared order control changed the signed bindings".to_owned(),
1695        ));
1696    }
1697    Ok(())
1698}
1699
1700fn parse_request_u64(value: &str, field: &str) -> Result<u64, SdkError> {
1701    value
1702        .parse::<u64>()
1703        .map_err(|_| SdkError::InvalidRequest(format!("{field} exceeds u64")))
1704}
1705
1706fn take_u16(source: &[u8], cursor: &mut usize, field: &str) -> Result<u16, SdkError> {
1707    let bytes: [u8; 2] = take_bytes(source, cursor, 2, field)?
1708        .try_into()
1709        .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
1710    Ok(u16::from_le_bytes(bytes))
1711}
1712
1713fn opaque_order_id(market_id: &str, order: &[u8]) -> String {
1714    let mut digest = Sha256::new();
1715    digest.update(b"strata-sdk-product:v1\0");
1716    digest.update(b"order");
1717    digest.update([0]);
1718    digest.update(market_id.as_bytes());
1719    digest.update(b":");
1720    digest.update(bs58::encode(order).into_string().as_bytes());
1721    format!("order_{}", hex::encode(&digest.finalize()[..16]))
1722}
1723
1724fn parse_atoms(field: &str, value: &str) -> Result<u64, SdkError> {
1725    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
1726        return Err(SdkError::InvalidResponse(format!(
1727            "{field} must be an unsigned atomic decimal string"
1728        )));
1729    }
1730    value
1731        .parse::<u64>()
1732        .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds the supported range")))
1733}
1734
1735fn valid_public_operation_path(path: &str) -> bool {
1736    let Some(market_id) = path
1737        .strip_prefix("/sonar/markets/")
1738        .and_then(|value| value.strip_suffix("/quote"))
1739    else {
1740        return false;
1741    };
1742    !market_id.is_empty()
1743        && !market_id.starts_with('-')
1744        && !market_id.ends_with('-')
1745        && market_id
1746            .bytes()
1747            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1748}
1749
1750fn validate_quote(
1751    quote: &QuoteResponse,
1752    market_id: &str,
1753    request: &QuoteRequest,
1754    requested_amount: u64,
1755) -> Result<(), SdkError> {
1756    validate_version(quote.schema_version, &quote.contract_version)?;
1757    if quote.provider != "Sonar"
1758        || quote.market_id != market_id
1759        || quote.side != request.side
1760        || quote.amount_in_atoms != request.amount_in_atoms
1761        || quote.quote_id.len() != 35
1762        || !quote.quote_id.starts_with("sq_")
1763        || !quote.quote_id[3..]
1764            .bytes()
1765            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1766        || quote.expires_at_ms <= quote.server_time_ms
1767    {
1768        return Err(SdkError::InvalidResponse(
1769            "quote binding or lifetime is invalid".to_owned(),
1770        ));
1771    }
1772
1773    let consumed = parse_atoms("amount_in_consumed_atoms", &quote.amount_in_consumed_atoms)?;
1774    let output = parse_atoms("amount_out_atoms", &quote.amount_out_atoms)?;
1775    let minimum = parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?;
1776    parse_atoms("input_fee_atoms", &quote.input_fee_atoms)?;
1777    parse_atoms("output_fee_atoms", &quote.output_fee_atoms)?;
1778    if consumed > requested_amount || minimum > output {
1779        return Err(SdkError::InvalidResponse(
1780            "quote economics are internally inconsistent".to_owned(),
1781        ));
1782    }
1783    quote
1784        .reference_price
1785        .parse::<f64>()
1786        .ok()
1787        .filter(|value| value.is_finite() && *value > 0.0)
1788        .ok_or_else(|| SdkError::InvalidResponse("reference_price is invalid".to_owned()))?;
1789    quote
1790        .price_impact_pct
1791        .parse::<f64>()
1792        .ok()
1793        .filter(|value| value.is_finite() && *value >= 0.0)
1794        .ok_or_else(|| SdkError::InvalidResponse("price_impact_pct is invalid".to_owned()))?;
1795    Ok(())
1796}
1797
1798struct ExecutionAuthorization {
1799    bytes: Vec<u8>,
1800    recent_blockhash: String,
1801    last_valid_block_height: u64,
1802}
1803
1804fn validate_execution_challenge(
1805    challenge: &ExecutionChallengeResponse,
1806    quote: &QuoteResponse,
1807) -> Result<(), SdkError> {
1808    validate_version(challenge.schema_version, &challenge.contract_version)?;
1809    validate_execution_binding(
1810        &challenge.quote_id,
1811        &challenge.market_id,
1812        challenge.side,
1813        &challenge.amount_in_atoms,
1814        &challenge.minimum_output_atoms,
1815        quote,
1816    )?;
1817    if !valid_handle(&challenge.challenge_id, "sc_")
1818        || challenge.expires_at_ms <= challenge.server_time_ms
1819        || challenge.expires_at_ms > quote.expires_at_ms
1820    {
1821        return Err(SdkError::InvalidResponse(
1822            "execution challenge binding or lifetime is invalid".to_owned(),
1823        ));
1824    }
1825    Ok(())
1826}
1827
1828fn validate_execution_prepare(
1829    prepared: &ExecutionPrepareResponse,
1830    quote: &QuoteResponse,
1831    challenge: &ExecutionChallengeResponse,
1832    authorization: &ExecutionAuthorization,
1833) -> Result<(), SdkError> {
1834    validate_version(prepared.schema_version, &prepared.contract_version)?;
1835    validate_execution_binding(
1836        &prepared.quote_id,
1837        &prepared.market_id,
1838        prepared.side,
1839        &prepared.amount_in_atoms,
1840        &prepared.minimum_output_atoms,
1841        quote,
1842    )?;
1843    if !valid_handle(&prepared.execution_id, "se_")
1844        || prepared.recent_blockhash != authorization.recent_blockhash
1845        || prepared.last_valid_block_height != authorization.last_valid_block_height
1846        || prepared.expires_at_ms > challenge.expires_at_ms
1847        || prepared.transaction_base64.trim().is_empty()
1848        || base64::engine::general_purpose::STANDARD
1849            .decode(prepared.transaction_base64.trim())
1850            .is_err()
1851    {
1852        return Err(SdkError::InvalidResponse(
1853            "prepared execution changed the signed authorization".to_owned(),
1854        ));
1855    }
1856    Ok(())
1857}
1858
1859fn validate_execution_binding(
1860    quote_id: &str,
1861    market_id: &str,
1862    side: QuoteSide,
1863    amount_in_atoms: &str,
1864    minimum_output_atoms: &str,
1865    quote: &QuoteResponse,
1866) -> Result<(), SdkError> {
1867    if quote_id != quote.quote_id
1868        || market_id != quote.market_id
1869        || side != quote.side
1870        || amount_in_atoms != quote.amount_in_atoms
1871        || minimum_output_atoms != quote.minimum_output_atoms
1872    {
1873        return Err(SdkError::InvalidResponse(
1874            "execution does not match the Sonar quote".to_owned(),
1875        ));
1876    }
1877    Ok(())
1878}
1879
1880fn validate_execution_authorization(
1881    challenge: &ExecutionChallengeResponse,
1882    quote: &QuoteResponse,
1883    owner_wallet: &str,
1884    session_public_key: &str,
1885    account_sequence: u64,
1886) -> Result<ExecutionAuthorization, SdkError> {
1887    let bytes = base64::engine::general_purpose::STANDARD
1888        .decode(challenge.authorization_payload_base64.trim())
1889        .map_err(|_| SdkError::InvalidResponse("authorization payload is not base64".to_owned()))?;
1890    let market = decode_public_key(&quote.market_id, "market_id")?;
1891    let owner = decode_public_key(owner_wallet, "owner_wallet")?;
1892    let session = decode_public_key(session_public_key, "session_public_key")?;
1893    let mut cursor = 0usize;
1894    take_expected(
1895        &bytes,
1896        &mut cursor,
1897        PUBLIC_EXECUTION_AUTH_DOMAIN,
1898        "authorization domain",
1899    )?;
1900    take_expected(&bytes, &mut cursor, &market, "authorization market")?;
1901    take_expected(
1902        &bytes,
1903        &mut cursor,
1904        quote.quote_id.as_bytes(),
1905        "authorization quote",
1906    )?;
1907    take_expected(&bytes, &mut cursor, &owner, "authorization owner")?;
1908    take_expected(&bytes, &mut cursor, &session, "authorization session")?;
1909    let side = take_bytes(&bytes, &mut cursor, 1, "authorization side")?[0];
1910    if side != if quote.side == QuoteSide::Buy { 0 } else { 1 } {
1911        return Err(SdkError::InvalidResponse(
1912            "authorization side changed".to_owned(),
1913        ));
1914    }
1915    take_u64_eq(
1916        &bytes,
1917        &mut cursor,
1918        parse_atoms("amount_in_atoms", &quote.amount_in_atoms)?,
1919        "authorization input",
1920    )?;
1921    take_u64_eq(
1922        &bytes,
1923        &mut cursor,
1924        parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?,
1925        "authorization minimum output",
1926    )?;
1927    take_u64_eq(
1928        &bytes,
1929        &mut cursor,
1930        account_sequence,
1931        "authorization account sequence",
1932    )?;
1933    let _output_balance = take_u64(&bytes, &mut cursor, "authorization output balance")?;
1934    let recent_blockhash = bs58::encode(take_bytes(
1935        &bytes,
1936        &mut cursor,
1937        32,
1938        "authorization blockhash",
1939    )?)
1940    .into_string();
1941    let last_valid_block_height =
1942        take_u64(&bytes, &mut cursor, "authorization last valid block height")?;
1943    take_u64_eq(
1944        &bytes,
1945        &mut cursor,
1946        challenge.expires_at_ms,
1947        "authorization expiry",
1948    )?;
1949    let nonce = take_bytes(&bytes, &mut cursor, 16, "authorization nonce")?;
1950    if hex::encode(nonce) != challenge.challenge_id[3..] {
1951        return Err(SdkError::InvalidResponse(
1952            "authorization challenge nonce changed".to_owned(),
1953        ));
1954    }
1955    let _epoch = take_bytes(&bytes, &mut cursor, 16, "authorization epoch")?;
1956    if cursor != bytes.len() {
1957        return Err(SdkError::InvalidResponse(
1958            "authorization contains unrecognized fields".to_owned(),
1959        ));
1960    }
1961    Ok(ExecutionAuthorization {
1962        bytes,
1963        recent_blockhash,
1964        last_valid_block_height,
1965    })
1966}
1967
1968fn take_expected(
1969    source: &[u8],
1970    cursor: &mut usize,
1971    expected: &[u8],
1972    field: &str,
1973) -> Result<(), SdkError> {
1974    if take_bytes(source, cursor, expected.len(), field)? != expected {
1975        return Err(SdkError::InvalidResponse(format!("{field} changed")));
1976    }
1977    Ok(())
1978}
1979
1980fn take_bytes<'a>(
1981    source: &'a [u8],
1982    cursor: &mut usize,
1983    length: usize,
1984    field: &str,
1985) -> Result<&'a [u8], SdkError> {
1986    let end = cursor
1987        .checked_add(length)
1988        .filter(|end| *end <= source.len())
1989        .ok_or_else(|| SdkError::InvalidResponse(format!("{field} is missing")))?;
1990    let value = &source[*cursor..end];
1991    *cursor = end;
1992    Ok(value)
1993}
1994
1995fn take_u64(source: &[u8], cursor: &mut usize, field: &str) -> Result<u64, SdkError> {
1996    let bytes: [u8; 8] = take_bytes(source, cursor, 8, field)?
1997        .try_into()
1998        .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
1999    Ok(u64::from_le_bytes(bytes))
2000}
2001
2002fn take_u64_eq(
2003    source: &[u8],
2004    cursor: &mut usize,
2005    expected: u64,
2006    field: &str,
2007) -> Result<(), SdkError> {
2008    if take_u64(source, cursor, field)? != expected {
2009        return Err(SdkError::InvalidResponse(format!("{field} changed")));
2010    }
2011    Ok(())
2012}
2013
2014fn decode_public_key(value: &str, field: &str) -> Result<Vec<u8>, SdkError> {
2015    let bytes = bs58::decode(value.trim())
2016        .into_vec()
2017        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
2018    if bytes.len() != 32 || bs58::encode(&bytes).into_string() != value.trim() {
2019        return Err(SdkError::InvalidRequest(format!(
2020            "{field} must be a canonical 32-byte public key"
2021        )));
2022    }
2023    Ok(bytes)
2024}
2025
2026fn canonical_public_key(value: &str, field: &str) -> Result<String, SdkError> {
2027    decode_public_key(value, field)?;
2028    Ok(value.trim().to_owned())
2029}
2030
2031fn valid_handle(value: &str, prefix: &str) -> bool {
2032    value.len() == prefix.len() + 32
2033        && value.starts_with(prefix)
2034        && value[prefix.len()..]
2035            .bytes()
2036            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2037}
2038
2039fn normalize_idempotency_key(value: &str) -> Result<String, SdkError> {
2040    let value = value.trim();
2041    if value.is_empty()
2042        || value.len() > 64
2043        || !value.bytes().all(|byte| {
2044            byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
2045        })
2046    {
2047        return Err(SdkError::InvalidRequest(
2048            "idempotency key must contain 1-64 URL-safe characters".to_owned(),
2049        ));
2050    }
2051    Ok(value.to_owned())
2052}
2053
2054fn unix_ms() -> Result<u64, SdkError> {
2055    let elapsed = SystemTime::now()
2056        .duration_since(UNIX_EPOCH)
2057        .map_err(|_| SdkError::InvalidRequest("system clock is before Unix epoch".to_owned()))?;
2058    u64::try_from(elapsed.as_millis())
2059        .map_err(|_| SdkError::InvalidRequest("system clock exceeds supported range".to_owned()))
2060}
2061
2062#[cfg(test)]
2063mod tests {
2064    use super::*;
2065    use wiremock::matchers::{body_json, method, path};
2066    use wiremock::{Mock, MockServer, ResponseTemplate};
2067
2068    fn fixture(path: &str) -> serde_json::Value {
2069        let raw = match path {
2070            "action-graph" => strata_public_contract::contract_fixtures::ACTION_GRAPH,
2071            "markets" => strata_public_contract::contract_fixtures::MARKETS,
2072            "quote" => strata_public_contract::contract_fixtures::QUOTE,
2073            "capabilities" => strata_public_contract::contract_fixtures::CAPABILITIES,
2074            "order-challenge" => strata_public_contract::platform::PLATFORM_ORDER_CHALLENGE_FIXTURE,
2075            "order-prepare" => strata_public_contract::platform::PLATFORM_ORDER_PREPARE_FIXTURE,
2076            "order-submit" => strata_public_contract::platform::PLATFORM_ORDER_SUBMIT_FIXTURE,
2077            "order-status" => strata_public_contract::platform::PLATFORM_ORDER_STATUS_FIXTURE,
2078            _ => unreachable!(),
2079        };
2080        serde_json::from_str(raw).unwrap()
2081    }
2082
2083    #[tokio::test]
2084    async fn reads_capabilities_and_quotes_without_internal_metadata() {
2085        let server = MockServer::start().await;
2086        Mock::given(method("GET"))
2087            .and(path("/sonar/capabilities"))
2088            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("capabilities")))
2089            .mount(&server)
2090            .await;
2091        Mock::given(method("GET"))
2092            .and(path("/sonar/markets"))
2093            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
2094            .expect(1)
2095            .mount(&server)
2096            .await;
2097        Mock::given(method("GET"))
2098            .and(path("/sonar/action-graph"))
2099            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("action-graph")))
2100            .expect(1)
2101            .mount(&server)
2102            .await;
2103        Mock::given(method("POST"))
2104            .and(path("/sonar/markets/sol-usdc/quote"))
2105            .and(body_json(serde_json::json!({
2106                "market_id": "11111111111111111111111111111111",
2107                "side": "sell",
2108                "amount_in_atoms": "10000000",
2109                "slippage_bps": 50
2110            })))
2111            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("quote")))
2112            .expect(1)
2113            .mount(&server)
2114            .await;
2115
2116        let client = StrataClient::new(server.uri()).unwrap();
2117        let capabilities = client.capabilities().await.unwrap();
2118        assert!(capabilities
2119            .capabilities
2120            .iter()
2121            .any(|capability| capability.id == "quotes.read"));
2122
2123        let graph = client.action_graph().await.unwrap();
2124        assert_eq!(graph.entry_node, "discover_capabilities");
2125        assert_eq!(graph.authority.permission_source, "external_agent_owner");
2126
2127        let quote = client
2128            .quote(QuoteRequest {
2129                market_id: "SOL/USDC".to_owned(),
2130                side: QuoteSide::Sell,
2131                amount_in_atoms: "10000000".to_owned(),
2132                slippage_bps: 50,
2133            })
2134            .await
2135            .unwrap();
2136        let public = serde_json::to_value(quote).unwrap();
2137        assert!(public.get("quote_id").is_some());
2138        assert!(public.get("unexpected_field").is_none());
2139    }
2140
2141    #[tokio::test]
2142    async fn resting_order_calls_use_only_product_paths_and_external_signatures() {
2143        let server = MockServer::start().await;
2144        let market_id = "market_22222222222222222222222222222222";
2145        let owner_wallet = bs58::encode([1u8; 32]).into_string();
2146        let session_public_key = bs58::encode([2u8; 32]).into_string();
2147        let authorization_signature = bs58::encode([3u8; 64]).into_string();
2148        Mock::given(method("POST"))
2149            .and(path(format!("/v2/markets/{market_id}/orders/challenge")))
2150            .and(body_json(serde_json::json!({
2151                "action": "place",
2152                "owner_wallet": owner_wallet,
2153                "session_public_key": session_public_key,
2154                "account_sequence": "7",
2155                "client_order_id": "agent-order-7",
2156                "side": "buy",
2157                "order_type": "post_only",
2158                "limit_price_atoms": "150000000",
2159                "size_atoms": "1000000"
2160            })))
2161            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-challenge")))
2162            .expect(1)
2163            .mount(&server)
2164            .await;
2165        Mock::given(method("POST"))
2166            .and(path(format!("/v2/markets/{market_id}/orders/prepare")))
2167            .and(body_json(serde_json::json!({
2168                "challenge_id": "oc_11111111111111111111111111111111",
2169                "authorization_signature": authorization_signature
2170            })))
2171            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-prepare")))
2172            .expect(1)
2173            .mount(&server)
2174            .await;
2175        Mock::given(method("POST"))
2176            .and(path(format!("/v2/markets/{market_id}/orders/submit")))
2177            .and(body_json(serde_json::json!({
2178                "order_control_id": "or_44444444444444444444444444444444",
2179                "signed_transaction_base64": "AQIDBA==",
2180                "idempotency_key": "order-attempt-7"
2181            })))
2182            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-submit")))
2183            .expect(1)
2184            .mount(&server)
2185            .await;
2186        Mock::given(method("POST"))
2187            .and(path(format!("/v2/markets/{market_id}/orders/status")))
2188            .and(body_json(serde_json::json!({
2189                "order_control_id": "or_44444444444444444444444444444444",
2190                "idempotency_key": "order-attempt-7"
2191            })))
2192            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-status")))
2193            .expect(1)
2194            .mount(&server)
2195            .await;
2196
2197        let client = StrataClient::new(server.uri()).unwrap();
2198        let challenge = client
2199            .order_challenge(
2200                market_id,
2201                PlatformOrderChallengeRequest::Place {
2202                    owner_wallet,
2203                    session_public_key,
2204                    account_sequence: "7".to_owned(),
2205                    client_order_id: "agent-order-7".to_owned(),
2206                    side: PlatformTradeSide::Buy,
2207                    order_type: PlatformOrderType::PostOnly,
2208                    limit_price_atoms: "150000000".to_owned(),
2209                    size_atoms: "1000000".to_owned(),
2210                },
2211            )
2212            .await
2213            .unwrap();
2214        let prepared = client
2215            .order_prepare(
2216                market_id,
2217                PlatformOrderPrepareRequest {
2218                    challenge_id: challenge.challenge_id,
2219                    authorization_signature,
2220                },
2221            )
2222            .await
2223            .unwrap();
2224        let receipt = client
2225            .order_submit(
2226                market_id,
2227                PlatformOrderSubmitRequest {
2228                    order_control_id: prepared.order_control_id,
2229                    signed_transaction_base64: "AQIDBA==".to_owned(),
2230                    idempotency_key: "order-attempt-7".to_owned(),
2231                },
2232            )
2233            .await
2234            .unwrap();
2235        assert_eq!(receipt.status, PlatformOrderSubmissionStatus::Submitted);
2236        let status = client
2237            .order_status(
2238                market_id,
2239                PlatformOrderStatusRequest {
2240                    order_control_id: receipt.order_control_id,
2241                    idempotency_key: "order-attempt-7".to_owned(),
2242                },
2243            )
2244            .await
2245            .unwrap();
2246        assert_eq!(status.status, PlatformOrderControlStatus::Submitting);
2247    }
2248
2249    #[test]
2250    fn order_authorization_parser_binds_every_public_place_field() {
2251        let owner = [1u8; 32];
2252        let session = [2u8; 32];
2253        let order = [3u8; 32];
2254        let nonce = [4u8; 16];
2255        let blockhash = [5u8; 32];
2256        let epoch = [6u8; 16];
2257        let market_id = "market_22222222222222222222222222222222";
2258        let expires_at_ms = 1_786_550_460_000u64;
2259        let request = PlatformOrderChallengeRequest::Place {
2260            owner_wallet: bs58::encode(owner).into_string(),
2261            session_public_key: bs58::encode(session).into_string(),
2262            account_sequence: "7".to_owned(),
2263            client_order_id: "agent-order-7".to_owned(),
2264            side: PlatformTradeSide::Buy,
2265            order_type: PlatformOrderType::PostOnly,
2266            limit_price_atoms: "150000000".to_owned(),
2267            size_atoms: "1000000".to_owned(),
2268        };
2269        let mut payload = Vec::new();
2270        payload.extend_from_slice(PUBLIC_ORDER_AUTH_DOMAIN);
2271        payload.extend_from_slice(&[9u8; 32]);
2272        payload.extend_from_slice(&owner);
2273        payload.extend_from_slice(&session);
2274        payload.push(0);
2275        payload.extend_from_slice(&7u64.to_le_bytes());
2276        payload.extend_from_slice(&("agent-order-7".len() as u16).to_le_bytes());
2277        payload.extend_from_slice(b"agent-order-7");
2278        payload.push(0);
2279        payload.push(3);
2280        payload.extend_from_slice(&150_000_000u64.to_le_bytes());
2281        payload.extend_from_slice(&1_000_000u64.to_le_bytes());
2282        payload.extend_from_slice(&order);
2283        payload.extend_from_slice(&blockhash);
2284        payload.extend_from_slice(&400_000_000u64.to_le_bytes());
2285        payload.extend_from_slice(&expires_at_ms.to_le_bytes());
2286        payload.extend_from_slice(&nonce);
2287        payload.extend_from_slice(&epoch);
2288        let challenge = PlatformOrderChallengeResponse {
2289            schema_version: 2,
2290            contract_version: "2.0".to_owned(),
2291            challenge_id: format!("oc_{}", hex::encode(nonce)),
2292            market_id: market_id.to_owned(),
2293            action: PlatformOrderAction::Place,
2294            order_ids: vec![opaque_order_id(market_id, &order)],
2295            authorization_payload_base64: base64::engine::general_purpose::STANDARD.encode(payload),
2296            server_time_ms: expires_at_ms - 60_000,
2297            expires_at_ms,
2298        };
2299        let authorization = validate_order_authorization(&challenge, &request).unwrap();
2300        assert_eq!(
2301            authorization.recent_blockhash,
2302            bs58::encode(blockhash).into_string()
2303        );
2304        assert_eq!(authorization.last_valid_block_height, 400_000_000);
2305
2306        let mut changed = request;
2307        if let PlatformOrderChallengeRequest::Place { size_atoms, .. } = &mut changed {
2308            *size_atoms = "1000001".to_owned();
2309        }
2310        assert!(validate_order_authorization(&challenge, &changed).is_err());
2311    }
2312
2313    #[test]
2314    fn order_authorization_parser_binds_atomic_batch_order_and_replacement_fields() {
2315        let owner = [1u8; 32];
2316        let session = [2u8; 32];
2317        let cancelled = [3u8; 32];
2318        let replaced = [4u8; 32];
2319        let replacement = [5u8; 32];
2320        let nonce = [6u8; 16];
2321        let blockhash = [7u8; 32];
2322        let market_id = "market_22222222222222222222222222222222";
2323        let expires_at_ms = 1_786_550_460_000u64;
2324        let request = PlatformOrderChallengeRequest::Batch {
2325            owner_wallet: bs58::encode(owner).into_string(),
2326            session_public_key: bs58::encode(session).into_string(),
2327            operations: vec![
2328                PlatformOrderBatchOperation::Cancel {
2329                    order_id: opaque_order_id(market_id, &cancelled),
2330                },
2331                PlatformOrderBatchOperation::Replace {
2332                    order_id: opaque_order_id(market_id, &replaced),
2333                    account_sequence: "8".to_owned(),
2334                    client_order_id: "replacement-8".to_owned(),
2335                    side: PlatformTradeSide::Sell,
2336                    order_type: PlatformOrderType::PostOnly,
2337                    limit_price_atoms: "151000000".to_owned(),
2338                    size_atoms: "2000000".to_owned(),
2339                },
2340            ],
2341        };
2342        let mut payload = Vec::new();
2343        payload.extend_from_slice(PUBLIC_ORDER_AUTH_DOMAIN);
2344        payload.extend_from_slice(&[9u8; 32]);
2345        payload.extend_from_slice(&owner);
2346        payload.extend_from_slice(&session);
2347        payload.push(4);
2348        payload.push(2);
2349        payload.push(1);
2350        payload.extend_from_slice(&cancelled);
2351        payload.push(1);
2352        payload.push(3);
2353        payload.extend_from_slice(&replaced);
2354        payload.push(0);
2355        payload.extend_from_slice(&8u64.to_le_bytes());
2356        payload.extend_from_slice(&("replacement-8".len() as u16).to_le_bytes());
2357        payload.extend_from_slice(b"replacement-8");
2358        payload.push(1);
2359        payload.push(3);
2360        payload.extend_from_slice(&151_000_000u64.to_le_bytes());
2361        payload.extend_from_slice(&2_000_000u64.to_le_bytes());
2362        payload.extend_from_slice(&replacement);
2363        payload.extend_from_slice(&blockhash);
2364        payload.extend_from_slice(&400_000_000u64.to_le_bytes());
2365        payload.extend_from_slice(&expires_at_ms.to_le_bytes());
2366        payload.extend_from_slice(&nonce);
2367        payload.extend_from_slice(&[8u8; 16]);
2368        let challenge = PlatformOrderChallengeResponse {
2369            schema_version: 2,
2370            contract_version: "2.0".to_owned(),
2371            challenge_id: format!("oc_{}", hex::encode(nonce)),
2372            market_id: market_id.to_owned(),
2373            action: PlatformOrderAction::Batch,
2374            order_ids: vec![
2375                opaque_order_id(market_id, &cancelled),
2376                opaque_order_id(market_id, &replaced),
2377                opaque_order_id(market_id, &replacement),
2378            ],
2379            authorization_payload_base64: base64::engine::general_purpose::STANDARD.encode(payload),
2380            server_time_ms: expires_at_ms - 60_000,
2381            expires_at_ms,
2382        };
2383        validate_order_authorization(&challenge, &request).unwrap();
2384
2385        let mut changed = request;
2386        if let PlatformOrderChallengeRequest::Batch { operations, .. } = &mut changed {
2387            if let PlatformOrderBatchOperation::Replace { size_atoms, .. } = &mut operations[1] {
2388                *size_atoms = "2000001".to_owned();
2389            }
2390        }
2391        assert!(validate_order_authorization(&challenge, &changed).is_err());
2392    }
2393
2394    #[test]
2395    fn rejects_non_http_base_urls() {
2396        assert!(matches!(
2397            StrataClient::new("file:///tmp/contract"),
2398            Err(SdkError::InvalidBaseUrl(_))
2399        ));
2400    }
2401
2402    #[test]
2403    fn accepts_only_product_level_quote_operation_paths() {
2404        assert!(valid_public_operation_path("/sonar/markets/sol-usdc/quote"));
2405        for unsupported_or_ambiguous in [
2406            "/unsupported/build",
2407            "/unsupported/quote",
2408            "/sonar/markets/../quote",
2409            "/sonar/markets/SOL-USDC/quote",
2410        ] {
2411            assert!(!valid_public_operation_path(unsupported_or_ambiguous));
2412        }
2413    }
2414}