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