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