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