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