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