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