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