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