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