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