Skip to main content

strata_sdk/
lib.rs

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