Skip to main content

strata_sdk/
lib.rs

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