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