Skip to main content

strata_sdk/
lib.rs

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