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 account_stream;
7mod execution_stream;
8mod maker_stream;
9mod market_stream;
10mod order_stream;
11pub mod transaction_verifier;
12mod twap_stream;
13
14pub use account_stream::{account_stream_auth_message, AccountStream, ACCOUNT_STREAM_AUTH_DOMAIN};
15pub use execution_stream::{ExecutionStream, MAX_WATCHED_EXECUTIONS};
16pub use maker_stream::{maker_stream_auth_message, MakerStream, MAKER_STREAM_AUTH_DOMAIN};
17pub use market_stream::MarketDataStream;
18pub use order_stream::{
19    DeadManGuard, OrderChallengeResult, OrderCommandStream, ORDER_STREAM_AUTH_DOMAIN,
20};
21pub use transaction_verifier::{
22    decode_transaction, verify_execution_transaction, verify_order_transaction,
23    verify_twap_transaction, DecodedInstruction, DecodedTransaction, DefaultTransactionVerifier,
24    TransactionVersion,
25};
26pub use twap_stream::TwapStream;
27
28use async_trait::async_trait;
29use base64::Engine as _;
30use reqwest::header::{HeaderMap, HeaderValue};
31use reqwest::{StatusCode, Url};
32use serde::de::DeserializeOwned;
33use sha2::{Digest, Sha256};
34use std::collections::{HashMap, HashSet};
35use std::time::{Duration, SystemTime, UNIX_EPOCH};
36use strata_public_contract::{ErrorResponse, CONTRACT_MAJOR, CONTRACT_VERSION};
37use thiserror::Error;
38
39pub use strata_public_contract::platform::{
40    LivePlatformCapability, PageInfo, PageRequest, PermissionSource, PlatformAccountEvent,
41    PlatformAccountFill, PlatformAccountOrder, PlatformAccountSnapshotResponse,
42    PlatformActionGraphResponse, PlatformAsset, PlatformAssetsResponse, PlatformAuthority,
43    PlatformBestBidAskResponse, PlatformBookChange, PlatformBookLevel, PlatformBookSide,
44    PlatformBookSnapshotResponse, PlatformBugReport, PlatformBugStatus, PlatformBugSubmitRequest,
45    PlatformBugSubmitResponse, PlatformBugsResponse, PlatformCandle, PlatformCandlesResponse,
46    PlatformDeadManState, PlatformDeadManStatus, PlatformDiscoveryResponse,
47    PlatformExecutionCommand, PlatformExecutionEvent, PlatformExecutionRow, PlatformExecutionState,
48    PlatformExecutionStatusResponse, PlatformFeeScheduleResponse, PlatformGraphModule,
49    PlatformGraphRelation, PlatformMakerEvent, PlatformMakerFill, PlatformMakerProduct,
50    PlatformMakerReputationResponse, PlatformMakerReputationTier, PlatformMakerStatusResponse,
51    PlatformMakerTierProgress, PlatformMarkResponse, PlatformMarket, PlatformMarketAction,
52    PlatformMarketDataEvent, PlatformMarketState, PlatformMarketStatusResponse,
53    PlatformMarketsResponse, PlatformOperation, PlatformOperationTransport, PlatformOrderAction,
54    PlatformOrderBatchOperation, PlatformOrderChallengeRequest, PlatformOrderChallengeResponse,
55    PlatformOrderCommand, PlatformOrderCommandBatchEvent, PlatformOrderCommandBatchFormat,
56    PlatformOrderCommandClientFrame, PlatformOrderCommandEvent, PlatformOrderCommandServerFrame,
57    PlatformOrderControlStatus, PlatformOrderPrepareAuthorization, PlatformOrderPrepareRequest,
58    PlatformOrderPrepareResponse, PlatformOrderState, PlatformOrderStatusRequest,
59    PlatformOrderStatusResponse, PlatformOrderSubmissionStatus, PlatformOrderSubmitRequest,
60    PlatformOrderSubmitResponse, PlatformOrderType, PlatformOwnerRewards,
61    PlatformPortfolioHistoryPoint, PlatformPortfolioHistoryRange, PlatformPortfolioHistoryResponse,
62    PlatformPortfolioResponse, PlatformReferralClaimRequest, PlatformReferralClaimResponse,
63    PlatformReferralLinkRequest, PlatformReferralLinkResponse, PlatformReferralsResponse,
64    PlatformRewardStanding, PlatformRewardsResponse, PlatformSelfTradePrevention,
65    PlatformServiceState, PlatformServiceStatusResponse, PlatformSettlementState,
66    PlatformSwapQuoteRequest, PlatformSwapQuoteResponse, PlatformTrade, PlatformTradeSide,
67    PlatformTradesResponse, PlatformTransport, PlatformTwap, PlatformTwapChallengeRequest,
68    PlatformTwapChallengeResponse, PlatformTwapControlAction, PlatformTwapEvent, PlatformTwapFill,
69    PlatformTwapPrepareAuthorization, PlatformTwapPrepareRequest, PlatformTwapPrepareResponse,
70    PlatformTwapState, PlatformTwapSubmitRequest, PlatformTwapSubmitResponse,
71    PlatformTwapsResponse, PlatformVaultAction, PlatformVaultDelegateAction,
72    PlatformVaultDelegatePrepareRequest, PlatformVaultDelegatePrepareResponse,
73    PlatformVaultDepositPrepareRequest, PlatformVaultDepositPrepareResponse,
74    PlatformVaultPausePrepareRequest, PlatformVaultPausePrepareResponse,
75    PlatformVaultPolicyPrepareRequest, PlatformVaultPolicyPrepareResponse,
76    PlatformVaultSessionState, PlatformVaultSessionStatus, PlatformVaultSetupMode,
77    PlatformVaultSetupPrepareRequest, PlatformVaultSetupPrepareResponse,
78    PlatformVaultSpendingLimit, PlatformVaultState, PlatformVaultStatusResponse,
79    PlatformVaultSubmissionStatus, PlatformVaultSubmitRequest, PlatformVaultSubmitResponse,
80    PlatformVaultWithdrawPrepareRequest, PlatformVaultWithdrawPrepareResponse,
81    PlatformVaultWithdrawalAccess, PlatformVaultWithdrawalMode, PlatformWorkflow,
82    PlatformWorkflowEdge, PlatformWorkflowNode, SigningLocation,
83    PLATFORM_SESSION_DEFAULT_MAXIMUM_TOLERANCE_BPS,
84    PLATFORM_SESSION_DEFAULT_MINIMUM_INTERVAL_SECONDS, PLATFORM_SESSION_MAX_SPENDING_LIMITS,
85};
86pub use strata_public_contract::{
87    ActionAuthorityModel, ActionEdge, ActionGraph, ActionNode, ActionNodeKind, ActionOperation,
88    CapabilityCatalog, CapabilityDescriptor, CapabilityRisk, CapabilityStability,
89    ExecutionChallengeRequest, ExecutionChallengeResponse, ExecutionPrepareAuthorization,
90    ExecutionPrepareRequest, ExecutionPrepareResponse, ExecutionStatus, ExecutionSubmitRequest,
91    ExecutionSubmitResponse, Market, MarketsResponse, McpExposure, QuoteRequest, QuoteResponse,
92    QuoteSide, DEFAULT_MAXIMUM_TOLERANCE_BPS, DEFAULT_SLIPPAGE_BPS,
93};
94
95pub const DEFAULT_API_BASE: &str = "https://api.stratabook.app";
96const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
97const PUBLIC_EXECUTION_AUTH_DOMAIN: &[u8] = b"strata-sonar-execution:v1\0";
98const PUBLIC_ORDER_AUTH_DOMAIN: &[u8] = b"strata-platform-order-control:v1\0";
99const PUBLIC_TWAP_AUTH_DOMAIN: &[u8] = b"strata-twap-control:v1\0";
100const MAX_PLATFORM_PAGE_SIZE: u32 = 200;
101const DEFAULT_ACCOUNT_FILL_LIMIT: u16 = 100;
102
103#[derive(Clone, Debug, Default, Eq, PartialEq)]
104pub struct PlatformBookRequest {
105    pub depth: Option<u16>,
106}
107
108#[derive(Clone, Debug, Default, Eq, PartialEq)]
109pub struct PlatformTradesRequest {
110    pub limit: Option<u16>,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct PlatformCandlesRequest {
115    pub from_ms: u64,
116    pub to_ms: u64,
117    pub resolution_seconds: Option<u32>,
118}
119
120#[derive(Clone, Debug, Default, Eq, PartialEq)]
121pub struct PlatformRewardsRequest {
122    pub wallet_address: Option<String>,
123    pub limit: Option<u16>,
124}
125
126#[derive(Clone, Debug, Default, Eq, PartialEq)]
127pub struct PlatformVaultStatusRequest {
128    pub session_public_key: Option<String>,
129}
130
131#[derive(Clone, Debug, Default, Eq, PartialEq)]
132pub struct PlatformAccountMarketRequest {
133    pub fill_limit: Option<u16>,
134}
135
136#[derive(Clone, Debug, Default, Eq, PartialEq)]
137pub struct PlatformAccountRequest {
138    pub fill_limit: Option<u16>,
139    /// Omit to read every currently discoverable public Strata market.
140    pub market_ids: Option<Vec<String>>,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct PlatformAccountSnapshot {
145    pub wallet_address: String,
146    pub server_time_ms: u64,
147    pub markets: Vec<PlatformAccountSnapshotResponse>,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct PlatformMakerReputationAuthorizedRequest {
152    pub market_id: String,
153    pub wallet_address: String,
154    pub authorization_time_ms: u64,
155    pub authorization_signature: String,
156}
157
158/// Detached external authorization for the owner-scoped maker status read.
159pub type PlatformMakerStatusAuthorizedRequest = PlatformMakerReputationAuthorizedRequest;
160
161#[async_trait]
162pub trait AccountSigner: Send + Sync {
163    /// Canonical base58 wallet address whose account state is being read.
164    fn public_key(&self) -> &str;
165
166    /// Sign only the exact SDK-generated, short-lived account-read message.
167    async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String>;
168}
169
170/// Type-level placeholder for "no signer" (public reads).
171pub struct NoSigner;
172
173#[async_trait]
174impl AccountSigner for NoSigner {
175    fn public_key(&self) -> &str {
176        ""
177    }
178
179    async fn sign_message(&self, _message: &[u8]) -> Result<Vec<u8>, String> {
180        Err("no signer".to_owned())
181    }
182}
183
184#[async_trait]
185pub trait SessionSigner: Send + Sync {
186    /// Canonical base58 Ed25519 public key registered as the Vault delegate.
187    fn public_key(&self) -> &str;
188
189    /// Sign the exact SDK-validated public operation authorization. Only the
190    /// two-step challenge path needs it; the one-call `execute_*` helpers and
191    /// the order command channel are one signature over the transaction and
192    /// never call it.
193    async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String>;
194
195    /// Add only the session signature to an already-verified transaction.
196    async fn sign_transaction(&self, transaction_base64: &str) -> Result<String, String>;
197}
198
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub enum OrderExecuteOperation {
201    Place {
202        owner_wallet: String,
203        /// Vault market account sequence. `None` lets Strata resolve the next
204        /// sequence from the Vault's confirmed market account when the
205        /// transaction is prepared.
206        account_sequence: Option<String>,
207        client_order_id: String,
208        side: PlatformTradeSide,
209        order_type: PlatformOrderType,
210        limit_price_atoms: String,
211        size_atoms: String,
212    },
213    Cancel {
214        owner_wallet: String,
215        order_id: String,
216    },
217    CancelAll {
218        owner_wallet: String,
219    },
220    Replace {
221        owner_wallet: String,
222        order_id: String,
223        account_sequence: Option<String>,
224        client_order_id: String,
225        side: PlatformTradeSide,
226        order_type: PlatformOrderType,
227        limit_price_atoms: String,
228        size_atoms: String,
229    },
230    Batch {
231        owner_wallet: String,
232        operations: Vec<PlatformOrderBatchOperation>,
233    },
234}
235
236impl OrderExecuteOperation {
237    pub(crate) fn challenge_request(
238        &self,
239        session_public_key: String,
240    ) -> PlatformOrderChallengeRequest {
241        match self {
242            Self::Place {
243                owner_wallet,
244                account_sequence,
245                client_order_id,
246                side,
247                order_type,
248                limit_price_atoms,
249                size_atoms,
250            } => PlatformOrderChallengeRequest::Place {
251                owner_wallet: owner_wallet.clone(),
252                session_public_key,
253                account_sequence: account_sequence.clone(),
254                client_order_id: client_order_id.clone(),
255                side: *side,
256                order_type: *order_type,
257                limit_price_atoms: limit_price_atoms.clone(),
258                size_atoms: size_atoms.clone(),
259            },
260            Self::Cancel {
261                owner_wallet,
262                order_id,
263            } => PlatformOrderChallengeRequest::Cancel {
264                owner_wallet: owner_wallet.clone(),
265                session_public_key,
266                order_id: order_id.clone(),
267            },
268            Self::CancelAll { owner_wallet } => PlatformOrderChallengeRequest::CancelAll {
269                owner_wallet: owner_wallet.clone(),
270                session_public_key,
271            },
272            Self::Replace {
273                owner_wallet,
274                order_id,
275                account_sequence,
276                client_order_id,
277                side,
278                order_type,
279                limit_price_atoms,
280                size_atoms,
281            } => PlatformOrderChallengeRequest::Replace {
282                owner_wallet: owner_wallet.clone(),
283                session_public_key,
284                order_id: order_id.clone(),
285                account_sequence: account_sequence.clone(),
286                client_order_id: client_order_id.clone(),
287                side: *side,
288                order_type: *order_type,
289                limit_price_atoms: limit_price_atoms.clone(),
290                size_atoms: size_atoms.clone(),
291            },
292            Self::Batch {
293                owner_wallet,
294                operations,
295            } => PlatformOrderChallengeRequest::Batch {
296                owner_wallet: owner_wallet.clone(),
297                session_public_key,
298                operations: operations.clone(),
299            },
300        }
301    }
302}
303
304/// Everything a verifier needs to decide whether the session may sign one
305/// prepared resting-order transaction.
306#[derive(Debug)]
307pub struct OrderVerificationContext<'a> {
308    /// Present only on the two-step (challenge) path.
309    pub challenge: Option<&'a PlatformOrderChallengeResponse>,
310    /// The bound operation: exactly as sent (direct path) or as made
311    /// effective by the challenge (order command channel).
312    pub operation: &'a PlatformOrderChallengeRequest,
313    pub market_id: &'a str,
314    pub prepared: &'a PlatformOrderPrepareResponse,
315    pub owner_wallet: &'a str,
316    pub session_public_key: &'a str,
317}
318
319#[async_trait]
320pub trait OrderVerifier: Send + Sync {
321    /// Reject unless the prepared transaction implements the exact signed
322    /// order operation for this Vault session.
323    async fn verify(&self, context: &OrderVerificationContext<'_>) -> Result<(), String>;
324}
325
326#[derive(Clone, Debug, Eq, PartialEq)]
327pub enum TwapExecuteOperation {
328    Place {
329        owner_wallet: String,
330        side: PlatformTradeSide,
331        total_size_atoms: String,
332        slices_total: u16,
333        maximum_tolerance_bps: u16,
334        interval_slots: u32,
335        limit_price_atoms: String,
336    },
337    Cancel {
338        owner_wallet: String,
339        twap_id: String,
340    },
341}
342
343impl TwapExecuteOperation {
344    fn challenge_request(&self, session_public_key: String) -> PlatformTwapChallengeRequest {
345        match self {
346            Self::Place {
347                owner_wallet,
348                side,
349                total_size_atoms,
350                slices_total,
351                maximum_tolerance_bps,
352                interval_slots,
353                limit_price_atoms,
354            } => PlatformTwapChallengeRequest::Place {
355                owner_wallet: owner_wallet.clone(),
356                session_public_key,
357                side: *side,
358                total_size_atoms: total_size_atoms.clone(),
359                slices_total: *slices_total,
360                maximum_tolerance_bps: *maximum_tolerance_bps,
361                interval_slots: *interval_slots,
362                limit_price_atoms: limit_price_atoms.clone(),
363            },
364            Self::Cancel {
365                owner_wallet,
366                twap_id,
367            } => PlatformTwapChallengeRequest::Cancel {
368                owner_wallet: owner_wallet.clone(),
369                session_public_key,
370                twap_id: twap_id.clone(),
371            },
372        }
373    }
374}
375
376/// Everything a verifier needs to decide whether the session may sign one
377/// prepared TWAP-control transaction.
378#[derive(Debug)]
379pub struct TwapVerificationContext<'a> {
380    /// Present only on the two-step (challenge) path.
381    pub challenge: Option<&'a PlatformTwapChallengeResponse>,
382    /// The requested action, exactly as sent.
383    pub operation: &'a PlatformTwapChallengeRequest,
384    pub market_id: &'a str,
385    pub prepared: &'a PlatformTwapPrepareResponse,
386    pub owner_wallet: &'a str,
387    pub session_public_key: &'a str,
388}
389
390#[async_trait]
391pub trait TwapVerifier: Send + Sync {
392    /// Reject unless the prepared transaction implements the exact bounded
393    /// TWAP action authorized by the external owner.
394    async fn verify(&self, context: &TwapVerificationContext<'_>) -> Result<(), String>;
395}
396
397/// Everything a verifier needs to decide whether the session may sign one
398/// prepared immediate execution.
399#[derive(Debug)]
400pub struct ExecutionVerificationContext<'a> {
401    pub quote: &'a QuoteResponse,
402    /// Present only on the two-step (challenge) path.
403    pub challenge: Option<&'a ExecutionChallengeResponse>,
404    pub prepared: &'a ExecutionPrepareResponse,
405    pub owner_wallet: &'a str,
406    pub session_public_key: &'a str,
407}
408
409#[async_trait]
410pub trait ExecutionVerifier: Send + Sync {
411    /// Reject unless the prepared transaction is acceptable for this exact
412    /// Vault session and public economic intent.
413    async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String>;
414}
415
416#[derive(Debug, Error)]
417pub enum SdkError {
418    #[error("invalid API base URL: {0}")]
419    InvalidBaseUrl(String),
420    #[error("invalid request: {0}")]
421    InvalidRequest(String),
422    #[error("market is not available: {0}")]
423    MarketNotFound(String),
424    #[error("operation is not available for market: {0}")]
425    OperationUnavailable(String),
426    #[error("Strata API error {status} ({code}): {message}")]
427    Api {
428        status: StatusCode,
429        code: String,
430        message: String,
431        retryable: bool,
432    },
433    #[error("invalid public contract response: {0}")]
434    InvalidResponse(String),
435    #[error("session signer rejected the operation: {0}")]
436    Signer(String),
437    #[error("prepared transaction was rejected: {0}")]
438    Verification(String),
439    #[error("persistent order command stream failed: {0}")]
440    Stream(String),
441    #[error("order command rejected ({code}): {message}")]
442    Command {
443        code: String,
444        message: String,
445        retryable: bool,
446    },
447    #[error(transparent)]
448    Transport(#[from] reqwest::Error),
449}
450
451#[derive(Clone, Debug)]
452pub struct StrataClient {
453    base_url: Url,
454    http: reqwest::Client,
455}
456
457impl StrataClient {
458    pub fn production() -> Result<Self, SdkError> {
459        Self::new(DEFAULT_API_BASE)
460    }
461
462    pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
463        Self::with_timeout(base_url, DEFAULT_TIMEOUT)
464    }
465
466    pub fn with_timeout(base_url: impl AsRef<str>, timeout: Duration) -> Result<Self, SdkError> {
467        if timeout.is_zero() {
468            return Err(SdkError::InvalidRequest(
469                "timeout must be greater than zero".to_owned(),
470            ));
471        }
472        let base_url = normalize_base_url(base_url.as_ref())?;
473        let http = reqwest::Client::builder().timeout(timeout).build()?;
474        Ok(Self { base_url, http })
475    }
476
477    /// Open one authenticated, persistent order-command connection. The
478    /// external session signer is used for authentication and is not retained.
479    pub async fn connect_order_commands<S: SessionSigner + ?Sized>(
480        &self,
481        market_id: &str,
482        owner_wallet: &str,
483        signer: &S,
484    ) -> Result<OrderCommandStream, SdkError> {
485        OrderCommandStream::connect(self, market_id, owner_wallet, signer).await
486    }
487
488    /// Open the sequenced Strata market-data stream. A sequence gap fails
489    /// closed so the caller can reconnect and recover from a new snapshot.
490    pub async fn connect_market_data(&self, market_id: &str) -> Result<MarketDataStream, SdkError> {
491        MarketDataStream::connect(self, market_id).await
492    }
493
494    /// Open the sequenced execution stream for one market, watching the opaque
495    /// handles issued by `execution.prepare`. It begins with a snapshot; a gap
496    /// fails closed so the caller reconnects and recovers.
497    pub async fn connect_executions(
498        &self,
499        market_id: &str,
500        execution_ids: &[String],
501    ) -> Result<ExecutionStream, SdkError> {
502        ExecutionStream::connect(self, market_id, execution_ids).await
503    }
504
505    /// Open the sequenced TWAP progress stream for a wallet in one market. It
506    /// begins with a snapshot and then delivers one complete sanitized TWAP row
507    /// per change; a gap fails closed so the caller reconnects and recovers.
508    pub async fn connect_twaps(
509        &self,
510        market_id: &str,
511        wallet_address: &str,
512    ) -> Result<TwapStream, SdkError> {
513        TwapStream::connect(self, market_id, wallet_address).await
514    }
515
516    /// Open the maker stream for one market by wallet address — public, no
517    /// signature: a maker snapshot followed by sequenced maker fills,
518    /// product/exposure changes, and heartbeats.
519    pub async fn connect_maker_for_wallet(
520        &self,
521        market_id: &str,
522        wallet_address: &str,
523    ) -> Result<MakerStream, SdkError> {
524        MakerStream::connect(self, market_id, wallet_address, None::<&NoSigner>).await
525    }
526
527    /// Same stream, addressed by a signer's public key; the server's
528    /// compatibility challenge is answered with the signer's signature.
529    pub async fn connect_maker<S: AccountSigner + ?Sized>(
530        &self,
531        market_id: &str,
532        signer: &S,
533    ) -> Result<MakerStream, SdkError> {
534        MakerStream::connect(self, market_id, signer.public_key(), Some(signer)).await
535    }
536
537    /// Open one externally authenticated private account stream. The signer
538    /// is used only for the server challenge and is not retained by the SDK.
539    pub async fn connect_account<S: AccountSigner + ?Sized>(
540        &self,
541        market_id: &str,
542        signer: &S,
543    ) -> Result<AccountStream, SdkError> {
544        AccountStream::connect(self, market_id, signer).await
545    }
546
547    /// Read the operations currently enabled through the public 2.0 product
548    /// contract. This response contains product capabilities only.
549    pub async fn platform_capabilities(&self) -> Result<PlatformDiscoveryResponse, SdkError> {
550        let discovery: PlatformDiscoveryResponse = self.get("v2/capabilities", &[]).await?;
551        validate_platform_discovery(&discovery)?;
552        Ok(discovery)
553    }
554
555    /// Read the complete customer-safe entity, operation, and workflow graph
556    /// projected against the capabilities that are live now.
557    pub async fn platform_action_graph(&self) -> Result<PlatformActionGraphResponse, SdkError> {
558        let graph: PlatformActionGraphResponse = self.get("v2/action-graph", &[]).await?;
559        validate_platform_action_graph(&graph)?;
560        Ok(graph)
561    }
562
563    /// Read product-level readiness without exposing internal services.
564    pub async fn platform_status(&self) -> Result<PlatformServiceStatusResponse, SdkError> {
565        let status: PlatformServiceStatusResponse = self.get("v2/status", &[]).await?;
566        validate_platform_version(status.schema_version, &status.contract_version)?;
567        Ok(status)
568    }
569
570    pub async fn platform_assets(
571        &self,
572        request: PageRequest,
573    ) -> Result<PlatformAssetsResponse, SdkError> {
574        let query = normalize_page_request(request)?;
575        let response: PlatformAssetsResponse = self.get("v2/assets", &query).await?;
576        validate_platform_version(response.schema_version, &response.contract_version)?;
577        validate_page_info(&response.page)?;
578        if response.assets.iter().any(|asset| {
579            asset.asset_id.trim().is_empty()
580                || asset.symbol.trim().is_empty()
581                || asset.name.trim().is_empty()
582                || asset.decimals > 18
583        }) {
584            return Err(SdkError::InvalidResponse(
585                "asset discovery contains an invalid public asset".to_owned(),
586            ));
587        }
588        Ok(response)
589    }
590
591    /// Request a short-lived exact-input quote between two assets returned by
592    /// [`Self::platform_assets`].
593    pub async fn platform_swap_quote(
594        &self,
595        request: PlatformSwapQuoteRequest,
596    ) -> Result<PlatformSwapQuoteResponse, SdkError> {
597        let input_asset_id = validate_platform_asset_id(&request.input_asset_id)?;
598        let output_asset_id = validate_platform_asset_id(&request.output_asset_id)?;
599        if input_asset_id == output_asset_id {
600            return Err(SdkError::InvalidRequest(
601                "input and output asset IDs must differ".to_owned(),
602            ));
603        }
604        let amount_in =
605            canonical_request_atoms(&request.amount_in_atoms, "amount_in_atoms", false)?
606                .parse::<u64>()
607                .expect("canonical atomic request was already range checked");
608        if request.maximum_tolerance_bps > 1_000 {
609            return Err(SdkError::InvalidRequest(
610                "maximum_tolerance_bps must be between 0 and 1,000".to_owned(),
611            ));
612        }
613        let quote: PlatformSwapQuoteResponse = self.post("v2/quotes", &request).await?;
614        validate_platform_version(quote.schema_version, &quote.contract_version)?;
615        if quote.provider != "Sonar"
616            || quote.input_asset_id != input_asset_id
617            || quote.output_asset_id != output_asset_id
618            || quote.amount_in_atoms != request.amount_in_atoms
619            || quote.maximum_tolerance_bps != request.maximum_tolerance_bps
620            || !valid_handle(&quote.quote_id, "sq_")
621            || quote.expires_at_ms <= quote.server_time_ms
622        {
623            return Err(SdkError::InvalidResponse(
624                "swap quote binding or lifetime is invalid".to_owned(),
625            ));
626        }
627        let consumed = validate_response_atoms(
628            &quote.amount_in_consumed_atoms,
629            "amount_in_consumed_atoms",
630            false,
631        )?;
632        let output = validate_response_atoms(&quote.amount_out_atoms, "amount_out_atoms", false)?;
633        let minimum =
634            validate_response_atoms(&quote.minimum_output_atoms, "minimum_output_atoms", true)?;
635        validate_response_atoms(&quote.input_fee_atoms, "input_fee_atoms", true)?;
636        validate_response_atoms(&quote.output_fee_atoms, "output_fee_atoms", true)?;
637        canonical_decimal(&quote.reference_price, "reference_price")?;
638        canonical_decimal(&quote.price_impact_pct, "price_impact_pct")?;
639        if consumed > amount_in || minimum > output {
640            return Err(SdkError::InvalidResponse(
641                "swap quote economics are internally inconsistent".to_owned(),
642            ));
643        }
644        Ok(quote)
645    }
646
647    pub async fn platform_markets(
648        &self,
649        request: PageRequest,
650    ) -> Result<PlatformMarketsResponse, SdkError> {
651        let query = normalize_page_request(request)?;
652        let response: PlatformMarketsResponse = self.get("v2/markets", &query).await?;
653        validate_platform_version(response.schema_version, &response.contract_version)?;
654        validate_page_info(&response.page)?;
655        let mut ids = HashSet::new();
656        if response.markets.iter().any(|market| {
657            validate_platform_market_id(&market.market_id).is_err()
658                || market.label.trim().is_empty()
659                || market.base_asset_id.trim().is_empty()
660                || market.quote_asset_id.trim().is_empty()
661                || !ids.insert(market.market_id.as_str())
662        }) {
663            return Err(SdkError::InvalidResponse(
664                "market discovery contains an invalid public market".to_owned(),
665            ));
666        }
667        Ok(response)
668    }
669
670    pub async fn platform_book(
671        &self,
672        market_id: &str,
673        request: PlatformBookRequest,
674    ) -> Result<PlatformBookSnapshotResponse, SdkError> {
675        let market_id = validate_platform_market_id(market_id)?;
676        let query = match request.depth {
677            Some(depth @ 1..=2_000) => vec![("depth".to_owned(), depth.to_string())],
678            Some(_) => {
679                return Err(SdkError::InvalidRequest(
680                    "depth must be between 1 and 2,000".to_owned(),
681                ))
682            }
683            None => Vec::new(),
684        };
685        let response: PlatformBookSnapshotResponse = self
686            .get(&format!("v2/markets/{market_id}/book"), &query)
687            .await?;
688        validate_platform_market_response(
689            response.schema_version,
690            &response.contract_version,
691            &response.market_id,
692            &market_id,
693        )?;
694        validate_book_levels(&response.bids, &response.asks)?;
695        validate_response_atoms(&response.sequence, "sequence", false)?;
696        if response.stream_id.trim().is_empty() || response.snapshot_id.trim().is_empty() {
697            return Err(SdkError::InvalidResponse(
698                "book snapshot identity is invalid".to_owned(),
699            ));
700        }
701        Ok(response)
702    }
703
704    pub async fn platform_best_bid_ask(
705        &self,
706        market_id: &str,
707    ) -> Result<PlatformBestBidAskResponse, SdkError> {
708        let market_id = validate_platform_market_id(market_id)?;
709        let response: PlatformBestBidAskResponse = self
710            .get(&format!("v2/markets/{market_id}/bbo"), &[])
711            .await?;
712        validate_platform_market_response(
713            response.schema_version,
714            &response.contract_version,
715            &response.market_id,
716            &market_id,
717        )?;
718        if let Some(level) = &response.best_bid {
719            validate_book_level(level)?;
720        }
721        if let Some(level) = &response.best_ask {
722            validate_book_level(level)?;
723        }
724        validate_response_atoms(&response.sequence, "sequence", false)?;
725        Ok(response)
726    }
727
728    pub async fn platform_fees(
729        &self,
730        market_id: &str,
731    ) -> Result<PlatformFeeScheduleResponse, SdkError> {
732        let market_id = validate_platform_market_id(market_id)?;
733        let response: PlatformFeeScheduleResponse = self
734            .get(&format!("v2/markets/{market_id}/fees"), &[])
735            .await?;
736        validate_platform_market_response(
737            response.schema_version,
738            &response.contract_version,
739            &response.market_id,
740            &market_id,
741        )?;
742        if response.passive_maker_fee_bps > 10_000
743            || response.maximum_immediate_execution_fee_bps > 10_000
744        {
745            return Err(SdkError::InvalidResponse(
746                "fee schedule is outside public bounds".to_owned(),
747            ));
748        }
749        Ok(response)
750    }
751
752    pub async fn platform_market_status(
753        &self,
754        market_id: &str,
755    ) -> Result<PlatformMarketStatusResponse, SdkError> {
756        let market_id = validate_platform_market_id(market_id)?;
757        let response: PlatformMarketStatusResponse = self
758            .get(&format!("v2/markets/{market_id}/status"), &[])
759            .await?;
760        validate_platform_market_response(
761            response.schema_version,
762            &response.contract_version,
763            &response.market_id,
764            &market_id,
765        )?;
766        validate_response_atoms(&response.tick_size_atoms, "tick_size_atoms", false)?;
767        validate_response_atoms(
768            &response.minimum_order_size_atoms,
769            "minimum_order_size_atoms",
770            false,
771        )?;
772        Ok(response)
773    }
774
775    pub async fn platform_trades(
776        &self,
777        market_id: &str,
778        request: PlatformTradesRequest,
779    ) -> Result<PlatformTradesResponse, SdkError> {
780        let market_id = validate_platform_market_id(market_id)?;
781        let query = match request.limit {
782            Some(limit @ 1..=500) => vec![("limit".to_owned(), limit.to_string())],
783            Some(_) => {
784                return Err(SdkError::InvalidRequest(
785                    "trade limit must be between 1 and 500".to_owned(),
786                ))
787            }
788            None => Vec::new(),
789        };
790        let response: PlatformTradesResponse = self
791            .get(&format!("v2/markets/{market_id}/trades"), &query)
792            .await?;
793        validate_platform_market_response(
794            response.schema_version,
795            &response.contract_version,
796            &response.market_id,
797            &market_id,
798        )?;
799        if response.trades.iter().any(|trade| {
800            trade.trade_id.trim().is_empty()
801                || validate_response_atoms(&trade.price_atoms, "price_atoms", false).is_err()
802                || validate_response_atoms(&trade.size_atoms, "size_atoms", false).is_err()
803        }) {
804            return Err(SdkError::InvalidResponse(
805                "trade history contains an invalid trade".to_owned(),
806            ));
807        }
808        Ok(response)
809    }
810
811    pub async fn platform_candles(
812        &self,
813        market_id: &str,
814        request: PlatformCandlesRequest,
815    ) -> Result<PlatformCandlesResponse, SdkError> {
816        let market_id = validate_platform_market_id(market_id)?;
817        if request.to_ms <= request.from_ms {
818            return Err(SdkError::InvalidRequest(
819                "candle timestamps must form an increasing range".to_owned(),
820            ));
821        }
822        let resolution = request.resolution_seconds.unwrap_or(300);
823        if !(60..=86_400).contains(&resolution) || !resolution.is_multiple_of(60) {
824            return Err(SdkError::InvalidRequest(
825                "candle resolution must be whole minutes up to one day".to_owned(),
826            ));
827        }
828        let query = vec![
829            ("from_ms".to_owned(), request.from_ms.to_string()),
830            ("to_ms".to_owned(), request.to_ms.to_string()),
831            ("resolution_seconds".to_owned(), resolution.to_string()),
832        ];
833        let response: PlatformCandlesResponse = self
834            .get(&format!("v2/markets/{market_id}/candles"), &query)
835            .await?;
836        validate_platform_market_response(
837            response.schema_version,
838            &response.contract_version,
839            &response.market_id,
840            &market_id,
841        )?;
842        if response.resolution_seconds != resolution
843            || response.candles.iter().any(|candle| {
844                candle.started_at_ms < request.from_ms
845                    || candle.started_at_ms >= request.to_ms
846                    || [
847                        &candle.open_price,
848                        &candle.high_price,
849                        &candle.low_price,
850                        &candle.close_price,
851                    ]
852                    .iter()
853                    .any(|price| canonical_decimal(price, "candle price").is_err())
854            })
855        {
856            return Err(SdkError::InvalidResponse(
857                "candle response does not match the requested range".to_owned(),
858            ));
859        }
860        Ok(response)
861    }
862
863    pub async fn platform_mark(&self, market_id: &str) -> Result<PlatformMarkResponse, SdkError> {
864        let market_id = validate_platform_market_id(market_id)?;
865        let response: PlatformMarkResponse = self
866            .get(&format!("v2/markets/{market_id}/marks"), &[])
867            .await?;
868        validate_platform_market_response(
869            response.schema_version,
870            &response.contract_version,
871            &response.market_id,
872            &market_id,
873        )?;
874        if let Some(price) = &response.price_atoms_per_base_unit {
875            validate_response_atoms(price, "price_atoms_per_base_unit", false)?;
876        }
877        if response.stale != response.price_atoms_per_base_unit.is_none()
878            || response.quote_decimals > 18
879        {
880            return Err(SdkError::InvalidResponse(
881                "mark staleness metadata is inconsistent".to_owned(),
882            ));
883        }
884        Ok(response)
885    }
886
887    pub async fn platform_execution_status(
888        &self,
889        market_id: &str,
890        execution_id: &str,
891    ) -> Result<PlatformExecutionStatusResponse, SdkError> {
892        let market_id = validate_platform_market_id(market_id)?;
893        let execution_id = execution_id.trim();
894        if !valid_handle(execution_id, "se_") {
895            return Err(SdkError::InvalidRequest(
896                "execution_id must be an opaque Strata execution ID".to_owned(),
897            ));
898        }
899        let response: PlatformExecutionStatusResponse = self
900            .get(
901                &format!("v2/markets/{market_id}/executions/{execution_id}"),
902                &[],
903            )
904            .await?;
905        validate_platform_market_response(
906            response.schema_version,
907            &response.contract_version,
908            &response.market_id,
909            &market_id,
910        )?;
911        if response.execution_id != execution_id
912            || (response.status == PlatformExecutionState::Confirmed
913                && response.signature.as_deref().is_none_or(str::is_empty))
914        {
915            return Err(SdkError::InvalidResponse(
916                "execution status does not match the requested execution".to_owned(),
917            ));
918        }
919        Ok(response)
920    }
921
922    pub async fn platform_twaps(
923        &self,
924        market_id: &str,
925        wallet_address: &str,
926    ) -> Result<PlatformTwapsResponse, SdkError> {
927        let market_id = validate_platform_market_id(market_id)?;
928        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
929        let response: PlatformTwapsResponse = self
930            .get(
931                &format!("v2/markets/{market_id}/account/{wallet_address}/twaps"),
932                &[],
933            )
934            .await?;
935        validate_platform_market_response(
936            response.schema_version,
937            &response.contract_version,
938            &response.market_id,
939            &market_id,
940        )?;
941        if response.wallet_address != wallet_address
942            || response
943                .twaps
944                .iter()
945                .any(|twap| !valid_handle(&twap.twap_id, "twap_"))
946        {
947            return Err(SdkError::InvalidResponse(
948                "TWAP history identity does not match the request".to_owned(),
949            ));
950        }
951        Ok(response)
952    }
953
954    /// The whole account in one public read, by wallet address: balances
955    /// (total / available / locked, exact USD), positions, open orders, and
956    /// recent fills across every live market. No signature, no session key,
957    /// no market selection. `platform_account` is the same read.
958    pub async fn platform_portfolio(
959        &self,
960        wallet_address: &str,
961    ) -> Result<PlatformPortfolioResponse, SdkError> {
962        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
963        let response: PlatformPortfolioResponse = self
964            .get(&format!("v2/account/{wallet_address}/portfolio"), &[])
965            .await?;
966        validate_platform_version(response.schema_version, &response.contract_version)?;
967        if response.wallet_address != wallet_address {
968            return Err(SdkError::InvalidResponse(
969                "portfolio identity does not match the request".to_owned(),
970            ));
971        }
972        validate_platform_portfolio(&response)?;
973        Ok(response)
974    }
975
976    /// Alias of `platform_portfolio`: the whole account in one public read.
977    pub async fn platform_account(
978        &self,
979        wallet_address: &str,
980    ) -> Result<PlatformPortfolioResponse, SdkError> {
981        self.platform_portfolio(wallet_address).await
982    }
983
984    pub async fn platform_portfolio_history(
985        &self,
986        wallet_address: &str,
987        range: PlatformPortfolioHistoryRange,
988    ) -> Result<PlatformPortfolioHistoryResponse, SdkError> {
989        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
990        let range_value = platform_history_range(range);
991        let query = vec![("range".to_owned(), range_value.to_owned())];
992        let response: PlatformPortfolioHistoryResponse = self
993            .get(
994                &format!("v2/account/{wallet_address}/portfolio/history"),
995                &query,
996            )
997            .await?;
998        validate_platform_version(response.schema_version, &response.contract_version)?;
999        if response.wallet_address != wallet_address || response.range != range {
1000            return Err(SdkError::InvalidResponse(
1001                "portfolio history identity does not match the request".to_owned(),
1002            ));
1003        }
1004        Ok(response)
1005    }
1006
1007    /// Read sealed Vault owner state and, optionally, one external session.
1008    pub async fn platform_vault_status(
1009        &self,
1010        wallet_address: &str,
1011        request: PlatformVaultStatusRequest,
1012    ) -> Result<PlatformVaultStatusResponse, SdkError> {
1013        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
1014        let session_public_key = request
1015            .session_public_key
1016            .as_deref()
1017            .map(|value| canonical_public_key(value, "session_public_key"))
1018            .transpose()?;
1019        let mut query = vec![("wallet_address".to_owned(), wallet_address.clone())];
1020        if let Some(session_public_key) = &session_public_key {
1021            query.push(("session_public_key".to_owned(), session_public_key.clone()));
1022        }
1023        let response: PlatformVaultStatusResponse = self.get("v2/vault/status", &query).await?;
1024        validate_platform_version(response.schema_version, &response.contract_version)?;
1025        if response.wallet_address != wallet_address
1026            || match (&session_public_key, &response.session) {
1027                (None, None) => false,
1028                (Some(expected), Some(session)) => session.session_public_key != *expected,
1029                _ => true,
1030            }
1031        {
1032            return Err(SdkError::InvalidResponse(
1033                "Vault status identity does not match the request".to_owned(),
1034            ));
1035        }
1036        let mut asset_ids = HashSet::new();
1037        if response.session.as_ref().is_some_and(|session| {
1038            session.spending_limits.len() > 4
1039                || session.maximum_tolerance_bps > 10_000
1040                || session.spending_limits.iter().any(|limit| {
1041                    validate_platform_asset_id(&limit.asset_id).is_err()
1042                        || !asset_ids.insert(limit.asset_id.clone())
1043                        || limit
1044                            .maximum_per_execution_atoms
1045                            .as_ref()
1046                            .is_some_and(|atoms| {
1047                                validate_response_atoms(atoms, "maximum_per_execution_atoms", false)
1048                                    .is_err()
1049                            })
1050                })
1051                || (session.state != PlatformVaultSessionState::Active
1052                    && (session.market_execution_ready || session.price_protection_active))
1053                || (response.state != PlatformVaultState::Active
1054                    && (session.market_execution_ready || session.price_protection_active))
1055                || (session.permanent
1056                    != (session.expires_at_ms.is_none()
1057                        && session.state != PlatformVaultSessionState::Absent))
1058                || (session.state == PlatformVaultSessionState::Active
1059                    && session
1060                        .expires_at_ms
1061                        .is_some_and(|expiry| expiry <= response.server_time_ms))
1062                || (session.state == PlatformVaultSessionState::Expired
1063                    && session
1064                        .expires_at_ms
1065                        .is_none_or(|expiry| expiry > response.server_time_ms))
1066        }) {
1067            return Err(SdkError::InvalidResponse(
1068                "Vault session state is inconsistent".to_owned(),
1069            ));
1070        }
1071        let mut allowed_wallets = HashSet::new();
1072        if response.withdrawal_access.allowed_wallet_addresses.len() > 8
1073            || response
1074                .withdrawal_access
1075                .allowed_wallet_addresses
1076                .iter()
1077                .any(|wallet| {
1078                    canonical_public_key(wallet, "allowed_wallet_address").is_err()
1079                        || !allowed_wallets.insert(wallet.clone())
1080                })
1081            || ((response.withdrawal_access.mode == PlatformVaultWithdrawalMode::Restricted)
1082                != !response
1083                    .withdrawal_access
1084                    .allowed_wallet_addresses
1085                    .is_empty())
1086        {
1087            return Err(SdkError::InvalidResponse(
1088                "Vault withdrawal access is inconsistent".to_owned(),
1089            ));
1090        }
1091        Ok(response)
1092    }
1093
1094    /// Prepare an owner-authorized Vault pause or resume transaction. The
1095    /// external owner verifies, signs, and broadcasts the returned bytes.
1096    pub async fn platform_vault_pause_prepare(
1097        &self,
1098        request: PlatformVaultPausePrepareRequest,
1099    ) -> Result<PlatformVaultPausePrepareResponse, SdkError> {
1100        let request = PlatformVaultPausePrepareRequest {
1101            wallet_address: canonical_public_key(&request.wallet_address, "wallet_address")?,
1102            paused: request.paused,
1103        };
1104        let response: PlatformVaultPausePrepareResponse =
1105            self.post("v2/vault/pause/prepare", &request).await?;
1106        validate_platform_version(response.schema_version, &response.contract_version)?;
1107        if response.wallet_address != request.wallet_address
1108            || response.paused != request.paused
1109            || !response.owner_signature_required
1110        {
1111            return Err(SdkError::InvalidResponse(
1112                "Vault pause preparation does not match the request".to_owned(),
1113            ));
1114        }
1115        canonical_base64(&response.transaction_base64, "transaction_base64")?;
1116        canonical_public_key(&response.recent_blockhash, "recent_blockhash")?;
1117        validate_vault_preparation(&response.preparation_id, response.submit_by_ms)?;
1118        Ok(response)
1119    }
1120
1121    /// Prepare one-signature Vault onboarding (or a further session) for
1122    /// external owner verification, signing, and broadcast. Only the wallet
1123    /// and the session key are required; the policy fields are optional and
1124    /// take the product defaults when absent.
1125    pub async fn platform_vault_setup_prepare(
1126        &self,
1127        request: PlatformVaultSetupPrepareRequest,
1128    ) -> Result<PlatformVaultSetupPrepareResponse, SdkError> {
1129        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1130        let session_public_key =
1131            canonical_public_key(&request.session_public_key, "session_public_key")?;
1132        if wallet_address == session_public_key {
1133            return Err(SdkError::InvalidRequest(
1134                "session_public_key must differ from wallet_address".to_owned(),
1135            ));
1136        }
1137        let market_id = request
1138            .market_id
1139            .as_deref()
1140            .map(validate_platform_market_id)
1141            .transpose()?;
1142        let minimum_interval_seconds = request
1143            .minimum_interval_seconds
1144            .unwrap_or(PLATFORM_SESSION_DEFAULT_MINIMUM_INTERVAL_SECONDS);
1145        let maximum_tolerance_bps = request
1146            .maximum_tolerance_bps
1147            .unwrap_or(PLATFORM_SESSION_DEFAULT_MAXIMUM_TOLERANCE_BPS);
1148        let now_ms = unix_ms()?;
1149        if request
1150            .expires_at_ms
1151            .is_some_and(|expiry| expiry % 1_000 != 0 || expiry <= now_ms.saturating_add(60_000))
1152            || !(1..=86_400).contains(&minimum_interval_seconds)
1153            || !(1..=1_000).contains(&maximum_tolerance_bps)
1154            || request.spending_limits.len() > PLATFORM_SESSION_MAX_SPENDING_LIMITS
1155        {
1156            return Err(SdkError::InvalidRequest(
1157                "Vault setup policy is invalid".to_owned(),
1158            ));
1159        }
1160        let mut asset_ids = HashSet::new();
1161        for limit in &request.spending_limits {
1162            validate_platform_asset_id(&limit.asset_id)?;
1163            if !asset_ids.insert(limit.asset_id.clone())
1164                || limit
1165                    .maximum_per_execution_atoms
1166                    .as_ref()
1167                    .is_some_and(|atoms| {
1168                        canonical_request_atoms(atoms, "maximum_per_execution_atoms", false)
1169                            .is_err()
1170                    })
1171            {
1172                return Err(SdkError::InvalidRequest(
1173                    "Vault setup spending limits are invalid".to_owned(),
1174                ));
1175            }
1176        }
1177        let request = PlatformVaultSetupPrepareRequest {
1178            wallet_address,
1179            session_public_key,
1180            market_id,
1181            expires_at_ms: request.expires_at_ms,
1182            minimum_interval_seconds: Some(minimum_interval_seconds),
1183            maximum_tolerance_bps: Some(maximum_tolerance_bps),
1184            spending_limits: request.spending_limits,
1185        };
1186        let response: PlatformVaultSetupPrepareResponse =
1187            self.post("v2/vault/setup/prepare", &request).await?;
1188        validate_platform_version(response.schema_version, &response.contract_version)?;
1189        if response.wallet_address != request.wallet_address
1190            || response.session_public_key != request.session_public_key
1191            || response.market_id != request.market_id
1192            || response.expires_at_ms != request.expires_at_ms
1193            || response.permanent != request.expires_at_ms.is_none()
1194            || response.minimum_interval_seconds != minimum_interval_seconds
1195            || response.maximum_tolerance_bps != maximum_tolerance_bps
1196            || response.spending_limits != request.spending_limits
1197            || !response.owner_signature_required
1198        {
1199            return Err(SdkError::InvalidResponse(
1200                "Vault setup preparation does not match the request".to_owned(),
1201            ));
1202        }
1203        canonical_base64(&response.transaction_base64, "transaction_base64")?;
1204        canonical_public_key(&response.recent_blockhash, "recent_blockhash")?;
1205        validate_vault_preparation(&response.preparation_id, response.submit_by_ms)?;
1206        Ok(response)
1207    }
1208
1209    /// Prepare owner-authorized revocation of one external Vault session. The
1210    /// SDK never signs or broadcasts this destructive action.
1211    pub async fn platform_vault_delegate_prepare(
1212        &self,
1213        request: PlatformVaultDelegatePrepareRequest,
1214    ) -> Result<PlatformVaultDelegatePrepareResponse, SdkError> {
1215        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1216        let session_public_key =
1217            canonical_public_key(&request.session_public_key, "session_public_key")?;
1218        if wallet_address == session_public_key {
1219            return Err(SdkError::InvalidRequest(
1220                "session_public_key must differ from wallet_address".to_owned(),
1221            ));
1222        }
1223        let request = PlatformVaultDelegatePrepareRequest {
1224            wallet_address,
1225            session_public_key,
1226            action: request.action,
1227        };
1228        let response: PlatformVaultDelegatePrepareResponse =
1229            self.post("v2/vault/delegates/prepare", &request).await?;
1230        validate_platform_version(response.schema_version, &response.contract_version)?;
1231        if response.wallet_address != request.wallet_address
1232            || response.session_public_key != request.session_public_key
1233            || response.action != request.action
1234            || !response.owner_signature_required
1235        {
1236            return Err(SdkError::InvalidResponse(
1237                "Vault delegate preparation does not match the request".to_owned(),
1238            ));
1239        }
1240        canonical_base64(&response.transaction_base64, "transaction_base64")?;
1241        canonical_public_key(&response.recent_blockhash, "recent_blockhash")?;
1242        validate_vault_preparation(&response.preparation_id, response.submit_by_ms)?;
1243        Ok(response)
1244    }
1245
1246    /// Prepare blocked or restricted Vault withdrawal access. The external
1247    /// owner verifies, signs, and broadcasts the returned transaction.
1248    pub async fn platform_vault_policy_prepare(
1249        &self,
1250        request: PlatformVaultPolicyPrepareRequest,
1251    ) -> Result<PlatformVaultPolicyPrepareResponse, SdkError> {
1252        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1253        let allowed = &request.withdrawal_access.allowed_wallet_addresses;
1254        let mut unique_wallets = HashSet::new();
1255        if allowed.len() > 8
1256            || allowed.iter().any(|wallet| {
1257                canonical_public_key(wallet, "allowed_wallet_address").is_err()
1258                    || !unique_wallets.insert(wallet.clone())
1259            })
1260            || match request.withdrawal_access.mode {
1261                PlatformVaultWithdrawalMode::Unrestricted => true,
1262                PlatformVaultWithdrawalMode::Blocked => !allowed.is_empty(),
1263                PlatformVaultWithdrawalMode::Restricted => allowed.is_empty(),
1264            }
1265        {
1266            return Err(SdkError::InvalidRequest(
1267                "Vault withdrawal access policy is invalid".to_owned(),
1268            ));
1269        }
1270        let request = PlatformVaultPolicyPrepareRequest {
1271            wallet_address,
1272            withdrawal_access: request.withdrawal_access,
1273        };
1274        let response: PlatformVaultPolicyPrepareResponse =
1275            self.post("v2/vault/policies/prepare", &request).await?;
1276        validate_platform_version(response.schema_version, &response.contract_version)?;
1277        if response.wallet_address != request.wallet_address
1278            || response.withdrawal_access != request.withdrawal_access
1279            || !response.owner_signature_required
1280        {
1281            return Err(SdkError::InvalidResponse(
1282                "Vault policy preparation does not match the request".to_owned(),
1283            ));
1284        }
1285        canonical_base64(&response.transaction_base64, "transaction_base64")?;
1286        canonical_public_key(&response.recent_blockhash, "recent_blockhash")?;
1287        validate_vault_preparation(&response.preparation_id, response.submit_by_ms)?;
1288        Ok(response)
1289    }
1290
1291    /// Prepare an exact owner-funded Vault deposit. With `session_public_key`
1292    /// set, a first deposit also registers that session in the same
1293    /// transaction (one owner signature onboards and funds the wallet). The
1294    /// SDK validates the echoed product intent and leaves signing and
1295    /// broadcast external.
1296    pub async fn platform_vault_deposit_prepare(
1297        &self,
1298        request: PlatformVaultDepositPrepareRequest,
1299    ) -> Result<PlatformVaultDepositPrepareResponse, SdkError> {
1300        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1301        let session_public_key = request
1302            .session_public_key
1303            .as_deref()
1304            .map(|session| canonical_public_key(session, "session_public_key"))
1305            .transpose()?;
1306        if session_public_key.as_deref() == Some(wallet_address.as_str()) {
1307            return Err(SdkError::InvalidRequest(
1308                "session_public_key must differ from wallet_address".to_owned(),
1309            ));
1310        }
1311        let request = PlatformVaultDepositPrepareRequest {
1312            wallet_address,
1313            market_id: validate_platform_market_id(&request.market_id)?,
1314            asset_id: validate_platform_asset_id(&request.asset_id)?,
1315            amount_atoms: canonical_request_atoms(&request.amount_atoms, "amount_atoms", false)?,
1316            session_public_key,
1317        };
1318        let response: PlatformVaultDepositPrepareResponse =
1319            self.post("v2/vault/deposits/prepare", &request).await?;
1320        validate_platform_version(response.schema_version, &response.contract_version)?;
1321        parse_atoms("network_cost_atoms", &response.network_cost_atoms)?;
1322        if response.wallet_address != request.wallet_address
1323            || response.market_id != request.market_id
1324            || response.asset_id != request.asset_id
1325            || response.amount_atoms != request.amount_atoms
1326            || response.session_public_key != request.session_public_key
1327            || (response.registers_session && response.session_public_key.is_none())
1328            || !response.owner_signature_required
1329        {
1330            return Err(SdkError::InvalidResponse(
1331                "Vault deposit preparation does not match the request".to_owned(),
1332            ));
1333        }
1334        canonical_base64(&response.transaction_base64, "transaction_base64")?;
1335        canonical_public_key(&response.recent_blockhash, "recent_blockhash")?;
1336        validate_vault_preparation(&response.preparation_id, response.submit_by_ms)?;
1337        Ok(response)
1338    }
1339
1340    /// Prepare an exact owner-authorized Vault withdrawal to one destination
1341    /// wallet. Signing and broadcast remain external.
1342    pub async fn platform_vault_withdraw_prepare(
1343        &self,
1344        request: PlatformVaultWithdrawPrepareRequest,
1345    ) -> Result<PlatformVaultWithdrawPrepareResponse, SdkError> {
1346        let request = PlatformVaultWithdrawPrepareRequest {
1347            wallet_address: canonical_public_key(&request.wallet_address, "wallet_address")?,
1348            market_id: validate_platform_market_id(&request.market_id)?,
1349            asset_id: validate_platform_asset_id(&request.asset_id)?,
1350            destination_wallet_address: canonical_public_key(
1351                &request.destination_wallet_address,
1352                "destination_wallet_address",
1353            )?,
1354            amount_atoms: canonical_request_atoms(&request.amount_atoms, "amount_atoms", false)?,
1355        };
1356        let response: PlatformVaultWithdrawPrepareResponse =
1357            self.post("v2/vault/withdrawals/prepare", &request).await?;
1358        validate_platform_version(response.schema_version, &response.contract_version)?;
1359        if response.wallet_address != request.wallet_address
1360            || response.market_id != request.market_id
1361            || response.asset_id != request.asset_id
1362            || response.destination_wallet_address != request.destination_wallet_address
1363            || response.amount_atoms != request.amount_atoms
1364            || !response.owner_signature_required
1365        {
1366            return Err(SdkError::InvalidResponse(
1367                "Vault withdrawal preparation does not match the request".to_owned(),
1368            ));
1369        }
1370        canonical_base64(&response.transaction_base64, "transaction_base64")?;
1371        canonical_public_key(&response.recent_blockhash, "recent_blockhash")?;
1372        validate_vault_preparation(&response.preparation_id, response.submit_by_ms)?;
1373        Ok(response)
1374    }
1375
1376    /// Submit an owner-signed prepared Vault transaction. Strata verifies it is
1377    /// exactly the prepared transaction, pays the fee (and any rent) when the
1378    /// preparation was sponsored, and broadcasts it. Idempotent per
1379    /// `idempotency_key`; read the outcome with `platform_vault_submission`.
1380    pub async fn platform_vault_submit(
1381        &self,
1382        request: PlatformVaultSubmitRequest,
1383    ) -> Result<PlatformVaultSubmitResponse, SdkError> {
1384        if !valid_handle(&request.preparation_id, "vp_") {
1385            return Err(SdkError::InvalidRequest(
1386                "preparation_id is invalid".to_owned(),
1387            ));
1388        }
1389        let request = PlatformVaultSubmitRequest {
1390            preparation_id: request.preparation_id,
1391            signed_transaction_base64: canonical_base64(
1392                &request.signed_transaction_base64,
1393                "signed_transaction_base64",
1394            )?,
1395            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
1396        };
1397        let response: PlatformVaultSubmitResponse = self.post("v2/vault/submit", &request).await?;
1398        validate_vault_submission(&response, &request.preparation_id)?;
1399        Ok(response)
1400    }
1401
1402    /// Durable outcome of a Vault submission (`submitted` → `confirmed` | `failed`).
1403    pub async fn platform_vault_submission(
1404        &self,
1405        preparation_id: &str,
1406    ) -> Result<PlatformVaultSubmitResponse, SdkError> {
1407        let preparation_id = preparation_id.trim();
1408        if !valid_handle(preparation_id, "vp_") {
1409            return Err(SdkError::InvalidRequest(
1410                "preparation_id is invalid".to_owned(),
1411            ));
1412        }
1413        let response: PlatformVaultSubmitResponse = self
1414            .get(&format!("v2/vault/submissions/{preparation_id}"), &[])
1415            .await?;
1416        validate_vault_submission(&response, preparation_id)?;
1417        Ok(response)
1418    }
1419
1420    pub async fn platform_rewards(
1421        &self,
1422        request: PlatformRewardsRequest,
1423    ) -> Result<PlatformRewardsResponse, SdkError> {
1424        let wallet = request
1425            .wallet_address
1426            .as_deref()
1427            .map(|value| canonical_public_key(value, "wallet_address"))
1428            .transpose()?;
1429        let mut query = Vec::new();
1430        if let Some(wallet) = &wallet {
1431            query.push(("wallet_address".to_owned(), wallet.clone()));
1432        }
1433        if let Some(limit @ 1..=100) = request.limit {
1434            query.push(("limit".to_owned(), limit.to_string()));
1435        } else if request.limit.is_some() {
1436            return Err(SdkError::InvalidRequest(
1437                "reward standings limit must be between 1 and 100".to_owned(),
1438            ));
1439        }
1440        let response: PlatformRewardsResponse = self.get("v2/rewards", &query).await?;
1441        validate_platform_version(response.schema_version, &response.contract_version)?;
1442        match (&wallet, &response.owner) {
1443            (Some(expected), Some(owner)) if owner.wallet_address == *expected => {}
1444            (None, None) => {}
1445            _ => {
1446                return Err(SdkError::InvalidResponse(
1447                    "reward owner does not match the request".to_owned(),
1448                ))
1449            }
1450        }
1451        Ok(response)
1452    }
1453
1454    pub async fn platform_referrals(
1455        &self,
1456        wallet_address: &str,
1457    ) -> Result<PlatformReferralsResponse, SdkError> {
1458        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
1459        let response: PlatformReferralsResponse = self
1460            .get(&format!("v2/referrals/{wallet_address}"), &[])
1461            .await?;
1462        validate_platform_version(response.schema_version, &response.contract_version)?;
1463        if response.wallet_address != wallet_address {
1464            return Err(SdkError::InvalidResponse(
1465                "referral owner does not match the request".to_owned(),
1466            ));
1467        }
1468        Ok(response)
1469    }
1470
1471    pub async fn platform_referral_link(
1472        &self,
1473        request: PlatformReferralLinkRequest,
1474    ) -> Result<PlatformReferralLinkResponse, SdkError> {
1475        let request = PlatformReferralLinkRequest {
1476            wallet_address: canonical_public_key(&request.wallet_address, "wallet_address")?,
1477            referral_code: normalize_referral_code(&request.referral_code)?,
1478            authorization_signature: canonical_hex_signature(
1479                &request.authorization_signature,
1480                "authorization_signature",
1481            )?,
1482        };
1483        let response: PlatformReferralLinkResponse =
1484            self.post("v2/referrals/link", &request).await?;
1485        validate_platform_version(response.schema_version, &response.contract_version)?;
1486        if response.wallet_address != request.wallet_address
1487            || response.referral_code != request.referral_code
1488            || response.status != "pending_first_fill"
1489        {
1490            return Err(SdkError::InvalidResponse(
1491                "referral link does not match the request".to_owned(),
1492            ));
1493        }
1494        Ok(response)
1495    }
1496
1497    pub async fn platform_referral_claim(
1498        &self,
1499        request: PlatformReferralClaimRequest,
1500    ) -> Result<PlatformReferralClaimResponse, SdkError> {
1501        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1502        let payout_wallet_address = request
1503            .payout_wallet_address
1504            .as_deref()
1505            .map(|value| canonical_public_key(value, "payout_wallet_address"))
1506            .transpose()?
1507            .unwrap_or_else(|| wallet_address.clone());
1508        let request = PlatformReferralClaimRequest {
1509            wallet_address: wallet_address.clone(),
1510            payout_wallet_address: Some(payout_wallet_address.clone()),
1511            authorization_signature: canonical_hex_signature(
1512                &request.authorization_signature,
1513                "authorization_signature",
1514            )?,
1515        };
1516        let response: PlatformReferralClaimResponse =
1517            self.post("v2/referrals/claim", &request).await?;
1518        validate_platform_version(response.schema_version, &response.contract_version)?;
1519        validate_response_atoms(&response.claimable_atoms, "claimable_atoms", false)?;
1520        if response.wallet_address != wallet_address
1521            || response.payout_wallet_address != payout_wallet_address
1522            || response.status != "requested"
1523        {
1524            return Err(SdkError::InvalidResponse(
1525                "referral claim does not match the request".to_owned(),
1526            ));
1527        }
1528        Ok(response)
1529    }
1530
1531    pub async fn platform_bugs(
1532        &self,
1533        wallet_address: &str,
1534    ) -> Result<PlatformBugsResponse, SdkError> {
1535        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
1536        let response: PlatformBugsResponse =
1537            self.get(&format!("v2/bugs/{wallet_address}"), &[]).await?;
1538        validate_platform_version(response.schema_version, &response.contract_version)?;
1539        if response.wallet_address != wallet_address {
1540            return Err(SdkError::InvalidResponse(
1541                "bug report owner does not match the request".to_owned(),
1542            ));
1543        }
1544        Ok(response)
1545    }
1546
1547    pub async fn platform_bug_submit(
1548        &self,
1549        request: PlatformBugSubmitRequest,
1550    ) -> Result<PlatformBugSubmitResponse, SdkError> {
1551        let request = PlatformBugSubmitRequest {
1552            owner_wallet: canonical_public_key(&request.owner_wallet, "owner_wallet")?,
1553            message: normalize_bug_message(&request.message)?,
1554            authorization_signature: canonical_hex_signature(
1555                &request.authorization_signature,
1556                "authorization_signature",
1557            )?,
1558        };
1559        let response: PlatformBugSubmitResponse = self.post("v2/bugs", &request).await?;
1560        validate_platform_version(response.schema_version, &response.contract_version)?;
1561        if !valid_handle(&response.bug_id, "bug_") {
1562            return Err(SdkError::InvalidResponse(
1563                "bug submission returned an invalid report ID".to_owned(),
1564            ));
1565        }
1566        Ok(response)
1567    }
1568
1569    /// Read one market's private account state after proving wallet control.
1570    /// The wallet signs an exact, server-time-bound read message outside Strata.
1571    pub async fn platform_account_market<S: AccountSigner + ?Sized>(
1572        &self,
1573        market_id: &str,
1574        signer: &S,
1575        request: PlatformAccountMarketRequest,
1576    ) -> Result<PlatformAccountSnapshotResponse, SdkError> {
1577        let market_id = validate_platform_market_id(market_id)?;
1578        let wallet_address =
1579            canonical_public_key(signer.public_key(), "account signer public key")?;
1580        let fill_limit = normalize_fill_limit(request.fill_limit)?;
1581        let timestamp_ms = self.platform_capabilities().await?.server_time_ms;
1582        let message =
1583            account_http_auth_message(&market_id, &wallet_address, timestamp_ms, fill_limit)?;
1584        let signature = signer
1585            .sign_message(&message)
1586            .await
1587            .map_err(SdkError::Signer)?;
1588        if signature.len() != 64 {
1589            return Err(SdkError::Signer(
1590                "account signer must return a 64-byte Ed25519 signature".to_owned(),
1591            ));
1592        }
1593        let mut headers = HeaderMap::new();
1594        headers.insert(
1595            "x-strata-auth-time",
1596            HeaderValue::from_str(&timestamp_ms.to_string()).map_err(|_| {
1597                SdkError::InvalidRequest("account authorization time is invalid".to_owned())
1598            })?,
1599        );
1600        headers.insert(
1601            "x-strata-auth-signature",
1602            HeaderValue::from_str(&hex::encode(signature)).map_err(|_| {
1603                SdkError::InvalidRequest("account authorization signature is invalid".to_owned())
1604            })?,
1605        );
1606        let query = match request.fill_limit {
1607            Some(_) => vec![("fill_limit".to_owned(), fill_limit.to_string())],
1608            None => Vec::new(),
1609        };
1610        let response: PlatformAccountSnapshotResponse = self
1611            .get_with_headers(
1612                &format!("v2/markets/{market_id}/account/{wallet_address}"),
1613                &query,
1614                headers,
1615            )
1616            .await?;
1617        validate_platform_market_response(
1618            response.schema_version,
1619            &response.contract_version,
1620            &response.market_id,
1621            &market_id,
1622        )?;
1623        if response.wallet_address != wallet_address {
1624            return Err(SdkError::InvalidResponse(
1625                "account response wallet does not match signed request".to_owned(),
1626            ));
1627        }
1628        account_stream::validate_account_state(&response.orders, &response.fills)?;
1629        Ok(response)
1630    }
1631
1632    /// Read private order and fill state across selected markets, or across
1633    /// every currently discoverable market when `market_ids` is omitted.
1634    pub async fn platform_account_snapshot<S: AccountSigner + ?Sized>(
1635        &self,
1636        signer: &S,
1637        request: PlatformAccountRequest,
1638    ) -> Result<PlatformAccountSnapshot, SdkError> {
1639        let wallet_address =
1640            canonical_public_key(signer.public_key(), "account signer public key")?;
1641        let market_ids = match request.market_ids {
1642            Some(ids) => normalize_market_ids(ids)?,
1643            None => self.all_platform_market_ids().await?,
1644        };
1645        if market_ids.is_empty() {
1646            return Err(SdkError::OperationUnavailable(
1647                "no public markets are currently discoverable".to_owned(),
1648            ));
1649        }
1650        let mut markets = Vec::with_capacity(market_ids.len());
1651        for market_id in market_ids {
1652            markets.push(
1653                self.platform_account_market(
1654                    &market_id,
1655                    signer,
1656                    PlatformAccountMarketRequest {
1657                        fill_limit: request.fill_limit,
1658                    },
1659                )
1660                .await?,
1661            );
1662        }
1663        let server_time_ms = markets
1664            .iter()
1665            .map(|market| market.server_time_ms)
1666            .max()
1667            .unwrap_or_default();
1668        Ok(PlatformAccountSnapshot {
1669            wallet_address,
1670            server_time_ms,
1671            markets,
1672        })
1673    }
1674
1675    /// A maker's products, exposure, health, and kill state in one market —
1676    /// public by wallet address, no signature.
1677    pub async fn platform_maker_status_for_wallet(
1678        &self,
1679        market_id: &str,
1680        wallet_address: &str,
1681    ) -> Result<PlatformMakerStatusResponse, SdkError> {
1682        let market_id = validate_platform_market_id(market_id)?;
1683        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
1684        self.read_platform_maker_status(&market_id, &wallet_address, None)
1685            .await
1686    }
1687
1688    /// Same read, addressed by a signer's public key. Reads are public, so the
1689    /// signer is never asked to sign; kept so existing callers keep compiling.
1690    pub async fn platform_maker_status<S: AccountSigner + ?Sized>(
1691        &self,
1692        market_id: &str,
1693        signer: &S,
1694    ) -> Result<PlatformMakerStatusResponse, SdkError> {
1695        self.platform_maker_status_for_wallet(market_id, signer.public_key())
1696            .await
1697    }
1698
1699    /// Submit a detached external signature for the maker status read. Reads
1700    /// are public now; a signed request is still accepted (deprecated path).
1701    pub async fn platform_maker_status_authorized(
1702        &self,
1703        request: PlatformMakerStatusAuthorizedRequest,
1704    ) -> Result<PlatformMakerStatusResponse, SdkError> {
1705        let market_id = validate_platform_market_id(&request.market_id)?;
1706        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1707        let signature =
1708            canonical_hex_signature(&request.authorization_signature, "authorization_signature")?;
1709        self.read_platform_maker_status(
1710            &market_id,
1711            &wallet_address,
1712            Some((request.authorization_time_ms, signature.as_str())),
1713        )
1714        .await
1715    }
1716
1717    async fn read_platform_maker_status(
1718        &self,
1719        market_id: &str,
1720        wallet_address: &str,
1721        authorization: Option<(u64, &str)>,
1722    ) -> Result<PlatformMakerStatusResponse, SdkError> {
1723        let headers = maker_auth_headers(authorization)?;
1724        let response: PlatformMakerStatusResponse = self
1725            .get_with_headers(
1726                &format!("v2/markets/{market_id}/makers/{wallet_address}"),
1727                &[],
1728                headers,
1729            )
1730            .await?;
1731        validate_platform_market_response(
1732            response.schema_version,
1733            &response.contract_version,
1734            &response.market_id,
1735            market_id,
1736        )?;
1737        if response.wallet_address != wallet_address {
1738            return Err(SdkError::InvalidResponse(
1739                "maker status wallet does not match signed request".to_owned(),
1740            ));
1741        }
1742        validate_maker_status(&response)?;
1743        Ok(response)
1744    }
1745
1746    /// A maker's reliability record in one market — public by wallet address,
1747    /// no signature.
1748    pub async fn platform_maker_reputation_for_wallet(
1749        &self,
1750        market_id: &str,
1751        wallet_address: &str,
1752    ) -> Result<PlatformMakerReputationResponse, SdkError> {
1753        let market_id = validate_platform_market_id(market_id)?;
1754        let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
1755        self.read_platform_maker_reputation(&market_id, &wallet_address, None)
1756            .await
1757    }
1758
1759    /// Same read, addressed by a signer's public key; the signer is never asked
1760    /// to sign (reads are public). Kept so existing callers keep compiling.
1761    pub async fn platform_maker_reputation<S: AccountSigner + ?Sized>(
1762        &self,
1763        market_id: &str,
1764        signer: &S,
1765    ) -> Result<PlatformMakerReputationResponse, SdkError> {
1766        self.platform_maker_reputation_for_wallet(market_id, signer.public_key())
1767            .await
1768    }
1769
1770    /// Submit a detached external signature. Reads are public now; a signed
1771    /// request is still accepted (deprecated path).
1772    pub async fn platform_maker_reputation_authorized(
1773        &self,
1774        request: PlatformMakerReputationAuthorizedRequest,
1775    ) -> Result<PlatformMakerReputationResponse, SdkError> {
1776        let market_id = validate_platform_market_id(&request.market_id)?;
1777        let wallet_address = canonical_public_key(&request.wallet_address, "wallet_address")?;
1778        let signature =
1779            canonical_hex_signature(&request.authorization_signature, "authorization_signature")?;
1780        self.read_platform_maker_reputation(
1781            &market_id,
1782            &wallet_address,
1783            Some((request.authorization_time_ms, signature.as_str())),
1784        )
1785        .await
1786    }
1787
1788    async fn read_platform_maker_reputation(
1789        &self,
1790        market_id: &str,
1791        wallet_address: &str,
1792        authorization: Option<(u64, &str)>,
1793    ) -> Result<PlatformMakerReputationResponse, SdkError> {
1794        let headers = maker_auth_headers(authorization)?;
1795        let response: PlatformMakerReputationResponse = self
1796            .get_with_headers(
1797                &format!("v2/markets/{market_id}/makers/{wallet_address}/reputation"),
1798                &[],
1799                headers,
1800            )
1801            .await?;
1802        validate_platform_market_response(
1803            response.schema_version,
1804            &response.contract_version,
1805            &response.market_id,
1806            market_id,
1807        )?;
1808        if response.wallet_address != wallet_address {
1809            return Err(SdkError::InvalidResponse(
1810                "maker reputation wallet does not match signed request".to_owned(),
1811            ));
1812        }
1813        validate_maker_reputation(&response)?;
1814        Ok(response)
1815    }
1816
1817    pub async fn capabilities(&self) -> Result<CapabilityCatalog, SdkError> {
1818        let catalog: CapabilityCatalog = self.get("sonar/capabilities", &[]).await?;
1819        validate_version(catalog.schema_version, &catalog.contract_version)?;
1820
1821        let mut ids = HashSet::new();
1822        if catalog
1823            .capabilities
1824            .iter()
1825            .any(|capability| !ids.insert(capability.id.as_str()))
1826        {
1827            return Err(SdkError::InvalidResponse(
1828                "capability IDs must be unique".to_owned(),
1829            ));
1830        }
1831        Ok(catalog)
1832    }
1833
1834    /// Return the live operation topology, including capability-gated nodes and
1835    /// the points where the agent owner's signer acts outside Strata.
1836    pub async fn action_graph(&self) -> Result<ActionGraph, SdkError> {
1837        let graph: ActionGraph = self.get("sonar/action-graph", &[]).await?;
1838        validate_action_graph(&graph)?;
1839        Ok(graph)
1840    }
1841
1842    pub async fn markets(&self) -> Result<MarketsResponse, SdkError> {
1843        let markets: MarketsResponse = self.get("sonar/markets", &[]).await?;
1844        validate_version(markets.schema_version, &markets.contract_version)?;
1845        Ok(markets)
1846    }
1847
1848    /// Request a short-lived Sonar quote by human market label or market ID.
1849    /// Give exactly one of `amount_in_atoms` (spend this much) or
1850    /// `amount_out_atoms` (receive this much; Strata resolves the input).
1851    /// `maximum_tolerance_bps` is the most you accept below the quoted output
1852    /// (default 0); it is your choice and unrelated to the measured
1853    /// `price_impact_pct` the response reports.
1854    pub async fn quote(&self, request: QuoteRequest) -> Result<QuoteResponse, SdkError> {
1855        let target = quote_target(&request)?;
1856        if request.maximum_tolerance_bps > 1_000 {
1857            return Err(SdkError::InvalidRequest(
1858                "maximum_tolerance_bps must be between 0 and 1,000".to_owned(),
1859            ));
1860        }
1861
1862        let markets = self.markets().await?;
1863        let market = markets
1864            .markets
1865            .iter()
1866            .find(|market| {
1867                market.label.eq_ignore_ascii_case(&request.market_id)
1868                    || market.market_pda.as_deref() == Some(request.market_id.as_str())
1869            })
1870            .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
1871        if !market.ready {
1872            return Err(SdkError::OperationUnavailable(market.label.clone()));
1873        }
1874        let market_pda = market
1875            .market_pda
1876            .as_deref()
1877            .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
1878        let quote_path = market
1879            .quote_path
1880            .as_deref()
1881            .filter(|path| valid_public_operation_path(path))
1882            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
1883        let wire = QuoteRequest {
1884            market_id: market_pda.to_owned(),
1885            side: request.side,
1886            amount_in_atoms: matches!(target, QuoteTarget::ExactInput(_))
1887                .then(|| target.amount().to_string()),
1888            amount_out_atoms: matches!(target, QuoteTarget::ExactOutput(_))
1889                .then(|| target.amount().to_string()),
1890            maximum_tolerance_bps: request.maximum_tolerance_bps,
1891        };
1892        let quote: QuoteResponse = self.post(quote_path, &wire).await?;
1893        validate_quote(&quote, market_pda, &request, target)?;
1894        Ok(quote)
1895    }
1896
1897    /// Request canonical authorization bytes for an external signer. This
1898    /// operation accepts public identity only; signing material stays external.
1899    pub async fn execution_challenge(
1900        &self,
1901        market: &str,
1902        request: ExecutionChallengeRequest,
1903    ) -> Result<ExecutionChallengeResponse, SdkError> {
1904        let request = normalize_execution_challenge_request(request)?;
1905        let execution_path = self.execution_path(market).await?;
1906        let challenge: ExecutionChallengeResponse = self
1907            .post(&format!("{execution_path}/challenge"), &request)
1908            .await?;
1909        validate_version(challenge.schema_version, &challenge.contract_version)?;
1910        if !valid_handle(&challenge.challenge_id, "sc_") || challenge.quote_id != request.quote_id {
1911            return Err(SdkError::InvalidResponse(
1912                "execution challenge does not match the requested quote".to_owned(),
1913            ));
1914        }
1915        Ok(challenge)
1916    }
1917
1918    /// Prepare a quote-bound, partially signed transaction: either exchange
1919    /// an external authorization signature (`Authorized`, two-step path) or
1920    /// bind the quote directly (`Direct`, one signature — the session's
1921    /// transaction signature is the authorization).
1922    pub async fn execution_prepare(
1923        &self,
1924        market: &str,
1925        request: ExecutionPrepareRequest,
1926    ) -> Result<ExecutionPrepareResponse, SdkError> {
1927        let request = match request {
1928            ExecutionPrepareRequest::Authorized(authorization) => {
1929                if !valid_handle(&authorization.challenge_id, "sc_") {
1930                    return Err(SdkError::InvalidRequest(
1931                        "challenge_id is invalid".to_owned(),
1932                    ));
1933                }
1934                let signature = bs58::decode(authorization.authorization_signature.trim())
1935                    .into_vec()
1936                    .map_err(|_| {
1937                        SdkError::InvalidRequest(
1938                            "authorization_signature must be base58".to_owned(),
1939                        )
1940                    })?;
1941                if signature.len() != 64
1942                    || bs58::encode(&signature).into_string()
1943                        != authorization.authorization_signature.trim()
1944                {
1945                    return Err(SdkError::InvalidRequest(
1946                        "authorization_signature must be a canonical Ed25519 signature".to_owned(),
1947                    ));
1948                }
1949                ExecutionPrepareRequest::Authorized(ExecutionPrepareAuthorization {
1950                    challenge_id: authorization.challenge_id,
1951                    authorization_signature: bs58::encode(signature).into_string(),
1952                })
1953            }
1954            ExecutionPrepareRequest::Direct(binding) => {
1955                ExecutionPrepareRequest::Direct(normalize_execution_challenge_request(binding)?)
1956            }
1957        };
1958        let execution_path = self.execution_path(market).await?;
1959        let prepared: ExecutionPrepareResponse = self
1960            .post(&format!("{execution_path}/prepare"), &request)
1961            .await?;
1962        validate_version(prepared.schema_version, &prepared.contract_version)?;
1963        if !valid_handle(&prepared.execution_id, "se_") {
1964            return Err(SdkError::InvalidResponse(
1965                "prepared execution ID is invalid".to_owned(),
1966            ));
1967        }
1968        if let ExecutionPrepareRequest::Direct(binding) = &request {
1969            if prepared.quote_id != binding.quote_id {
1970                return Err(SdkError::InvalidResponse(
1971                    "prepared execution does not match the requested quote".to_owned(),
1972                ));
1973            }
1974        }
1975        Ok(prepared)
1976    }
1977
1978    /// Submit an externally signed transaction. Reusing the same idempotency
1979    /// key cannot create a second execution.
1980    pub async fn execution_submit(
1981        &self,
1982        market: &str,
1983        request: ExecutionSubmitRequest,
1984    ) -> Result<ExecutionSubmitResponse, SdkError> {
1985        if !valid_handle(&request.execution_id, "se_") {
1986            return Err(SdkError::InvalidRequest(
1987                "execution_id is invalid".to_owned(),
1988            ));
1989        }
1990        let transaction = request.signed_transaction_base64.trim();
1991        let decoded = base64::engine::general_purpose::STANDARD
1992            .decode(transaction)
1993            .map_err(|_| {
1994                SdkError::InvalidRequest(
1995                    "signed_transaction_base64 must be canonical base64".to_owned(),
1996                )
1997            })?;
1998        if decoded.is_empty()
1999            || base64::engine::general_purpose::STANDARD.encode(&decoded) != transaction
2000        {
2001            return Err(SdkError::InvalidRequest(
2002                "signed_transaction_base64 must be canonical base64".to_owned(),
2003            ));
2004        }
2005        let request = ExecutionSubmitRequest {
2006            execution_id: request.execution_id,
2007            signed_transaction_base64: transaction.to_owned(),
2008            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
2009        };
2010        let execution_path = self.execution_path(market).await?;
2011        let submitted: ExecutionSubmitResponse = self
2012            .post(&format!("{execution_path}/submit"), &request)
2013            .await?;
2014        validate_version(submitted.schema_version, &submitted.contract_version)?;
2015        if submitted.execution_id != request.execution_id
2016            || submitted.status != ExecutionStatus::Submitted
2017            || submitted.signature.trim().is_empty()
2018        {
2019            return Err(SdkError::InvalidResponse(
2020                "execution receipt does not match the submitted transaction".to_owned(),
2021            ));
2022        }
2023        Ok(submitted)
2024    }
2025
2026    /// Request exact authorization bytes for one product-level resting-order
2027    /// operation. Private key material never enters this client or Strata.
2028    pub async fn order_challenge(
2029        &self,
2030        market_id: &str,
2031        request: PlatformOrderChallengeRequest,
2032    ) -> Result<PlatformOrderChallengeResponse, SdkError> {
2033        let market_id = validate_platform_market_id(market_id)?;
2034        let request = normalize_order_challenge_request(request)?;
2035        let expected_action = order_request_action(&request);
2036        let challenge: PlatformOrderChallengeResponse = self
2037            .post(
2038                &format!("v2/markets/{market_id}/orders/challenge"),
2039                &request,
2040            )
2041            .await?;
2042        validate_platform_version(challenge.schema_version, &challenge.contract_version)?;
2043        if challenge.market_id != market_id
2044            || challenge.action != expected_action
2045            || !valid_handle(&challenge.challenge_id, "oc_")
2046            || challenge.order_ids.is_empty()
2047            || challenge.order_ids.len() > 12
2048            || challenge.expires_at_ms <= challenge.server_time_ms
2049            || challenge
2050                .order_ids
2051                .iter()
2052                .any(|order_id| !valid_handle(order_id, "order_"))
2053        {
2054            return Err(SdkError::InvalidResponse(
2055                "order challenge bindings are invalid".to_owned(),
2056            ));
2057        }
2058        canonical_base64(
2059            &challenge.authorization_payload_base64,
2060            "authorization_payload_base64",
2061        )?;
2062        Ok(challenge)
2063    }
2064
2065    /// Prepare a backend-partially-signed v0 order-control transaction:
2066    /// either hand back a signed challenge (`Authorized`, two-step path) or
2067    /// send the operation itself (`Direct`, one signature — Strata builds the
2068    /// transaction from the operation and the session's signature over that
2069    /// transaction is the whole authorization).
2070    pub async fn order_prepare(
2071        &self,
2072        market_id: &str,
2073        request: PlatformOrderPrepareRequest,
2074    ) -> Result<PlatformOrderPrepareResponse, SdkError> {
2075        let market_id = validate_platform_market_id(market_id)?;
2076        let request = match request {
2077            PlatformOrderPrepareRequest::Authorized(authorization) => {
2078                PlatformOrderPrepareRequest::Authorized(normalize_order_prepare_authorization(
2079                    authorization,
2080                )?)
2081            }
2082            PlatformOrderPrepareRequest::Direct(operation) => {
2083                PlatformOrderPrepareRequest::Direct(normalize_order_challenge_request(operation)?)
2084            }
2085        };
2086        let prepared: PlatformOrderPrepareResponse = self
2087            .post(&format!("v2/markets/{market_id}/orders/prepare"), &request)
2088            .await?;
2089        validate_platform_version(prepared.schema_version, &prepared.contract_version)?;
2090        if prepared.market_id != market_id
2091            || !valid_handle(&prepared.order_control_id, "or_")
2092            || prepared.order_ids.is_empty()
2093            || prepared.order_ids.len() > 12
2094            || prepared.transaction_base64.trim().is_empty()
2095            || prepared.expires_at_ms == 0
2096        {
2097            return Err(SdkError::InvalidResponse(
2098                "prepared order control is invalid".to_owned(),
2099            ));
2100        }
2101        canonical_base64(&prepared.transaction_base64, "transaction_base64")?;
2102        canonical_base58_32(&prepared.recent_blockhash, "recent_blockhash")?;
2103        if let PlatformOrderPrepareRequest::Direct(operation) = &request {
2104            if prepared.action != order_request_action(operation) {
2105                return Err(SdkError::InvalidResponse(
2106                    "prepared order action does not match request".to_owned(),
2107                ));
2108            }
2109        }
2110        Ok(prepared)
2111    }
2112
2113    /// Submit an externally signed order-control transaction. The same
2114    /// control ID and idempotency key return the same receipt.
2115    pub async fn order_submit(
2116        &self,
2117        market_id: &str,
2118        request: PlatformOrderSubmitRequest,
2119    ) -> Result<PlatformOrderSubmitResponse, SdkError> {
2120        let market_id = validate_platform_market_id(market_id)?;
2121        if !valid_handle(&request.order_control_id, "or_") {
2122            return Err(SdkError::InvalidRequest(
2123                "order_control_id is invalid".to_owned(),
2124            ));
2125        }
2126        let transaction = canonical_base64(
2127            &request.signed_transaction_base64,
2128            "signed_transaction_base64",
2129        )?;
2130        let request = PlatformOrderSubmitRequest {
2131            order_control_id: request.order_control_id,
2132            signed_transaction_base64: transaction,
2133            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
2134        };
2135        let submitted: PlatformOrderSubmitResponse = self
2136            .post(&format!("v2/markets/{market_id}/orders/submit"), &request)
2137            .await?;
2138        validate_platform_version(submitted.schema_version, &submitted.contract_version)?;
2139        if submitted.market_id != market_id
2140            || submitted.order_control_id != request.order_control_id
2141            || submitted.status != PlatformOrderSubmissionStatus::Submitted
2142            || submitted.signature.trim().is_empty()
2143        {
2144            return Err(SdkError::InvalidResponse(
2145                "order control receipt is invalid".to_owned(),
2146            ));
2147        }
2148        canonical_signature(&submitted.signature, "signature")?;
2149        Ok(submitted)
2150    }
2151
2152    /// Recover the durable result for a prior submission. The same opaque
2153    /// control ID and idempotency key are required, so status polling never
2154    /// broadens authority beyond the original external submission.
2155    pub async fn order_status(
2156        &self,
2157        market_id: &str,
2158        request: PlatformOrderStatusRequest,
2159    ) -> Result<PlatformOrderStatusResponse, SdkError> {
2160        let market_id = validate_platform_market_id(market_id)?;
2161        if !valid_handle(&request.order_control_id, "or_") {
2162            return Err(SdkError::InvalidRequest(
2163                "order_control_id is invalid".to_owned(),
2164            ));
2165        }
2166        let request = PlatformOrderStatusRequest {
2167            order_control_id: request.order_control_id,
2168            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
2169        };
2170        let status: PlatformOrderStatusResponse = self
2171            .post(&format!("v2/markets/{market_id}/orders/status"), &request)
2172            .await?;
2173        validate_platform_version(status.schema_version, &status.contract_version)?;
2174        if status.market_id != market_id
2175            || status.order_control_id != request.order_control_id
2176            || status.order_ids.is_empty()
2177            || status.order_ids.len() > 12
2178            || status
2179                .order_ids
2180                .iter()
2181                .any(|order_id| !valid_handle(order_id, "order_"))
2182            || (status.status == PlatformOrderControlStatus::Failed
2183                && status.failure_code.as_deref().is_none_or(str::is_empty))
2184            || (status.status != PlatformOrderControlStatus::Failed
2185                && status.failure_code.is_some())
2186        {
2187            return Err(SdkError::InvalidResponse(
2188                "order control status is invalid".to_owned(),
2189            ));
2190        }
2191        canonical_signature(&status.signature, "signature")?;
2192        Ok(status)
2193    }
2194
2195    /// Request exact authorization bytes for one bounded TWAP placement or
2196    /// cancellation. The session private key remains outside Strata.
2197    pub async fn twap_challenge(
2198        &self,
2199        market_id: &str,
2200        request: PlatformTwapChallengeRequest,
2201    ) -> Result<PlatformTwapChallengeResponse, SdkError> {
2202        let market_id = validate_platform_market_id(market_id)?;
2203        let request = normalize_twap_challenge_request(request)?;
2204        let expected_action = twap_request_action(&request);
2205        let challenge: PlatformTwapChallengeResponse = self
2206            .post(&format!("v2/markets/{market_id}/twaps/challenge"), &request)
2207            .await?;
2208        validate_platform_version(challenge.schema_version, &challenge.contract_version)?;
2209        if challenge.market_id != market_id
2210            || challenge.action != expected_action
2211            || !valid_handle(&challenge.challenge_id, "twc_")
2212            || !valid_handle(&challenge.twap_id, "twap_")
2213            || challenge.expires_at_ms <= challenge.server_time_ms
2214        {
2215            return Err(SdkError::InvalidResponse(
2216                "TWAP challenge bindings are invalid".to_owned(),
2217            ));
2218        }
2219        canonical_base64(
2220            &challenge.authorization_payload_base64,
2221            "authorization_payload_base64",
2222        )?;
2223        Ok(challenge)
2224    }
2225
2226    /// Prepare a backend-partially-signed TWAP-control transaction that the
2227    /// external session must verify: either the exact detached TWAP
2228    /// authorization (`Authorized`, two-step path) or the action itself
2229    /// (`Direct`, one signature — the transaction signature is the
2230    /// authorization).
2231    pub async fn twap_prepare(
2232        &self,
2233        market_id: &str,
2234        request: PlatformTwapPrepareRequest,
2235    ) -> Result<PlatformTwapPrepareResponse, SdkError> {
2236        let market_id = validate_platform_market_id(market_id)?;
2237        let request = match request {
2238            PlatformTwapPrepareRequest::Authorized(authorization) => {
2239                if !valid_handle(&authorization.challenge_id, "twc_") {
2240                    return Err(SdkError::InvalidRequest(
2241                        "TWAP challenge_id is invalid".to_owned(),
2242                    ));
2243                }
2244                PlatformTwapPrepareRequest::Authorized(PlatformTwapPrepareAuthorization {
2245                    challenge_id: authorization.challenge_id,
2246                    authorization_signature: canonical_signature(
2247                        &authorization.authorization_signature,
2248                        "authorization_signature",
2249                    )?,
2250                })
2251            }
2252            PlatformTwapPrepareRequest::Direct(operation) => {
2253                PlatformTwapPrepareRequest::Direct(normalize_twap_challenge_request(operation)?)
2254            }
2255        };
2256        let prepared: PlatformTwapPrepareResponse = self
2257            .post(&format!("v2/markets/{market_id}/twaps/prepare"), &request)
2258            .await?;
2259        validate_platform_version(prepared.schema_version, &prepared.contract_version)?;
2260        if prepared.market_id != market_id
2261            || !valid_handle(&prepared.twap_control_id, "twctl_")
2262            || !valid_handle(&prepared.twap_id, "twap_")
2263            || prepared.expires_at_ms == 0
2264        {
2265            return Err(SdkError::InvalidResponse(
2266                "prepared TWAP control is invalid".to_owned(),
2267            ));
2268        }
2269        canonical_base64(&prepared.transaction_base64, "transaction_base64")?;
2270        canonical_base58_32(&prepared.recent_blockhash, "recent_blockhash")?;
2271        if let PlatformTwapPrepareRequest::Direct(operation) = &request {
2272            if prepared.action != twap_request_action(operation) {
2273                return Err(SdkError::InvalidResponse(
2274                    "prepared TWAP action does not match request".to_owned(),
2275                ));
2276            }
2277        }
2278        Ok(prepared)
2279    }
2280
2281    /// Submit one externally signed TWAP transaction idempotently.
2282    pub async fn twap_submit(
2283        &self,
2284        market_id: &str,
2285        request: PlatformTwapSubmitRequest,
2286    ) -> Result<PlatformTwapSubmitResponse, SdkError> {
2287        let market_id = validate_platform_market_id(market_id)?;
2288        if !valid_handle(&request.twap_control_id, "twctl_") {
2289            return Err(SdkError::InvalidRequest(
2290                "twap_control_id is invalid".to_owned(),
2291            ));
2292        }
2293        let request = PlatformTwapSubmitRequest {
2294            twap_control_id: request.twap_control_id,
2295            signed_transaction_base64: canonical_base64(
2296                &request.signed_transaction_base64,
2297                "signed_transaction_base64",
2298            )?,
2299            idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
2300        };
2301        let submitted: PlatformTwapSubmitResponse = self
2302            .post(&format!("v2/markets/{market_id}/twaps/submit"), &request)
2303            .await?;
2304        validate_platform_version(submitted.schema_version, &submitted.contract_version)?;
2305        if submitted.market_id != market_id
2306            || submitted.twap_control_id != request.twap_control_id
2307            || !valid_handle(&submitted.twap_id, "twap_")
2308            || submitted.status != PlatformOrderSubmissionStatus::Submitted
2309        {
2310            return Err(SdkError::InvalidResponse(
2311                "TWAP control receipt is invalid".to_owned(),
2312            ));
2313        }
2314        canonical_signature(&submitted.signature, "signature")?;
2315        Ok(submitted)
2316    }
2317
2318    /// Complete the externally signed TWAP flow with one signature: the
2319    /// action is bound and built in one step (direct prepare), the prepared
2320    /// bindings are checked against the request, the mandatory verifier runs
2321    /// (see [`DefaultTransactionVerifier`]), and only then is the session's
2322    /// transaction signature requested. `SessionSigner::sign_message` is not
2323    /// called on this path.
2324    pub async fn execute_twap<S, V>(
2325        &self,
2326        market_id: &str,
2327        operation: &TwapExecuteOperation,
2328        signer: &S,
2329        verifier: &V,
2330        idempotency_key: Option<&str>,
2331    ) -> Result<PlatformTwapSubmitResponse, SdkError>
2332    where
2333        S: SessionSigner + ?Sized,
2334        V: TwapVerifier + ?Sized,
2335    {
2336        let market_id = validate_platform_market_id(market_id)?;
2337        let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
2338        let request = normalize_twap_challenge_request(
2339            operation.challenge_request(session_public_key.clone()),
2340        )?;
2341        let owner_wallet = twap_request_owner(&request).to_owned();
2342        // One signature: the action is bound and built in one step and the
2343        // session signs only the resulting transaction.
2344        let prepared = self
2345            .twap_prepare(
2346                &market_id,
2347                PlatformTwapPrepareRequest::Direct(request.clone()),
2348            )
2349            .await?;
2350        validate_twap_direct_binding(&prepared, &request, &market_id)?;
2351        verifier
2352            .verify(&TwapVerificationContext {
2353                challenge: None,
2354                operation: &request,
2355                market_id: &market_id,
2356                prepared: &prepared,
2357                owner_wallet: &owner_wallet,
2358                session_public_key: &session_public_key,
2359            })
2360            .await
2361            .map_err(SdkError::Verification)?;
2362        let signed_transaction = signer
2363            .sign_transaction(&prepared.transaction_base64)
2364            .await
2365            .map_err(SdkError::Signer)?;
2366        self.twap_submit(
2367            &market_id,
2368            PlatformTwapSubmitRequest {
2369                twap_control_id: prepared.twap_control_id.clone(),
2370                signed_transaction_base64: canonical_base64(
2371                    &signed_transaction,
2372                    "signed_transaction_base64",
2373                )?,
2374                idempotency_key: normalize_idempotency_key(
2375                    idempotency_key.unwrap_or(&prepared.twap_control_id),
2376                )?,
2377            },
2378        )
2379        .await
2380    }
2381
2382    /// Execute one resting-order operation with one signature while all
2383    /// private keys and signing policy remain in the caller's signer adapter.
2384    /// The operation is bound and built in one step (direct prepare), the
2385    /// prepared bindings are checked against the request, and the mandatory
2386    /// verifier (see [`DefaultTransactionVerifier`], which decodes the
2387    /// transaction and requires it to be exactly this operation) runs before
2388    /// the transaction signature is requested. `SessionSigner::sign_message`
2389    /// is not called on this path.
2390    pub async fn execute_order<S, V>(
2391        &self,
2392        market_id: &str,
2393        operation: &OrderExecuteOperation,
2394        signer: &S,
2395        verifier: &V,
2396        idempotency_key: Option<&str>,
2397    ) -> Result<PlatformOrderSubmitResponse, SdkError>
2398    where
2399        S: SessionSigner + ?Sized,
2400        V: OrderVerifier + ?Sized,
2401    {
2402        let market_id = validate_platform_market_id(market_id)?;
2403        let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
2404        let request = normalize_order_challenge_request(
2405            operation.challenge_request(session_public_key.clone()),
2406        )?;
2407        let owner_wallet = order_request_owner(&request).to_owned();
2408        if owner_wallet == session_public_key {
2409            return Err(SdkError::InvalidRequest(
2410                "session_public_key must be distinct from owner_wallet".to_owned(),
2411            ));
2412        }
2413        // One signature: the operation is bound and built in one step and the
2414        // session signs only the resulting transaction, after the verifier
2415        // has checked it is exactly this operation.
2416        let prepared = self
2417            .order_prepare(
2418                &market_id,
2419                PlatformOrderPrepareRequest::Direct(request.clone()),
2420            )
2421            .await?;
2422        validate_order_direct_binding(&prepared, &request, &market_id)?;
2423        verifier
2424            .verify(&OrderVerificationContext {
2425                challenge: None,
2426                operation: &request,
2427                market_id: &market_id,
2428                prepared: &prepared,
2429                owner_wallet: &owner_wallet,
2430                session_public_key: &session_public_key,
2431            })
2432            .await
2433            .map_err(SdkError::Verification)?;
2434        let signed_transaction = signer
2435            .sign_transaction(&prepared.transaction_base64)
2436            .await
2437            .map_err(SdkError::Signer)?;
2438        let signed_transaction =
2439            canonical_base64(&signed_transaction, "signed_transaction_base64")?;
2440        self.order_submit(
2441            &market_id,
2442            PlatformOrderSubmitRequest {
2443                order_control_id: prepared.order_control_id.clone(),
2444                signed_transaction_base64: signed_transaction,
2445                idempotency_key: normalize_idempotency_key(
2446                    idempotency_key.unwrap_or(&prepared.order_control_id),
2447                )?,
2448            },
2449        )
2450        .await
2451    }
2452
2453    /// Execute one short-lived Sonar quote with one signature, without giving
2454    /// the SDK custody of a session private key. The quote is bound and built
2455    /// in one step (direct prepare), the prepared bindings are checked against
2456    /// the quote, and the transaction verifier (see
2457    /// [`DefaultTransactionVerifier`]) always runs before the session adapter
2458    /// is allowed to sign. `SessionSigner::sign_message` is not called on this
2459    /// path.
2460    pub async fn execute_quote<S, V>(
2461        &self,
2462        quote: &QuoteResponse,
2463        owner_wallet: &str,
2464        account_sequence: Option<u64>,
2465        signer: &S,
2466        verifier: &V,
2467        idempotency_key: Option<&str>,
2468    ) -> Result<ExecutionSubmitResponse, SdkError>
2469    where
2470        S: SessionSigner + ?Sized,
2471        V: ExecutionVerifier + ?Sized,
2472    {
2473        validate_version(quote.schema_version, &quote.contract_version)?;
2474        let now_ms = unix_ms()?;
2475        if quote.expires_at_ms <= now_ms {
2476            return Err(SdkError::InvalidRequest("quote has expired".to_owned()));
2477        }
2478        let owner_wallet = canonical_public_key(owner_wallet, "owner_wallet")?;
2479        let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
2480        let markets = self.markets().await?;
2481        let market = markets
2482            .markets
2483            .iter()
2484            .find(|market| market.market_pda.as_deref() == Some(quote.market_id.as_str()))
2485            .ok_or_else(|| SdkError::MarketNotFound(quote.market_id.clone()))?;
2486        let quote_path = market
2487            .quote_path
2488            .as_deref()
2489            .filter(|path| valid_public_operation_path(path))
2490            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
2491        let execution_path = format!(
2492            "{}/execution",
2493            quote_path
2494                .strip_suffix("/quote")
2495                .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
2496        );
2497        // One signature: the quote is bound and built in one step and the
2498        // session signs only the resulting transaction.
2499        let prepared: ExecutionPrepareResponse = self
2500            .post(
2501                &format!("{execution_path}/prepare"),
2502                &ExecutionPrepareRequest::Direct(ExecutionChallengeRequest {
2503                    quote_id: quote.quote_id.clone(),
2504                    owner_wallet: owner_wallet.clone(),
2505                    session_public_key: session_public_key.clone(),
2506                    account_sequence: account_sequence.map(|value| value.to_string()),
2507                }),
2508            )
2509            .await?;
2510        validate_execution_direct_prepare(&prepared, quote)?;
2511        verifier
2512            .verify(&ExecutionVerificationContext {
2513                quote,
2514                challenge: None,
2515                prepared: &prepared,
2516                owner_wallet: &owner_wallet,
2517                session_public_key: &session_public_key,
2518            })
2519            .await
2520            .map_err(SdkError::Verification)?;
2521        let signed_transaction = signer
2522            .sign_transaction(&prepared.transaction_base64)
2523            .await
2524            .map_err(SdkError::Signer)?;
2525        base64::engine::general_purpose::STANDARD
2526            .decode(signed_transaction.trim())
2527            .map_err(|_| {
2528                SdkError::InvalidResponse(
2529                    "session signer returned an invalid base64 transaction".to_owned(),
2530                )
2531            })?;
2532        let idempotency_key =
2533            normalize_idempotency_key(idempotency_key.unwrap_or(&prepared.execution_id))?;
2534        let submitted: ExecutionSubmitResponse = self
2535            .post(
2536                &format!("{execution_path}/submit"),
2537                &ExecutionSubmitRequest {
2538                    execution_id: prepared.execution_id.clone(),
2539                    signed_transaction_base64: signed_transaction,
2540                    idempotency_key,
2541                },
2542            )
2543            .await?;
2544        validate_version(submitted.schema_version, &submitted.contract_version)?;
2545        if submitted.execution_id != prepared.execution_id
2546            || submitted.status != ExecutionStatus::Submitted
2547            || submitted.signature.trim().is_empty()
2548        {
2549            return Err(SdkError::InvalidResponse(
2550                "execution receipt does not match the prepared transaction".to_owned(),
2551            ));
2552        }
2553        Ok(submitted)
2554    }
2555
2556    async fn get<T: DeserializeOwned>(
2557        &self,
2558        path: &str,
2559        query: &[(String, String)],
2560    ) -> Result<T, SdkError> {
2561        self.get_with_headers(path, query, HeaderMap::new()).await
2562    }
2563
2564    async fn get_with_headers<T: DeserializeOwned>(
2565        &self,
2566        path: &str,
2567        query: &[(String, String)],
2568        headers: HeaderMap,
2569    ) -> Result<T, SdkError> {
2570        let mut url = self.base_url.join(path).map_err(|error| {
2571            SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
2572        })?;
2573        url.query_pairs_mut().extend_pairs(
2574            query
2575                .iter()
2576                .map(|(key, value)| (key.as_str(), value.as_str())),
2577        );
2578
2579        let response = self
2580            .http
2581            .get(url)
2582            .header(reqwest::header::ACCEPT, "application/json")
2583            .headers(headers)
2584            .send()
2585            .await?;
2586        let status = response.status();
2587        let bytes = response.bytes().await?;
2588        if !status.is_success() {
2589            return match serde_json::from_slice::<ErrorResponse>(&bytes) {
2590                Ok(error) => Err(SdkError::Api {
2591                    status,
2592                    code: error.error.code,
2593                    message: error.error.message,
2594                    retryable: error.error.retryable,
2595                }),
2596                Err(_) => Err(SdkError::Api {
2597                    status,
2598                    code: "request_failed".to_owned(),
2599                    message: "Strata could not complete the request.".to_owned(),
2600                    retryable: status.is_server_error(),
2601                }),
2602            };
2603        }
2604        serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
2605    }
2606
2607    async fn all_platform_market_ids(&self) -> Result<Vec<String>, SdkError> {
2608        let mut market_ids = Vec::new();
2609        let mut cursor = None;
2610        let mut seen_cursors = HashSet::new();
2611        loop {
2612            let response = self
2613                .platform_markets(PageRequest {
2614                    cursor: cursor.clone(),
2615                    limit: Some(MAX_PLATFORM_PAGE_SIZE),
2616                })
2617                .await?;
2618            market_ids.extend(response.markets.into_iter().map(|market| market.market_id));
2619            if !response.page.has_more {
2620                break;
2621            }
2622            let next = response.page.next_cursor.ok_or_else(|| {
2623                SdkError::InvalidResponse(
2624                    "market pagination omitted the required next cursor".to_owned(),
2625                )
2626            })?;
2627            if !seen_cursors.insert(next.clone()) {
2628                return Err(SdkError::InvalidResponse(
2629                    "market pagination repeated a cursor".to_owned(),
2630                ));
2631            }
2632            cursor = Some(next);
2633        }
2634        normalize_market_ids(market_ids)
2635    }
2636
2637    async fn post<T: DeserializeOwned, B: serde::Serialize>(
2638        &self,
2639        path: &str,
2640        body: &B,
2641    ) -> Result<T, SdkError> {
2642        let url = self.base_url.join(path).map_err(|error| {
2643            SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
2644        })?;
2645        let response = self
2646            .http
2647            .post(url)
2648            .header(reqwest::header::ACCEPT, "application/json")
2649            .json(body)
2650            .send()
2651            .await?;
2652        let status = response.status();
2653        let bytes = response.bytes().await?;
2654        if !status.is_success() {
2655            return match serde_json::from_slice::<ErrorResponse>(&bytes) {
2656                Ok(error) => Err(SdkError::Api {
2657                    status,
2658                    code: error.error.code,
2659                    message: error.error.message,
2660                    retryable: error.error.retryable,
2661                }),
2662                Err(_) => Err(SdkError::Api {
2663                    status,
2664                    code: "request_failed".to_owned(),
2665                    message: "Strata could not complete the request.".to_owned(),
2666                    retryable: status.is_server_error(),
2667                }),
2668            };
2669        }
2670        serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
2671    }
2672
2673    async fn execution_path(&self, requested_market: &str) -> Result<String, SdkError> {
2674        let markets = self.markets().await?;
2675        let market = markets
2676            .markets
2677            .iter()
2678            .find(|market| {
2679                market.label.eq_ignore_ascii_case(requested_market.trim())
2680                    || market.market_pda.as_deref() == Some(requested_market.trim())
2681            })
2682            .ok_or_else(|| SdkError::MarketNotFound(requested_market.to_owned()))?;
2683        if !market.ready {
2684            return Err(SdkError::OperationUnavailable(market.label.clone()));
2685        }
2686        let quote_path = market
2687            .quote_path
2688            .as_deref()
2689            .filter(|path| valid_public_operation_path(path))
2690            .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
2691        Ok(format!(
2692            "{}/execution",
2693            quote_path
2694                .strip_suffix("/quote")
2695                .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
2696        ))
2697    }
2698}
2699
2700fn normalize_base_url(value: &str) -> Result<Url, SdkError> {
2701    let mut normalized = value.trim().to_owned();
2702    if !normalized.ends_with('/') {
2703        normalized.push('/');
2704    }
2705    let url =
2706        Url::parse(&normalized).map_err(|error| SdkError::InvalidBaseUrl(error.to_string()))?;
2707    if !matches!(url.scheme(), "http" | "https") || url.cannot_be_a_base() {
2708        return Err(SdkError::InvalidBaseUrl(
2709            "URL must use http or https and include a host".to_owned(),
2710        ));
2711    }
2712    Ok(url)
2713}
2714
2715fn validate_action_graph(graph: &ActionGraph) -> Result<(), SdkError> {
2716    validate_version(graph.schema_version, &graph.contract_version)?;
2717    if graph.graph_version != "1.0"
2718        || graph.authority.permission_source != "external_agent_owner"
2719        || graph.authority.signing_location != "external"
2720        || graph.authority.accepts_private_keys
2721    {
2722        return Err(SdkError::InvalidResponse(
2723            "unsupported action graph authority model".to_owned(),
2724        ));
2725    }
2726    let ids = graph
2727        .nodes
2728        .iter()
2729        .map(|node| node.id.as_str())
2730        .collect::<HashSet<_>>();
2731    if ids.len() != graph.nodes.len() || !ids.contains(graph.entry_node.as_str()) {
2732        return Err(SdkError::InvalidResponse(
2733            "action graph node IDs are invalid".to_owned(),
2734        ));
2735    }
2736    if graph.edges.iter().any(|edge| {
2737        !ids.contains(edge.from.as_str())
2738            || !ids.contains(edge.to.as_str())
2739            || edge.condition.trim().is_empty()
2740    }) {
2741        return Err(SdkError::InvalidResponse(
2742            "action graph contains an invalid edge".to_owned(),
2743        ));
2744    }
2745    Ok(())
2746}
2747
2748fn validate_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
2749    if schema_version != CONTRACT_MAJOR || contract_version != CONTRACT_VERSION {
2750        return Err(SdkError::InvalidResponse(format!(
2751            "unsupported contract {contract_version} (schema {schema_version})"
2752        )));
2753    }
2754    Ok(())
2755}
2756
2757fn validate_platform_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
2758    if schema_version != strata_public_contract::platform::PLATFORM_SCHEMA_VERSION
2759        || contract_version != strata_public_contract::platform::PLATFORM_CONTRACT_VERSION
2760    {
2761        return Err(SdkError::InvalidResponse(format!(
2762            "unsupported platform contract {contract_version} (schema {schema_version})"
2763        )));
2764    }
2765    Ok(())
2766}
2767
2768fn validate_vault_preparation(preparation_id: &str, submit_by_ms: u64) -> Result<(), SdkError> {
2769    if !valid_handle(preparation_id, "vp_") || submit_by_ms == 0 {
2770        return Err(SdkError::InvalidResponse(
2771            "Vault preparation identity is invalid".to_owned(),
2772        ));
2773    }
2774    Ok(())
2775}
2776
2777fn validate_vault_submission(
2778    response: &PlatformVaultSubmitResponse,
2779    preparation_id: &str,
2780) -> Result<(), SdkError> {
2781    validate_platform_version(response.schema_version, &response.contract_version)?;
2782    if response.preparation_id != preparation_id
2783        || (response.status == PlatformVaultSubmissionStatus::Failed)
2784            != response.failure_code.is_some()
2785        || response.failure_code.as_deref().is_some_and(|code| {
2786            code.len() < 3
2787                || code.len() > 64
2788                || !code
2789                    .bytes()
2790                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
2791        })
2792    {
2793        return Err(SdkError::InvalidResponse(
2794            "Vault submission receipt is invalid".to_owned(),
2795        ));
2796    }
2797    canonical_public_key(&response.wallet_address, "wallet_address")?;
2798    canonical_signature(&response.signature, "signature")?;
2799    Ok(())
2800}
2801
2802fn validate_platform_market_id(value: &str) -> Result<String, SdkError> {
2803    let value = value.trim();
2804    if !valid_handle(value, "market_") {
2805        return Err(SdkError::InvalidRequest(
2806            "market_id must be an opaque Strata market ID".to_owned(),
2807        ));
2808    }
2809    Ok(value.to_owned())
2810}
2811
2812fn validate_platform_asset_id(value: &str) -> Result<String, SdkError> {
2813    let value = value.trim();
2814    if !valid_handle(value, "asset_") {
2815        return Err(SdkError::InvalidRequest(
2816            "asset_id must be an opaque Strata asset ID".to_owned(),
2817        ));
2818    }
2819    Ok(value.to_owned())
2820}
2821
2822fn validate_platform_authority(authority: &PlatformAuthority) -> Result<(), SdkError> {
2823    if authority.permission_source != PermissionSource::ExternalAgentOwner
2824        || authority.signing_location != SigningLocation::External
2825        || authority.accepts_private_keys
2826    {
2827        return Err(SdkError::InvalidResponse(
2828            "platform authority must remain with the external agent owner".to_owned(),
2829        ));
2830    }
2831    Ok(())
2832}
2833
2834fn validate_platform_discovery(discovery: &PlatformDiscoveryResponse) -> Result<(), SdkError> {
2835    validate_platform_version(discovery.schema_version, &discovery.contract_version)?;
2836    validate_platform_authority(&discovery.authority)?;
2837    let mut ids = HashSet::new();
2838    if discovery.capabilities.iter().any(|capability| {
2839        capability.id.trim().is_empty()
2840            || capability.required_scope.trim().is_empty()
2841            || capability.transports.is_empty()
2842            || !ids.insert(capability.id.as_str())
2843    }) {
2844        return Err(SdkError::InvalidResponse(
2845            "platform capability discovery is invalid".to_owned(),
2846        ));
2847    }
2848    Ok(())
2849}
2850
2851fn validate_platform_action_graph(graph: &PlatformActionGraphResponse) -> Result<(), SdkError> {
2852    validate_platform_version(graph.schema_version, &graph.contract_version)?;
2853    validate_platform_authority(&graph.authority)?;
2854    if graph.graph_version != "2.0" {
2855        return Err(SdkError::InvalidResponse(
2856            "unsupported platform action graph version".to_owned(),
2857        ));
2858    }
2859
2860    let entities = graph
2861        .entities
2862        .iter()
2863        .map(String::as_str)
2864        .collect::<HashSet<_>>();
2865    if entities.len() != graph.entities.len()
2866        || entities.contains("")
2867        || graph.relations.iter().any(|relation| {
2868            !entities.contains(relation.from.as_str())
2869                || !entities.contains(relation.to.as_str())
2870                || relation.kind.trim().is_empty()
2871        })
2872    {
2873        return Err(SdkError::InvalidResponse(
2874            "platform entity graph is invalid".to_owned(),
2875        ));
2876    }
2877
2878    let mut operation_ids = HashSet::new();
2879    let mut operation_capabilities = HashMap::new();
2880    if graph.operations.iter().any(|operation| {
2881        operation.id.trim().is_empty()
2882            || operation.capability_id.trim().is_empty()
2883            || operation.summary.trim().is_empty()
2884            || operation.transports.is_empty()
2885            || !operation_ids.insert(operation.id.as_str())
2886            || operation_capabilities
2887                .insert(operation.id.as_str(), operation.capability_id.as_str())
2888                .is_some()
2889            || operation
2890                .transports
2891                .iter()
2892                .any(|transport| match transport.transport {
2893                    PlatformTransport::Http => {
2894                        transport.method.as_deref().is_none_or(str::is_empty)
2895                            || transport
2896                                .path
2897                                .as_deref()
2898                                .is_none_or(|path| !valid_platform_operation_path(path))
2899                            || transport.tool.is_some()
2900                    }
2901                    PlatformTransport::Websocket => {
2902                        transport
2903                            .path
2904                            .as_deref()
2905                            .is_none_or(|path| !valid_platform_operation_path(path))
2906                            || transport.method.is_some()
2907                            || transport.tool.is_some()
2908                    }
2909                    PlatformTransport::Mcp => {
2910                        transport.tool.as_deref().is_none_or(str::is_empty)
2911                            || transport.method.is_some()
2912                            || transport.path.is_some()
2913                    }
2914                })
2915    }) || !operation_ids.contains(graph.entry_operation_id.as_str())
2916    {
2917        return Err(SdkError::InvalidResponse(
2918            "platform operation graph is invalid".to_owned(),
2919        ));
2920    }
2921
2922    let mut module_ids = HashSet::new();
2923    if graph.modules.iter().any(|module| {
2924        module.id.trim().is_empty()
2925            || module.client_property.trim().is_empty()
2926            || module.capability_ids.is_empty()
2927            || !module_ids.insert(module.id.as_str())
2928    }) {
2929        return Err(SdkError::InvalidResponse(
2930            "platform module graph is invalid".to_owned(),
2931        ));
2932    }
2933
2934    let mut workflow_ids = HashSet::new();
2935    let mut covered_operation_ids = HashSet::new();
2936    if graph.workflows.iter().any(|workflow| {
2937        if workflow.id.trim().is_empty() || !workflow_ids.insert(workflow.id.as_str()) {
2938            return true;
2939        }
2940        let node_ids = workflow
2941            .nodes
2942            .iter()
2943            .map(|node| node.id.as_str())
2944            .collect::<HashSet<_>>();
2945        let mut outgoing = node_ids
2946            .iter()
2947            .copied()
2948            .map(|node_id| (node_id, Vec::new()))
2949            .collect::<HashMap<_, _>>();
2950        let nodes_are_invalid = node_ids.len() != workflow.nodes.len()
2951            || !node_ids.contains(workflow.entry_node.as_str())
2952            || workflow.nodes.iter().any(|node| {
2953                if node.id.trim().is_empty() {
2954                    return true;
2955                }
2956                match node.capability_id.as_deref() {
2957                    None => node.kind
2958                        != strata_public_contract::platform::PlatformActionKind::ExternalSignature
2959                        || !node.operation_ids.is_empty(),
2960                    Some(capability_id) => node.kind
2961                        == strata_public_contract::platform::PlatformActionKind::ExternalSignature
2962                        || node.operation_ids.is_empty()
2963                        || node.operation_ids.iter().any(|operation_id| {
2964                            let Some(operation_capability) =
2965                                operation_capabilities.get(operation_id.as_str())
2966                            else {
2967                                return true;
2968                            };
2969                            if *operation_capability != capability_id {
2970                                return true;
2971                            }
2972                            covered_operation_ids.insert(operation_id.as_str());
2973                            false
2974                        }),
2975                }
2976            });
2977        if nodes_are_invalid || workflow.edges.is_empty() {
2978            return true;
2979        }
2980        if workflow.edges.iter().any(|edge| {
2981            if !node_ids.contains(edge.from.as_str())
2982                || !node_ids.contains(edge.to.as_str())
2983                || edge.condition.trim().is_empty()
2984            {
2985                return true;
2986            }
2987            outgoing
2988                .get_mut(edge.from.as_str())
2989                .expect("validated workflow source node")
2990                .push(edge.to.as_str());
2991            false
2992        }) {
2993            return true;
2994        }
2995        let mut reached = HashSet::from([workflow.entry_node.as_str()]);
2996        let mut pending = vec![workflow.entry_node.as_str()];
2997        while let Some(node_id) = pending.pop() {
2998            for target in outgoing.get(node_id).into_iter().flatten() {
2999                if reached.insert(*target) {
3000                    pending.push(*target);
3001                }
3002            }
3003        }
3004        reached.len() != node_ids.len()
3005    }) {
3006        return Err(SdkError::InvalidResponse(
3007            "platform workflow graph is invalid".to_owned(),
3008        ));
3009    }
3010    if covered_operation_ids.len() != operation_ids.len() {
3011        return Err(SdkError::InvalidResponse(
3012            "platform action graph contains an orphaned operation".to_owned(),
3013        ));
3014    }
3015    Ok(())
3016}
3017
3018fn valid_platform_operation_path(path: &str) -> bool {
3019    path.starts_with('/')
3020        && !path.starts_with("//")
3021        && !path.contains("..")
3022        && !path.to_ascii_lowercase().contains("/internal")
3023        && !path.to_ascii_lowercase().contains("/admin")
3024}
3025
3026fn validate_platform_market_response(
3027    schema_version: u16,
3028    contract_version: &str,
3029    actual_market_id: &str,
3030    expected_market_id: &str,
3031) -> Result<(), SdkError> {
3032    validate_platform_version(schema_version, contract_version)?;
3033    if actual_market_id != expected_market_id {
3034        return Err(SdkError::InvalidResponse(
3035            "response market does not match request".to_owned(),
3036        ));
3037    }
3038    Ok(())
3039}
3040
3041fn normalize_page_request(request: PageRequest) -> Result<Vec<(String, String)>, SdkError> {
3042    let mut query = Vec::new();
3043    if let Some(limit) = request.limit {
3044        if !(1..=MAX_PLATFORM_PAGE_SIZE).contains(&limit) {
3045            return Err(SdkError::InvalidRequest(format!(
3046                "page limit must be between 1 and {MAX_PLATFORM_PAGE_SIZE}"
3047            )));
3048        }
3049        query.push(("limit".to_owned(), limit.to_string()));
3050    }
3051    if let Some(cursor) = request.cursor {
3052        let cursor = cursor.trim();
3053        if cursor.is_empty()
3054            || cursor.len() > 512
3055            || !cursor
3056                .bytes()
3057                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
3058        {
3059            return Err(SdkError::InvalidRequest(
3060                "cursor must be a non-empty opaque URL-safe value".to_owned(),
3061            ));
3062        }
3063        query.push(("cursor".to_owned(), cursor.to_owned()));
3064    }
3065    Ok(query)
3066}
3067
3068fn validate_page_info(page: &PageInfo) -> Result<(), SdkError> {
3069    match (&page.next_cursor, page.has_more) {
3070        (Some(cursor), true)
3071            if !cursor.is_empty()
3072                && cursor.len() <= 512
3073                && cursor
3074                    .bytes()
3075                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') =>
3076        {
3077            Ok(())
3078        }
3079        (None, false) => Ok(()),
3080        _ => Err(SdkError::InvalidResponse(
3081            "pagination metadata is inconsistent".to_owned(),
3082        )),
3083    }
3084}
3085
3086fn validate_response_atoms(value: &str, field: &str, allow_zero: bool) -> Result<u64, SdkError> {
3087    if value.is_empty()
3088        || !value.bytes().all(|byte| byte.is_ascii_digit())
3089        || (value.len() > 1 && value.starts_with('0'))
3090    {
3091        return Err(SdkError::InvalidResponse(format!(
3092            "{field} must be a canonical unsigned atomic decimal string"
3093        )));
3094    }
3095    let parsed = value
3096        .parse::<u64>()
3097        .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds u64")))?;
3098    if !allow_zero && parsed == 0 {
3099        return Err(SdkError::InvalidResponse(format!(
3100            "{field} must be greater than zero"
3101        )));
3102    }
3103    Ok(parsed)
3104}
3105
3106fn validate_book_level(level: &PlatformBookLevel) -> Result<(u64, u64), SdkError> {
3107    Ok((
3108        validate_response_atoms(&level.price_atoms, "price_atoms", false)?,
3109        validate_response_atoms(&level.size_atoms, "size_atoms", false)?,
3110    ))
3111}
3112
3113fn validate_book_levels(
3114    bids: &[PlatformBookLevel],
3115    asks: &[PlatformBookLevel],
3116) -> Result<(), SdkError> {
3117    let bid_prices = bids
3118        .iter()
3119        .map(validate_book_level)
3120        .collect::<Result<Vec<_>, _>>()?;
3121    let ask_prices = asks
3122        .iter()
3123        .map(validate_book_level)
3124        .collect::<Result<Vec<_>, _>>()?;
3125    if bid_prices
3126        .windows(2)
3127        .any(|levels| levels[0].0 <= levels[1].0)
3128        || ask_prices
3129            .windows(2)
3130            .any(|levels| levels[0].0 >= levels[1].0)
3131        || bid_prices
3132            .first()
3133            .zip(ask_prices.first())
3134            .is_some_and(|(bid, ask)| bid.0 >= ask.0)
3135    {
3136        return Err(SdkError::InvalidResponse(
3137            "book levels are not strictly ordered".to_owned(),
3138        ));
3139    }
3140    Ok(())
3141}
3142
3143fn canonical_decimal(value: &str, field: &str) -> Result<(), SdkError> {
3144    let (mantissa, exponent) = value
3145        .split_once(['e', 'E'])
3146        .map_or((value, None), |(mantissa, exponent)| {
3147            (mantissa, Some(exponent))
3148        });
3149    let (whole, fraction) = mantissa
3150        .split_once('.')
3151        .map_or((mantissa, None), |(whole, fraction)| {
3152            (whole, Some(fraction))
3153        });
3154    let valid_whole = whole == "0"
3155        || (!whole.starts_with('0') && whole.bytes().all(|byte| byte.is_ascii_digit()));
3156    let valid_fraction = fraction.is_none_or(|fraction| {
3157        !fraction.is_empty() && fraction.bytes().all(|byte| byte.is_ascii_digit())
3158    });
3159    let valid_exponent = exponent.is_none_or(|exponent| {
3160        let digits = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
3161        !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
3162    });
3163    if !valid_whole
3164        || !valid_fraction
3165        || !valid_exponent
3166        || value.parse::<f64>().is_err()
3167        || !value.parse::<f64>().is_ok_and(f64::is_finite)
3168    {
3169        return Err(SdkError::InvalidResponse(format!(
3170            "{field} must be a canonical non-negative decimal string"
3171        )));
3172    }
3173    Ok(())
3174}
3175
3176fn platform_history_range(range: PlatformPortfolioHistoryRange) -> &'static str {
3177    match range {
3178        PlatformPortfolioHistoryRange::Day => "24h",
3179        PlatformPortfolioHistoryRange::Week => "7d",
3180        PlatformPortfolioHistoryRange::Month => "30d",
3181    }
3182}
3183
3184fn normalize_fill_limit(value: Option<u16>) -> Result<u16, SdkError> {
3185    match value {
3186        Some(limit @ 1..=200) => Ok(limit),
3187        Some(_) => Err(SdkError::InvalidRequest(
3188            "fill limit must be between 1 and 200".to_owned(),
3189        )),
3190        None => Ok(DEFAULT_ACCOUNT_FILL_LIMIT),
3191    }
3192}
3193
3194fn normalize_market_ids(values: Vec<String>) -> Result<Vec<String>, SdkError> {
3195    let mut ids = Vec::with_capacity(values.len());
3196    let mut seen = HashSet::new();
3197    for value in values {
3198        let id = validate_platform_market_id(&value)?;
3199        if seen.insert(id.clone()) {
3200            ids.push(id);
3201        }
3202    }
3203    Ok(ids)
3204}
3205
3206pub fn account_http_auth_message(
3207    market_id: &str,
3208    wallet_address: &str,
3209    timestamp_ms: u64,
3210    fill_limit: u16,
3211) -> Result<Vec<u8>, SdkError> {
3212    let market_id = validate_platform_market_id(market_id)?;
3213    let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
3214    let fill_limit = normalize_fill_limit(Some(fill_limit))?;
3215    Ok(format!(
3216        "strata:account-read:v2\n{market_id}\n{wallet_address}\n{timestamp_ms}\n{fill_limit}"
3217    )
3218    .into_bytes())
3219}
3220
3221/// Optional signed-read headers (deprecated path); public reads send none.
3222fn maker_auth_headers(authorization: Option<(u64, &str)>) -> Result<HeaderMap, SdkError> {
3223    let mut headers = HeaderMap::new();
3224    if let Some((authorization_time_ms, authorization_signature)) = authorization {
3225        headers.insert(
3226            "x-strata-auth-time",
3227            HeaderValue::from_str(&authorization_time_ms.to_string()).map_err(|_| {
3228                SdkError::InvalidRequest("maker authorization time is invalid".to_owned())
3229            })?,
3230        );
3231        headers.insert(
3232            "x-strata-auth-signature",
3233            HeaderValue::from_str(authorization_signature).map_err(|_| {
3234                SdkError::InvalidRequest("maker authorization signature is invalid".to_owned())
3235            })?,
3236        );
3237    }
3238    Ok(headers)
3239}
3240
3241pub fn maker_status_auth_message(
3242    market_id: &str,
3243    wallet_address: &str,
3244    timestamp_ms: u64,
3245) -> Result<Vec<u8>, SdkError> {
3246    let market_id = validate_platform_market_id(market_id)?;
3247    let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
3248    Ok(
3249        format!("strata:mm-status-read:v2\n{market_id}\n{wallet_address}\n{timestamp_ms}")
3250            .into_bytes(),
3251    )
3252}
3253
3254fn validate_maker_status(response: &PlatformMakerStatusResponse) -> Result<(), SdkError> {
3255    let invalid = |detail: &str| SdkError::InvalidResponse(format!("maker status {detail}"));
3256    if !valid_handle(&response.maker_id, "maker_") {
3257        return Err(invalid("maker_id is invalid"));
3258    }
3259    let current_slot = validate_response_u128(&response.current_slot, "current_slot")?;
3260    let firm = &response.firm_orders;
3261    if u64::from(firm.bid_orders) + u64::from(firm.ask_orders) != u64::from(firm.resting_orders) {
3262        return Err(invalid("firm order counts are inconsistent"));
3263    }
3264    validate_response_u128(&firm.bid_size_atoms, "bid_size_atoms")?;
3265    validate_response_u128(&firm.ask_size_atoms, "ask_size_atoms")?;
3266    let mut expected_active: u32 = u32::from(firm.resting_orders > 0);
3267    if let Some(intent) = &response.intent {
3268        let minimum = validate_response_u128(&intent.minimum_price_atoms, "minimum_price_atoms")?;
3269        let maximum = validate_response_u128(&intent.maximum_price_atoms, "maximum_price_atoms")?;
3270        let maximum_fill =
3271            validate_response_u128(&intent.maximum_fill_size_atoms, "maximum_fill_size_atoms")?;
3272        let remaining = validate_response_u128(
3273            &intent.remaining_fill_size_atoms,
3274            "remaining_fill_size_atoms",
3275        )?;
3276        validate_response_u128(&intent.stake_atoms, "stake_atoms")?;
3277        if minimum > maximum || remaining > maximum_fill || intent.minimum_spread_bps > 10_000 {
3278            return Err(invalid("intent bounds are inconsistent"));
3279        }
3280        expected_active += u32::from(intent.active);
3281    }
3282    if response.signed_quotes.live_quotes.len() > 2 {
3283        return Err(invalid("cannot hold more than one live quote per side"));
3284    }
3285    for quote in &response.signed_quotes.live_quotes {
3286        validate_response_u128(&quote.price_atoms, "price_atoms")?;
3287        validate_response_u128(&quote.size_atoms, "size_atoms")?;
3288        validate_response_u128(&quote.nonce, "nonce")?;
3289        if quote.expires_at_ms < quote.issued_at_ms {
3290            return Err(invalid("signed quote expires before it was issued"));
3291        }
3292    }
3293    if response.strands.len() > 256 || response.currents.len() > 256 {
3294        return Err(invalid("maker product lists exceed the bounded size"));
3295    }
3296    for strand in &response.strands {
3297        validate_response_u128(&strand.mid_price_atoms, "mid_price_atoms")?;
3298        validate_response_u128(&strand.tick_size_atoms, "tick_size_atoms")?;
3299        let maximum =
3300            validate_response_u128(&strand.maximum_exposure_atoms, "maximum_exposure_atoms")?;
3301        let remaining =
3302            validate_response_u128(&strand.remaining_exposure_atoms, "remaining_exposure_atoms")?;
3303        if remaining > maximum || strand.bids.len() > 16 || strand.asks.len() > 16 {
3304            return Err(invalid("strand exposure or levels are inconsistent"));
3305        }
3306        for level in strand.bids.iter().chain(strand.asks.iter()) {
3307            if let Some(price) = &level.price_atoms {
3308                validate_response_u128(price, "price_atoms")?;
3309            }
3310            let size = validate_response_u128(&level.size_atoms, "size_atoms")?;
3311            let remaining =
3312                validate_response_u128(&level.remaining_size_atoms, "remaining_size_atoms")?;
3313            if remaining > size {
3314                return Err(invalid("strand level remaining exceeds size"));
3315            }
3316        }
3317        let expected_expired = match &strand.valid_until_slot {
3318            Some(slot) => current_slot > validate_response_u128(slot, "valid_until_slot")?,
3319            None => false,
3320        };
3321        if strand.expired != expected_expired {
3322            return Err(invalid("strand expiry disagrees with the current slot"));
3323        }
3324        expected_active += u32::from(strand.enabled && !strand.expired);
3325    }
3326    for current in &response.currents {
3327        let maximum =
3328            validate_response_u128(&current.maximum_exposure_atoms, "maximum_exposure_atoms")?;
3329        let remaining = validate_response_u128(
3330            &current.remaining_exposure_atoms,
3331            "remaining_exposure_atoms",
3332        )?;
3333        if remaining > maximum
3334            || current.bid_depth_atoms.len() > 8
3335            || current.ask_depth_atoms.len() > 8
3336            || current.half_spread_bps > 10_000
3337            || current.band_step_bps > 10_000
3338            || current.sync_spread_bps > 10_000
3339        {
3340            return Err(invalid("current exposure or bands are inconsistent"));
3341        }
3342        for depth in current
3343            .bid_depth_atoms
3344            .iter()
3345            .chain(current.ask_depth_atoms.iter())
3346        {
3347            validate_response_u128(depth, "depth_atoms")?;
3348        }
3349        let expected_expired = match &current.valid_until_slot {
3350            Some(slot) => current_slot > validate_response_u128(slot, "valid_until_slot")?,
3351            None => false,
3352        };
3353        if current.expired != expected_expired {
3354            return Err(invalid("current expiry disagrees with the current slot"));
3355        }
3356        expected_active += u32::from(current.enabled && !current.expired);
3357    }
3358    if response.dead_man_guards.len() > 32 {
3359        return Err(invalid("dead-man guard list exceeds the bounded size"));
3360    }
3361    for guard in &response.dead_man_guards {
3362        canonical_public_key(&guard.session_public_key, "session_public_key")?;
3363    }
3364    if u32::from(response.active_products) != expected_active {
3365        return Err(invalid(
3366            "active_products disagrees with the reported products",
3367        ));
3368    }
3369    Ok(())
3370}
3371
3372pub fn maker_reputation_auth_message(
3373    market_id: &str,
3374    wallet_address: &str,
3375    timestamp_ms: u64,
3376) -> Result<Vec<u8>, SdkError> {
3377    let market_id = validate_platform_market_id(market_id)?;
3378    let wallet_address = canonical_public_key(wallet_address, "wallet_address")?;
3379    Ok(
3380        format!("strata:mm-reputation-read:v2\n{market_id}\n{wallet_address}\n{timestamp_ms}")
3381            .into_bytes(),
3382    )
3383}
3384
3385fn validate_response_u128(value: &str, field: &str) -> Result<u128, SdkError> {
3386    if value.is_empty()
3387        || !value.bytes().all(|byte| byte.is_ascii_digit())
3388        || (value.len() > 1 && value.starts_with('0'))
3389    {
3390        return Err(SdkError::InvalidResponse(format!(
3391            "{field} must be a canonical unsigned atomic decimal string"
3392        )));
3393    }
3394    value
3395        .parse::<u128>()
3396        .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds u128")))
3397}
3398
3399fn validate_platform_portfolio(response: &PlatformPortfolioResponse) -> Result<(), SdkError> {
3400    let invalid = |detail: &str| SdkError::InvalidResponse(format!("portfolio {detail}"));
3401    validate_response_u128(&response.observed_slot, "observed_slot")?;
3402    if response.observed_at_ms > response.server_time_ms {
3403        return Err(invalid("cannot be observed after server time"));
3404    }
3405    if response.balances.len() > 10_000
3406        || response.positions.len() > 10_000
3407        || response.open_orders.len() > 10_000
3408        || response.recent_fills.len() > 10_000
3409        || response.unavailable_market_ids.len() > 10_000
3410        || response.unpriced_asset_ids.len() > 10_000
3411    {
3412        return Err(invalid("collections exceed the bounded size"));
3413    }
3414    let mut seen_orders = std::collections::BTreeSet::new();
3415    for order in &response.open_orders {
3416        validate_platform_market_id(&order.market_id)?;
3417        if !valid_handle(&order.order_id, "order_") || !seen_orders.insert(order.order_id.as_str())
3418        {
3419            return Err(invalid("open orders must carry unique opaque order IDs"));
3420        }
3421        let original = validate_response_u128(&order.original_size_atoms, "original_size_atoms")?;
3422        let remaining =
3423            validate_response_u128(&order.remaining_size_atoms, "remaining_size_atoms")?;
3424        if remaining > original || original == 0 {
3425            return Err(invalid("open order sizes are inconsistent"));
3426        }
3427    }
3428    let mut seen_fills = std::collections::BTreeSet::new();
3429    for fill in &response.recent_fills {
3430        validate_platform_market_id(&fill.market_id)?;
3431        if !valid_handle(&fill.fill_id, "fill_") || !seen_fills.insert(fill.fill_id.as_str()) {
3432            return Err(invalid("recent fills must carry unique opaque fill IDs"));
3433        }
3434        validate_response_u128(&fill.price_atoms, "price_atoms")?;
3435        validate_response_u128(&fill.size_atoms, "size_atoms")?;
3436    }
3437    for market_id in &response.unavailable_market_ids {
3438        validate_platform_market_id(market_id)?;
3439    }
3440    let mut seen_assets = std::collections::BTreeSet::new();
3441    let mut summed_value = 0u128;
3442    for balance in &response.balances {
3443        validate_platform_asset_id(&balance.asset_id)?;
3444        if !seen_assets.insert(balance.asset_id.as_str()) {
3445            return Err(invalid("balances must be unique per asset"));
3446        }
3447        let available = validate_response_u128(&balance.available_atoms, "available_atoms")?;
3448        let locked = validate_response_u128(&balance.locked_atoms, "locked_atoms")?;
3449        let total = validate_response_u128(&balance.total_atoms, "total_atoms")?;
3450        if total == 0 || available.checked_add(locked) != Some(total) {
3451            return Err(invalid("balance totals are inconsistent"));
3452        }
3453        let unpriced = response
3454            .unpriced_asset_ids
3455            .iter()
3456            .any(|asset_id| asset_id == &balance.asset_id);
3457        match &balance.value_usd_micros {
3458            Some(value) if !unpriced => {
3459                let value = validate_response_u128(value, "value_usd_micros")?;
3460                summed_value = summed_value
3461                    .checked_add(value)
3462                    .ok_or_else(|| invalid("value overflow"))?;
3463            }
3464            None if unpriced => {}
3465            _ => {
3466                return Err(invalid(
3467                    "balance valuation disagrees with unpriced_asset_ids",
3468                ))
3469            }
3470        }
3471    }
3472    let mut seen_unpriced = std::collections::BTreeSet::new();
3473    for asset_id in &response.unpriced_asset_ids {
3474        validate_platform_asset_id(asset_id)?;
3475        if !seen_unpriced.insert(asset_id.as_str()) || !seen_assets.contains(asset_id.as_str()) {
3476            return Err(invalid("unpriced assets must be unique held assets"));
3477        }
3478    }
3479    let mut seen_markets = std::collections::BTreeSet::new();
3480    for position in &response.positions {
3481        validate_platform_market_id(&position.market_id)?;
3482        validate_platform_asset_id(&position.base_asset_id)?;
3483        validate_platform_asset_id(&position.quote_asset_id)?;
3484        if position.base_asset_id == position.quote_asset_id
3485            || !seen_markets.insert(position.market_id.as_str())
3486        {
3487            return Err(invalid(
3488                "positions must be unique markets with distinct assets",
3489            ));
3490        }
3491        for (value, field) in [
3492            (&position.base_available_atoms, "base_available_atoms"),
3493            (&position.base_locked_atoms, "base_locked_atoms"),
3494            (&position.quote_available_atoms, "quote_available_atoms"),
3495            (&position.quote_locked_atoms, "quote_locked_atoms"),
3496        ] {
3497            validate_response_u128(value, field)?;
3498        }
3499    }
3500    match (
3501        response.valuation_complete,
3502        &response.equity_usd_micros,
3503        &response.available_usd_micros,
3504        &response.locked_usd_micros,
3505    ) {
3506        (true, Some(equity), Some(available), Some(locked)) => {
3507            if !response.unpriced_asset_ids.is_empty() {
3508                return Err(invalid("complete valuation cannot list unpriced assets"));
3509            }
3510            let equity = validate_response_u128(equity, "equity_usd_micros")?;
3511            let available = validate_response_u128(available, "available_usd_micros")?;
3512            let locked = validate_response_u128(locked, "locked_usd_micros")?;
3513            if available.checked_add(locked) != Some(equity) || summed_value != equity {
3514                return Err(invalid("USD totals are inconsistent"));
3515            }
3516        }
3517        (false, None, None, None) => {
3518            if response.unpriced_asset_ids.is_empty() {
3519                return Err(invalid("incomplete valuation must list unpriced assets"));
3520            }
3521        }
3522        _ => return Err(invalid("valuation flags disagree with USD totals")),
3523    }
3524    Ok(())
3525}
3526
3527fn validate_maker_reputation(response: &PlatformMakerReputationResponse) -> Result<(), SdkError> {
3528    let expected_interval = if response.active {
3529        match response.tier {
3530            PlatformMakerReputationTier::Silver | PlatformMakerReputationTier::Gold => Some(100),
3531            PlatformMakerReputationTier::Platinum => Some(10),
3532            PlatformMakerReputationTier::Probation | PlatformMakerReputationTier::Bronze => None,
3533        }
3534    } else {
3535        None
3536    };
3537    let expected_next_tier = match response.tier {
3538        PlatformMakerReputationTier::Probation | PlatformMakerReputationTier::Bronze => {
3539            Some(PlatformMakerReputationTier::Silver)
3540        }
3541        PlatformMakerReputationTier::Silver => Some(PlatformMakerReputationTier::Gold),
3542        PlatformMakerReputationTier::Gold => Some(PlatformMakerReputationTier::Platinum),
3543        PlatformMakerReputationTier::Platinum => None,
3544    };
3545    if !valid_handle(&response.maker_id, "maker_")
3546        || response.reputation_score > 10_000
3547        || response.fill_rate_bps > 10_000
3548        || response.epoch_slashed_bps > 10_000
3549        || response.minimum_quote_interval_ms != expected_interval
3550        || response.signed_quote_stream_eligible != expected_interval.is_some()
3551        || response.tier_progress.next_tier != expected_next_tier
3552        || response
3553            .tier_progress
3554            .reputation_score_required
3555            .is_some_and(|score| score > 10_000)
3556    {
3557        return Err(SdkError::InvalidResponse(
3558            "maker reputation response violates its public contract".to_owned(),
3559        ));
3560    }
3561    let total_quote_requests =
3562        validate_response_atoms(&response.total_quote_requests, "total_quote_requests", true)?;
3563    let stake_atoms = validate_response_atoms(&response.stake_atoms, "stake_atoms", true)?;
3564    let tenure_slots = validate_response_atoms(&response.tenure_slots, "tenure_slots", true)?;
3565    for (value, field) in [
3566        (&response.successful_fills, "successful_fills"),
3567        (&response.missed_quote_requests, "missed_quote_requests"),
3568        (
3569            &response.lifetime_filled_quote_atoms,
3570            "lifetime_filled_quote_atoms",
3571        ),
3572        (&response.epoch_start_stake_atoms, "epoch_start_stake_atoms"),
3573        (&response.epoch_slashed_atoms, "epoch_slashed_atoms"),
3574        (
3575            &response.lifetime_auto_slashed_atoms,
3576            "lifetime_auto_slashed_atoms",
3577        ),
3578        (&response.registered_slot, "registered_slot"),
3579        (&response.last_active_slot, "last_active_slot"),
3580        (&response.last_settled_slot, "last_settled_slot"),
3581    ] {
3582        validate_response_atoms(value, field, true)?;
3583    }
3584    if let Some(value) = &response.revoked_at_slot {
3585        validate_response_atoms(value, "revoked_at_slot", true)?;
3586    }
3587    let progress = &response.tier_progress;
3588    let quote_requests_remaining = validate_response_atoms(
3589        &progress.quote_requests_remaining,
3590        "tier_progress.quote_requests_remaining",
3591        true,
3592    )?;
3593    let stake_atoms_remaining = validate_response_atoms(
3594        &progress.stake_atoms_remaining,
3595        "tier_progress.stake_atoms_remaining",
3596        true,
3597    )?;
3598    let tenure_slots_remaining = validate_response_atoms(
3599        &progress.tenure_slots_remaining,
3600        "tier_progress.tenure_slots_remaining",
3601        true,
3602    )?;
3603    let quote_requests_required = progress
3604        .quote_requests_required
3605        .as_deref()
3606        .map(|value| validate_response_atoms(value, "tier_progress.quote_requests_required", true))
3607        .transpose()?;
3608    let stake_atoms_required = progress
3609        .stake_atoms_required
3610        .as_deref()
3611        .map(|value| validate_response_atoms(value, "tier_progress.stake_atoms_required", true))
3612        .transpose()?;
3613    let tenure_slots_required = progress
3614        .tenure_slots_required
3615        .as_deref()
3616        .map(|value| validate_response_atoms(value, "tier_progress.tenure_slots_required", true))
3617        .transpose()?;
3618    let progress_shape_is_valid = match response.tier {
3619        PlatformMakerReputationTier::Probation => {
3620            progress.reputation_score_required == Some(5_000)
3621                && quote_requests_required == Some(50)
3622                && stake_atoms_required.is_none()
3623                && tenure_slots_required.is_none()
3624        }
3625        PlatformMakerReputationTier::Bronze => {
3626            progress.reputation_score_required == Some(5_000)
3627                && quote_requests_required.is_none()
3628                && stake_atoms_required.is_none()
3629                && tenure_slots_required.is_none()
3630        }
3631        PlatformMakerReputationTier::Silver => {
3632            progress.reputation_score_required == Some(7_500)
3633                && quote_requests_required.is_none()
3634                && stake_atoms_required.is_none()
3635                && tenure_slots_required.is_none()
3636        }
3637        PlatformMakerReputationTier::Gold => {
3638            progress.reputation_score_required == Some(9_000)
3639                && quote_requests_required.is_none()
3640                && stake_atoms_required.is_some()
3641                && tenure_slots_required == Some(6_480_000)
3642        }
3643        PlatformMakerReputationTier::Platinum => {
3644            progress.reputation_score_required.is_none()
3645                && quote_requests_required.is_none()
3646                && stake_atoms_required.is_none()
3647                && tenure_slots_required.is_none()
3648        }
3649    };
3650    let expected_reputation_remaining = progress
3651        .reputation_score_required
3652        .unwrap_or(response.reputation_score)
3653        .saturating_sub(response.reputation_score);
3654    if !progress_shape_is_valid
3655        || progress.reputation_score_remaining != expected_reputation_remaining
3656        || quote_requests_remaining
3657            != quote_requests_required
3658                .unwrap_or(total_quote_requests)
3659                .saturating_sub(total_quote_requests)
3660        || stake_atoms_remaining
3661            != stake_atoms_required
3662                .unwrap_or(stake_atoms)
3663                .saturating_sub(stake_atoms)
3664        || tenure_slots_remaining
3665            != tenure_slots_required
3666                .unwrap_or(tenure_slots)
3667                .saturating_sub(tenure_slots)
3668    {
3669        return Err(SdkError::InvalidResponse(
3670            "maker reputation tier progress is inconsistent".to_owned(),
3671        ));
3672    }
3673    Ok(())
3674}
3675
3676fn normalize_bug_message(value: &str) -> Result<String, SdkError> {
3677    let message = value.trim();
3678    if !(1..=2_000).contains(&message.chars().count()) {
3679        return Err(SdkError::InvalidRequest(
3680            "bug message must contain between 1 and 2,000 characters".to_owned(),
3681        ));
3682    }
3683    Ok(message.to_owned())
3684}
3685
3686pub fn bug_authorization_payload(message: &str) -> Result<Vec<u8>, SdkError> {
3687    Ok(format!("strata-bug-report:v1:{}", normalize_bug_message(message)?).into_bytes())
3688}
3689
3690fn normalize_referral_code(value: &str) -> Result<String, SdkError> {
3691    let code = value.trim();
3692    if code.is_empty()
3693        || code.len() > 64
3694        || !code
3695            .bytes()
3696            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
3697    {
3698        return Err(SdkError::InvalidRequest(
3699            "referral_code must contain 1-64 letters, numbers, underscores, or dashes".to_owned(),
3700        ));
3701    }
3702    Ok(code.to_owned())
3703}
3704
3705pub fn referral_link_authorization_payload(referral_code: &str) -> Result<Vec<u8>, SdkError> {
3706    Ok(format!(
3707        "strata-referral:v1:{}",
3708        normalize_referral_code(referral_code)?
3709    )
3710    .into_bytes())
3711}
3712
3713pub fn referral_claim_authorization_payload(
3714    payout_wallet_address: &str,
3715) -> Result<Vec<u8>, SdkError> {
3716    Ok(format!(
3717        "strata-referral-claim:v1:{}",
3718        canonical_public_key(payout_wallet_address, "payout_wallet_address")?
3719    )
3720    .into_bytes())
3721}
3722
3723fn canonical_hex_signature(value: &str, field: &str) -> Result<String, SdkError> {
3724    let signature = value
3725        .trim()
3726        .strip_prefix("0x")
3727        .or_else(|| value.trim().strip_prefix("0X"))
3728        .unwrap_or(value.trim())
3729        .to_ascii_lowercase();
3730    if signature.len() != 128
3731        || !signature
3732            .bytes()
3733            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
3734    {
3735        return Err(SdkError::InvalidRequest(format!(
3736            "{field} must be a 64-byte hexadecimal Ed25519 signature"
3737        )));
3738    }
3739    Ok(signature)
3740}
3741
3742/// Canonicalize an optional atomic value; `None` stays `None` so the server
3743/// resolves it (the account sequence is the one such field today).
3744fn canonical_optional_request_atoms(
3745    value: Option<&str>,
3746    field: &str,
3747) -> Result<Option<String>, SdkError> {
3748    value
3749        .map(|value| canonical_request_atoms(value, field, true))
3750        .transpose()
3751}
3752
3753fn canonical_request_atoms(value: &str, field: &str, allow_zero: bool) -> Result<String, SdkError> {
3754    if value.is_empty()
3755        || !value.bytes().all(|byte| byte.is_ascii_digit())
3756        || (value.len() > 1 && value.starts_with('0'))
3757    {
3758        return Err(SdkError::InvalidRequest(format!(
3759            "{field} must be a canonical unsigned atomic decimal string"
3760        )));
3761    }
3762    let parsed = value
3763        .parse::<u64>()
3764        .map_err(|_| SdkError::InvalidRequest(format!("{field} exceeds u64")))?;
3765    if !allow_zero && parsed == 0 {
3766        return Err(SdkError::InvalidRequest(format!(
3767            "{field} must be greater than zero"
3768        )));
3769    }
3770    Ok(parsed.to_string())
3771}
3772
3773fn canonical_signature(value: &str, field: &str) -> Result<String, SdkError> {
3774    let value = value.trim();
3775    let decoded = bs58::decode(value)
3776        .into_vec()
3777        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
3778    if decoded.len() != 64 || bs58::encode(&decoded).into_string() != value {
3779        return Err(SdkError::InvalidRequest(format!(
3780            "{field} must be a canonical Ed25519 signature"
3781        )));
3782    }
3783    Ok(value.to_owned())
3784}
3785
3786fn canonical_base58_32(value: &str, field: &str) -> Result<String, SdkError> {
3787    let value = value.trim();
3788    let decoded = bs58::decode(value)
3789        .into_vec()
3790        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
3791    if decoded.len() != 32 || bs58::encode(&decoded).into_string() != value {
3792        return Err(SdkError::InvalidRequest(format!(
3793            "{field} must be a canonical 32-byte base58 value"
3794        )));
3795    }
3796    Ok(value.to_owned())
3797}
3798
3799fn canonical_base64(value: &str, field: &str) -> Result<String, SdkError> {
3800    let value = value.trim();
3801    let decoded = base64::engine::general_purpose::STANDARD
3802        .decode(value)
3803        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base64")))?;
3804    if decoded.is_empty() || base64::engine::general_purpose::STANDARD.encode(decoded) != value {
3805        return Err(SdkError::InvalidRequest(format!(
3806            "{field} must be canonical base64"
3807        )));
3808    }
3809    Ok(value.to_owned())
3810}
3811
3812fn normalize_twap_challenge_request(
3813    request: PlatformTwapChallengeRequest,
3814) -> Result<PlatformTwapChallengeRequest, SdkError> {
3815    let request = match request {
3816        PlatformTwapChallengeRequest::Place {
3817            owner_wallet,
3818            session_public_key,
3819            side,
3820            total_size_atoms,
3821            slices_total,
3822            maximum_tolerance_bps,
3823            interval_slots,
3824            limit_price_atoms,
3825        } => {
3826            if !(2..=120).contains(&slices_total)
3827                || !(1..=1_000).contains(&maximum_tolerance_bps)
3828                || !(25..=4_500).contains(&interval_slots)
3829            {
3830                return Err(SdkError::InvalidRequest(
3831                    "TWAP schedule bounds are invalid".to_owned(),
3832                ));
3833            }
3834            PlatformTwapChallengeRequest::Place {
3835                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
3836                session_public_key: canonical_public_key(
3837                    &session_public_key,
3838                    "session_public_key",
3839                )?,
3840                side,
3841                total_size_atoms: canonical_request_atoms(
3842                    &total_size_atoms,
3843                    "total_size_atoms",
3844                    false,
3845                )?,
3846                slices_total,
3847                maximum_tolerance_bps,
3848                interval_slots,
3849                limit_price_atoms: canonical_request_atoms(
3850                    &limit_price_atoms,
3851                    "limit_price_atoms",
3852                    false,
3853                )?,
3854            }
3855        }
3856        PlatformTwapChallengeRequest::Cancel {
3857            owner_wallet,
3858            session_public_key,
3859            twap_id,
3860        } => {
3861            if !valid_handle(twap_id.trim(), "twap_") {
3862                return Err(SdkError::InvalidRequest("twap_id is invalid".to_owned()));
3863            }
3864            PlatformTwapChallengeRequest::Cancel {
3865                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
3866                session_public_key: canonical_public_key(
3867                    &session_public_key,
3868                    "session_public_key",
3869                )?,
3870                twap_id: twap_id.trim().to_owned(),
3871            }
3872        }
3873    };
3874    if twap_request_owner(&request) == twap_request_session(&request) {
3875        return Err(SdkError::InvalidRequest(
3876            "session_public_key must be distinct from owner_wallet".to_owned(),
3877        ));
3878    }
3879    Ok(request)
3880}
3881
3882fn twap_request_action(request: &PlatformTwapChallengeRequest) -> PlatformTwapControlAction {
3883    match request {
3884        PlatformTwapChallengeRequest::Place { .. } => PlatformTwapControlAction::Place,
3885        PlatformTwapChallengeRequest::Cancel { .. } => PlatformTwapControlAction::Cancel,
3886    }
3887}
3888
3889fn twap_request_owner(request: &PlatformTwapChallengeRequest) -> &str {
3890    match request {
3891        PlatformTwapChallengeRequest::Place { owner_wallet, .. }
3892        | PlatformTwapChallengeRequest::Cancel { owner_wallet, .. } => owner_wallet,
3893    }
3894}
3895
3896fn twap_request_session(request: &PlatformTwapChallengeRequest) -> &str {
3897    match request {
3898        PlatformTwapChallengeRequest::Place {
3899            session_public_key, ..
3900        }
3901        | PlatformTwapChallengeRequest::Cancel {
3902            session_public_key, ..
3903        } => session_public_key,
3904    }
3905}
3906
3907/// A parsed two-step TWAP authorization: the exact bytes to sign and the
3908/// blockhash lease they bind.
3909#[derive(Clone, Debug, Eq, PartialEq)]
3910pub struct TwapAuthorization {
3911    pub bytes: Vec<u8>,
3912    pub recent_blockhash: String,
3913    pub last_valid_block_height: u64,
3914}
3915
3916fn opaque_twap_id(pda: &[u8]) -> String {
3917    opaque_product_id("twap", &bs58::encode(pda).into_string())
3918}
3919
3920/// Two-step path helper: check a TWAP challenge's authorization payload
3921/// binds exactly this request before signing it. The one-call
3922/// [`StrataClient::execute_twap`] no longer needs it (one signature over the
3923/// transaction).
3924pub fn validate_twap_authorization(
3925    challenge: &PlatformTwapChallengeResponse,
3926    request: &PlatformTwapChallengeRequest,
3927) -> Result<TwapAuthorization, SdkError> {
3928    let bytes = base64::engine::general_purpose::STANDARD
3929        .decode(challenge.authorization_payload_base64.trim())
3930        .map_err(|_| SdkError::InvalidResponse("TWAP authorization is not base64".to_owned()))?;
3931    let owner = decode_public_key(twap_request_owner(request), "owner_wallet")?;
3932    let session = decode_public_key(twap_request_session(request), "session_public_key")?;
3933    let mut cursor = 0usize;
3934    take_expected(
3935        &bytes,
3936        &mut cursor,
3937        PUBLIC_TWAP_AUTH_DOMAIN,
3938        "TWAP authorization domain",
3939    )?;
3940    take_bytes(&bytes, &mut cursor, 64, "TWAP authorization product")?;
3941    take_expected(&bytes, &mut cursor, &owner, "TWAP authorization owner")?;
3942    take_expected(&bytes, &mut cursor, &session, "TWAP authorization session")?;
3943    let action = take_bytes(&bytes, &mut cursor, 1, "TWAP authorization action")?[0];
3944    let expected_action = twap_request_action(request);
3945    if action
3946        != match expected_action {
3947            PlatformTwapControlAction::Place => 0,
3948            PlatformTwapControlAction::Cancel => 1,
3949        }
3950        || challenge.action != expected_action
3951    {
3952        return Err(SdkError::InvalidResponse(
3953            "TWAP authorization action changed".to_owned(),
3954        ));
3955    }
3956    let pda = match request {
3957        PlatformTwapChallengeRequest::Place {
3958            side,
3959            total_size_atoms,
3960            slices_total,
3961            maximum_tolerance_bps,
3962            interval_slots,
3963            limit_price_atoms,
3964            ..
3965        } => {
3966            let encoded_side = take_bytes(&bytes, &mut cursor, 1, "TWAP side")?[0];
3967            let expected_side = match side {
3968                PlatformTradeSide::Buy => 0,
3969                PlatformTradeSide::Sell => 1,
3970            };
3971            if encoded_side != expected_side {
3972                return Err(SdkError::InvalidResponse("TWAP side changed".to_owned()));
3973            }
3974            take_u64_eq(
3975                &bytes,
3976                &mut cursor,
3977                parse_request_u64(total_size_atoms, "total_size_atoms")?,
3978                "TWAP total size",
3979            )?;
3980            if take_u16(&bytes, &mut cursor, "TWAP slices")? != *slices_total
3981                || take_u16(&bytes, &mut cursor, "TWAP tolerance")? != *maximum_tolerance_bps
3982            {
3983                return Err(SdkError::InvalidResponse(
3984                    "TWAP schedule bounds changed".to_owned(),
3985                ));
3986            }
3987            let interval_bytes: [u8; 4] = take_bytes(&bytes, &mut cursor, 4, "TWAP interval")?
3988                .try_into()
3989                .map_err(|_| SdkError::InvalidResponse("TWAP interval is invalid".to_owned()))?;
3990            if u32::from_le_bytes(interval_bytes) != *interval_slots {
3991                return Err(SdkError::InvalidResponse(
3992                    "TWAP interval changed".to_owned(),
3993                ));
3994            }
3995            take_u64_eq(
3996                &bytes,
3997                &mut cursor,
3998                parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
3999                "TWAP limit price",
4000            )?;
4001            take_bytes(&bytes, &mut cursor, 8, "TWAP schedule nonce")?;
4002            take_bytes(&bytes, &mut cursor, 32, "TWAP identity")?.to_vec()
4003        }
4004        PlatformTwapChallengeRequest::Cancel { twap_id, .. } => {
4005            let pda = take_bytes(&bytes, &mut cursor, 32, "TWAP identity")?.to_vec();
4006            if opaque_twap_id(&pda) != *twap_id {
4007                return Err(SdkError::InvalidResponse(
4008                    "TWAP cancellation identity changed".to_owned(),
4009                ));
4010            }
4011            pda
4012        }
4013    };
4014    if opaque_twap_id(&pda) != challenge.twap_id {
4015        return Err(SdkError::InvalidResponse(
4016            "TWAP authorization identity changed".to_owned(),
4017        ));
4018    }
4019    let blockhash = take_bytes(&bytes, &mut cursor, 32, "TWAP recent blockhash")?;
4020    let recent_blockhash = bs58::encode(blockhash).into_string();
4021    let last_valid_block_height = take_u64(&bytes, &mut cursor, "TWAP block height")?;
4022    take_u64_eq(
4023        &bytes,
4024        &mut cursor,
4025        challenge.expires_at_ms,
4026        "TWAP authorization expiry",
4027    )?;
4028    let nonce = take_bytes(&bytes, &mut cursor, 16, "TWAP authorization nonce")?;
4029    if hex::encode(nonce) != challenge.challenge_id[4..] {
4030        return Err(SdkError::InvalidResponse(
4031            "TWAP challenge nonce changed".to_owned(),
4032        ));
4033    }
4034    if cursor != bytes.len() {
4035        return Err(SdkError::InvalidResponse(
4036            "TWAP authorization contains unrecognized fields".to_owned(),
4037        ));
4038    }
4039    Ok(TwapAuthorization {
4040        bytes,
4041        recent_blockhash,
4042        last_valid_block_height,
4043    })
4044}
4045
4046/// Two-step path helper: check a prepared TWAP control preserved the signed
4047/// challenge bindings.
4048pub fn validate_twap_prepare_binding(
4049    prepared: &PlatformTwapPrepareResponse,
4050    challenge: &PlatformTwapChallengeResponse,
4051    authorization: &TwapAuthorization,
4052) -> Result<(), SdkError> {
4053    if prepared.market_id != challenge.market_id
4054        || prepared.action != challenge.action
4055        || prepared.twap_id != challenge.twap_id
4056        || prepared.recent_blockhash != authorization.recent_blockhash
4057        || prepared.last_valid_block_height != authorization.last_valid_block_height
4058        || prepared.expires_at_ms != challenge.expires_at_ms
4059    {
4060        return Err(SdkError::InvalidResponse(
4061            "prepared TWAP control changed the signed bindings".to_owned(),
4062        ));
4063    }
4064    Ok(())
4065}
4066
4067fn normalize_order_challenge_request(
4068    request: PlatformOrderChallengeRequest,
4069) -> Result<PlatformOrderChallengeRequest, SdkError> {
4070    let normalized = match request {
4071        PlatformOrderChallengeRequest::Place {
4072            owner_wallet,
4073            session_public_key,
4074            account_sequence,
4075            client_order_id,
4076            side,
4077            order_type,
4078            limit_price_atoms,
4079            size_atoms,
4080        } => {
4081            let client_order_id = client_order_id.trim().to_owned();
4082            if client_order_id.is_empty()
4083                || client_order_id.len() > 64
4084                || !client_order_id
4085                    .bytes()
4086                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
4087                || !matches!(
4088                    order_type,
4089                    PlatformOrderType::GoodUntilCancelled | PlatformOrderType::PostOnly
4090                )
4091            {
4092                return Err(SdkError::InvalidRequest(
4093                    "resting order client ID or type is invalid".to_owned(),
4094                ));
4095            }
4096            PlatformOrderChallengeRequest::Place {
4097                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
4098                session_public_key: canonical_public_key(
4099                    &session_public_key,
4100                    "session_public_key",
4101                )?,
4102                account_sequence: canonical_optional_request_atoms(
4103                    account_sequence.as_deref(),
4104                    "account_sequence",
4105                )?,
4106                client_order_id,
4107                side,
4108                order_type,
4109                limit_price_atoms: canonical_request_atoms(
4110                    &limit_price_atoms,
4111                    "limit_price_atoms",
4112                    false,
4113                )?,
4114                size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
4115            }
4116        }
4117        PlatformOrderChallengeRequest::Cancel {
4118            owner_wallet,
4119            session_public_key,
4120            order_id,
4121        } => {
4122            if !valid_handle(order_id.trim(), "order_") {
4123                return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
4124            }
4125            PlatformOrderChallengeRequest::Cancel {
4126                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
4127                session_public_key: canonical_public_key(
4128                    &session_public_key,
4129                    "session_public_key",
4130                )?,
4131                order_id: order_id.trim().to_owned(),
4132            }
4133        }
4134        PlatformOrderChallengeRequest::CancelAll {
4135            owner_wallet,
4136            session_public_key,
4137        } => PlatformOrderChallengeRequest::CancelAll {
4138            owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
4139            session_public_key: canonical_public_key(&session_public_key, "session_public_key")?,
4140        },
4141        PlatformOrderChallengeRequest::Replace {
4142            owner_wallet,
4143            session_public_key,
4144            order_id,
4145            account_sequence,
4146            client_order_id,
4147            side,
4148            order_type,
4149            limit_price_atoms,
4150            size_atoms,
4151        } => {
4152            let PlatformOrderBatchOperation::Replace {
4153                order_id,
4154                account_sequence,
4155                client_order_id,
4156                side,
4157                order_type,
4158                limit_price_atoms,
4159                size_atoms,
4160            } = normalize_order_batch_operation(PlatformOrderBatchOperation::Replace {
4161                order_id,
4162                account_sequence,
4163                client_order_id,
4164                side,
4165                order_type,
4166                limit_price_atoms,
4167                size_atoms,
4168            })?
4169            else {
4170                unreachable!()
4171            };
4172            PlatformOrderChallengeRequest::Replace {
4173                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
4174                session_public_key: canonical_public_key(
4175                    &session_public_key,
4176                    "session_public_key",
4177                )?,
4178                order_id,
4179                account_sequence,
4180                client_order_id,
4181                side,
4182                order_type,
4183                limit_price_atoms,
4184                size_atoms,
4185            }
4186        }
4187        PlatformOrderChallengeRequest::Batch {
4188            owner_wallet,
4189            session_public_key,
4190            operations,
4191        } => {
4192            if operations.is_empty() || operations.len() > 6 {
4193                return Err(SdkError::InvalidRequest(
4194                    "order batch must contain between one and six operations".to_owned(),
4195                ));
4196            }
4197            PlatformOrderChallengeRequest::Batch {
4198                owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
4199                session_public_key: canonical_public_key(
4200                    &session_public_key,
4201                    "session_public_key",
4202                )?,
4203                operations: operations
4204                    .into_iter()
4205                    .map(normalize_order_batch_operation)
4206                    .collect::<Result<_, _>>()?,
4207            }
4208        }
4209    };
4210    if order_request_owner(&normalized) == order_request_session(&normalized) {
4211        return Err(SdkError::InvalidRequest(
4212            "session_public_key must be distinct from owner_wallet".to_owned(),
4213        ));
4214    }
4215    Ok(normalized)
4216}
4217
4218fn normalize_order_batch_operation(
4219    operation: PlatformOrderBatchOperation,
4220) -> Result<PlatformOrderBatchOperation, SdkError> {
4221    match operation {
4222        PlatformOrderBatchOperation::Place {
4223            account_sequence,
4224            client_order_id,
4225            side,
4226            order_type,
4227            limit_price_atoms,
4228            size_atoms,
4229        } => {
4230            let client_order_id = normalize_order_client_id(client_order_id, order_type)?;
4231            Ok(PlatformOrderBatchOperation::Place {
4232                account_sequence: canonical_optional_request_atoms(
4233                    account_sequence.as_deref(),
4234                    "account_sequence",
4235                )?,
4236                client_order_id,
4237                side,
4238                order_type,
4239                limit_price_atoms: canonical_request_atoms(
4240                    &limit_price_atoms,
4241                    "limit_price_atoms",
4242                    false,
4243                )?,
4244                size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
4245            })
4246        }
4247        PlatformOrderBatchOperation::Cancel { order_id } => {
4248            if !valid_handle(order_id.trim(), "order_") {
4249                return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
4250            }
4251            Ok(PlatformOrderBatchOperation::Cancel {
4252                order_id: order_id.trim().to_owned(),
4253            })
4254        }
4255        PlatformOrderBatchOperation::Replace {
4256            order_id,
4257            account_sequence,
4258            client_order_id,
4259            side,
4260            order_type,
4261            limit_price_atoms,
4262            size_atoms,
4263        } => {
4264            if !valid_handle(order_id.trim(), "order_") {
4265                return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
4266            }
4267            let client_order_id = normalize_order_client_id(client_order_id, order_type)?;
4268            Ok(PlatformOrderBatchOperation::Replace {
4269                order_id: order_id.trim().to_owned(),
4270                account_sequence: canonical_optional_request_atoms(
4271                    account_sequence.as_deref(),
4272                    "account_sequence",
4273                )?,
4274                client_order_id,
4275                side,
4276                order_type,
4277                limit_price_atoms: canonical_request_atoms(
4278                    &limit_price_atoms,
4279                    "limit_price_atoms",
4280                    false,
4281                )?,
4282                size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
4283            })
4284        }
4285    }
4286}
4287
4288fn normalize_order_client_id(
4289    client_order_id: String,
4290    order_type: PlatformOrderType,
4291) -> Result<String, SdkError> {
4292    let client_order_id = client_order_id.trim().to_owned();
4293    if client_order_id.is_empty()
4294        || client_order_id.len() > 64
4295        || !client_order_id
4296            .bytes()
4297            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
4298        || !matches!(
4299            order_type,
4300            PlatformOrderType::GoodUntilCancelled | PlatformOrderType::PostOnly
4301        )
4302    {
4303        return Err(SdkError::InvalidRequest(
4304            "resting order client ID or type is invalid".to_owned(),
4305        ));
4306    }
4307    Ok(client_order_id)
4308}
4309
4310fn order_request_action(request: &PlatformOrderChallengeRequest) -> PlatformOrderAction {
4311    match request {
4312        PlatformOrderChallengeRequest::Place { .. } => PlatformOrderAction::Place,
4313        PlatformOrderChallengeRequest::Cancel { .. } => PlatformOrderAction::Cancel,
4314        PlatformOrderChallengeRequest::CancelAll { .. } => PlatformOrderAction::CancelAll,
4315        PlatformOrderChallengeRequest::Replace { .. } => PlatformOrderAction::Replace,
4316        PlatformOrderChallengeRequest::Batch { .. } => PlatformOrderAction::Batch,
4317    }
4318}
4319
4320fn order_request_owner(request: &PlatformOrderChallengeRequest) -> &str {
4321    match request {
4322        PlatformOrderChallengeRequest::Place { owner_wallet, .. }
4323        | PlatformOrderChallengeRequest::Cancel { owner_wallet, .. }
4324        | PlatformOrderChallengeRequest::CancelAll { owner_wallet, .. }
4325        | PlatformOrderChallengeRequest::Replace { owner_wallet, .. }
4326        | PlatformOrderChallengeRequest::Batch { owner_wallet, .. } => owner_wallet,
4327    }
4328}
4329
4330fn order_request_session(request: &PlatformOrderChallengeRequest) -> &str {
4331    match request {
4332        PlatformOrderChallengeRequest::Place {
4333            session_public_key, ..
4334        }
4335        | PlatformOrderChallengeRequest::Cancel {
4336            session_public_key, ..
4337        }
4338        | PlatformOrderChallengeRequest::CancelAll {
4339            session_public_key, ..
4340        }
4341        | PlatformOrderChallengeRequest::Replace {
4342            session_public_key, ..
4343        }
4344        | PlatformOrderChallengeRequest::Batch {
4345            session_public_key, ..
4346        } => session_public_key,
4347    }
4348}
4349
4350/// A parsed two-step order authorization: the exact bytes to sign and the
4351/// blockhash lease they bind.
4352#[derive(Clone, Debug, Eq, PartialEq)]
4353pub struct OrderAuthorization {
4354    pub bytes: Vec<u8>,
4355    pub recent_blockhash: String,
4356    pub last_valid_block_height: u64,
4357}
4358
4359/// A supplied account sequence must match the signed authorization exactly; a
4360/// sequence left to Strata is read from it (the server resolved it from the
4361/// Vault's confirmed market account) and every other binding is still checked.
4362fn take_order_account_sequence(
4363    bytes: &[u8],
4364    cursor: &mut usize,
4365    account_sequence: Option<&str>,
4366) -> Result<u64, SdkError> {
4367    match account_sequence {
4368        Some(expected) => {
4369            let expected = parse_request_u64(expected, "account_sequence")?;
4370            take_u64_eq(bytes, cursor, expected, "order account sequence")?;
4371            Ok(expected)
4372        }
4373        None => take_u64(bytes, cursor, "order account sequence"),
4374    }
4375}
4376
4377#[allow(clippy::too_many_arguments)]
4378fn validate_order_place_authorization(
4379    bytes: &[u8],
4380    cursor: &mut usize,
4381    challenge: &PlatformOrderChallengeResponse,
4382    account_sequence: Option<&str>,
4383    client_order_id: &str,
4384    side: PlatformTradeSide,
4385    order_type: PlatformOrderType,
4386    limit_price_atoms: &str,
4387    size_atoms: &str,
4388) -> Result<String, SdkError> {
4389    take_order_account_sequence(bytes, cursor, account_sequence)?;
4390    let client_length = take_u16(bytes, cursor, "client order ID length")? as usize;
4391    if client_length != client_order_id.len() {
4392        return Err(SdkError::InvalidResponse(
4393            "client order ID length changed".to_owned(),
4394        ));
4395    }
4396    take_expected(bytes, cursor, client_order_id.as_bytes(), "client order ID")?;
4397    let actual_side = take_bytes(bytes, cursor, 1, "order side")?[0];
4398    let expected_side = if side == PlatformTradeSide::Buy { 0 } else { 1 };
4399    if actual_side != expected_side {
4400        return Err(SdkError::InvalidResponse("order side changed".to_owned()));
4401    }
4402    let actual_type = take_bytes(bytes, cursor, 1, "order type")?[0];
4403    let expected_type = match order_type {
4404        PlatformOrderType::GoodUntilCancelled => 0,
4405        PlatformOrderType::PostOnly => 3,
4406        PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
4407            return Err(SdkError::InvalidRequest(
4408                "order type is not a resting order".to_owned(),
4409            ));
4410        }
4411    };
4412    if actual_type != expected_type {
4413        return Err(SdkError::InvalidResponse("order type changed".to_owned()));
4414    }
4415    take_u64_eq(
4416        bytes,
4417        cursor,
4418        parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
4419        "order limit price",
4420    )?;
4421    take_u64_eq(
4422        bytes,
4423        cursor,
4424        parse_request_u64(size_atoms, "size_atoms")?,
4425        "order size",
4426    )?;
4427    let order = take_bytes(bytes, cursor, 32, "order identity")?;
4428    Ok(opaque_order_id(&challenge.market_id, order))
4429}
4430
4431fn validate_order_cancel_authorization(
4432    bytes: &[u8],
4433    cursor: &mut usize,
4434    challenge: &PlatformOrderChallengeResponse,
4435    expected_order_id: &str,
4436) -> Result<String, SdkError> {
4437    let order = take_bytes(bytes, cursor, 32, "cancel order identity")?;
4438    let rent_source = take_bytes(bytes, cursor, 1, "cancel rent source")?[0];
4439    if rent_source > 1 {
4440        return Err(SdkError::InvalidResponse(
4441            "cancel rent source is invalid".to_owned(),
4442        ));
4443    }
4444    let order_id = opaque_order_id(&challenge.market_id, order);
4445    if order_id != expected_order_id {
4446        return Err(SdkError::InvalidResponse(
4447            "cancel order identity changed".to_owned(),
4448        ));
4449    }
4450    Ok(order_id)
4451}
4452
4453/// Two-step path helper: check an order challenge's authorization payload
4454/// binds exactly this operation (every field, opaque order identity, and
4455/// replay value) before signing it. The one-call
4456/// [`StrataClient::execute_order`] no longer needs it (one signature over the
4457/// transaction); the order command channel still uses it to bind the
4458/// challenge without a message signature.
4459pub fn validate_order_authorization(
4460    challenge: &PlatformOrderChallengeResponse,
4461    request: &PlatformOrderChallengeRequest,
4462) -> Result<OrderAuthorization, SdkError> {
4463    let bytes = base64::engine::general_purpose::STANDARD
4464        .decode(challenge.authorization_payload_base64.trim())
4465        .map_err(|_| SdkError::InvalidResponse("order authorization is not base64".to_owned()))?;
4466    let owner = decode_public_key(order_request_owner(request), "owner_wallet")?;
4467    let session = decode_public_key(order_request_session(request), "session_public_key")?;
4468    let mut cursor = 0usize;
4469    take_expected(
4470        &bytes,
4471        &mut cursor,
4472        PUBLIC_ORDER_AUTH_DOMAIN,
4473        "order authorization domain",
4474    )?;
4475    let _market = take_bytes(&bytes, &mut cursor, 32, "order authorization market")?;
4476    take_expected(&bytes, &mut cursor, &owner, "order authorization owner")?;
4477    take_expected(&bytes, &mut cursor, &session, "order authorization session")?;
4478    let action = take_bytes(&bytes, &mut cursor, 1, "order authorization action")?[0];
4479    let expected_action = match order_request_action(request) {
4480        PlatformOrderAction::Place => 0,
4481        PlatformOrderAction::Cancel => 1,
4482        PlatformOrderAction::CancelAll => 2,
4483        PlatformOrderAction::Replace => 3,
4484        PlatformOrderAction::Batch => 4,
4485    };
4486    if action != expected_action || challenge.action != order_request_action(request) {
4487        return Err(SdkError::InvalidResponse(
4488            "order authorization action changed".to_owned(),
4489        ));
4490    }
4491    let mut derived_order_ids = Vec::new();
4492    match request {
4493        PlatformOrderChallengeRequest::Place {
4494            account_sequence,
4495            client_order_id,
4496            side,
4497            order_type,
4498            limit_price_atoms,
4499            size_atoms,
4500            ..
4501        } => {
4502            take_order_account_sequence(&bytes, &mut cursor, account_sequence.as_deref())?;
4503            let client_length = take_u16(&bytes, &mut cursor, "client order ID length")? as usize;
4504            if client_length != client_order_id.len() {
4505                return Err(SdkError::InvalidResponse(
4506                    "client order ID length changed".to_owned(),
4507                ));
4508            }
4509            take_expected(
4510                &bytes,
4511                &mut cursor,
4512                client_order_id.as_bytes(),
4513                "client order ID",
4514            )?;
4515            let actual_side = take_bytes(&bytes, &mut cursor, 1, "order side")?[0];
4516            let expected_side = if *side == PlatformTradeSide::Buy {
4517                0
4518            } else {
4519                1
4520            };
4521            if actual_side != expected_side {
4522                return Err(SdkError::InvalidResponse("order side changed".to_owned()));
4523            }
4524            let actual_type = take_bytes(&bytes, &mut cursor, 1, "order type")?[0];
4525            let expected_type = match order_type {
4526                PlatformOrderType::GoodUntilCancelled => 0,
4527                PlatformOrderType::PostOnly => 3,
4528                PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
4529                    return Err(SdkError::InvalidRequest(
4530                        "order type is not a resting order".to_owned(),
4531                    ));
4532                }
4533            };
4534            if actual_type != expected_type {
4535                return Err(SdkError::InvalidResponse("order type changed".to_owned()));
4536            }
4537            take_u64_eq(
4538                &bytes,
4539                &mut cursor,
4540                parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
4541                "order limit price",
4542            )?;
4543            take_u64_eq(
4544                &bytes,
4545                &mut cursor,
4546                parse_request_u64(size_atoms, "size_atoms")?,
4547                "order size",
4548            )?;
4549            let order = take_bytes(&bytes, &mut cursor, 32, "order identity")?;
4550            derived_order_ids.push(opaque_order_id(&challenge.market_id, order));
4551        }
4552        PlatformOrderChallengeRequest::Cancel { .. }
4553        | PlatformOrderChallengeRequest::CancelAll { .. } => {
4554            let count = usize::from(take_bytes(&bytes, &mut cursor, 1, "cancel order count")?[0]);
4555            if count == 0
4556                || count > 6
4557                || (matches!(request, PlatformOrderChallengeRequest::Cancel { .. }) && count != 1)
4558            {
4559                return Err(SdkError::InvalidResponse(
4560                    "cancel order count changed".to_owned(),
4561                ));
4562            }
4563            for index in 0..count {
4564                let order = take_bytes(&bytes, &mut cursor, 32, &format!("cancel order {index}"))?;
4565                let rent_source = take_bytes(
4566                    &bytes,
4567                    &mut cursor,
4568                    1,
4569                    &format!("cancel rent source {index}"),
4570                )?[0];
4571                if rent_source > 1 {
4572                    return Err(SdkError::InvalidResponse(
4573                        "cancel rent source is invalid".to_owned(),
4574                    ));
4575                }
4576                derived_order_ids.push(opaque_order_id(&challenge.market_id, order));
4577            }
4578            if let PlatformOrderChallengeRequest::Cancel { order_id, .. } = request {
4579                if derived_order_ids.first() != Some(order_id) {
4580                    return Err(SdkError::InvalidResponse(
4581                        "cancel order identity changed".to_owned(),
4582                    ));
4583                }
4584            }
4585        }
4586        PlatformOrderChallengeRequest::Replace {
4587            order_id,
4588            account_sequence,
4589            client_order_id,
4590            side,
4591            order_type,
4592            limit_price_atoms,
4593            size_atoms,
4594            ..
4595        } => {
4596            derived_order_ids.push(validate_order_cancel_authorization(
4597                &bytes,
4598                &mut cursor,
4599                challenge,
4600                order_id,
4601            )?);
4602            derived_order_ids.push(validate_order_place_authorization(
4603                &bytes,
4604                &mut cursor,
4605                challenge,
4606                account_sequence.as_deref(),
4607                client_order_id,
4608                *side,
4609                *order_type,
4610                limit_price_atoms,
4611                size_atoms,
4612            )?);
4613        }
4614        PlatformOrderChallengeRequest::Batch { operations, .. } => {
4615            let count = usize::from(take_bytes(&bytes, &mut cursor, 1, "batch count")?[0]);
4616            if count == 0 || count > 6 || count != operations.len() {
4617                return Err(SdkError::InvalidResponse(
4618                    "order batch count changed".to_owned(),
4619                ));
4620            }
4621            for operation in operations {
4622                let tag = take_bytes(&bytes, &mut cursor, 1, "batch action")?[0];
4623                match operation {
4624                    PlatformOrderBatchOperation::Place {
4625                        account_sequence,
4626                        client_order_id,
4627                        side,
4628                        order_type,
4629                        limit_price_atoms,
4630                        size_atoms,
4631                    } if tag == 0 => derived_order_ids.push(validate_order_place_authorization(
4632                        &bytes,
4633                        &mut cursor,
4634                        challenge,
4635                        account_sequence.as_deref(),
4636                        client_order_id,
4637                        *side,
4638                        *order_type,
4639                        limit_price_atoms,
4640                        size_atoms,
4641                    )?),
4642                    PlatformOrderBatchOperation::Cancel { order_id } if tag == 1 => {
4643                        derived_order_ids.push(validate_order_cancel_authorization(
4644                            &bytes,
4645                            &mut cursor,
4646                            challenge,
4647                            order_id,
4648                        )?)
4649                    }
4650                    PlatformOrderBatchOperation::Replace {
4651                        order_id,
4652                        account_sequence,
4653                        client_order_id,
4654                        side,
4655                        order_type,
4656                        limit_price_atoms,
4657                        size_atoms,
4658                    } if tag == 3 => {
4659                        derived_order_ids.push(validate_order_cancel_authorization(
4660                            &bytes,
4661                            &mut cursor,
4662                            challenge,
4663                            order_id,
4664                        )?);
4665                        derived_order_ids.push(validate_order_place_authorization(
4666                            &bytes,
4667                            &mut cursor,
4668                            challenge,
4669                            account_sequence.as_deref(),
4670                            client_order_id,
4671                            *side,
4672                            *order_type,
4673                            limit_price_atoms,
4674                            size_atoms,
4675                        )?);
4676                    }
4677                    _ => {
4678                        return Err(SdkError::InvalidResponse(
4679                            "order batch action changed".to_owned(),
4680                        ))
4681                    }
4682                }
4683            }
4684        }
4685    }
4686    if derived_order_ids != challenge.order_ids {
4687        return Err(SdkError::InvalidResponse(
4688            "order authorization opaque identities changed".to_owned(),
4689        ));
4690    }
4691    let recent_blockhash = bs58::encode(take_bytes(
4692        &bytes,
4693        &mut cursor,
4694        32,
4695        "order authorization blockhash",
4696    )?)
4697    .into_string();
4698    let last_valid_block_height = take_u64(
4699        &bytes,
4700        &mut cursor,
4701        "order authorization last valid block height",
4702    )?;
4703    take_u64_eq(
4704        &bytes,
4705        &mut cursor,
4706        challenge.expires_at_ms,
4707        "order authorization expiry",
4708    )?;
4709    let nonce = take_bytes(&bytes, &mut cursor, 16, "order authorization nonce")?;
4710    if hex::encode(nonce) != challenge.challenge_id[3..] {
4711        return Err(SdkError::InvalidResponse(
4712            "order challenge nonce changed".to_owned(),
4713        ));
4714    }
4715    let _epoch = take_bytes(&bytes, &mut cursor, 16, "order authorization epoch")?;
4716    if cursor != bytes.len() {
4717        return Err(SdkError::InvalidResponse(
4718            "order authorization contains unrecognized fields".to_owned(),
4719        ));
4720    }
4721    Ok(OrderAuthorization {
4722        bytes,
4723        recent_blockhash,
4724        last_valid_block_height,
4725    })
4726}
4727
4728/// Direct-path binding: the prepared control must be for this market and
4729/// action, and its echoed order IDs must follow the request — every
4730/// requested cancel ID in request order (replace: old then new; batch
4731/// flattened in request order) with one fresh ID per place. `cancel_all`
4732/// only requires at least one order.
4733fn validate_order_direct_binding(
4734    prepared: &PlatformOrderPrepareResponse,
4735    request: &PlatformOrderChallengeRequest,
4736    market_id: &str,
4737) -> Result<(), SdkError> {
4738    let bound = prepared.market_id == market_id
4739        && prepared.action == order_request_action(request)
4740        && prepared
4741            .order_ids
4742            .iter()
4743            .all(|order_id| valid_handle(order_id, "order_"))
4744        && match request {
4745            PlatformOrderChallengeRequest::Place { .. } => prepared.order_ids.len() == 1,
4746            PlatformOrderChallengeRequest::Cancel { order_id, .. } => {
4747                prepared.order_ids.len() == 1 && prepared.order_ids[0] == *order_id
4748            }
4749            PlatformOrderChallengeRequest::CancelAll { .. } => !prepared.order_ids.is_empty(),
4750            PlatformOrderChallengeRequest::Replace { order_id, .. } => {
4751                prepared.order_ids.len() == 2 && prepared.order_ids[0] == *order_id
4752            }
4753            PlatformOrderChallengeRequest::Batch { operations, .. } => {
4754                let mut expected: Vec<Option<&str>> = Vec::new();
4755                for operation in operations {
4756                    match operation {
4757                        PlatformOrderBatchOperation::Place { .. } => expected.push(None),
4758                        PlatformOrderBatchOperation::Cancel { order_id } => {
4759                            expected.push(Some(order_id))
4760                        }
4761                        PlatformOrderBatchOperation::Replace { order_id, .. } => {
4762                            expected.push(Some(order_id));
4763                            expected.push(None);
4764                        }
4765                    }
4766                }
4767                expected.len() == prepared.order_ids.len()
4768                    && expected
4769                        .iter()
4770                        .zip(&prepared.order_ids)
4771                        .all(|(expected, actual)| expected.is_none_or(|id| id == actual))
4772            }
4773        };
4774    if !bound {
4775        return Err(SdkError::InvalidResponse(
4776            "prepared order control does not match the request".to_owned(),
4777        ));
4778    }
4779    Ok(())
4780}
4781
4782/// Direct-path binding: the prepared TWAP control must be for this market and
4783/// action and, for a cancellation, the requested TWAP.
4784fn validate_twap_direct_binding(
4785    prepared: &PlatformTwapPrepareResponse,
4786    request: &PlatformTwapChallengeRequest,
4787    market_id: &str,
4788) -> Result<(), SdkError> {
4789    let bound = prepared.market_id == market_id
4790        && prepared.action == twap_request_action(request)
4791        && match request {
4792            PlatformTwapChallengeRequest::Place { .. } => valid_handle(&prepared.twap_id, "twap_"),
4793            PlatformTwapChallengeRequest::Cancel { twap_id, .. } => prepared.twap_id == *twap_id,
4794        };
4795    if !bound {
4796        return Err(SdkError::InvalidResponse(
4797            "prepared TWAP control does not match the request".to_owned(),
4798        ));
4799    }
4800    Ok(())
4801}
4802
4803/// The order-control prepare authorization, checked: a valid challenge handle
4804/// and, when present, a canonical detached signature. `None` is sent as-is;
4805/// only the session-authenticated order command channel accepts it.
4806fn normalize_order_prepare_authorization(
4807    authorization: PlatformOrderPrepareAuthorization,
4808) -> Result<PlatformOrderPrepareAuthorization, SdkError> {
4809    if !valid_handle(&authorization.challenge_id, "oc_") {
4810        return Err(SdkError::InvalidRequest(
4811            "order challenge_id is invalid".to_owned(),
4812        ));
4813    }
4814    Ok(PlatformOrderPrepareAuthorization {
4815        challenge_id: authorization.challenge_id,
4816        authorization_signature: authorization
4817            .authorization_signature
4818            .as_deref()
4819            .map(|signature| canonical_signature(signature, "authorization_signature"))
4820            .transpose()?,
4821    })
4822}
4823
4824/// Two-step path helper: check a prepared order control preserved the signed
4825/// challenge bindings.
4826pub fn validate_order_prepare_binding(
4827    prepared: &PlatformOrderPrepareResponse,
4828    challenge: &PlatformOrderChallengeResponse,
4829    authorization: &OrderAuthorization,
4830) -> Result<(), SdkError> {
4831    if prepared.market_id != challenge.market_id
4832        || prepared.action != challenge.action
4833        || prepared.order_ids != challenge.order_ids
4834        || prepared.recent_blockhash != authorization.recent_blockhash
4835        || prepared.last_valid_block_height != authorization.last_valid_block_height
4836        || prepared.expires_at_ms != challenge.expires_at_ms
4837    {
4838        return Err(SdkError::InvalidResponse(
4839            "prepared order control changed the signed bindings".to_owned(),
4840        ));
4841    }
4842    Ok(())
4843}
4844
4845fn parse_request_u64(value: &str, field: &str) -> Result<u64, SdkError> {
4846    value
4847        .parse::<u64>()
4848        .map_err(|_| SdkError::InvalidRequest(format!("{field} exceeds u64")))
4849}
4850
4851fn take_u16(source: &[u8], cursor: &mut usize, field: &str) -> Result<u16, SdkError> {
4852    let bytes: [u8; 2] = take_bytes(source, cursor, 2, field)?
4853        .try_into()
4854        .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
4855    Ok(u16::from_le_bytes(bytes))
4856}
4857
4858/// Opaque product identity: `{kind}_` + hex of the first 16 bytes of
4859/// `sha256("strata-sdk-product:v1\0{kind}\0{value}")`.
4860pub(crate) fn opaque_product_id(kind: &str, value: &str) -> String {
4861    let mut digest = Sha256::new();
4862    digest.update(b"strata-sdk-product:v1\0");
4863    digest.update(kind.as_bytes());
4864    digest.update([0]);
4865    digest.update(value.as_bytes());
4866    format!("{kind}_{}", hex::encode(&digest.finalize()[..16]))
4867}
4868
4869/// The opaque market ID for a base58 market account key.
4870pub(crate) fn opaque_market_id(market_key: &str) -> String {
4871    opaque_product_id("market", market_key)
4872}
4873
4874pub(crate) fn opaque_order_id(market_id: &str, order: &[u8]) -> String {
4875    opaque_product_id(
4876        "order",
4877        &format!("{market_id}:{}", bs58::encode(order).into_string()),
4878    )
4879}
4880
4881fn parse_atoms(field: &str, value: &str) -> Result<u64, SdkError> {
4882    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
4883        return Err(SdkError::InvalidResponse(format!(
4884            "{field} must be an unsigned atomic decimal string"
4885        )));
4886    }
4887    value
4888        .parse::<u64>()
4889        .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds the supported range")))
4890}
4891
4892fn valid_public_operation_path(path: &str) -> bool {
4893    let Some(market_id) = path
4894        .strip_prefix("/sonar/markets/")
4895        .and_then(|value| value.strip_suffix("/quote"))
4896    else {
4897        return false;
4898    };
4899    !market_id.is_empty()
4900        && !market_id.starts_with('-')
4901        && !market_id.ends_with('-')
4902        && market_id
4903            .bytes()
4904            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
4905}
4906
4907/// Which amount a quote request fixes.
4908#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4909pub enum QuoteTarget {
4910    /// Spend exactly this input.
4911    ExactInput(u64),
4912    /// Receive at least this output; Strata resolves the input.
4913    ExactOutput(u64),
4914}
4915
4916impl QuoteTarget {
4917    pub fn amount(self) -> u64 {
4918        match self {
4919            Self::ExactInput(amount) | Self::ExactOutput(amount) => amount,
4920        }
4921    }
4922}
4923
4924/// The output floor an exact-output quote must carry: the requested amount
4925/// lowered by `maximum_tolerance_bps` (truncating), zero tolerance meaning the
4926/// requested amount itself.
4927pub fn exact_output_floor(amount_out: u64, maximum_tolerance_bps: u16) -> u64 {
4928    u64::try_from(
4929        u128::from(amount_out) * u128::from(10_000u16.saturating_sub(maximum_tolerance_bps))
4930            / 10_000,
4931    )
4932    .unwrap_or(0)
4933}
4934
4935/// Exactly one of `amount_in_atoms` / `amount_out_atoms`, canonical and > 0.
4936pub fn quote_target(request: &QuoteRequest) -> Result<QuoteTarget, SdkError> {
4937    match (
4938        request.amount_in_atoms.as_deref(),
4939        request.amount_out_atoms.as_deref(),
4940    ) {
4941        (Some(amount_in), None) => {
4942            let amount = parse_atoms("amount_in_atoms", amount_in)?;
4943            if amount == 0 {
4944                return Err(SdkError::InvalidRequest(
4945                    "amount_in_atoms must be greater than zero".to_owned(),
4946                ));
4947            }
4948            Ok(QuoteTarget::ExactInput(amount))
4949        }
4950        (None, Some(amount_out)) => {
4951            let amount = parse_atoms("amount_out_atoms", amount_out)?;
4952            if amount == 0 {
4953                return Err(SdkError::InvalidRequest(
4954                    "amount_out_atoms must be greater than zero".to_owned(),
4955                ));
4956            }
4957            Ok(QuoteTarget::ExactOutput(amount))
4958        }
4959        _ => Err(SdkError::InvalidRequest(
4960            "provide exactly one of amount_in_atoms or amount_out_atoms".to_owned(),
4961        )),
4962    }
4963}
4964
4965fn validate_quote(
4966    quote: &QuoteResponse,
4967    market_id: &str,
4968    request: &QuoteRequest,
4969    target: QuoteTarget,
4970) -> Result<(), SdkError> {
4971    validate_version(quote.schema_version, &quote.contract_version)?;
4972    let bound_to_request = match target {
4973        QuoteTarget::ExactInput(amount_in) => quote.amount_in_atoms == amount_in.to_string(),
4974        // The floor is the requested output with the caller's tolerance
4975        // applied the same way an exact-input quote applies it.
4976        QuoteTarget::ExactOutput(amount_out) => {
4977            quote.minimum_output_atoms
4978                == exact_output_floor(amount_out, request.maximum_tolerance_bps).to_string()
4979        }
4980    };
4981    if quote.provider != "Sonar"
4982        || quote.market_id != market_id
4983        || quote.side != request.side
4984        || quote.maximum_tolerance_bps != request.maximum_tolerance_bps
4985        || !bound_to_request
4986        || quote.quote_id.len() != 35
4987        || !quote.quote_id.starts_with("sq_")
4988        || !quote.quote_id[3..]
4989            .bytes()
4990            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
4991        || quote.expires_at_ms <= quote.server_time_ms
4992    {
4993        return Err(SdkError::InvalidResponse(
4994            "quote binding or lifetime is invalid".to_owned(),
4995        ));
4996    }
4997
4998    let amount_in = parse_atoms("amount_in_atoms", &quote.amount_in_atoms)?;
4999    let consumed = parse_atoms("amount_in_consumed_atoms", &quote.amount_in_consumed_atoms)?;
5000    let output = parse_atoms("amount_out_atoms", &quote.amount_out_atoms)?;
5001    let minimum = parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?;
5002    parse_atoms("input_fee_atoms", &quote.input_fee_atoms)?;
5003    parse_atoms("output_fee_atoms", &quote.output_fee_atoms)?;
5004    if consumed > amount_in || minimum > output {
5005        return Err(SdkError::InvalidResponse(
5006            "quote economics are internally inconsistent".to_owned(),
5007        ));
5008    }
5009    quote
5010        .reference_price
5011        .parse::<f64>()
5012        .ok()
5013        .filter(|value| value.is_finite() && *value > 0.0)
5014        .ok_or_else(|| SdkError::InvalidResponse("reference_price is invalid".to_owned()))?;
5015    quote
5016        .price_impact_pct
5017        .parse::<f64>()
5018        .ok()
5019        .filter(|value| value.is_finite() && *value >= 0.0)
5020        .ok_or_else(|| SdkError::InvalidResponse("price_impact_pct is invalid".to_owned()))?;
5021    Ok(())
5022}
5023
5024/// A parsed two-step execution authorization: the exact bytes to sign and
5025/// the blockhash lease they bind.
5026#[derive(Clone, Debug, Eq, PartialEq)]
5027pub struct ExecutionAuthorization {
5028    pub bytes: Vec<u8>,
5029    pub recent_blockhash: String,
5030    pub last_valid_block_height: u64,
5031}
5032
5033/// Two-step path helper: check an execution challenge is bound to this quote
5034/// and still inside its lifetime.
5035pub fn validate_execution_challenge(
5036    challenge: &ExecutionChallengeResponse,
5037    quote: &QuoteResponse,
5038) -> Result<(), SdkError> {
5039    validate_version(challenge.schema_version, &challenge.contract_version)?;
5040    validate_execution_binding(
5041        &challenge.quote_id,
5042        &challenge.market_id,
5043        challenge.side,
5044        &challenge.amount_in_atoms,
5045        &challenge.minimum_output_atoms,
5046        quote,
5047    )?;
5048    if !valid_handle(&challenge.challenge_id, "sc_")
5049        || challenge.expires_at_ms <= challenge.server_time_ms
5050        || challenge.expires_at_ms > quote.expires_at_ms
5051    {
5052        return Err(SdkError::InvalidResponse(
5053            "execution challenge binding or lifetime is invalid".to_owned(),
5054        ));
5055    }
5056    Ok(())
5057}
5058
5059/// Two-step path helper: check a prepared execution preserved the signed
5060/// challenge bindings.
5061pub fn validate_execution_prepare(
5062    prepared: &ExecutionPrepareResponse,
5063    quote: &QuoteResponse,
5064    challenge: &ExecutionChallengeResponse,
5065    authorization: &ExecutionAuthorization,
5066) -> Result<(), SdkError> {
5067    validate_version(prepared.schema_version, &prepared.contract_version)?;
5068    validate_execution_binding(
5069        &prepared.quote_id,
5070        &prepared.market_id,
5071        prepared.side,
5072        &prepared.amount_in_atoms,
5073        &prepared.minimum_output_atoms,
5074        quote,
5075    )?;
5076    if !valid_handle(&prepared.execution_id, "se_")
5077        || prepared.recent_blockhash != authorization.recent_blockhash
5078        || prepared.last_valid_block_height != authorization.last_valid_block_height
5079        || prepared.expires_at_ms > challenge.expires_at_ms
5080        || prepared.transaction_base64.trim().is_empty()
5081        || base64::engine::general_purpose::STANDARD
5082            .decode(prepared.transaction_base64.trim())
5083            .is_err()
5084    {
5085        return Err(SdkError::InvalidResponse(
5086            "prepared execution changed the signed authorization".to_owned(),
5087        ));
5088    }
5089    Ok(())
5090}
5091
5092fn normalize_execution_challenge_request(
5093    request: ExecutionChallengeRequest,
5094) -> Result<ExecutionChallengeRequest, SdkError> {
5095    if !valid_handle(&request.quote_id, "sq_") {
5096        return Err(SdkError::InvalidRequest("quote_id is invalid".to_owned()));
5097    }
5098    Ok(ExecutionChallengeRequest {
5099        quote_id: request.quote_id,
5100        owner_wallet: canonical_public_key(&request.owner_wallet, "owner_wallet")?,
5101        session_public_key: canonical_public_key(
5102            &request.session_public_key,
5103            "session_public_key",
5104        )?,
5105        account_sequence: canonical_optional_request_atoms(
5106            request.account_sequence.as_deref(),
5107            "account_sequence",
5108        )?,
5109    })
5110}
5111
5112/// Direct-path binding: a prepared execution must be bound to exactly this
5113/// quote and carry a well-formed transaction envelope.
5114fn validate_execution_direct_prepare(
5115    prepared: &ExecutionPrepareResponse,
5116    quote: &QuoteResponse,
5117) -> Result<(), SdkError> {
5118    validate_version(prepared.schema_version, &prepared.contract_version)?;
5119    validate_execution_binding(
5120        &prepared.quote_id,
5121        &prepared.market_id,
5122        prepared.side,
5123        &prepared.amount_in_atoms,
5124        &prepared.minimum_output_atoms,
5125        quote,
5126    )?;
5127    if !valid_handle(&prepared.execution_id, "se_") || prepared.expires_at_ms == 0 {
5128        return Err(SdkError::InvalidResponse(
5129            "prepared execution does not match the requested quote".to_owned(),
5130        ));
5131    }
5132    canonical_base64(&prepared.transaction_base64, "transaction_base64")?;
5133    canonical_base58_32(&prepared.recent_blockhash, "recent_blockhash")?;
5134    Ok(())
5135}
5136
5137fn validate_execution_binding(
5138    quote_id: &str,
5139    market_id: &str,
5140    side: QuoteSide,
5141    amount_in_atoms: &str,
5142    minimum_output_atoms: &str,
5143    quote: &QuoteResponse,
5144) -> Result<(), SdkError> {
5145    if quote_id != quote.quote_id
5146        || market_id != quote.market_id
5147        || side != quote.side
5148        || amount_in_atoms != quote.amount_in_atoms
5149        || minimum_output_atoms != quote.minimum_output_atoms
5150    {
5151        return Err(SdkError::InvalidResponse(
5152            "execution does not match the Sonar quote".to_owned(),
5153        ));
5154    }
5155    Ok(())
5156}
5157
5158/// Two-step path helper: check a challenge's authorization payload binds
5159/// exactly this quote, owner, and session before signing it. The one-call
5160/// [`StrataClient::execute_quote`] no longer needs it (one signature over the
5161/// transaction).
5162pub fn validate_execution_authorization(
5163    challenge: &ExecutionChallengeResponse,
5164    quote: &QuoteResponse,
5165    owner_wallet: &str,
5166    session_public_key: &str,
5167    account_sequence: Option<u64>,
5168) -> Result<ExecutionAuthorization, SdkError> {
5169    let bytes = base64::engine::general_purpose::STANDARD
5170        .decode(challenge.authorization_payload_base64.trim())
5171        .map_err(|_| SdkError::InvalidResponse("authorization payload is not base64".to_owned()))?;
5172    let market = decode_public_key(&quote.market_id, "market_id")?;
5173    let owner = decode_public_key(owner_wallet, "owner_wallet")?;
5174    let session = decode_public_key(session_public_key, "session_public_key")?;
5175    let mut cursor = 0usize;
5176    take_expected(
5177        &bytes,
5178        &mut cursor,
5179        PUBLIC_EXECUTION_AUTH_DOMAIN,
5180        "authorization domain",
5181    )?;
5182    take_expected(&bytes, &mut cursor, &market, "authorization market")?;
5183    take_expected(
5184        &bytes,
5185        &mut cursor,
5186        quote.quote_id.as_bytes(),
5187        "authorization quote",
5188    )?;
5189    take_expected(&bytes, &mut cursor, &owner, "authorization owner")?;
5190    take_expected(&bytes, &mut cursor, &session, "authorization session")?;
5191    let side = take_bytes(&bytes, &mut cursor, 1, "authorization side")?[0];
5192    if side != if quote.side == QuoteSide::Buy { 0 } else { 1 } {
5193        return Err(SdkError::InvalidResponse(
5194            "authorization side changed".to_owned(),
5195        ));
5196    }
5197    take_u64_eq(
5198        &bytes,
5199        &mut cursor,
5200        parse_atoms("amount_in_atoms", &quote.amount_in_atoms)?,
5201        "authorization input",
5202    )?;
5203    take_u64_eq(
5204        &bytes,
5205        &mut cursor,
5206        parse_atoms("minimum_output_atoms", &quote.minimum_output_atoms)?,
5207        "authorization minimum output",
5208    )?;
5209    match account_sequence {
5210        Some(expected) => take_u64_eq(
5211            &bytes,
5212            &mut cursor,
5213            expected,
5214            "authorization account sequence",
5215        )?,
5216        // Left to Strata: the resolved sequence is whatever the signed
5217        // authorization carries; every other binding is still checked.
5218        None => {
5219            take_u64(&bytes, &mut cursor, "authorization account sequence")?;
5220        }
5221    }
5222    let _output_balance = take_u64(&bytes, &mut cursor, "authorization output balance")?;
5223    let recent_blockhash = bs58::encode(take_bytes(
5224        &bytes,
5225        &mut cursor,
5226        32,
5227        "authorization blockhash",
5228    )?)
5229    .into_string();
5230    let last_valid_block_height =
5231        take_u64(&bytes, &mut cursor, "authorization last valid block height")?;
5232    take_u64_eq(
5233        &bytes,
5234        &mut cursor,
5235        challenge.expires_at_ms,
5236        "authorization expiry",
5237    )?;
5238    let nonce = take_bytes(&bytes, &mut cursor, 16, "authorization nonce")?;
5239    if hex::encode(nonce) != challenge.challenge_id[3..] {
5240        return Err(SdkError::InvalidResponse(
5241            "authorization challenge nonce changed".to_owned(),
5242        ));
5243    }
5244    let _epoch = take_bytes(&bytes, &mut cursor, 16, "authorization epoch")?;
5245    if cursor != bytes.len() {
5246        return Err(SdkError::InvalidResponse(
5247            "authorization contains unrecognized fields".to_owned(),
5248        ));
5249    }
5250    Ok(ExecutionAuthorization {
5251        bytes,
5252        recent_blockhash,
5253        last_valid_block_height,
5254    })
5255}
5256
5257fn take_expected(
5258    source: &[u8],
5259    cursor: &mut usize,
5260    expected: &[u8],
5261    field: &str,
5262) -> Result<(), SdkError> {
5263    if take_bytes(source, cursor, expected.len(), field)? != expected {
5264        return Err(SdkError::InvalidResponse(format!("{field} changed")));
5265    }
5266    Ok(())
5267}
5268
5269fn take_bytes<'a>(
5270    source: &'a [u8],
5271    cursor: &mut usize,
5272    length: usize,
5273    field: &str,
5274) -> Result<&'a [u8], SdkError> {
5275    let end = cursor
5276        .checked_add(length)
5277        .filter(|end| *end <= source.len())
5278        .ok_or_else(|| SdkError::InvalidResponse(format!("{field} is missing")))?;
5279    let value = &source[*cursor..end];
5280    *cursor = end;
5281    Ok(value)
5282}
5283
5284fn take_u64(source: &[u8], cursor: &mut usize, field: &str) -> Result<u64, SdkError> {
5285    let bytes: [u8; 8] = take_bytes(source, cursor, 8, field)?
5286        .try_into()
5287        .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
5288    Ok(u64::from_le_bytes(bytes))
5289}
5290
5291fn take_u64_eq(
5292    source: &[u8],
5293    cursor: &mut usize,
5294    expected: u64,
5295    field: &str,
5296) -> Result<(), SdkError> {
5297    if take_u64(source, cursor, field)? != expected {
5298        return Err(SdkError::InvalidResponse(format!("{field} changed")));
5299    }
5300    Ok(())
5301}
5302
5303fn decode_public_key(value: &str, field: &str) -> Result<Vec<u8>, SdkError> {
5304    let bytes = bs58::decode(value.trim())
5305        .into_vec()
5306        .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
5307    if bytes.len() != 32 || bs58::encode(&bytes).into_string() != value.trim() {
5308        return Err(SdkError::InvalidRequest(format!(
5309            "{field} must be a canonical 32-byte public key"
5310        )));
5311    }
5312    Ok(bytes)
5313}
5314
5315fn canonical_public_key(value: &str, field: &str) -> Result<String, SdkError> {
5316    decode_public_key(value, field)?;
5317    Ok(value.trim().to_owned())
5318}
5319
5320fn valid_handle(value: &str, prefix: &str) -> bool {
5321    value.len() == prefix.len() + 32
5322        && value.starts_with(prefix)
5323        && value[prefix.len()..]
5324            .bytes()
5325            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
5326}
5327
5328fn normalize_idempotency_key(value: &str) -> Result<String, SdkError> {
5329    let value = value.trim();
5330    if value.is_empty()
5331        || value.len() > 64
5332        || !value.bytes().all(|byte| {
5333            byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
5334        })
5335    {
5336        return Err(SdkError::InvalidRequest(
5337            "idempotency key must contain 1-64 URL-safe characters".to_owned(),
5338        ));
5339    }
5340    Ok(value.to_owned())
5341}
5342
5343fn unix_ms() -> Result<u64, SdkError> {
5344    let elapsed = SystemTime::now()
5345        .duration_since(UNIX_EPOCH)
5346        .map_err(|_| SdkError::InvalidRequest("system clock is before Unix epoch".to_owned()))?;
5347    u64::try_from(elapsed.as_millis())
5348        .map_err(|_| SdkError::InvalidRequest("system clock exceeds supported range".to_owned()))
5349}
5350
5351#[cfg(test)]
5352mod tests {
5353    use super::*;
5354    use futures_util::{SinkExt, StreamExt};
5355    use tokio::net::TcpListener;
5356    use tokio_tungstenite::tungstenite::Message;
5357    use wiremock::matchers::{body_json, header, method, path, query_param};
5358    use wiremock::{Mock, MockServer, ResponseTemplate};
5359
5360    fn fixture(path: &str) -> serde_json::Value {
5361        let raw = match path {
5362            "action-graph" => strata_public_contract::contract_fixtures::ACTION_GRAPH,
5363            "markets" => strata_public_contract::contract_fixtures::MARKETS,
5364            "quote" => strata_public_contract::contract_fixtures::QUOTE,
5365            "capabilities" => strata_public_contract::contract_fixtures::CAPABILITIES,
5366            "execution-prepare" => strata_public_contract::contract_fixtures::EXECUTION_PREPARE,
5367            "execution-submit" => strata_public_contract::contract_fixtures::EXECUTION_SUBMIT,
5368            "order-challenge" => strata_public_contract::platform::PLATFORM_ORDER_CHALLENGE_FIXTURE,
5369            "order-prepare" => strata_public_contract::platform::PLATFORM_ORDER_PREPARE_FIXTURE,
5370            "order-submit" => strata_public_contract::platform::PLATFORM_ORDER_SUBMIT_FIXTURE,
5371            "order-status" => strata_public_contract::platform::PLATFORM_ORDER_STATUS_FIXTURE,
5372            "twap-challenge" => strata_public_contract::platform::PLATFORM_TWAP_CHALLENGE_FIXTURE,
5373            "twap-prepare" => strata_public_contract::platform::PLATFORM_TWAP_PREPARE_FIXTURE,
5374            "twap-submit" => strata_public_contract::platform::PLATFORM_TWAP_SUBMIT_FIXTURE,
5375            "platform-capabilities" => {
5376                strata_public_contract::platform::PLATFORM_CAPABILITIES_FIXTURE
5377            }
5378            "platform-action-graph" => strata_public_contract::platform::PLATFORM_ACTION_GRAPH,
5379            "platform-status" => strata_public_contract::platform::PLATFORM_SERVICE_STATUS_FIXTURE,
5380            "assets" => strata_public_contract::platform::PLATFORM_ASSETS_FIXTURE,
5381            "swap-quote" => strata_public_contract::platform::PLATFORM_SWAP_QUOTE_FIXTURE,
5382            "platform-markets" => strata_public_contract::platform::PLATFORM_MARKETS_FIXTURE,
5383            "book" => strata_public_contract::platform::PLATFORM_BOOK_FIXTURE,
5384            "bbo" => strata_public_contract::platform::PLATFORM_BBO_FIXTURE,
5385            "fees" => strata_public_contract::platform::PLATFORM_FEES_FIXTURE,
5386            "market-status" => strata_public_contract::platform::PLATFORM_STATUS_FIXTURE,
5387            "trades" => strata_public_contract::platform::PLATFORM_TRADES_FIXTURE,
5388            "candles" => strata_public_contract::platform::PLATFORM_CANDLES_FIXTURE,
5389            "mark" => strata_public_contract::platform::PLATFORM_MARK_FIXTURE,
5390            "execution-status" => {
5391                strata_public_contract::platform::PLATFORM_EXECUTION_STATUS_FIXTURE
5392            }
5393            "twaps" => strata_public_contract::platform::PLATFORM_TWAPS_FIXTURE,
5394            "portfolio" => strata_public_contract::platform::PLATFORM_PORTFOLIO_FIXTURE,
5395            "maker-status" => strata_public_contract::platform::PLATFORM_MAKER_STATUS_FIXTURE,
5396            "maker-stream" => strata_public_contract::platform::PLATFORM_MAKER_STREAM_FIXTURE,
5397            "twap-stream" => strata_public_contract::platform::PLATFORM_TWAP_STREAM_FIXTURE,
5398            "execution-stream" => {
5399                strata_public_contract::platform::PLATFORM_EXECUTION_STREAM_FIXTURE
5400            }
5401            "portfolio-history" => {
5402                strata_public_contract::platform::PLATFORM_PORTFOLIO_HISTORY_FIXTURE
5403            }
5404            "vault-status" => strata_public_contract::platform::PLATFORM_VAULT_STATUS_FIXTURE,
5405            "vault-pause-prepare" => {
5406                strata_public_contract::platform::PLATFORM_VAULT_PAUSE_PREPARE_FIXTURE
5407            }
5408            "vault-setup-prepare" => {
5409                strata_public_contract::platform::PLATFORM_VAULT_SETUP_PREPARE_FIXTURE
5410            }
5411            "vault-delegate-prepare" => {
5412                strata_public_contract::platform::PLATFORM_VAULT_DELEGATE_PREPARE_FIXTURE
5413            }
5414            "vault-policy-prepare" => {
5415                strata_public_contract::platform::PLATFORM_VAULT_POLICY_PREPARE_FIXTURE
5416            }
5417            "vault-deposit-prepare" => {
5418                strata_public_contract::platform::PLATFORM_VAULT_DEPOSIT_PREPARE_FIXTURE
5419            }
5420            "vault-withdraw-prepare" => {
5421                strata_public_contract::platform::PLATFORM_VAULT_WITHDRAW_PREPARE_FIXTURE
5422            }
5423            "vault-submit" => strata_public_contract::platform::PLATFORM_VAULT_SUBMIT_FIXTURE,
5424            "rewards" => strata_public_contract::platform::PLATFORM_REWARDS_FIXTURE,
5425            "referrals" => strata_public_contract::platform::PLATFORM_REFERRALS_FIXTURE,
5426            "referral-link" => strata_public_contract::platform::PLATFORM_REFERRAL_LINK_FIXTURE,
5427            "referral-claim" => strata_public_contract::platform::PLATFORM_REFERRAL_CLAIM_FIXTURE,
5428            "bugs" => strata_public_contract::platform::PLATFORM_BUGS_FIXTURE,
5429            "bug-submit" => strata_public_contract::platform::PLATFORM_BUG_SUBMIT_FIXTURE,
5430            "account" => strata_public_contract::platform::PLATFORM_ACCOUNT_FIXTURE,
5431            _ => unreachable!(),
5432        };
5433        serde_json::from_str(raw).unwrap()
5434    }
5435
5436    async fn mount_get(server: &MockServer, operation_path: &str, fixture_name: &str) {
5437        Mock::given(method("GET"))
5438            .and(path(operation_path))
5439            .respond_with(ResponseTemplate::new(200).set_body_json(fixture(fixture_name)))
5440            .expect(1)
5441            .mount(server)
5442            .await;
5443    }
5444
5445    #[tokio::test]
5446    async fn reads_capabilities_and_quotes_without_internal_metadata() {
5447        let server = MockServer::start().await;
5448        Mock::given(method("GET"))
5449            .and(path("/sonar/capabilities"))
5450            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("capabilities")))
5451            .mount(&server)
5452            .await;
5453        Mock::given(method("GET"))
5454            .and(path("/sonar/markets"))
5455            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
5456            .expect(1)
5457            .mount(&server)
5458            .await;
5459        Mock::given(method("GET"))
5460            .and(path("/sonar/action-graph"))
5461            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("action-graph")))
5462            .expect(1)
5463            .mount(&server)
5464            .await;
5465        Mock::given(method("POST"))
5466            .and(path("/sonar/markets/sol-usdc/quote"))
5467            .and(body_json(serde_json::json!({
5468                "market_id": "11111111111111111111111111111111",
5469                "side": "sell",
5470                "amount_in_atoms": "10000000",
5471                "maximum_tolerance_bps": 50
5472            })))
5473            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("quote")))
5474            .expect(1)
5475            .mount(&server)
5476            .await;
5477
5478        let client = StrataClient::new(server.uri()).unwrap();
5479        let capabilities = client.capabilities().await.unwrap();
5480        assert!(capabilities
5481            .capabilities
5482            .iter()
5483            .any(|capability| capability.id == "quotes.read"));
5484
5485        let graph = client.action_graph().await.unwrap();
5486        assert_eq!(graph.entry_node, "discover_capabilities");
5487        assert_eq!(graph.authority.permission_source, "external_agent_owner");
5488
5489        let quote = client
5490            .quote(QuoteRequest {
5491                market_id: "SOL/USDC".to_owned(),
5492                side: QuoteSide::Sell,
5493                amount_in_atoms: Some("10000000".to_owned()),
5494                amount_out_atoms: None,
5495                maximum_tolerance_bps: 50,
5496            })
5497            .await
5498            .unwrap();
5499        let public = serde_json::to_value(quote).unwrap();
5500        assert!(public.get("quote_id").is_some());
5501        assert!(public.get("unexpected_field").is_none());
5502
5503        // The request must fix exactly one amount.
5504        for (amount_in, amount_out) in [(None, None), (Some("1"), Some("1")), (Some("0"), None)] {
5505            let request = QuoteRequest {
5506                market_id: "SOL/USDC".to_owned(),
5507                side: QuoteSide::Sell,
5508                amount_in_atoms: amount_in.map(str::to_owned),
5509                amount_out_atoms: amount_out.map(str::to_owned),
5510                maximum_tolerance_bps: 50,
5511            };
5512            assert!(matches!(
5513                quote_target(&request),
5514                Err(SdkError::InvalidRequest(_))
5515            ));
5516        }
5517    }
5518
5519    #[test]
5520    fn exact_output_quotes_bind_to_the_requested_minimum_output() {
5521        let raw: QuoteResponse = serde_json::from_str(include_str!(
5522            "../../strata-public-contract/fixtures/v1/quote.json"
5523        ))
5524        .unwrap();
5525        let market_id = raw.market_id.clone();
5526        let mut quote = raw.clone();
5527        // Zero tolerance: the floor is the requested amount itself and the
5528        // best route delivers it (within a basis point) at quote time. The
5529        // response echoes the tolerance next to the measured impact.
5530        quote.minimum_output_atoms = "1000000000".to_owned();
5531        quote.amount_out_atoms = "1000000004".to_owned();
5532        quote.maximum_tolerance_bps = 0;
5533        let request = QuoteRequest {
5534            market_id: market_id.clone(),
5535            side: quote.side,
5536            amount_in_atoms: None,
5537            amount_out_atoms: Some("1000000000".to_owned()),
5538            maximum_tolerance_bps: 0,
5539        };
5540        let target = quote_target(&request).unwrap();
5541        assert_eq!(target, QuoteTarget::ExactOutput(1_000_000_000));
5542        // Serialization leaves the unused amount out so older servers reject
5543        // rather than misread the request.
5544        let wire = serde_json::to_string(&request).unwrap();
5545        assert!(wire.contains("amount_out_atoms") && !wire.contains("amount_in_atoms"));
5546        validate_quote(&quote, &market_id, &request, target).unwrap();
5547        // A response whose floor is not the requested output is refused.
5548        quote.minimum_output_atoms = "999999999".to_owned();
5549        assert!(validate_quote(&quote, &market_id, &request, target).is_err());
5550        // With a tolerance the floor is the requested amount lowered by it,
5551        // exactly as an exact-input quote lowers its own floor.
5552        assert_eq!(exact_output_floor(1_000_000_000, 25), 997_500_000);
5553        let tolerant = QuoteRequest {
5554            maximum_tolerance_bps: 25,
5555            ..request.clone()
5556        };
5557        quote.minimum_output_atoms = "997500000".to_owned();
5558        quote.maximum_tolerance_bps = 25;
5559        validate_quote(
5560            &quote,
5561            &market_id,
5562            &tolerant,
5563            quote_target(&tolerant).unwrap(),
5564        )
5565        .unwrap();
5566        // A quote that echoes a different tolerance than requested is foreign.
5567        quote.maximum_tolerance_bps = 10;
5568        assert!(validate_quote(
5569            &quote,
5570            &market_id,
5571            &tolerant,
5572            quote_target(&tolerant).unwrap()
5573        )
5574        .is_err());
5575        // An exact-input request still binds on the input amount (the fixture
5576        // carries a 50 bps tolerance).
5577        let exact_input = QuoteRequest {
5578            market_id: market_id.clone(),
5579            side: raw.side,
5580            amount_in_atoms: Some(raw.amount_in_atoms.clone()),
5581            amount_out_atoms: None,
5582            maximum_tolerance_bps: 50,
5583        };
5584        let input_target = quote_target(&exact_input).unwrap();
5585        validate_quote(&raw, &market_id, &exact_input, input_target).unwrap();
5586    }
5587
5588    #[test]
5589    fn platform_graph_rejects_orphaned_operations() {
5590        let mut graph = PlatformActionGraphResponse::foundation();
5591        let mut orphan = graph.operations[0].clone();
5592        orphan.id = "platform.unmapped.read".to_owned();
5593        orphan.summary =
5594            "This test operation is deliberately absent from every workflow.".to_owned();
5595        graph.operations.push(orphan);
5596
5597        assert!(matches!(
5598            validate_platform_action_graph(&graph),
5599            Err(SdkError::InvalidResponse(message))
5600                if message.contains("orphaned operation")
5601        ));
5602    }
5603
5604    #[tokio::test]
5605    async fn platform_reads_map_the_complete_live_product_surface() {
5606        let server = MockServer::start().await;
5607        let market_id = "market_33333333333333333333333333333333";
5608        let wallet = "5Ji61Fbeb22Yntgv1hhHeSSLgdEdZchHeM1Tv1MjGhSL";
5609        mount_get(&server, "/v2/capabilities", "platform-capabilities").await;
5610        mount_get(&server, "/v2/action-graph", "platform-action-graph").await;
5611        mount_get(&server, "/v2/status", "platform-status").await;
5612        mount_get(&server, "/v2/assets", "assets").await;
5613        mount_get(&server, "/v2/markets", "platform-markets").await;
5614        Mock::given(method("POST"))
5615            .and(path("/v2/quotes"))
5616            .and(body_json(serde_json::json!({
5617                "input_asset_id": "asset_11111111111111111111111111111111",
5618                "output_asset_id": "asset_22222222222222222222222222222222",
5619                "amount_in_atoms": "10000000",
5620                "maximum_tolerance_bps": 50
5621            })))
5622            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("swap-quote")))
5623            .expect(1)
5624            .mount(&server)
5625            .await;
5626        Mock::given(method("GET"))
5627            .and(path(format!("/v2/markets/{market_id}/book")))
5628            .and(query_param("depth", "50"))
5629            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("book")))
5630            .expect(1)
5631            .mount(&server)
5632            .await;
5633        mount_get(&server, &format!("/v2/markets/{market_id}/bbo"), "bbo").await;
5634        mount_get(&server, &format!("/v2/markets/{market_id}/fees"), "fees").await;
5635        mount_get(
5636            &server,
5637            &format!("/v2/markets/{market_id}/status"),
5638            "market-status",
5639        )
5640        .await;
5641        Mock::given(method("GET"))
5642            .and(path(format!("/v2/markets/{market_id}/trades")))
5643            .and(query_param("limit", "25"))
5644            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("trades")))
5645            .expect(1)
5646            .mount(&server)
5647            .await;
5648        Mock::given(method("GET"))
5649            .and(path(format!("/v2/markets/{market_id}/candles")))
5650            .and(query_param("from_ms", "1786549800000"))
5651            .and(query_param("to_ms", "1786550400001"))
5652            .and(query_param("resolution_seconds", "300"))
5653            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("candles")))
5654            .expect(1)
5655            .mount(&server)
5656            .await;
5657        mount_get(&server, &format!("/v2/markets/{market_id}/marks"), "mark").await;
5658        mount_get(
5659            &server,
5660            &format!("/v2/markets/{market_id}/executions/se_0123456789abcdef0123456789abcdef"),
5661            "execution-status",
5662        )
5663        .await;
5664        mount_get(
5665            &server,
5666            &format!("/v2/markets/{market_id}/account/{wallet}/twaps"),
5667            "twaps",
5668        )
5669        .await;
5670
5671        let client = StrataClient::new(server.uri()).unwrap();
5672        assert!(!client
5673            .platform_capabilities()
5674            .await
5675            .unwrap()
5676            .capabilities
5677            .is_empty());
5678        assert_eq!(
5679            client
5680                .platform_action_graph()
5681                .await
5682                .unwrap()
5683                .entry_operation_id,
5684            "platform.capabilities.read"
5685        );
5686        assert_eq!(
5687            client.platform_status().await.unwrap().available_operations,
5688            59
5689        );
5690        assert!(!client
5691            .platform_assets(PageRequest::default())
5692            .await
5693            .unwrap()
5694            .assets
5695            .is_empty());
5696        assert!(!client
5697            .platform_markets(PageRequest::default())
5698            .await
5699            .unwrap()
5700            .markets
5701            .is_empty());
5702        assert_eq!(
5703            client
5704                .platform_swap_quote(PlatformSwapQuoteRequest {
5705                    input_asset_id: "asset_11111111111111111111111111111111".to_owned(),
5706                    output_asset_id: "asset_22222222222222222222222222222222".to_owned(),
5707                    amount_in_atoms: "10000000".to_owned(),
5708                    maximum_tolerance_bps: 50,
5709                })
5710                .await
5711                .unwrap()
5712                .amount_out_atoms,
5713            "1990000"
5714        );
5715        assert_eq!(
5716            client
5717                .platform_book(market_id, PlatformBookRequest { depth: Some(50) },)
5718                .await
5719                .unwrap()
5720                .bids
5721                .len(),
5722            2
5723        );
5724        assert!(client
5725            .platform_best_bid_ask(market_id)
5726            .await
5727            .unwrap()
5728            .best_bid
5729            .is_some());
5730        assert!(
5731            client
5732                .platform_fees(market_id)
5733                .await
5734                .unwrap()
5735                .exact_fee_returned_by_quote
5736        );
5737        assert_eq!(
5738            client
5739                .platform_market_status(market_id)
5740                .await
5741                .unwrap()
5742                .market_id,
5743            market_id
5744        );
5745        assert!(!client
5746            .platform_trades(market_id, PlatformTradesRequest { limit: Some(25) },)
5747            .await
5748            .unwrap()
5749            .trades
5750            .is_empty());
5751        assert_eq!(
5752            client
5753                .platform_candles(
5754                    market_id,
5755                    PlatformCandlesRequest {
5756                        from_ms: 1_786_549_800_000,
5757                        to_ms: 1_786_550_400_001,
5758                        resolution_seconds: Some(300),
5759                    },
5760                )
5761                .await
5762                .unwrap()
5763                .resolution_seconds,
5764            300
5765        );
5766        assert!(!client.platform_mark(market_id).await.unwrap().stale);
5767        assert_eq!(
5768            client
5769                .platform_execution_status(market_id, "se_0123456789abcdef0123456789abcdef",)
5770                .await
5771                .unwrap()
5772                .status,
5773            PlatformExecutionState::Confirmed
5774        );
5775        assert!(!client
5776            .platform_twaps(market_id, wallet)
5777            .await
5778            .unwrap()
5779            .twaps
5780            .is_empty());
5781    }
5782
5783    struct TestAccountSigner {
5784        wallet: String,
5785        expected_message: Vec<u8>,
5786        signature_byte: u8,
5787    }
5788
5789    #[async_trait]
5790    impl AccountSigner for TestAccountSigner {
5791        fn public_key(&self) -> &str {
5792            &self.wallet
5793        }
5794
5795        async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String> {
5796            assert_eq!(message, self.expected_message);
5797            Ok(vec![self.signature_byte; 64])
5798        }
5799    }
5800
5801    #[tokio::test]
5802    async fn platform_account_and_community_reads_preserve_external_authority() {
5803        let server = MockServer::start().await;
5804        let market_id = "market_33333333333333333333333333333333";
5805        let wallet = "5Ji61Fbeb22Yntgv1hhHeSSLgdEdZchHeM1Tv1MjGhSL";
5806        mount_get(&server, "/v2/capabilities", "platform-capabilities").await;
5807        Mock::given(method("GET"))
5808            .and(path(format!("/v2/markets/{market_id}/account/{wallet}")))
5809            .and(query_param("fill_limit", "25"))
5810            .and(header("x-strata-auth-time", "1786550400000"))
5811            .and(header("x-strata-auth-signature", "07".repeat(64)))
5812            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("account")))
5813            .expect(1)
5814            .mount(&server)
5815            .await;
5816        Mock::given(method("GET"))
5817            .and(path(format!("/v2/account/{wallet}/portfolio")))
5818            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("portfolio")))
5819            .expect(1)
5820            .mount(&server)
5821            .await;
5822        Mock::given(method("GET"))
5823            .and(path(format!(
5824                "/v2/markets/market_33333333333333333333333333333333/makers/{wallet}"
5825            )))
5826            .and(header("x-strata-auth-time", "1786550400000"))
5827            .and(header("x-strata-auth-signature", "0a".repeat(64)))
5828            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("maker-status")))
5829            .expect(1)
5830            .mount(&server)
5831            .await;
5832        Mock::given(method("GET"))
5833            .and(path(format!("/v2/account/{wallet}/portfolio/history")))
5834            .and(query_param("range", "24h"))
5835            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("portfolio-history")))
5836            .expect(1)
5837            .mount(&server)
5838            .await;
5839        Mock::given(method("GET"))
5840            .and(path("/v2/vault/status"))
5841            .and(query_param("wallet_address", wallet))
5842            .and(query_param(
5843                "session_public_key",
5844                "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2",
5845            ))
5846            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("vault-status")))
5847            .expect(1)
5848            .mount(&server)
5849            .await;
5850        Mock::given(method("POST"))
5851            .and(path("/v2/vault/pause/prepare"))
5852            .and(body_json(serde_json::json!({
5853                "wallet_address": wallet,
5854                "paused": true,
5855            })))
5856            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("vault-pause-prepare")))
5857            .expect(1)
5858            .mount(&server)
5859            .await;
5860        Mock::given(method("POST"))
5861            .and(path("/v2/vault/setup/prepare"))
5862            .and(body_json(serde_json::json!({
5863                "wallet_address": wallet,
5864                "session_public_key": "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2",
5865                "market_id": "market_33333333333333333333333333333333",
5866                "expires_at_ms": null,
5867                "minimum_interval_seconds": 1,
5868                "maximum_tolerance_bps": 100,
5869                "spending_limits": [
5870                    {
5871                        "asset_id": "asset_0123456789abcdef0123456789abcdef",
5872                        "maximum_per_execution_atoms": null,
5873                    },
5874                    {
5875                        "asset_id": "asset_fedcba9876543210fedcba9876543210",
5876                        "maximum_per_execution_atoms": "100000000",
5877                    },
5878                ],
5879            })))
5880            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("vault-setup-prepare")))
5881            .expect(1)
5882            .mount(&server)
5883            .await;
5884        Mock::given(method("POST"))
5885            .and(path("/v2/vault/deposits/prepare"))
5886            .and(body_json(serde_json::json!({
5887                "wallet_address": wallet,
5888                "market_id": "market_33333333333333333333333333333333",
5889                "asset_id": "asset_0123456789abcdef0123456789abcdef",
5890                "amount_atoms": "10000000",
5891                "session_public_key": "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2",
5892            })))
5893            .respond_with(
5894                ResponseTemplate::new(200).set_body_json(fixture("vault-deposit-prepare")),
5895            )
5896            .expect(1)
5897            .mount(&server)
5898            .await;
5899        Mock::given(method("POST"))
5900            .and(path("/v2/vault/withdrawals/prepare"))
5901            .and(body_json(serde_json::json!({
5902                "wallet_address": wallet,
5903                "market_id": "market_33333333333333333333333333333333",
5904                "asset_id": "asset_fedcba9876543210fedcba9876543210",
5905                "destination_wallet_address": wallet,
5906                "amount_atoms": "5000000",
5907            })))
5908            .respond_with(
5909                ResponseTemplate::new(200).set_body_json(fixture("vault-withdraw-prepare")),
5910            )
5911            .expect(1)
5912            .mount(&server)
5913            .await;
5914        Mock::given(method("POST"))
5915            .and(path("/v2/vault/submit"))
5916            .and(body_json(serde_json::json!({
5917                "preparation_id": "vp_4d5e6f708192a3b4c5d6e7f8091a2b3c",
5918                "signed_transaction_base64": "AQIDBA==",
5919                "idempotency_key": "deposit-1",
5920            })))
5921            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("vault-submit")))
5922            .expect(1)
5923            .mount(&server)
5924            .await;
5925        Mock::given(method("GET"))
5926            .and(path(
5927                "/v2/vault/submissions/vp_4d5e6f708192a3b4c5d6e7f8091a2b3c",
5928            ))
5929            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("vault-submit")))
5930            .expect(1)
5931            .mount(&server)
5932            .await;
5933        Mock::given(method("POST"))
5934            .and(path("/v2/vault/delegates/prepare"))
5935            .and(body_json(serde_json::json!({
5936                "wallet_address": wallet,
5937                "session_public_key": "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2",
5938                "action": "revoke",
5939            })))
5940            .respond_with(
5941                ResponseTemplate::new(200).set_body_json(fixture("vault-delegate-prepare")),
5942            )
5943            .expect(1)
5944            .mount(&server)
5945            .await;
5946        Mock::given(method("POST"))
5947            .and(path("/v2/vault/policies/prepare"))
5948            .and(body_json(serde_json::json!({
5949                "wallet_address": wallet,
5950                "withdrawal_access": {
5951                    "mode": "restricted",
5952                    "allowed_wallet_addresses": [wallet],
5953                },
5954            })))
5955            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("vault-policy-prepare")))
5956            .expect(1)
5957            .mount(&server)
5958            .await;
5959        Mock::given(method("GET"))
5960            .and(path("/v2/rewards"))
5961            .and(query_param("wallet_address", wallet))
5962            .and(query_param("limit", "20"))
5963            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("rewards")))
5964            .expect(1)
5965            .mount(&server)
5966            .await;
5967        mount_get(&server, &format!("/v2/referrals/{wallet}"), "referrals").await;
5968        Mock::given(method("POST"))
5969            .and(path("/v2/referrals/link"))
5970            .and(body_json(serde_json::json!({
5971                "wallet_address": wallet,
5972                "referral_code": "STRATA1",
5973                "authorization_signature": "22".repeat(64),
5974            })))
5975            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("referral-link")))
5976            .expect(1)
5977            .mount(&server)
5978            .await;
5979        Mock::given(method("POST"))
5980            .and(path("/v2/referrals/claim"))
5981            .and(body_json(serde_json::json!({
5982                "wallet_address": wallet,
5983                "payout_wallet_address": wallet,
5984                "authorization_signature": "33".repeat(64),
5985            })))
5986            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("referral-claim")))
5987            .expect(1)
5988            .mount(&server)
5989            .await;
5990        mount_get(&server, &format!("/v2/bugs/{wallet}"), "bugs").await;
5991        Mock::given(method("POST"))
5992            .and(path("/v2/bugs"))
5993            .and(body_json(serde_json::json!({
5994                "owner_wallet": wallet,
5995                "message": "public report",
5996                "authorization_signature": "07".repeat(64),
5997            })))
5998            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("bug-submit")))
5999            .expect(1)
6000            .mount(&server)
6001            .await;
6002
6003        let signer = TestAccountSigner {
6004            wallet: wallet.to_owned(),
6005            expected_message: format!(
6006                "strata:account-read:v2\n{market_id}\n{wallet}\n1786550400000\n25"
6007            )
6008            .into_bytes(),
6009            signature_byte: 7,
6010        };
6011        let client = StrataClient::new(server.uri()).unwrap();
6012        let account = client
6013            .platform_account_market(
6014                market_id,
6015                &signer,
6016                PlatformAccountMarketRequest {
6017                    fill_limit: Some(25),
6018                },
6019            )
6020            .await
6021            .unwrap();
6022        assert_eq!(account.wallet_address, wallet);
6023        assert!(!account.orders.is_empty());
6024        let maker_status = client
6025            .platform_maker_status_authorized(PlatformMakerStatusAuthorizedRequest {
6026                market_id: "market_33333333333333333333333333333333".to_owned(),
6027                wallet_address: wallet.to_owned(),
6028                authorization_time_ms: 1_786_550_400_000,
6029                authorization_signature: "0a".repeat(64),
6030            })
6031            .await
6032            .unwrap();
6033        assert_eq!(maker_status.active_products, 3);
6034        assert_eq!(maker_status.strands.len(), 1);
6035        assert!(maker_status
6036            .intent
6037            .as_ref()
6038            .is_some_and(|intent| intent.active));
6039        assert_eq!(
6040            maker_status_auth_message(
6041                "market_33333333333333333333333333333333",
6042                wallet,
6043                1_786_550_400_000
6044            )
6045            .unwrap(),
6046            format!(
6047                "strata:mm-status-read:v2\nmarket_33333333333333333333333333333333\n{wallet}\n1786550400000"
6048            )
6049            .into_bytes()
6050        );
6051        let portfolio = client.platform_portfolio(wallet).await.unwrap();
6052        assert_eq!(portfolio.wallet_address, wallet);
6053        assert_eq!(portfolio.balances.len(), 2);
6054        assert_eq!(portfolio.positions.len(), 1);
6055        assert_eq!(portfolio.equity_usd_micros.as_deref(), Some("439989500"));
6056        assert!(portfolio.valuation_complete);
6057        assert_eq!(
6058            client
6059                .platform_portfolio_history(wallet, PlatformPortfolioHistoryRange::Day)
6060                .await
6061                .unwrap()
6062                .range,
6063            PlatformPortfolioHistoryRange::Day
6064        );
6065        assert!(client
6066            .platform_vault_status(
6067                wallet,
6068                PlatformVaultStatusRequest {
6069                    session_public_key: Some(
6070                        "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2".to_owned(),
6071                    ),
6072                },
6073            )
6074            .await
6075            .unwrap()
6076            .session
6077            .is_some_and(|session| session.market_execution_ready));
6078        assert!(
6079            client
6080                .platform_vault_pause_prepare(PlatformVaultPausePrepareRequest {
6081                    wallet_address: wallet.to_owned(),
6082                    paused: true,
6083                })
6084                .await
6085                .unwrap()
6086                .owner_signature_required
6087        );
6088        let setup = client
6089            .platform_vault_setup_prepare(PlatformVaultSetupPrepareRequest {
6090                wallet_address: wallet.to_owned(),
6091                session_public_key: "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2".to_owned(),
6092                market_id: Some("market_33333333333333333333333333333333".to_owned()),
6093                expires_at_ms: None,
6094                minimum_interval_seconds: None,
6095                maximum_tolerance_bps: None,
6096                spending_limits: vec![
6097                    PlatformVaultSpendingLimit {
6098                        asset_id: "asset_0123456789abcdef0123456789abcdef".to_owned(),
6099                        maximum_per_execution_atoms: None,
6100                    },
6101                    PlatformVaultSpendingLimit {
6102                        asset_id: "asset_fedcba9876543210fedcba9876543210".to_owned(),
6103                        maximum_per_execution_atoms: Some("100000000".to_owned()),
6104                    },
6105                ],
6106            })
6107            .await
6108            .unwrap();
6109        assert_eq!(setup.mode, PlatformVaultSetupMode::Create);
6110        assert!(setup.owner_signature_required);
6111        assert_eq!(
6112            setup.minimum_interval_seconds,
6113            PLATFORM_SESSION_DEFAULT_MINIMUM_INTERVAL_SECONDS
6114        );
6115        assert_eq!(
6116            setup.maximum_tolerance_bps,
6117            PLATFORM_SESSION_DEFAULT_MAXIMUM_TOLERANCE_BPS
6118        );
6119        // A first deposit that names the session key onboards in the same
6120        // owner signature.
6121        let deposit = client
6122            .platform_vault_deposit_prepare(PlatformVaultDepositPrepareRequest {
6123                wallet_address: wallet.to_owned(),
6124                market_id: "market_33333333333333333333333333333333".to_owned(),
6125                asset_id: "asset_0123456789abcdef0123456789abcdef".to_owned(),
6126                amount_atoms: "10000000".to_owned(),
6127                session_public_key: Some("9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2".to_owned()),
6128            })
6129            .await
6130            .unwrap();
6131        assert_eq!(deposit.amount_atoms, "10000000");
6132        assert!(deposit.owner_signature_required);
6133        assert!(deposit.sponsored);
6134        assert!(deposit.registers_session);
6135        assert_eq!(
6136            deposit.preparation_id,
6137            "vp_4d5e6f708192a3b4c5d6e7f8091a2b3c"
6138        );
6139        // Owner signs, hands it back: Strata pays and broadcasts, then reports.
6140        let receipt = client
6141            .platform_vault_submit(PlatformVaultSubmitRequest {
6142                preparation_id: deposit.preparation_id.clone(),
6143                signed_transaction_base64: "AQIDBA==".to_owned(),
6144                idempotency_key: "deposit-1".to_owned(),
6145            })
6146            .await
6147            .unwrap();
6148        assert_eq!(receipt.action, PlatformVaultAction::Deposit);
6149        assert_eq!(receipt.status, PlatformVaultSubmissionStatus::Submitted);
6150        assert!(receipt.sponsored);
6151        let outcome = client
6152            .platform_vault_submission(&deposit.preparation_id)
6153            .await
6154            .unwrap();
6155        assert_eq!(outcome.preparation_id, deposit.preparation_id);
6156        assert!(client
6157            .platform_vault_submission("or_4d5e6f708192a3b4c5d6e7f8091a2b3c")
6158            .await
6159            .is_err());
6160        let withdrawal = client
6161            .platform_vault_withdraw_prepare(PlatformVaultWithdrawPrepareRequest {
6162                wallet_address: wallet.to_owned(),
6163                market_id: "market_33333333333333333333333333333333".to_owned(),
6164                asset_id: "asset_fedcba9876543210fedcba9876543210".to_owned(),
6165                destination_wallet_address: wallet.to_owned(),
6166                amount_atoms: "5000000".to_owned(),
6167            })
6168            .await
6169            .unwrap();
6170        assert_eq!(withdrawal.amount_atoms, "5000000");
6171        assert!(withdrawal.owner_signature_required);
6172        let delegate = client
6173            .platform_vault_delegate_prepare(PlatformVaultDelegatePrepareRequest {
6174                wallet_address: wallet.to_owned(),
6175                session_public_key: "9Uu7cLBgfMk233BAjMvTS8XJy6KbZK7oQ7NXuCTi3Fg2".to_owned(),
6176                action: PlatformVaultDelegateAction::Revoke,
6177            })
6178            .await
6179            .unwrap();
6180        assert_eq!(delegate.action, PlatformVaultDelegateAction::Revoke);
6181        assert!(delegate.owner_signature_required);
6182        let policy = client
6183            .platform_vault_policy_prepare(PlatformVaultPolicyPrepareRequest {
6184                wallet_address: wallet.to_owned(),
6185                withdrawal_access: PlatformVaultWithdrawalAccess {
6186                    mode: PlatformVaultWithdrawalMode::Restricted,
6187                    allowed_wallet_addresses: vec![wallet.to_owned()],
6188                },
6189            })
6190            .await
6191            .unwrap();
6192        assert_eq!(
6193            policy.withdrawal_access.mode,
6194            PlatformVaultWithdrawalMode::Restricted
6195        );
6196        assert!(policy.owner_signature_required);
6197        assert!(client
6198            .platform_rewards(PlatformRewardsRequest {
6199                wallet_address: Some(wallet.to_owned()),
6200                limit: Some(20),
6201            })
6202            .await
6203            .unwrap()
6204            .owner
6205            .is_some());
6206        assert_eq!(
6207            client
6208                .platform_referrals(wallet)
6209                .await
6210                .unwrap()
6211                .wallet_address,
6212            wallet
6213        );
6214        assert_eq!(
6215            client
6216                .platform_referral_link(PlatformReferralLinkRequest {
6217                    wallet_address: wallet.to_owned(),
6218                    referral_code: "STRATA1".to_owned(),
6219                    authorization_signature: "22".repeat(64),
6220                })
6221                .await
6222                .unwrap()
6223                .status,
6224            "pending_first_fill"
6225        );
6226        assert_eq!(
6227            client
6228                .platform_referral_claim(PlatformReferralClaimRequest {
6229                    wallet_address: wallet.to_owned(),
6230                    payout_wallet_address: None,
6231                    authorization_signature: "33".repeat(64),
6232                })
6233                .await
6234                .unwrap()
6235                .status,
6236            "requested"
6237        );
6238        assert_eq!(
6239            client.platform_bugs(wallet).await.unwrap().wallet_address,
6240            wallet
6241        );
6242        assert_eq!(
6243            client
6244                .platform_bug_submit(PlatformBugSubmitRequest {
6245                    owner_wallet: wallet.to_owned(),
6246                    message: " public report ".to_owned(),
6247                    authorization_signature: format!("0x{}", "07".repeat(64)),
6248                })
6249                .await
6250                .unwrap()
6251                .status,
6252            PlatformBugStatus::Pending
6253        );
6254        assert_eq!(
6255            bug_authorization_payload(" public report ").unwrap(),
6256            b"strata-bug-report:v1:public report"
6257        );
6258        assert_eq!(
6259            referral_link_authorization_payload(" STRATA1 ").unwrap(),
6260            b"strata-referral:v1:STRATA1"
6261        );
6262        assert_eq!(
6263            referral_claim_authorization_payload(wallet).unwrap(),
6264            format!("strata-referral-claim:v1:{wallet}").as_bytes()
6265        );
6266    }
6267
6268    #[tokio::test]
6269    async fn market_data_stream_fails_closed_on_a_book_sequence_gap() {
6270        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6271        let address = listener.local_addr().unwrap();
6272        let market_id = "market_33333333333333333333333333333333";
6273        let mut snapshot = fixture("book");
6274        snapshot
6275            .as_object_mut()
6276            .unwrap()
6277            .insert("type".to_owned(), serde_json::json!("book_snapshot"));
6278        let gap = serde_json::json!({
6279            "type": "book_delta",
6280            "schema_version": 2,
6281            "contract_version": "2.0",
6282            "market_id": market_id,
6283            "stream_id": "book:market_33333333333333333333333333333333",
6284            "sequence": "44",
6285            "previous_sequence": "42",
6286            "server_time_ms": 1786550400100u64,
6287            "changes": [{
6288                "side": "bid",
6289                "price_atoms": "149990000",
6290                "size_atoms": "0"
6291            }]
6292        });
6293        let server = tokio::spawn(async move {
6294            let (connection, _) = listener.accept().await.unwrap();
6295            let mut socket = tokio_tungstenite::accept_async(connection).await.unwrap();
6296            socket
6297                .send(Message::Text(snapshot.to_string().into()))
6298                .await
6299                .unwrap();
6300            socket
6301                .send(Message::Text(gap.to_string().into()))
6302                .await
6303                .unwrap();
6304            let _ = socket.next().await;
6305        });
6306
6307        let client = StrataClient::new(format!("http://{address}")).unwrap();
6308        let mut stream = client.connect_market_data(market_id).await.unwrap();
6309        assert!(matches!(
6310            stream.next_event().await.unwrap(),
6311            Some(PlatformMarketDataEvent::BookSnapshot { .. })
6312        ));
6313        assert!(matches!(
6314            stream.next_event().await,
6315            Err(SdkError::InvalidResponse(message))
6316                if message == "market stream sequence gap detected"
6317        ));
6318        server.await.unwrap();
6319    }
6320
6321    #[tokio::test]
6322    async fn account_stream_signs_the_exact_challenge_and_sequences_state() {
6323        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6324        let address = listener.local_addr().unwrap();
6325        let market_id = "market_33333333333333333333333333333333";
6326        let wallet = "5Ji61Fbeb22Yntgv1hhHeSSLgdEdZchHeM1Tv1MjGhSL";
6327        let challenge = "ab".repeat(32);
6328        let challenge_for_server = challenge.clone();
6329        let mut snapshot = fixture("account");
6330        snapshot.as_object_mut().unwrap().extend([
6331            ("type".to_owned(), serde_json::json!("account_snapshot")),
6332            (
6333                "stream_id".to_owned(),
6334                serde_json::json!("account_stream_66666666666666666666666666666666"),
6335            ),
6336            ("sequence".to_owned(), serde_json::json!("1")),
6337        ]);
6338        let orders = serde_json::json!({
6339            "type": "orders_snapshot",
6340            "schema_version": 2,
6341            "contract_version": "2.0",
6342            "market_id": market_id,
6343            "wallet_address": wallet,
6344            "stream_id": "account_stream_66666666666666666666666666666666",
6345            "sequence": "2",
6346            "previous_sequence": "1",
6347            "server_time_ms": 1786550400100u64,
6348            "orders": []
6349        });
6350        let server = tokio::spawn(async move {
6351            let (connection, _) = listener.accept().await.unwrap();
6352            let mut socket = tokio_tungstenite::accept_async(connection).await.unwrap();
6353            socket
6354                .send(Message::Text(
6355                    serde_json::json!({
6356                        "type": "auth_challenge",
6357                        "schema_version": 2,
6358                        "contract_version": "2.0",
6359                        "market_id": market_id,
6360                        "wallet_address": wallet,
6361                        "challenge": challenge_for_server,
6362                        "server_time_ms": 1786550400000u64,
6363                        "expires_at_ms": 1786550405000u64
6364                    })
6365                    .to_string()
6366                    .into(),
6367                ))
6368                .await
6369                .unwrap();
6370            let Message::Text(authentication) = socket.next().await.unwrap().unwrap() else {
6371                panic!("expected text authentication");
6372            };
6373            assert_eq!(
6374                serde_json::from_str::<serde_json::Value>(&authentication).unwrap(),
6375                serde_json::json!({
6376                    "type": "authenticate",
6377                    "signature": "09".repeat(64),
6378                })
6379            );
6380            socket
6381                .send(Message::Text(snapshot.to_string().into()))
6382                .await
6383                .unwrap();
6384            socket
6385                .send(Message::Text(orders.to_string().into()))
6386                .await
6387                .unwrap();
6388            let _ = socket.next().await;
6389        });
6390
6391        let signer = TestAccountSigner {
6392            wallet: wallet.to_owned(),
6393            expected_message: format!(
6394                "strata:account-stream:v2\n{market_id}\n{wallet}\n{challenge}"
6395            )
6396            .into_bytes(),
6397            signature_byte: 9,
6398        };
6399        let client = StrataClient::new(format!("http://{address}")).unwrap();
6400        let mut stream = client.connect_account(market_id, &signer).await.unwrap();
6401        assert!(matches!(
6402            stream.next_event().await.unwrap(),
6403            Some(PlatformAccountEvent::AccountSnapshot { .. })
6404        ));
6405        assert!(matches!(
6406            stream.next_event().await.unwrap(),
6407            Some(PlatformAccountEvent::OrdersSnapshot { .. })
6408        ));
6409        stream.close().await.unwrap();
6410        server.await.unwrap();
6411    }
6412
6413    #[tokio::test]
6414    async fn execution_stream_watches_handles_and_sequences_updates() {
6415        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6416        let address = listener.local_addr().unwrap();
6417        let snapshot = fixture("execution-stream");
6418        let market_id = snapshot["market_id"].as_str().unwrap().to_owned();
6419        let watched: Vec<String> = vec![
6420            snapshot["executions"][0]["execution_id"]
6421                .as_str()
6422                .unwrap()
6423                .to_owned(),
6424            snapshot["executions"][1]["execution_id"]
6425                .as_str()
6426                .unwrap()
6427                .to_owned(),
6428            snapshot["unknown_execution_ids"][0]
6429                .as_str()
6430                .unwrap()
6431                .to_owned(),
6432        ];
6433        let expected_watch = serde_json::json!({"type": "watch", "execution_ids": watched});
6434        let mut confirmed = snapshot["executions"][1].clone();
6435        confirmed["status"] = serde_json::json!("confirmed");
6436        confirmed["signature"] = serde_json::json!("2".repeat(64));
6437        confirmed["settlement"] = serde_json::json!("confirmed");
6438        let update = serde_json::json!({
6439            "type": "execution_update",
6440            "schema_version": 2,
6441            "contract_version": "2.0",
6442            "market_id": market_id,
6443            "stream_id": snapshot["stream_id"],
6444            "sequence": "2",
6445            "previous_sequence": "1",
6446            "server_time_ms": 1786550400100u64,
6447            "execution": confirmed,
6448        });
6449        let unknown = serde_json::json!({
6450            "type": "execution_unknown",
6451            "schema_version": 2,
6452            "contract_version": "2.0",
6453            "market_id": market_id,
6454            "stream_id": snapshot["stream_id"],
6455            "sequence": "3",
6456            "previous_sequence": "2",
6457            "server_time_ms": 1786550400200u64,
6458            "execution_id": "se_abcdefabcdefabcdefabcdefabcdefab",
6459        });
6460        let gap = serde_json::json!({
6461            "type": "heartbeat",
6462            "schema_version": 2,
6463            "contract_version": "2.0",
6464            "market_id": market_id,
6465            "stream_id": snapshot["stream_id"],
6466            "sequence": "5",
6467            "previous_sequence": "4",
6468            "server_time_ms": 1786550400300u64,
6469        });
6470        let server = tokio::spawn(async move {
6471            let (connection, _) = listener.accept().await.unwrap();
6472            let mut socket = tokio_tungstenite::accept_async(connection).await.unwrap();
6473            let Message::Text(watch) = socket.next().await.unwrap().unwrap() else {
6474                panic!("expected a watch frame");
6475            };
6476            assert_eq!(
6477                serde_json::from_str::<serde_json::Value>(&watch).unwrap(),
6478                expected_watch
6479            );
6480            socket
6481                .send(Message::Text(snapshot.to_string().into()))
6482                .await
6483                .unwrap();
6484            socket
6485                .send(Message::Text(update.to_string().into()))
6486                .await
6487                .unwrap();
6488            let Message::Text(more) = socket.next().await.unwrap().unwrap() else {
6489                panic!("expected a second watch frame");
6490            };
6491            assert_eq!(
6492                serde_json::from_str::<serde_json::Value>(&more).unwrap(),
6493                serde_json::json!({"type": "watch", "execution_ids": ["se_abcdefabcdefabcdefabcdefabcdefab"]})
6494            );
6495            for frame in [unknown, gap] {
6496                socket
6497                    .send(Message::Text(frame.to_string().into()))
6498                    .await
6499                    .unwrap();
6500            }
6501            let _ = socket.next().await;
6502        });
6503        let client = StrataClient::new(format!("http://{address}")).unwrap();
6504        let ids: Vec<String> = vec![
6505            "se_0123456789abcdef0123456789abcdef".to_owned(),
6506            "se_fedcba9876543210fedcba9876543210".to_owned(),
6507            "se_00000000000000000000000000000000".to_owned(),
6508        ];
6509        let mut stream = client
6510            .connect_executions("market_33333333333333333333333333333333", &ids)
6511            .await
6512            .unwrap();
6513        match stream.next_event().await.unwrap() {
6514            Some(PlatformExecutionEvent::ExecutionsSnapshot {
6515                executions,
6516                unknown_execution_ids,
6517                ..
6518            }) => {
6519                assert_eq!(executions.len(), 2);
6520                assert_eq!(unknown_execution_ids.len(), 1);
6521            }
6522            other => panic!("expected execution snapshot, got {other:?}"),
6523        }
6524        match stream.next_event().await.unwrap() {
6525            Some(PlatformExecutionEvent::ExecutionUpdate { execution, .. }) => {
6526                assert_eq!(execution.status, PlatformExecutionState::Confirmed);
6527            }
6528            other => panic!("expected execution update, got {other:?}"),
6529        }
6530        stream
6531            .watch(&["se_abcdefabcdefabcdefabcdefabcdefab".to_owned()])
6532            .await
6533            .unwrap();
6534        match stream.next_event().await.unwrap() {
6535            Some(PlatformExecutionEvent::ExecutionUnknown { execution_id, .. }) => {
6536                assert_eq!(execution_id, "se_abcdefabcdefabcdefabcdefabcdefab");
6537            }
6538            other => panic!("expected execution unknown, got {other:?}"),
6539        }
6540        assert!(
6541            stream.next_event().await.is_err(),
6542            "a sequence gap must fail closed"
6543        );
6544        server.await.unwrap();
6545    }
6546
6547    #[tokio::test]
6548    #[allow(clippy::result_large_err)]
6549    async fn twap_stream_sequences_progress_and_fails_closed_on_gaps() {
6550        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6551        let address = listener.local_addr().unwrap();
6552        let snapshot = fixture("twap-stream");
6553        let market_id = snapshot["market_id"].as_str().unwrap().to_owned();
6554        let wallet = snapshot["wallet_address"].as_str().unwrap().to_owned();
6555        let mut update = serde_json::json!({
6556            "type": "twap_update",
6557            "schema_version": 2,
6558            "contract_version": "2.0",
6559            "market_id": market_id,
6560            "wallet_address": wallet,
6561            "stream_id": snapshot["stream_id"],
6562            "sequence": "2",
6563            "previous_sequence": "1",
6564            "server_time_ms": 1786550400100u64,
6565        });
6566        let mut twap = snapshot["twaps"][0].clone();
6567        let executed = twap["slices_executed"].as_u64().unwrap() + 1;
6568        twap["slices_executed"] = serde_json::json!(executed);
6569        update["twap"] = twap;
6570        let gap = serde_json::json!({
6571            "type": "heartbeat",
6572            "schema_version": 2,
6573            "contract_version": "2.0",
6574            "market_id": market_id,
6575            "wallet_address": wallet,
6576            "stream_id": snapshot["stream_id"],
6577            "sequence": "4",
6578            "previous_sequence": "3",
6579            "server_time_ms": 1786550400200u64,
6580        });
6581        let expected_path = format!("/v2/markets/{market_id}/account/{wallet}/twaps/stream");
6582        let server = tokio::spawn(async move {
6583            let (connection, _) = listener.accept().await.unwrap();
6584            let mut requested_path = String::new();
6585            let mut socket = tokio_tungstenite::accept_hdr_async(
6586                connection,
6587                |request: &tokio_tungstenite::tungstenite::handshake::server::Request,
6588                 response: tokio_tungstenite::tungstenite::handshake::server::Response| {
6589                    requested_path = request.uri().path().to_owned();
6590                    Ok(response)
6591                },
6592            )
6593            .await
6594            .unwrap();
6595            assert_eq!(requested_path, expected_path);
6596            for frame in [snapshot, update, gap] {
6597                socket
6598                    .send(Message::Text(frame.to_string().into()))
6599                    .await
6600                    .unwrap();
6601            }
6602            let _ = socket.next().await;
6603        });
6604        let client = StrataClient::new(format!("http://{address}")).unwrap();
6605        let mut stream = client.connect_twaps(&market_id, &wallet).await.unwrap();
6606        match stream.next_event().await.unwrap() {
6607            Some(PlatformTwapEvent::TwapsSnapshot { twaps, .. }) => assert_eq!(twaps.len(), 1),
6608            other => panic!("expected TWAP snapshot, got {other:?}"),
6609        }
6610        match stream.next_event().await.unwrap() {
6611            Some(PlatformTwapEvent::TwapUpdate { twap, .. }) => {
6612                assert_eq!(u64::from(twap.slices_executed), executed);
6613            }
6614            other => panic!("expected TWAP update, got {other:?}"),
6615        }
6616        assert!(
6617            stream.next_event().await.is_err(),
6618            "a sequence gap must fail closed"
6619        );
6620        server.await.unwrap();
6621    }
6622
6623    #[tokio::test]
6624    async fn maker_stream_signs_the_exact_challenge_and_sequences_maker_state() {
6625        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6626        let address = listener.local_addr().unwrap();
6627        let market_id = "market_33333333333333333333333333333333";
6628        let wallet = "5Ji61Fbeb22Yntgv1hhHeSSLgdEdZchHeM1Tv1MjGhSL";
6629        let challenge = "cd".repeat(32);
6630        let challenge_for_server = challenge.clone();
6631        let snapshot = fixture("maker-stream");
6632        let mut fill_event = serde_json::json!({
6633            "type": "maker_fill",
6634            "schema_version": 2,
6635            "contract_version": "2.0",
6636            "market_id": market_id,
6637            "wallet_address": wallet,
6638            "stream_id": snapshot["stream_id"],
6639            "sequence": "2",
6640            "previous_sequence": "1",
6641            "server_time_ms": 1786896000100u64,
6642        });
6643        let mut fill = snapshot["fills"][0].clone();
6644        fill["fill_id"] = serde_json::json!("fill_99999999999999999999999999999999");
6645        fill["product"] = serde_json::json!("intent");
6646        fill_event["fill"] = fill;
6647        let mut status = snapshot["status"].clone();
6648        status["intent"] = serde_json::Value::Null;
6649        status["active_products"] = serde_json::json!(2);
6650        let status_event = serde_json::json!({
6651            "type": "maker_status",
6652            "schema_version": 2,
6653            "contract_version": "2.0",
6654            "market_id": market_id,
6655            "wallet_address": wallet,
6656            "stream_id": snapshot["stream_id"],
6657            "sequence": "3",
6658            "previous_sequence": "2",
6659            "server_time_ms": 1786896000200u64,
6660            "status": status,
6661        });
6662        let gap = serde_json::json!({
6663            "type": "heartbeat",
6664            "schema_version": 2,
6665            "contract_version": "2.0",
6666            "market_id": market_id,
6667            "wallet_address": wallet,
6668            "stream_id": snapshot["stream_id"],
6669            "sequence": "5",
6670            "previous_sequence": "4",
6671            "server_time_ms": 1786896000300u64,
6672        });
6673        let server = tokio::spawn(async move {
6674            let (connection, _) = listener.accept().await.unwrap();
6675            let mut socket = tokio_tungstenite::accept_async(connection).await.unwrap();
6676            socket
6677                .send(Message::Text(
6678                    serde_json::json!({
6679                        "type": "auth_challenge",
6680                        "schema_version": 2,
6681                        "contract_version": "2.0",
6682                        "market_id": market_id,
6683                        "wallet_address": wallet,
6684                        "challenge": challenge_for_server,
6685                        "server_time_ms": 1786896000000u64,
6686                        "expires_at_ms": 1786896005000u64
6687                    })
6688                    .to_string()
6689                    .into(),
6690                ))
6691                .await
6692                .unwrap();
6693            let Message::Text(authentication) = socket.next().await.unwrap().unwrap() else {
6694                panic!("expected text authentication");
6695            };
6696            assert_eq!(
6697                serde_json::from_str::<serde_json::Value>(&authentication).unwrap(),
6698                serde_json::json!({
6699                    "type": "authenticate",
6700                    "signature": "07".repeat(64),
6701                })
6702            );
6703            for frame in [snapshot, fill_event, status_event, gap] {
6704                socket
6705                    .send(Message::Text(frame.to_string().into()))
6706                    .await
6707                    .unwrap();
6708            }
6709            let _ = socket.next().await;
6710        });
6711
6712        let signer = TestAccountSigner {
6713            wallet: wallet.to_owned(),
6714            expected_message: format!(
6715                "strata:mm-fills-stream:v2\n{market_id}\n{wallet}\n{challenge}"
6716            )
6717            .into_bytes(),
6718            signature_byte: 7,
6719        };
6720        let client = StrataClient::new(format!("http://{address}")).unwrap();
6721        let mut stream = client.connect_maker(market_id, &signer).await.unwrap();
6722        match stream.next_event().await.unwrap() {
6723            Some(PlatformMakerEvent::MakerSnapshot { status, fills, .. }) => {
6724                assert_eq!(status.active_products, 3);
6725                assert_eq!(fills.len(), 1);
6726            }
6727            other => panic!("expected maker snapshot, got {other:?}"),
6728        }
6729        match stream.next_event().await.unwrap() {
6730            Some(PlatformMakerEvent::MakerFill { fill, .. }) => {
6731                assert_eq!(fill.product, PlatformMakerProduct::Intent);
6732            }
6733            other => panic!("expected maker fill, got {other:?}"),
6734        }
6735        match stream.next_event().await.unwrap() {
6736            Some(PlatformMakerEvent::MakerStatus { status, .. }) => {
6737                assert!(status.intent.is_none());
6738                assert_eq!(status.active_products, 2);
6739            }
6740            other => panic!("expected maker status, got {other:?}"),
6741        }
6742        assert!(
6743            stream.next_event().await.is_err(),
6744            "a sequence gap must fail closed"
6745        );
6746        server.await.unwrap();
6747    }
6748
6749    #[tokio::test]
6750    async fn resting_order_calls_use_only_product_paths_and_external_signatures() {
6751        let server = MockServer::start().await;
6752        let market_id = "market_22222222222222222222222222222222";
6753        let owner_wallet = bs58::encode([1u8; 32]).into_string();
6754        let session_public_key = bs58::encode([2u8; 32]).into_string();
6755        let authorization_signature = bs58::encode([3u8; 64]).into_string();
6756        Mock::given(method("POST"))
6757            .and(path(format!("/v2/markets/{market_id}/orders/challenge")))
6758            .and(body_json(serde_json::json!({
6759                "action": "place",
6760                "owner_wallet": owner_wallet,
6761                "session_public_key": session_public_key,
6762                "account_sequence": "7",
6763                "client_order_id": "agent-order-7",
6764                "side": "buy",
6765                "order_type": "post_only",
6766                "limit_price_atoms": "150000000",
6767                "size_atoms": "1000000"
6768            })))
6769            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-challenge")))
6770            .expect(1)
6771            .mount(&server)
6772            .await;
6773        Mock::given(method("POST"))
6774            .and(path(format!("/v2/markets/{market_id}/orders/prepare")))
6775            .and(body_json(serde_json::json!({
6776                "challenge_id": "oc_11111111111111111111111111111111",
6777                "authorization_signature": authorization_signature
6778            })))
6779            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-prepare")))
6780            .expect(1)
6781            .mount(&server)
6782            .await;
6783        Mock::given(method("POST"))
6784            .and(path(format!("/v2/markets/{market_id}/orders/submit")))
6785            .and(body_json(serde_json::json!({
6786                "order_control_id": "or_44444444444444444444444444444444",
6787                "signed_transaction_base64": "AQIDBA==",
6788                "idempotency_key": "order-attempt-7"
6789            })))
6790            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-submit")))
6791            .expect(1)
6792            .mount(&server)
6793            .await;
6794        Mock::given(method("POST"))
6795            .and(path(format!("/v2/markets/{market_id}/orders/status")))
6796            .and(body_json(serde_json::json!({
6797                "order_control_id": "or_44444444444444444444444444444444",
6798                "idempotency_key": "order-attempt-7"
6799            })))
6800            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-status")))
6801            .expect(1)
6802            .mount(&server)
6803            .await;
6804
6805        let client = StrataClient::new(server.uri()).unwrap();
6806        let challenge = client
6807            .order_challenge(
6808                market_id,
6809                PlatformOrderChallengeRequest::Place {
6810                    owner_wallet,
6811                    session_public_key,
6812                    account_sequence: Some("7".to_owned()),
6813                    client_order_id: "agent-order-7".to_owned(),
6814                    side: PlatformTradeSide::Buy,
6815                    order_type: PlatformOrderType::PostOnly,
6816                    limit_price_atoms: "150000000".to_owned(),
6817                    size_atoms: "1000000".to_owned(),
6818                },
6819            )
6820            .await
6821            .unwrap();
6822        let prepared = client
6823            .order_prepare(
6824                market_id,
6825                PlatformOrderPrepareRequest::Authorized(PlatformOrderPrepareAuthorization {
6826                    challenge_id: challenge.challenge_id,
6827                    authorization_signature: Some(authorization_signature),
6828                }),
6829            )
6830            .await
6831            .unwrap();
6832        let receipt = client
6833            .order_submit(
6834                market_id,
6835                PlatformOrderSubmitRequest {
6836                    order_control_id: prepared.order_control_id,
6837                    signed_transaction_base64: "AQIDBA==".to_owned(),
6838                    idempotency_key: "order-attempt-7".to_owned(),
6839                },
6840            )
6841            .await
6842            .unwrap();
6843        assert_eq!(receipt.status, PlatformOrderSubmissionStatus::Submitted);
6844        let status = client
6845            .order_status(
6846                market_id,
6847                PlatformOrderStatusRequest {
6848                    order_control_id: receipt.order_control_id,
6849                    idempotency_key: "order-attempt-7".to_owned(),
6850                },
6851            )
6852            .await
6853            .unwrap();
6854        assert_eq!(status.status, PlatformOrderControlStatus::Submitting);
6855    }
6856
6857    #[tokio::test]
6858    async fn twap_calls_use_only_product_paths_and_external_signatures() {
6859        let server = MockServer::start().await;
6860        let market_id = "market_22222222222222222222222222222222";
6861        let owner_wallet = bs58::encode([1u8; 32]).into_string();
6862        let session_public_key = bs58::encode([2u8; 32]).into_string();
6863        let authorization_signature = bs58::encode([3u8; 64]).into_string();
6864        Mock::given(method("POST"))
6865            .and(path(format!("/v2/markets/{market_id}/twaps/challenge")))
6866            .and(body_json(serde_json::json!({
6867                "action": "place",
6868                "owner_wallet": owner_wallet,
6869                "session_public_key": session_public_key,
6870                "side": "buy",
6871                "total_size_atoms": "10000000",
6872                "slices_total": 10,
6873                "maximum_tolerance_bps": 100,
6874                "interval_slots": 100,
6875                "limit_price_atoms": "150000000"
6876            })))
6877            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("twap-challenge")))
6878            .expect(1)
6879            .mount(&server)
6880            .await;
6881        Mock::given(method("POST"))
6882            .and(path(format!("/v2/markets/{market_id}/twaps/prepare")))
6883            .and(body_json(serde_json::json!({
6884                "challenge_id": "twc_0123456789abcdef0123456789abcdef",
6885                "authorization_signature": authorization_signature
6886            })))
6887            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("twap-prepare")))
6888            .expect(1)
6889            .mount(&server)
6890            .await;
6891        Mock::given(method("POST"))
6892            .and(path(format!("/v2/markets/{market_id}/twaps/submit")))
6893            .and(body_json(serde_json::json!({
6894                "twap_control_id": "twctl_44444444444444444444444444444444",
6895                "signed_transaction_base64": "AQIDBA==",
6896                "idempotency_key": "twap-attempt-7"
6897            })))
6898            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("twap-submit")))
6899            .expect(1)
6900            .mount(&server)
6901            .await;
6902
6903        let client = StrataClient::new(server.uri()).unwrap();
6904        let challenge = client
6905            .twap_challenge(
6906                market_id,
6907                PlatformTwapChallengeRequest::Place {
6908                    owner_wallet,
6909                    session_public_key,
6910                    side: PlatformTradeSide::Buy,
6911                    total_size_atoms: "10000000".to_owned(),
6912                    slices_total: 10,
6913                    maximum_tolerance_bps: 100,
6914                    interval_slots: 100,
6915                    limit_price_atoms: "150000000".to_owned(),
6916                },
6917            )
6918            .await
6919            .unwrap();
6920        let prepared = client
6921            .twap_prepare(
6922                market_id,
6923                PlatformTwapPrepareRequest::Authorized(PlatformTwapPrepareAuthorization {
6924                    challenge_id: challenge.challenge_id,
6925                    authorization_signature,
6926                }),
6927            )
6928            .await
6929            .unwrap();
6930        let receipt = client
6931            .twap_submit(
6932                market_id,
6933                PlatformTwapSubmitRequest {
6934                    twap_control_id: prepared.twap_control_id,
6935                    signed_transaction_base64: "AQIDBA==".to_owned(),
6936                    idempotency_key: "twap-attempt-7".to_owned(),
6937                },
6938            )
6939            .await
6940            .unwrap();
6941        assert_eq!(receipt.status, PlatformOrderSubmissionStatus::Submitted);
6942        assert_eq!(receipt.action, PlatformTwapControlAction::Place);
6943    }
6944
6945    /// A session signer for the one-signature helpers: it signs transactions
6946    /// only and fails the test if a message signature is ever requested.
6947    struct OneSignatureSigner {
6948        expected_transaction: String,
6949    }
6950
6951    #[async_trait]
6952    impl SessionSigner for OneSignatureSigner {
6953        fn public_key(&self) -> &str {
6954            transaction_verifier::test_support::SESSION_PUBLIC_KEY
6955        }
6956
6957        async fn sign_message(&self, _message: &[u8]) -> Result<Vec<u8>, String> {
6958            panic!("one-signature path must not sign a message");
6959        }
6960
6961        async fn sign_transaction(&self, transaction_base64: &str) -> Result<String, String> {
6962            assert_eq!(transaction_base64, self.expected_transaction);
6963            Ok("BQYHCA==".to_owned())
6964        }
6965    }
6966
6967    /// Records what a custom verifier is handed on the direct path.
6968    struct RecordingVerifier {
6969        market_id: String,
6970        seen: std::sync::Mutex<Vec<String>>,
6971    }
6972
6973    #[async_trait]
6974    impl OrderVerifier for RecordingVerifier {
6975        async fn verify(&self, context: &OrderVerificationContext<'_>) -> Result<(), String> {
6976            assert!(context.challenge.is_none());
6977            assert_eq!(context.market_id, self.market_id);
6978            assert_eq!(context.prepared.market_id, self.market_id);
6979            assert_eq!(
6980                context.owner_wallet,
6981                transaction_verifier::test_support::OWNER_WALLET
6982            );
6983            assert_eq!(
6984                context.session_public_key,
6985                transaction_verifier::test_support::SESSION_PUBLIC_KEY
6986            );
6987            assert_eq!(
6988                order_request_action(context.operation),
6989                context.prepared.action
6990            );
6991            self.seen.lock().unwrap().push("order".to_owned());
6992            Ok(())
6993        }
6994    }
6995
6996    #[async_trait]
6997    impl TwapVerifier for RecordingVerifier {
6998        async fn verify(&self, context: &TwapVerificationContext<'_>) -> Result<(), String> {
6999            assert!(context.challenge.is_none());
7000            assert_eq!(context.market_id, self.market_id);
7001            assert_eq!(context.prepared.market_id, self.market_id);
7002            assert_eq!(
7003                context.owner_wallet,
7004                transaction_verifier::test_support::OWNER_WALLET
7005            );
7006            assert_eq!(
7007                twap_request_action(context.operation),
7008                context.prepared.action
7009            );
7010            self.seen.lock().unwrap().push("twap".to_owned());
7011            Ok(())
7012        }
7013    }
7014
7015    #[async_trait]
7016    impl ExecutionVerifier for RecordingVerifier {
7017        async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String> {
7018            assert!(context.challenge.is_none());
7019            assert_eq!(context.prepared.quote_id, context.quote.quote_id);
7020            assert_eq!(context.prepared.market_id, self.market_id);
7021            assert_eq!(
7022                context.owner_wallet,
7023                transaction_verifier::test_support::OWNER_WALLET
7024            );
7025            self.seen.lock().unwrap().push("execution".to_owned());
7026            Ok(())
7027        }
7028    }
7029
7030    #[tokio::test]
7031    async fn execute_order_uses_one_signature_over_a_verified_direct_prepare() {
7032        use transaction_verifier::test_support::{
7033            market_id, order_id, place_transaction, recent_blockhash, PlaceTransactionOptions,
7034            OWNER_WALLET, PLACE_PRICE, PLACE_SIZE, SESSION_PUBLIC_KEY,
7035        };
7036        let server = MockServer::start().await;
7037        let market_id = market_id();
7038        let transaction = place_transaction(PlaceTransactionOptions::default());
7039        let mut prepared = fixture("order-prepare");
7040        prepared["market_id"] = serde_json::json!(market_id);
7041        prepared["order_ids"] = serde_json::json!([order_id()]);
7042        prepared["transaction_base64"] = serde_json::json!(transaction);
7043        prepared["recent_blockhash"] = serde_json::json!(recent_blockhash());
7044        let mut submitted = fixture("order-submit");
7045        submitted["market_id"] = serde_json::json!(market_id);
7046        submitted["order_ids"] = serde_json::json!([order_id()]);
7047        // Direct prepare: the operation itself is the body — no challenge, no
7048        // challenge_id, no message signature.
7049        Mock::given(method("POST"))
7050            .and(path(format!("/v2/markets/{market_id}/orders/prepare")))
7051            .and(body_json(serde_json::json!({
7052                "action": "place",
7053                "owner_wallet": OWNER_WALLET,
7054                "session_public_key": SESSION_PUBLIC_KEY,
7055                "client_order_id": "agent-42",
7056                "side": "buy",
7057                "order_type": "post_only",
7058                "limit_price_atoms": PLACE_PRICE.to_string(),
7059                "size_atoms": PLACE_SIZE.to_string()
7060            })))
7061            .respond_with(ResponseTemplate::new(200).set_body_json(prepared))
7062            .expect(2)
7063            .mount(&server)
7064            .await;
7065        Mock::given(method("POST"))
7066            .and(path(format!("/v2/markets/{market_id}/orders/submit")))
7067            .and(body_json(serde_json::json!({
7068                "order_control_id": "or_44444444444444444444444444444444",
7069                "signed_transaction_base64": "BQYHCA==",
7070                "idempotency_key": "or_44444444444444444444444444444444"
7071            })))
7072            .respond_with(ResponseTemplate::new(200).set_body_json(submitted))
7073            .expect(2)
7074            .mount(&server)
7075            .await;
7076
7077        let client = StrataClient::new(server.uri()).unwrap();
7078        let signer = OneSignatureSigner {
7079            expected_transaction: transaction,
7080        };
7081        let operation = OrderExecuteOperation::Place {
7082            owner_wallet: OWNER_WALLET.to_owned(),
7083            account_sequence: None,
7084            client_order_id: "agent-42".to_owned(),
7085            side: PlatformTradeSide::Buy,
7086            order_type: PlatformOrderType::PostOnly,
7087            limit_price_atoms: PLACE_PRICE.to_string(),
7088            size_atoms: PLACE_SIZE.to_string(),
7089        };
7090        // Built-in verifier: the SDK decodes the transaction and requires it
7091        // to be exactly this operation before the one signature.
7092        let receipt = client
7093            .execute_order(
7094                &market_id,
7095                &operation,
7096                &signer,
7097                &DefaultTransactionVerifier,
7098                None,
7099            )
7100            .await
7101            .unwrap();
7102        assert_eq!(receipt.status, PlatformOrderSubmissionStatus::Submitted);
7103        assert_eq!(receipt.order_ids, vec![order_id()]);
7104
7105        // A custom verifier still receives the operation and prepared
7106        // transaction, with no challenge.
7107        let recording = RecordingVerifier {
7108            market_id: market_id.clone(),
7109            seen: std::sync::Mutex::new(Vec::new()),
7110        };
7111        client
7112            .execute_order(&market_id, &operation, &signer, &recording, None)
7113            .await
7114            .unwrap();
7115        assert_eq!(*recording.seen.lock().unwrap(), vec!["order".to_owned()]);
7116    }
7117
7118    #[tokio::test]
7119    async fn execute_order_refuses_a_transaction_that_is_not_the_operation() {
7120        use transaction_verifier::test_support::{
7121            market_id, order_id, place_transaction, recent_blockhash, PlaceTransactionOptions,
7122            OWNER_WALLET, PLACE_PRICE, PLACE_SIZE,
7123        };
7124        let market_id = market_id();
7125        let operation = OrderExecuteOperation::Place {
7126            owner_wallet: OWNER_WALLET.to_owned(),
7127            account_sequence: None,
7128            client_order_id: "agent-42".to_owned(),
7129            side: PlatformTradeSide::Buy,
7130            order_type: PlatformOrderType::PostOnly,
7131            limit_price_atoms: PLACE_PRICE.to_string(),
7132            size_atoms: PLACE_SIZE.to_string(),
7133        };
7134        // Built-in verifier refusals: a different side, the session as fee
7135        // payer, a session-signed system transfer, another market.
7136        let cases = [
7137            (
7138                PlaceTransactionOptions {
7139                    side: 1,
7140                    ..PlaceTransactionOptions::default()
7141                },
7142                "exactly the requested orders",
7143            ),
7144            (
7145                PlaceTransactionOptions {
7146                    session_pays: true,
7147                    ..PlaceTransactionOptions::default()
7148                },
7149                "fee payer",
7150            ),
7151            (
7152                PlaceTransactionOptions {
7153                    extra_system_transfer: true,
7154                    ..PlaceTransactionOptions::default()
7155                },
7156                "system or token instruction",
7157            ),
7158            (
7159                PlaceTransactionOptions {
7160                    market: Some([7; 32]),
7161                    ..PlaceTransactionOptions::default()
7162                },
7163                "another market",
7164            ),
7165        ];
7166        for (options, expected) in cases {
7167            let server = MockServer::start().await;
7168            let transaction = place_transaction(options);
7169            let mut prepared = fixture("order-prepare");
7170            prepared["market_id"] = serde_json::json!(market_id);
7171            prepared["order_ids"] = serde_json::json!([order_id()]);
7172            prepared["transaction_base64"] = serde_json::json!(transaction);
7173            prepared["recent_blockhash"] = serde_json::json!(recent_blockhash());
7174            Mock::given(method("POST"))
7175                .and(path(format!("/v2/markets/{market_id}/orders/prepare")))
7176                .respond_with(ResponseTemplate::new(200).set_body_json(prepared))
7177                .expect(1)
7178                .mount(&server)
7179                .await;
7180            // No submit mount: a refusal must stop before signing.
7181            let client = StrataClient::new(server.uri()).unwrap();
7182            let signer = OneSignatureSigner {
7183                expected_transaction: "never signed".to_owned(),
7184            };
7185            let error = client
7186                .execute_order(
7187                    &market_id,
7188                    &operation,
7189                    &signer,
7190                    &DefaultTransactionVerifier,
7191                    None,
7192                )
7193                .await
7194                .unwrap_err();
7195            match error {
7196                SdkError::Verification(message) => {
7197                    assert!(message.contains(expected), "{message}")
7198                }
7199                other => panic!("expected a verification refusal, got {other:?}"),
7200            }
7201        }
7202    }
7203
7204    #[tokio::test]
7205    async fn execute_twap_uses_the_direct_prepare_body_and_one_signature() {
7206        use transaction_verifier::test_support::{OWNER_WALLET, SESSION_PUBLIC_KEY};
7207        let server = MockServer::start().await;
7208        let market_id = "market_22222222222222222222222222222222";
7209        Mock::given(method("POST"))
7210            .and(path(format!("/v2/markets/{market_id}/twaps/prepare")))
7211            .and(body_json(serde_json::json!({
7212                "action": "place",
7213                "owner_wallet": OWNER_WALLET,
7214                "session_public_key": SESSION_PUBLIC_KEY,
7215                "side": "buy",
7216                "total_size_atoms": "10000000",
7217                "slices_total": 10,
7218                "maximum_tolerance_bps": 100,
7219                "interval_slots": 100,
7220                "limit_price_atoms": "150000000"
7221            })))
7222            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("twap-prepare")))
7223            .expect(1)
7224            .mount(&server)
7225            .await;
7226        Mock::given(method("POST"))
7227            .and(path(format!("/v2/markets/{market_id}/twaps/submit")))
7228            .and(body_json(serde_json::json!({
7229                "twap_control_id": "twctl_44444444444444444444444444444444",
7230                "signed_transaction_base64": "BQYHCA==",
7231                "idempotency_key": "twap-attempt-7"
7232            })))
7233            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("twap-submit")))
7234            .expect(1)
7235            .mount(&server)
7236            .await;
7237
7238        let client = StrataClient::new(server.uri()).unwrap();
7239        let signer = OneSignatureSigner {
7240            expected_transaction: "AQ==".to_owned(),
7241        };
7242        let recording = RecordingVerifier {
7243            market_id: market_id.to_owned(),
7244            seen: std::sync::Mutex::new(Vec::new()),
7245        };
7246        let receipt = client
7247            .execute_twap(
7248                market_id,
7249                &TwapExecuteOperation::Place {
7250                    owner_wallet: OWNER_WALLET.to_owned(),
7251                    side: PlatformTradeSide::Buy,
7252                    total_size_atoms: "10000000".to_owned(),
7253                    slices_total: 10,
7254                    maximum_tolerance_bps: 100,
7255                    interval_slots: 100,
7256                    limit_price_atoms: "150000000".to_owned(),
7257                },
7258                &signer,
7259                &recording,
7260                Some("twap-attempt-7"),
7261            )
7262            .await
7263            .unwrap();
7264        assert_eq!(receipt.status, PlatformOrderSubmissionStatus::Submitted);
7265        assert_eq!(*recording.seen.lock().unwrap(), vec!["twap".to_owned()]);
7266    }
7267
7268    #[tokio::test]
7269    async fn execute_quote_uses_the_direct_prepare_body_and_one_signature() {
7270        use transaction_verifier::test_support::{OWNER_WALLET, SESSION_PUBLIC_KEY};
7271        let server = MockServer::start().await;
7272        Mock::given(method("GET"))
7273            .and(path("/sonar/markets"))
7274            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
7275            .expect(1)
7276            .mount(&server)
7277            .await;
7278        let mut quote: QuoteResponse = serde_json::from_value(fixture("quote")).unwrap();
7279        quote.expires_at_ms = unix_ms().unwrap() + 60_000;
7280        let prepared = fixture("execution-prepare");
7281        Mock::given(method("POST"))
7282            .and(path("/sonar/markets/sol-usdc/execution/prepare"))
7283            .and(body_json(serde_json::json!({
7284                "quote_id": quote.quote_id,
7285                "owner_wallet": OWNER_WALLET,
7286                "session_public_key": SESSION_PUBLIC_KEY
7287            })))
7288            .respond_with(ResponseTemplate::new(200).set_body_json(prepared.clone()))
7289            .expect(1)
7290            .mount(&server)
7291            .await;
7292        Mock::given(method("POST"))
7293            .and(path("/sonar/markets/sol-usdc/execution/submit"))
7294            .and(body_json(serde_json::json!({
7295                "execution_id": prepared["execution_id"],
7296                "signed_transaction_base64": "BQYHCA==",
7297                "idempotency_key": prepared["execution_id"]
7298            })))
7299            .respond_with(ResponseTemplate::new(200).set_body_json(fixture("execution-submit")))
7300            .expect(1)
7301            .mount(&server)
7302            .await;
7303
7304        let client = StrataClient::new(server.uri()).unwrap();
7305        let signer = OneSignatureSigner {
7306            expected_transaction: prepared["transaction_base64"].as_str().unwrap().to_owned(),
7307        };
7308        let recording = RecordingVerifier {
7309            market_id: quote.market_id.clone(),
7310            seen: std::sync::Mutex::new(Vec::new()),
7311        };
7312        let receipt = client
7313            .execute_quote(&quote, OWNER_WALLET, None, &signer, &recording, None)
7314            .await
7315            .unwrap();
7316        assert_eq!(receipt.status, ExecutionStatus::Submitted);
7317        assert_eq!(
7318            *recording.seen.lock().unwrap(),
7319            vec!["execution".to_owned()]
7320        );
7321    }
7322
7323    #[test]
7324    fn twap_authorization_parser_binds_every_public_place_field() {
7325        let owner = [1u8; 32];
7326        let session = [2u8; 32];
7327        let pda = [3u8; 32];
7328        let blockhash = [4u8; 32];
7329        let nonce = [5u8; 16];
7330        let expires_at_ms = 1_786_550_460_000u64;
7331        let request = PlatformTwapChallengeRequest::Place {
7332            owner_wallet: bs58::encode(owner).into_string(),
7333            session_public_key: bs58::encode(session).into_string(),
7334            side: PlatformTradeSide::Buy,
7335            total_size_atoms: "10000000".to_owned(),
7336            slices_total: 10,
7337            maximum_tolerance_bps: 100,
7338            interval_slots: 100,
7339            limit_price_atoms: "150000000".to_owned(),
7340        };
7341        let mut payload = Vec::new();
7342        payload.extend_from_slice(PUBLIC_TWAP_AUTH_DOMAIN);
7343        payload.extend_from_slice(&[9u8; 32]);
7344        payload.extend_from_slice(&[8u8; 32]);
7345        payload.extend_from_slice(&owner);
7346        payload.extend_from_slice(&session);
7347        payload.push(0);
7348        payload.push(0);
7349        payload.extend_from_slice(&10_000_000u64.to_le_bytes());
7350        payload.extend_from_slice(&10u16.to_le_bytes());
7351        payload.extend_from_slice(&100u16.to_le_bytes());
7352        payload.extend_from_slice(&100u32.to_le_bytes());
7353        payload.extend_from_slice(&150_000_000u64.to_le_bytes());
7354        payload.extend_from_slice(&7u64.to_le_bytes());
7355        payload.extend_from_slice(&pda);
7356        payload.extend_from_slice(&blockhash);
7357        payload.extend_from_slice(&123u64.to_le_bytes());
7358        payload.extend_from_slice(&expires_at_ms.to_le_bytes());
7359        payload.extend_from_slice(&nonce);
7360        let challenge = PlatformTwapChallengeResponse {
7361            schema_version: 2,
7362            contract_version: "2.0".to_owned(),
7363            challenge_id: format!("twc_{}", hex::encode(nonce)),
7364            market_id: "market_22222222222222222222222222222222".to_owned(),
7365            action: PlatformTwapControlAction::Place,
7366            twap_id: opaque_twap_id(&pda),
7367            authorization_payload_base64: base64::engine::general_purpose::STANDARD
7368                .encode(&payload),
7369            server_time_ms: expires_at_ms - 60_000,
7370            expires_at_ms,
7371        };
7372        let authorization = validate_twap_authorization(&challenge, &request).unwrap();
7373        assert_eq!(authorization.bytes, payload);
7374        assert_eq!(authorization.last_valid_block_height, 123);
7375        assert_eq!(
7376            authorization.recent_blockhash,
7377            bs58::encode(blockhash).into_string()
7378        );
7379
7380        let mut changed = request.clone();
7381        if let PlatformTwapChallengeRequest::Place {
7382            total_size_atoms, ..
7383        } = &mut changed
7384        {
7385            *total_size_atoms = "10000001".to_owned();
7386        }
7387        assert!(validate_twap_authorization(&challenge, &changed).is_err());
7388    }
7389
7390    #[test]
7391    fn order_authorization_parser_binds_every_public_place_field() {
7392        let owner = [1u8; 32];
7393        let session = [2u8; 32];
7394        let order = [3u8; 32];
7395        let nonce = [4u8; 16];
7396        let blockhash = [5u8; 32];
7397        let epoch = [6u8; 16];
7398        let market_id = "market_22222222222222222222222222222222";
7399        let expires_at_ms = 1_786_550_460_000u64;
7400        let request = PlatformOrderChallengeRequest::Place {
7401            owner_wallet: bs58::encode(owner).into_string(),
7402            session_public_key: bs58::encode(session).into_string(),
7403            account_sequence: Some("7".to_owned()),
7404            client_order_id: "agent-order-7".to_owned(),
7405            side: PlatformTradeSide::Buy,
7406            order_type: PlatformOrderType::PostOnly,
7407            limit_price_atoms: "150000000".to_owned(),
7408            size_atoms: "1000000".to_owned(),
7409        };
7410        let mut payload = Vec::new();
7411        payload.extend_from_slice(PUBLIC_ORDER_AUTH_DOMAIN);
7412        payload.extend_from_slice(&[9u8; 32]);
7413        payload.extend_from_slice(&owner);
7414        payload.extend_from_slice(&session);
7415        payload.push(0);
7416        payload.extend_from_slice(&7u64.to_le_bytes());
7417        payload.extend_from_slice(&("agent-order-7".len() as u16).to_le_bytes());
7418        payload.extend_from_slice(b"agent-order-7");
7419        payload.push(0);
7420        payload.push(3);
7421        payload.extend_from_slice(&150_000_000u64.to_le_bytes());
7422        payload.extend_from_slice(&1_000_000u64.to_le_bytes());
7423        payload.extend_from_slice(&order);
7424        payload.extend_from_slice(&blockhash);
7425        payload.extend_from_slice(&400_000_000u64.to_le_bytes());
7426        payload.extend_from_slice(&expires_at_ms.to_le_bytes());
7427        payload.extend_from_slice(&nonce);
7428        payload.extend_from_slice(&epoch);
7429        let challenge = PlatformOrderChallengeResponse {
7430            schema_version: 2,
7431            contract_version: "2.0".to_owned(),
7432            challenge_id: format!("oc_{}", hex::encode(nonce)),
7433            market_id: market_id.to_owned(),
7434            action: PlatformOrderAction::Place,
7435            order_ids: vec![opaque_order_id(market_id, &order)],
7436            authorization_payload_base64: base64::engine::general_purpose::STANDARD.encode(payload),
7437            server_time_ms: expires_at_ms - 60_000,
7438            expires_at_ms,
7439        };
7440        let authorization = validate_order_authorization(&challenge, &request).unwrap();
7441        assert_eq!(
7442            authorization.recent_blockhash,
7443            bs58::encode(blockhash).into_string()
7444        );
7445        assert_eq!(authorization.last_valid_block_height, 400_000_000);
7446
7447        // A sequence left to Strata is accepted from the signed authorization
7448        // while every other binding is still enforced; a supplied sequence
7449        // that differs from it is rejected.
7450        let mut resolved = request.clone();
7451        if let PlatformOrderChallengeRequest::Place {
7452            account_sequence, ..
7453        } = &mut resolved
7454        {
7455            *account_sequence = None;
7456        }
7457        assert!(validate_order_authorization(&challenge, &resolved).is_ok());
7458        let mut pinned_elsewhere = request.clone();
7459        if let PlatformOrderChallengeRequest::Place {
7460            account_sequence, ..
7461        } = &mut pinned_elsewhere
7462        {
7463            *account_sequence = Some("8".to_owned());
7464        }
7465        assert!(validate_order_authorization(&challenge, &pinned_elsewhere).is_err());
7466
7467        let mut changed = request;
7468        if let PlatformOrderChallengeRequest::Place { size_atoms, .. } = &mut changed {
7469            *size_atoms = "1000001".to_owned();
7470        }
7471        assert!(validate_order_authorization(&challenge, &changed).is_err());
7472    }
7473
7474    #[test]
7475    fn order_authorization_parser_binds_atomic_batch_order_and_replacement_fields() {
7476        let owner = [1u8; 32];
7477        let session = [2u8; 32];
7478        let cancelled = [3u8; 32];
7479        let replaced = [4u8; 32];
7480        let replacement = [5u8; 32];
7481        let nonce = [6u8; 16];
7482        let blockhash = [7u8; 32];
7483        let market_id = "market_22222222222222222222222222222222";
7484        let expires_at_ms = 1_786_550_460_000u64;
7485        let request = PlatformOrderChallengeRequest::Batch {
7486            owner_wallet: bs58::encode(owner).into_string(),
7487            session_public_key: bs58::encode(session).into_string(),
7488            operations: vec![
7489                PlatformOrderBatchOperation::Cancel {
7490                    order_id: opaque_order_id(market_id, &cancelled),
7491                },
7492                PlatformOrderBatchOperation::Replace {
7493                    order_id: opaque_order_id(market_id, &replaced),
7494                    account_sequence: Some("8".to_owned()),
7495                    client_order_id: "replacement-8".to_owned(),
7496                    side: PlatformTradeSide::Sell,
7497                    order_type: PlatformOrderType::PostOnly,
7498                    limit_price_atoms: "151000000".to_owned(),
7499                    size_atoms: "2000000".to_owned(),
7500                },
7501            ],
7502        };
7503        let mut payload = Vec::new();
7504        payload.extend_from_slice(PUBLIC_ORDER_AUTH_DOMAIN);
7505        payload.extend_from_slice(&[9u8; 32]);
7506        payload.extend_from_slice(&owner);
7507        payload.extend_from_slice(&session);
7508        payload.push(4);
7509        payload.push(2);
7510        payload.push(1);
7511        payload.extend_from_slice(&cancelled);
7512        payload.push(1);
7513        payload.push(3);
7514        payload.extend_from_slice(&replaced);
7515        payload.push(0);
7516        payload.extend_from_slice(&8u64.to_le_bytes());
7517        payload.extend_from_slice(&("replacement-8".len() as u16).to_le_bytes());
7518        payload.extend_from_slice(b"replacement-8");
7519        payload.push(1);
7520        payload.push(3);
7521        payload.extend_from_slice(&151_000_000u64.to_le_bytes());
7522        payload.extend_from_slice(&2_000_000u64.to_le_bytes());
7523        payload.extend_from_slice(&replacement);
7524        payload.extend_from_slice(&blockhash);
7525        payload.extend_from_slice(&400_000_000u64.to_le_bytes());
7526        payload.extend_from_slice(&expires_at_ms.to_le_bytes());
7527        payload.extend_from_slice(&nonce);
7528        payload.extend_from_slice(&[8u8; 16]);
7529        let challenge = PlatformOrderChallengeResponse {
7530            schema_version: 2,
7531            contract_version: "2.0".to_owned(),
7532            challenge_id: format!("oc_{}", hex::encode(nonce)),
7533            market_id: market_id.to_owned(),
7534            action: PlatformOrderAction::Batch,
7535            order_ids: vec![
7536                opaque_order_id(market_id, &cancelled),
7537                opaque_order_id(market_id, &replaced),
7538                opaque_order_id(market_id, &replacement),
7539            ],
7540            authorization_payload_base64: base64::engine::general_purpose::STANDARD.encode(payload),
7541            server_time_ms: expires_at_ms - 60_000,
7542            expires_at_ms,
7543        };
7544        validate_order_authorization(&challenge, &request).unwrap();
7545
7546        let mut changed = request;
7547        if let PlatformOrderChallengeRequest::Batch { operations, .. } = &mut changed {
7548            if let PlatformOrderBatchOperation::Replace { size_atoms, .. } = &mut operations[1] {
7549                *size_atoms = "2000001".to_owned();
7550            }
7551        }
7552        assert!(validate_order_authorization(&challenge, &changed).is_err());
7553    }
7554
7555    #[test]
7556    fn rejects_non_http_base_urls() {
7557        assert!(matches!(
7558            StrataClient::new("file:///tmp/contract"),
7559            Err(SdkError::InvalidBaseUrl(_))
7560        ));
7561    }
7562
7563    #[test]
7564    fn accepts_only_product_level_quote_operation_paths() {
7565        assert!(valid_public_operation_path("/sonar/markets/sol-usdc/quote"));
7566        for unsupported_or_ambiguous in [
7567            "/unsupported/build",
7568            "/unsupported/quote",
7569            "/sonar/markets/../quote",
7570            "/sonar/markets/SOL-USDC/quote",
7571        ] {
7572            assert!(!valid_public_operation_path(unsupported_or_ambiguous));
7573        }
7574    }
7575}