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