1use async_trait::async_trait;
7use base64::Engine as _;
8use reqwest::{StatusCode, Url};
9use serde::de::DeserializeOwned;
10use sha2::{Digest, Sha256};
11use std::collections::HashSet;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use strata_public_contract::{ErrorResponse, CONTRACT_MAJOR, CONTRACT_VERSION};
14use thiserror::Error;
15
16pub use strata_public_contract::platform::{
17 PlatformOrderAction, PlatformOrderBatchOperation, PlatformOrderChallengeRequest,
18 PlatformOrderChallengeResponse, PlatformOrderControlStatus, PlatformOrderPrepareRequest,
19 PlatformOrderPrepareResponse, PlatformOrderStatusRequest, PlatformOrderStatusResponse,
20 PlatformOrderSubmissionStatus, PlatformOrderSubmitRequest, PlatformOrderSubmitResponse,
21 PlatformOrderType, PlatformTradeSide,
22};
23pub use strata_public_contract::{
24 ActionAuthorityModel, ActionEdge, ActionGraph, ActionNode, ActionNodeKind, ActionOperation,
25 CapabilityCatalog, CapabilityDescriptor, CapabilityRisk, CapabilityStability,
26 ExecutionChallengeRequest, ExecutionChallengeResponse, ExecutionPrepareRequest,
27 ExecutionPrepareResponse, ExecutionStatus, ExecutionSubmitRequest, ExecutionSubmitResponse,
28 Market, MarketsResponse, McpExposure, QuoteRequest, QuoteResponse, QuoteSide,
29 DEFAULT_SLIPPAGE_BPS,
30};
31
32pub const DEFAULT_API_BASE: &str = "https://api.stratabook.app";
33const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
34const PUBLIC_EXECUTION_AUTH_DOMAIN: &[u8] = b"strata-sonar-execution:v1\0";
35const PUBLIC_ORDER_AUTH_DOMAIN: &[u8] = b"strata-platform-order-control:v1\0";
36
37#[async_trait]
38pub trait SessionSigner: Send + Sync {
39 fn public_key(&self) -> &str;
41
42 async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String>;
44
45 async fn sign_transaction(&self, transaction_base64: &str) -> Result<String, String>;
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum OrderExecuteOperation {
51 Place {
52 owner_wallet: String,
53 account_sequence: String,
54 client_order_id: String,
55 side: PlatformTradeSide,
56 order_type: PlatformOrderType,
57 limit_price_atoms: String,
58 size_atoms: String,
59 },
60 Cancel {
61 owner_wallet: String,
62 order_id: String,
63 },
64 CancelAll {
65 owner_wallet: String,
66 },
67 Replace {
68 owner_wallet: String,
69 order_id: String,
70 account_sequence: String,
71 client_order_id: String,
72 side: PlatformTradeSide,
73 order_type: PlatformOrderType,
74 limit_price_atoms: String,
75 size_atoms: String,
76 },
77 Batch {
78 owner_wallet: String,
79 operations: Vec<PlatformOrderBatchOperation>,
80 },
81}
82
83impl OrderExecuteOperation {
84 fn challenge_request(&self, session_public_key: String) -> PlatformOrderChallengeRequest {
85 match self {
86 Self::Place {
87 owner_wallet,
88 account_sequence,
89 client_order_id,
90 side,
91 order_type,
92 limit_price_atoms,
93 size_atoms,
94 } => PlatformOrderChallengeRequest::Place {
95 owner_wallet: owner_wallet.clone(),
96 session_public_key,
97 account_sequence: account_sequence.clone(),
98 client_order_id: client_order_id.clone(),
99 side: *side,
100 order_type: *order_type,
101 limit_price_atoms: limit_price_atoms.clone(),
102 size_atoms: size_atoms.clone(),
103 },
104 Self::Cancel {
105 owner_wallet,
106 order_id,
107 } => PlatformOrderChallengeRequest::Cancel {
108 owner_wallet: owner_wallet.clone(),
109 session_public_key,
110 order_id: order_id.clone(),
111 },
112 Self::CancelAll { owner_wallet } => PlatformOrderChallengeRequest::CancelAll {
113 owner_wallet: owner_wallet.clone(),
114 session_public_key,
115 },
116 Self::Replace {
117 owner_wallet,
118 order_id,
119 account_sequence,
120 client_order_id,
121 side,
122 order_type,
123 limit_price_atoms,
124 size_atoms,
125 } => PlatformOrderChallengeRequest::Replace {
126 owner_wallet: owner_wallet.clone(),
127 session_public_key,
128 order_id: order_id.clone(),
129 account_sequence: account_sequence.clone(),
130 client_order_id: client_order_id.clone(),
131 side: *side,
132 order_type: *order_type,
133 limit_price_atoms: limit_price_atoms.clone(),
134 size_atoms: size_atoms.clone(),
135 },
136 Self::Batch {
137 owner_wallet,
138 operations,
139 } => PlatformOrderChallengeRequest::Batch {
140 owner_wallet: owner_wallet.clone(),
141 session_public_key,
142 operations: operations.clone(),
143 },
144 }
145 }
146}
147
148#[derive(Debug)]
149pub struct OrderVerificationContext<'a> {
150 pub challenge: &'a PlatformOrderChallengeResponse,
151 pub prepared: &'a PlatformOrderPrepareResponse,
152 pub owner_wallet: &'a str,
153 pub session_public_key: &'a str,
154}
155
156#[async_trait]
157pub trait OrderVerifier: Send + Sync {
158 async fn verify(&self, context: &OrderVerificationContext<'_>) -> Result<(), String>;
161}
162
163#[derive(Debug)]
164pub struct ExecutionVerificationContext<'a> {
165 pub quote: &'a QuoteResponse,
166 pub challenge: &'a ExecutionChallengeResponse,
167 pub prepared: &'a ExecutionPrepareResponse,
168 pub owner_wallet: &'a str,
169 pub session_public_key: &'a str,
170}
171
172#[async_trait]
173pub trait ExecutionVerifier: Send + Sync {
174 async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String>;
177}
178
179#[derive(Debug, Error)]
180pub enum SdkError {
181 #[error("invalid API base URL: {0}")]
182 InvalidBaseUrl(String),
183 #[error("invalid request: {0}")]
184 InvalidRequest(String),
185 #[error("market is not available: {0}")]
186 MarketNotFound(String),
187 #[error("operation is not available for market: {0}")]
188 OperationUnavailable(String),
189 #[error("Strata API error {status} ({code}): {message}")]
190 Api {
191 status: StatusCode,
192 code: String,
193 message: String,
194 retryable: bool,
195 },
196 #[error("invalid public contract response: {0}")]
197 InvalidResponse(String),
198 #[error("session signer rejected the operation: {0}")]
199 Signer(String),
200 #[error("prepared transaction was rejected: {0}")]
201 Verification(String),
202 #[error(transparent)]
203 Transport(#[from] reqwest::Error),
204}
205
206#[derive(Clone, Debug)]
207pub struct StrataClient {
208 base_url: Url,
209 http: reqwest::Client,
210}
211
212impl StrataClient {
213 pub fn production() -> Result<Self, SdkError> {
214 Self::new(DEFAULT_API_BASE)
215 }
216
217 pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
218 Self::with_timeout(base_url, DEFAULT_TIMEOUT)
219 }
220
221 pub fn with_timeout(base_url: impl AsRef<str>, timeout: Duration) -> Result<Self, SdkError> {
222 if timeout.is_zero() {
223 return Err(SdkError::InvalidRequest(
224 "timeout must be greater than zero".to_owned(),
225 ));
226 }
227 let base_url = normalize_base_url(base_url.as_ref())?;
228 let http = reqwest::Client::builder().timeout(timeout).build()?;
229 Ok(Self { base_url, http })
230 }
231
232 pub async fn capabilities(&self) -> Result<CapabilityCatalog, SdkError> {
233 let catalog: CapabilityCatalog = self.get("sonar/capabilities", &[]).await?;
234 validate_version(catalog.schema_version, &catalog.contract_version)?;
235
236 let mut ids = HashSet::new();
237 if catalog
238 .capabilities
239 .iter()
240 .any(|capability| !ids.insert(capability.id.as_str()))
241 {
242 return Err(SdkError::InvalidResponse(
243 "capability IDs must be unique".to_owned(),
244 ));
245 }
246 Ok(catalog)
247 }
248
249 pub async fn action_graph(&self) -> Result<ActionGraph, SdkError> {
252 let graph: ActionGraph = self.get("sonar/action-graph", &[]).await?;
253 validate_action_graph(&graph)?;
254 Ok(graph)
255 }
256
257 pub async fn markets(&self) -> Result<MarketsResponse, SdkError> {
258 let markets: MarketsResponse = self.get("sonar/markets", &[]).await?;
259 validate_version(markets.schema_version, &markets.contract_version)?;
260 Ok(markets)
261 }
262
263 pub async fn quote(&self, request: QuoteRequest) -> Result<QuoteResponse, SdkError> {
265 let amount_in = parse_atoms("amount_in_atoms", &request.amount_in_atoms)?;
266 if amount_in == 0 {
267 return Err(SdkError::InvalidRequest(
268 "amount_in_atoms must be greater than zero".to_owned(),
269 ));
270 }
271 if request.slippage_bps > 1_000 {
272 return Err(SdkError::InvalidRequest(
273 "slippage_bps must be between 0 and 1,000".to_owned(),
274 ));
275 }
276
277 let markets = self.markets().await?;
278 let market = markets
279 .markets
280 .iter()
281 .find(|market| {
282 market.label.eq_ignore_ascii_case(&request.market_id)
283 || market.market_pda.as_deref() == Some(request.market_id.as_str())
284 })
285 .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
286 if !market.ready {
287 return Err(SdkError::OperationUnavailable(market.label.clone()));
288 }
289 let market_pda = market
290 .market_pda
291 .as_deref()
292 .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
293 let quote_path = market
294 .quote_path
295 .as_deref()
296 .filter(|path| valid_public_operation_path(path))
297 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
298 let wire = QuoteRequest {
299 market_id: market_pda.to_owned(),
300 side: request.side,
301 amount_in_atoms: request.amount_in_atoms.clone(),
302 slippage_bps: request.slippage_bps,
303 };
304 let quote: QuoteResponse = self.post(quote_path, &wire).await?;
305 validate_quote("e, market_pda, &request, amount_in)?;
306 Ok(quote)
307 }
308
309 pub async fn execution_challenge(
312 &self,
313 market: &str,
314 request: ExecutionChallengeRequest,
315 ) -> Result<ExecutionChallengeResponse, SdkError> {
316 if !valid_handle(&request.quote_id, "sq_") {
317 return Err(SdkError::InvalidRequest("quote_id is invalid".to_owned()));
318 }
319 let request = ExecutionChallengeRequest {
320 quote_id: request.quote_id,
321 owner_wallet: canonical_public_key(&request.owner_wallet, "owner_wallet")?,
322 session_public_key: canonical_public_key(
323 &request.session_public_key,
324 "session_public_key",
325 )?,
326 account_sequence: parse_atoms("account_sequence", &request.account_sequence)?
327 .to_string(),
328 };
329 let execution_path = self.execution_path(market).await?;
330 let challenge: ExecutionChallengeResponse = self
331 .post(&format!("{execution_path}/challenge"), &request)
332 .await?;
333 validate_version(challenge.schema_version, &challenge.contract_version)?;
334 if !valid_handle(&challenge.challenge_id, "sc_") || challenge.quote_id != request.quote_id {
335 return Err(SdkError::InvalidResponse(
336 "execution challenge does not match the requested quote".to_owned(),
337 ));
338 }
339 Ok(challenge)
340 }
341
342 pub async fn execution_prepare(
345 &self,
346 market: &str,
347 request: ExecutionPrepareRequest,
348 ) -> Result<ExecutionPrepareResponse, SdkError> {
349 if !valid_handle(&request.challenge_id, "sc_") {
350 return Err(SdkError::InvalidRequest(
351 "challenge_id is invalid".to_owned(),
352 ));
353 }
354 let signature = bs58::decode(request.authorization_signature.trim())
355 .into_vec()
356 .map_err(|_| {
357 SdkError::InvalidRequest("authorization_signature must be base58".to_owned())
358 })?;
359 if signature.len() != 64
360 || bs58::encode(&signature).into_string() != request.authorization_signature.trim()
361 {
362 return Err(SdkError::InvalidRequest(
363 "authorization_signature must be a canonical Ed25519 signature".to_owned(),
364 ));
365 }
366 let request = ExecutionPrepareRequest {
367 challenge_id: request.challenge_id,
368 authorization_signature: bs58::encode(signature).into_string(),
369 };
370 let execution_path = self.execution_path(market).await?;
371 let prepared: ExecutionPrepareResponse = self
372 .post(&format!("{execution_path}/prepare"), &request)
373 .await?;
374 validate_version(prepared.schema_version, &prepared.contract_version)?;
375 if !valid_handle(&prepared.execution_id, "se_") {
376 return Err(SdkError::InvalidResponse(
377 "prepared execution ID is invalid".to_owned(),
378 ));
379 }
380 Ok(prepared)
381 }
382
383 pub async fn execution_submit(
386 &self,
387 market: &str,
388 request: ExecutionSubmitRequest,
389 ) -> Result<ExecutionSubmitResponse, SdkError> {
390 if !valid_handle(&request.execution_id, "se_") {
391 return Err(SdkError::InvalidRequest(
392 "execution_id is invalid".to_owned(),
393 ));
394 }
395 let transaction = request.signed_transaction_base64.trim();
396 let decoded = base64::engine::general_purpose::STANDARD
397 .decode(transaction)
398 .map_err(|_| {
399 SdkError::InvalidRequest(
400 "signed_transaction_base64 must be canonical base64".to_owned(),
401 )
402 })?;
403 if decoded.is_empty()
404 || base64::engine::general_purpose::STANDARD.encode(&decoded) != transaction
405 {
406 return Err(SdkError::InvalidRequest(
407 "signed_transaction_base64 must be canonical base64".to_owned(),
408 ));
409 }
410 let request = ExecutionSubmitRequest {
411 execution_id: request.execution_id,
412 signed_transaction_base64: transaction.to_owned(),
413 idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
414 };
415 let execution_path = self.execution_path(market).await?;
416 let submitted: ExecutionSubmitResponse = self
417 .post(&format!("{execution_path}/submit"), &request)
418 .await?;
419 validate_version(submitted.schema_version, &submitted.contract_version)?;
420 if submitted.execution_id != request.execution_id
421 || submitted.status != ExecutionStatus::Submitted
422 || submitted.signature.trim().is_empty()
423 {
424 return Err(SdkError::InvalidResponse(
425 "execution receipt does not match the submitted transaction".to_owned(),
426 ));
427 }
428 Ok(submitted)
429 }
430
431 pub async fn order_challenge(
434 &self,
435 market_id: &str,
436 request: PlatformOrderChallengeRequest,
437 ) -> Result<PlatformOrderChallengeResponse, SdkError> {
438 let market_id = validate_platform_market_id(market_id)?;
439 let request = normalize_order_challenge_request(request)?;
440 let expected_action = order_request_action(&request);
441 let challenge: PlatformOrderChallengeResponse = self
442 .post(
443 &format!("v2/markets/{market_id}/orders/challenge"),
444 &request,
445 )
446 .await?;
447 validate_platform_version(challenge.schema_version, &challenge.contract_version)?;
448 if challenge.market_id != market_id
449 || challenge.action != expected_action
450 || !valid_handle(&challenge.challenge_id, "oc_")
451 || challenge.order_ids.is_empty()
452 || challenge.order_ids.len() > 12
453 || challenge.expires_at_ms <= challenge.server_time_ms
454 || challenge
455 .order_ids
456 .iter()
457 .any(|order_id| !valid_handle(order_id, "order_"))
458 {
459 return Err(SdkError::InvalidResponse(
460 "order challenge bindings are invalid".to_owned(),
461 ));
462 }
463 canonical_base64(
464 &challenge.authorization_payload_base64,
465 "authorization_payload_base64",
466 )?;
467 Ok(challenge)
468 }
469
470 pub async fn order_prepare(
473 &self,
474 market_id: &str,
475 request: PlatformOrderPrepareRequest,
476 ) -> Result<PlatformOrderPrepareResponse, SdkError> {
477 let market_id = validate_platform_market_id(market_id)?;
478 if !valid_handle(&request.challenge_id, "oc_") {
479 return Err(SdkError::InvalidRequest(
480 "order challenge_id is invalid".to_owned(),
481 ));
482 }
483 let signature =
484 canonical_signature(&request.authorization_signature, "authorization_signature")?;
485 let prepared: PlatformOrderPrepareResponse = self
486 .post(
487 &format!("v2/markets/{market_id}/orders/prepare"),
488 &PlatformOrderPrepareRequest {
489 challenge_id: request.challenge_id,
490 authorization_signature: signature,
491 },
492 )
493 .await?;
494 validate_platform_version(prepared.schema_version, &prepared.contract_version)?;
495 if prepared.market_id != market_id
496 || !valid_handle(&prepared.order_control_id, "or_")
497 || prepared.order_ids.is_empty()
498 || prepared.order_ids.len() > 12
499 || prepared.transaction_base64.trim().is_empty()
500 || prepared.expires_at_ms == 0
501 {
502 return Err(SdkError::InvalidResponse(
503 "prepared order control is invalid".to_owned(),
504 ));
505 }
506 canonical_base64(&prepared.transaction_base64, "transaction_base64")?;
507 canonical_base58_32(&prepared.recent_blockhash, "recent_blockhash")?;
508 Ok(prepared)
509 }
510
511 pub async fn order_submit(
514 &self,
515 market_id: &str,
516 request: PlatformOrderSubmitRequest,
517 ) -> Result<PlatformOrderSubmitResponse, SdkError> {
518 let market_id = validate_platform_market_id(market_id)?;
519 if !valid_handle(&request.order_control_id, "or_") {
520 return Err(SdkError::InvalidRequest(
521 "order_control_id is invalid".to_owned(),
522 ));
523 }
524 let transaction = canonical_base64(
525 &request.signed_transaction_base64,
526 "signed_transaction_base64",
527 )?;
528 let request = PlatformOrderSubmitRequest {
529 order_control_id: request.order_control_id,
530 signed_transaction_base64: transaction,
531 idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
532 };
533 let submitted: PlatformOrderSubmitResponse = self
534 .post(&format!("v2/markets/{market_id}/orders/submit"), &request)
535 .await?;
536 validate_platform_version(submitted.schema_version, &submitted.contract_version)?;
537 if submitted.market_id != market_id
538 || submitted.order_control_id != request.order_control_id
539 || submitted.status != PlatformOrderSubmissionStatus::Submitted
540 || submitted.signature.trim().is_empty()
541 {
542 return Err(SdkError::InvalidResponse(
543 "order control receipt is invalid".to_owned(),
544 ));
545 }
546 canonical_signature(&submitted.signature, "signature")?;
547 Ok(submitted)
548 }
549
550 pub async fn order_status(
554 &self,
555 market_id: &str,
556 request: PlatformOrderStatusRequest,
557 ) -> Result<PlatformOrderStatusResponse, SdkError> {
558 let market_id = validate_platform_market_id(market_id)?;
559 if !valid_handle(&request.order_control_id, "or_") {
560 return Err(SdkError::InvalidRequest(
561 "order_control_id is invalid".to_owned(),
562 ));
563 }
564 let request = PlatformOrderStatusRequest {
565 order_control_id: request.order_control_id,
566 idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
567 };
568 let status: PlatformOrderStatusResponse = self
569 .post(&format!("v2/markets/{market_id}/orders/status"), &request)
570 .await?;
571 validate_platform_version(status.schema_version, &status.contract_version)?;
572 if status.market_id != market_id
573 || status.order_control_id != request.order_control_id
574 || status.order_ids.is_empty()
575 || status.order_ids.len() > 12
576 || status
577 .order_ids
578 .iter()
579 .any(|order_id| !valid_handle(order_id, "order_"))
580 || (status.status == PlatformOrderControlStatus::Failed
581 && status.failure_code.as_deref().is_none_or(str::is_empty))
582 || (status.status != PlatformOrderControlStatus::Failed
583 && status.failure_code.is_some())
584 {
585 return Err(SdkError::InvalidResponse(
586 "order control status is invalid".to_owned(),
587 ));
588 }
589 canonical_signature(&status.signature, "signature")?;
590 Ok(status)
591 }
592
593 pub async fn execute_order<S, V>(
598 &self,
599 market_id: &str,
600 operation: &OrderExecuteOperation,
601 signer: &S,
602 verifier: &V,
603 idempotency_key: Option<&str>,
604 ) -> Result<PlatformOrderSubmitResponse, SdkError>
605 where
606 S: SessionSigner + ?Sized,
607 V: OrderVerifier + ?Sized,
608 {
609 let market_id = validate_platform_market_id(market_id)?;
610 let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
611 let request = normalize_order_challenge_request(
612 operation.challenge_request(session_public_key.clone()),
613 )?;
614 let owner_wallet = order_request_owner(&request).to_owned();
615 if owner_wallet == session_public_key {
616 return Err(SdkError::InvalidRequest(
617 "session_public_key must be distinct from owner_wallet".to_owned(),
618 ));
619 }
620 let challenge = self.order_challenge(&market_id, request.clone()).await?;
621 if challenge.action != order_request_action(&request) {
622 return Err(SdkError::InvalidResponse(
623 "order challenge action changed".to_owned(),
624 ));
625 }
626 let authorization = validate_order_authorization(&challenge, &request)?;
627 let signature = signer
628 .sign_message(&authorization.bytes)
629 .await
630 .map_err(SdkError::Signer)?;
631 if signature.len() != 64 {
632 return Err(SdkError::InvalidResponse(
633 "order authorization signature must contain 64 bytes".to_owned(),
634 ));
635 }
636 let prepared = self
637 .order_prepare(
638 &market_id,
639 PlatformOrderPrepareRequest {
640 challenge_id: challenge.challenge_id.clone(),
641 authorization_signature: bs58::encode(signature).into_string(),
642 },
643 )
644 .await?;
645 validate_order_prepare_binding(&prepared, &challenge, &authorization)?;
646 verifier
647 .verify(&OrderVerificationContext {
648 challenge: &challenge,
649 prepared: &prepared,
650 owner_wallet: &owner_wallet,
651 session_public_key: &session_public_key,
652 })
653 .await
654 .map_err(SdkError::Verification)?;
655 let signed_transaction = signer
656 .sign_transaction(&prepared.transaction_base64)
657 .await
658 .map_err(SdkError::Signer)?;
659 let signed_transaction =
660 canonical_base64(&signed_transaction, "signed_transaction_base64")?;
661 self.order_submit(
662 &market_id,
663 PlatformOrderSubmitRequest {
664 order_control_id: prepared.order_control_id.clone(),
665 signed_transaction_base64: signed_transaction,
666 idempotency_key: normalize_idempotency_key(
667 idempotency_key.unwrap_or(&prepared.order_control_id),
668 )?,
669 },
670 )
671 .await
672 }
673
674 pub async fn execute_quote<S, V>(
678 &self,
679 quote: &QuoteResponse,
680 owner_wallet: &str,
681 account_sequence: u64,
682 signer: &S,
683 verifier: &V,
684 idempotency_key: Option<&str>,
685 ) -> Result<ExecutionSubmitResponse, SdkError>
686 where
687 S: SessionSigner + ?Sized,
688 V: ExecutionVerifier + ?Sized,
689 {
690 validate_version(quote.schema_version, "e.contract_version)?;
691 let now_ms = unix_ms()?;
692 if quote.expires_at_ms <= now_ms {
693 return Err(SdkError::InvalidRequest("quote has expired".to_owned()));
694 }
695 let owner_wallet = canonical_public_key(owner_wallet, "owner_wallet")?;
696 let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
697 let markets = self.markets().await?;
698 let market = markets
699 .markets
700 .iter()
701 .find(|market| market.market_pda.as_deref() == Some(quote.market_id.as_str()))
702 .ok_or_else(|| SdkError::MarketNotFound(quote.market_id.clone()))?;
703 let quote_path = market
704 .quote_path
705 .as_deref()
706 .filter(|path| valid_public_operation_path(path))
707 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
708 let execution_path = format!(
709 "{}/execution",
710 quote_path
711 .strip_suffix("/quote")
712 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
713 );
714 let challenge: ExecutionChallengeResponse = self
715 .post(
716 &format!("{execution_path}/challenge"),
717 &ExecutionChallengeRequest {
718 quote_id: quote.quote_id.clone(),
719 owner_wallet: owner_wallet.clone(),
720 session_public_key: session_public_key.clone(),
721 account_sequence: account_sequence.to_string(),
722 },
723 )
724 .await?;
725 validate_execution_challenge(&challenge, quote)?;
726 let authorization = validate_execution_authorization(
727 &challenge,
728 quote,
729 &owner_wallet,
730 &session_public_key,
731 account_sequence,
732 )?;
733 let signature = signer
734 .sign_message(&authorization.bytes)
735 .await
736 .map_err(SdkError::Signer)?;
737 if signature.len() != 64 {
738 return Err(SdkError::InvalidResponse(
739 "session authorization signature must contain 64 bytes".to_owned(),
740 ));
741 }
742 let prepared: ExecutionPrepareResponse = self
743 .post(
744 &format!("{execution_path}/prepare"),
745 &ExecutionPrepareRequest {
746 challenge_id: challenge.challenge_id.clone(),
747 authorization_signature: bs58::encode(signature).into_string(),
748 },
749 )
750 .await?;
751 validate_execution_prepare(&prepared, quote, &challenge, &authorization)?;
752 verifier
753 .verify(&ExecutionVerificationContext {
754 quote,
755 challenge: &challenge,
756 prepared: &prepared,
757 owner_wallet: &owner_wallet,
758 session_public_key: &session_public_key,
759 })
760 .await
761 .map_err(SdkError::Verification)?;
762 let signed_transaction = signer
763 .sign_transaction(&prepared.transaction_base64)
764 .await
765 .map_err(SdkError::Signer)?;
766 base64::engine::general_purpose::STANDARD
767 .decode(signed_transaction.trim())
768 .map_err(|_| {
769 SdkError::InvalidResponse(
770 "session signer returned an invalid base64 transaction".to_owned(),
771 )
772 })?;
773 let idempotency_key =
774 normalize_idempotency_key(idempotency_key.unwrap_or(&prepared.execution_id))?;
775 let submitted: ExecutionSubmitResponse = self
776 .post(
777 &format!("{execution_path}/submit"),
778 &ExecutionSubmitRequest {
779 execution_id: prepared.execution_id.clone(),
780 signed_transaction_base64: signed_transaction,
781 idempotency_key,
782 },
783 )
784 .await?;
785 validate_version(submitted.schema_version, &submitted.contract_version)?;
786 if submitted.execution_id != prepared.execution_id
787 || submitted.status != ExecutionStatus::Submitted
788 || submitted.signature.trim().is_empty()
789 {
790 return Err(SdkError::InvalidResponse(
791 "execution receipt does not match the prepared transaction".to_owned(),
792 ));
793 }
794 Ok(submitted)
795 }
796
797 async fn get<T: DeserializeOwned>(
798 &self,
799 path: &str,
800 query: &[(&str, &str)],
801 ) -> Result<T, SdkError> {
802 let mut url = self.base_url.join(path).map_err(|error| {
803 SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
804 })?;
805 url.query_pairs_mut().extend_pairs(query.iter().copied());
806
807 let response = self
808 .http
809 .get(url)
810 .header(reqwest::header::ACCEPT, "application/json")
811 .send()
812 .await?;
813 let status = response.status();
814 let bytes = response.bytes().await?;
815 if !status.is_success() {
816 return match serde_json::from_slice::<ErrorResponse>(&bytes) {
817 Ok(error) => Err(SdkError::Api {
818 status,
819 code: error.error.code,
820 message: error.error.message,
821 retryable: error.error.retryable,
822 }),
823 Err(_) => Err(SdkError::Api {
824 status,
825 code: "request_failed".to_owned(),
826 message: "Strata could not complete the request.".to_owned(),
827 retryable: status.is_server_error(),
828 }),
829 };
830 }
831 serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
832 }
833
834 async fn post<T: DeserializeOwned, B: serde::Serialize>(
835 &self,
836 path: &str,
837 body: &B,
838 ) -> Result<T, SdkError> {
839 let url = self.base_url.join(path).map_err(|error| {
840 SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
841 })?;
842 let response = self
843 .http
844 .post(url)
845 .header(reqwest::header::ACCEPT, "application/json")
846 .json(body)
847 .send()
848 .await?;
849 let status = response.status();
850 let bytes = response.bytes().await?;
851 if !status.is_success() {
852 return match serde_json::from_slice::<ErrorResponse>(&bytes) {
853 Ok(error) => Err(SdkError::Api {
854 status,
855 code: error.error.code,
856 message: error.error.message,
857 retryable: error.error.retryable,
858 }),
859 Err(_) => Err(SdkError::Api {
860 status,
861 code: "request_failed".to_owned(),
862 message: "Strata could not complete the request.".to_owned(),
863 retryable: status.is_server_error(),
864 }),
865 };
866 }
867 serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
868 }
869
870 async fn execution_path(&self, requested_market: &str) -> Result<String, SdkError> {
871 let markets = self.markets().await?;
872 let market = markets
873 .markets
874 .iter()
875 .find(|market| {
876 market.label.eq_ignore_ascii_case(requested_market.trim())
877 || market.market_pda.as_deref() == Some(requested_market.trim())
878 })
879 .ok_or_else(|| SdkError::MarketNotFound(requested_market.to_owned()))?;
880 if !market.ready {
881 return Err(SdkError::OperationUnavailable(market.label.clone()));
882 }
883 let quote_path = market
884 .quote_path
885 .as_deref()
886 .filter(|path| valid_public_operation_path(path))
887 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
888 Ok(format!(
889 "{}/execution",
890 quote_path
891 .strip_suffix("/quote")
892 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
893 ))
894 }
895}
896
897fn normalize_base_url(value: &str) -> Result<Url, SdkError> {
898 let mut normalized = value.trim().to_owned();
899 if !normalized.ends_with('/') {
900 normalized.push('/');
901 }
902 let url =
903 Url::parse(&normalized).map_err(|error| SdkError::InvalidBaseUrl(error.to_string()))?;
904 if !matches!(url.scheme(), "http" | "https") || url.cannot_be_a_base() {
905 return Err(SdkError::InvalidBaseUrl(
906 "URL must use http or https and include a host".to_owned(),
907 ));
908 }
909 Ok(url)
910}
911
912fn validate_action_graph(graph: &ActionGraph) -> Result<(), SdkError> {
913 validate_version(graph.schema_version, &graph.contract_version)?;
914 if graph.graph_version != "1.0"
915 || graph.authority.permission_source != "external_agent_owner"
916 || graph.authority.signing_location != "external"
917 || graph.authority.accepts_private_keys
918 {
919 return Err(SdkError::InvalidResponse(
920 "unsupported action graph authority model".to_owned(),
921 ));
922 }
923 let ids = graph
924 .nodes
925 .iter()
926 .map(|node| node.id.as_str())
927 .collect::<HashSet<_>>();
928 if ids.len() != graph.nodes.len() || !ids.contains(graph.entry_node.as_str()) {
929 return Err(SdkError::InvalidResponse(
930 "action graph node IDs are invalid".to_owned(),
931 ));
932 }
933 if graph.edges.iter().any(|edge| {
934 !ids.contains(edge.from.as_str())
935 || !ids.contains(edge.to.as_str())
936 || edge.condition.trim().is_empty()
937 }) {
938 return Err(SdkError::InvalidResponse(
939 "action graph contains an invalid edge".to_owned(),
940 ));
941 }
942 Ok(())
943}
944
945fn validate_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
946 if schema_version != CONTRACT_MAJOR || contract_version != CONTRACT_VERSION {
947 return Err(SdkError::InvalidResponse(format!(
948 "unsupported contract {contract_version} (schema {schema_version})"
949 )));
950 }
951 Ok(())
952}
953
954fn validate_platform_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
955 if schema_version != strata_public_contract::platform::PLATFORM_SCHEMA_VERSION
956 || contract_version != strata_public_contract::platform::PLATFORM_CONTRACT_VERSION
957 {
958 return Err(SdkError::InvalidResponse(format!(
959 "unsupported platform contract {contract_version} (schema {schema_version})"
960 )));
961 }
962 Ok(())
963}
964
965fn validate_platform_market_id(value: &str) -> Result<String, SdkError> {
966 let value = value.trim();
967 if !valid_handle(value, "market_") {
968 return Err(SdkError::InvalidRequest(
969 "market_id must be an opaque Strata market ID".to_owned(),
970 ));
971 }
972 Ok(value.to_owned())
973}
974
975fn canonical_request_atoms(value: &str, field: &str, allow_zero: bool) -> Result<String, SdkError> {
976 if value.is_empty()
977 || !value.bytes().all(|byte| byte.is_ascii_digit())
978 || (value.len() > 1 && value.starts_with('0'))
979 {
980 return Err(SdkError::InvalidRequest(format!(
981 "{field} must be a canonical unsigned atomic decimal string"
982 )));
983 }
984 let parsed = value
985 .parse::<u64>()
986 .map_err(|_| SdkError::InvalidRequest(format!("{field} exceeds u64")))?;
987 if !allow_zero && parsed == 0 {
988 return Err(SdkError::InvalidRequest(format!(
989 "{field} must be greater than zero"
990 )));
991 }
992 Ok(parsed.to_string())
993}
994
995fn canonical_signature(value: &str, field: &str) -> Result<String, SdkError> {
996 let value = value.trim();
997 let decoded = bs58::decode(value)
998 .into_vec()
999 .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
1000 if decoded.len() != 64 || bs58::encode(&decoded).into_string() != value {
1001 return Err(SdkError::InvalidRequest(format!(
1002 "{field} must be a canonical Ed25519 signature"
1003 )));
1004 }
1005 Ok(value.to_owned())
1006}
1007
1008fn canonical_base58_32(value: &str, field: &str) -> Result<String, SdkError> {
1009 let value = value.trim();
1010 let decoded = bs58::decode(value)
1011 .into_vec()
1012 .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
1013 if decoded.len() != 32 || bs58::encode(&decoded).into_string() != value {
1014 return Err(SdkError::InvalidRequest(format!(
1015 "{field} must be a canonical 32-byte base58 value"
1016 )));
1017 }
1018 Ok(value.to_owned())
1019}
1020
1021fn canonical_base64(value: &str, field: &str) -> Result<String, SdkError> {
1022 let value = value.trim();
1023 let decoded = base64::engine::general_purpose::STANDARD
1024 .decode(value)
1025 .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base64")))?;
1026 if decoded.is_empty() || base64::engine::general_purpose::STANDARD.encode(decoded) != value {
1027 return Err(SdkError::InvalidRequest(format!(
1028 "{field} must be canonical base64"
1029 )));
1030 }
1031 Ok(value.to_owned())
1032}
1033
1034fn normalize_order_challenge_request(
1035 request: PlatformOrderChallengeRequest,
1036) -> Result<PlatformOrderChallengeRequest, SdkError> {
1037 let normalized = match request {
1038 PlatformOrderChallengeRequest::Place {
1039 owner_wallet,
1040 session_public_key,
1041 account_sequence,
1042 client_order_id,
1043 side,
1044 order_type,
1045 limit_price_atoms,
1046 size_atoms,
1047 } => {
1048 let client_order_id = client_order_id.trim().to_owned();
1049 if client_order_id.is_empty()
1050 || client_order_id.len() > 64
1051 || !client_order_id
1052 .bytes()
1053 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1054 || !matches!(
1055 order_type,
1056 PlatformOrderType::GoodUntilCancelled | PlatformOrderType::PostOnly
1057 )
1058 {
1059 return Err(SdkError::InvalidRequest(
1060 "resting order client ID or type is invalid".to_owned(),
1061 ));
1062 }
1063 PlatformOrderChallengeRequest::Place {
1064 owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1065 session_public_key: canonical_public_key(
1066 &session_public_key,
1067 "session_public_key",
1068 )?,
1069 account_sequence: canonical_request_atoms(
1070 &account_sequence,
1071 "account_sequence",
1072 true,
1073 )?,
1074 client_order_id,
1075 side,
1076 order_type,
1077 limit_price_atoms: canonical_request_atoms(
1078 &limit_price_atoms,
1079 "limit_price_atoms",
1080 false,
1081 )?,
1082 size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
1083 }
1084 }
1085 PlatformOrderChallengeRequest::Cancel {
1086 owner_wallet,
1087 session_public_key,
1088 order_id,
1089 } => {
1090 if !valid_handle(order_id.trim(), "order_") {
1091 return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
1092 }
1093 PlatformOrderChallengeRequest::Cancel {
1094 owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1095 session_public_key: canonical_public_key(
1096 &session_public_key,
1097 "session_public_key",
1098 )?,
1099 order_id: order_id.trim().to_owned(),
1100 }
1101 }
1102 PlatformOrderChallengeRequest::CancelAll {
1103 owner_wallet,
1104 session_public_key,
1105 } => PlatformOrderChallengeRequest::CancelAll {
1106 owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1107 session_public_key: canonical_public_key(&session_public_key, "session_public_key")?,
1108 },
1109 PlatformOrderChallengeRequest::Replace {
1110 owner_wallet,
1111 session_public_key,
1112 order_id,
1113 account_sequence,
1114 client_order_id,
1115 side,
1116 order_type,
1117 limit_price_atoms,
1118 size_atoms,
1119 } => {
1120 let PlatformOrderBatchOperation::Replace {
1121 order_id,
1122 account_sequence,
1123 client_order_id,
1124 side,
1125 order_type,
1126 limit_price_atoms,
1127 size_atoms,
1128 } = normalize_order_batch_operation(PlatformOrderBatchOperation::Replace {
1129 order_id,
1130 account_sequence,
1131 client_order_id,
1132 side,
1133 order_type,
1134 limit_price_atoms,
1135 size_atoms,
1136 })?
1137 else {
1138 unreachable!()
1139 };
1140 PlatformOrderChallengeRequest::Replace {
1141 owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1142 session_public_key: canonical_public_key(
1143 &session_public_key,
1144 "session_public_key",
1145 )?,
1146 order_id,
1147 account_sequence,
1148 client_order_id,
1149 side,
1150 order_type,
1151 limit_price_atoms,
1152 size_atoms,
1153 }
1154 }
1155 PlatformOrderChallengeRequest::Batch {
1156 owner_wallet,
1157 session_public_key,
1158 operations,
1159 } => {
1160 if operations.is_empty() || operations.len() > 6 {
1161 return Err(SdkError::InvalidRequest(
1162 "order batch must contain between one and six operations".to_owned(),
1163 ));
1164 }
1165 PlatformOrderChallengeRequest::Batch {
1166 owner_wallet: canonical_public_key(&owner_wallet, "owner_wallet")?,
1167 session_public_key: canonical_public_key(
1168 &session_public_key,
1169 "session_public_key",
1170 )?,
1171 operations: operations
1172 .into_iter()
1173 .map(normalize_order_batch_operation)
1174 .collect::<Result<_, _>>()?,
1175 }
1176 }
1177 };
1178 if order_request_owner(&normalized) == order_request_session(&normalized) {
1179 return Err(SdkError::InvalidRequest(
1180 "session_public_key must be distinct from owner_wallet".to_owned(),
1181 ));
1182 }
1183 Ok(normalized)
1184}
1185
1186fn normalize_order_batch_operation(
1187 operation: PlatformOrderBatchOperation,
1188) -> Result<PlatformOrderBatchOperation, SdkError> {
1189 match operation {
1190 PlatformOrderBatchOperation::Place {
1191 account_sequence,
1192 client_order_id,
1193 side,
1194 order_type,
1195 limit_price_atoms,
1196 size_atoms,
1197 } => {
1198 let client_order_id = normalize_order_client_id(client_order_id, order_type)?;
1199 Ok(PlatformOrderBatchOperation::Place {
1200 account_sequence: canonical_request_atoms(
1201 &account_sequence,
1202 "account_sequence",
1203 true,
1204 )?,
1205 client_order_id,
1206 side,
1207 order_type,
1208 limit_price_atoms: canonical_request_atoms(
1209 &limit_price_atoms,
1210 "limit_price_atoms",
1211 false,
1212 )?,
1213 size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
1214 })
1215 }
1216 PlatformOrderBatchOperation::Cancel { order_id } => {
1217 if !valid_handle(order_id.trim(), "order_") {
1218 return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
1219 }
1220 Ok(PlatformOrderBatchOperation::Cancel {
1221 order_id: order_id.trim().to_owned(),
1222 })
1223 }
1224 PlatformOrderBatchOperation::Replace {
1225 order_id,
1226 account_sequence,
1227 client_order_id,
1228 side,
1229 order_type,
1230 limit_price_atoms,
1231 size_atoms,
1232 } => {
1233 if !valid_handle(order_id.trim(), "order_") {
1234 return Err(SdkError::InvalidRequest("order_id is invalid".to_owned()));
1235 }
1236 let client_order_id = normalize_order_client_id(client_order_id, order_type)?;
1237 Ok(PlatformOrderBatchOperation::Replace {
1238 order_id: order_id.trim().to_owned(),
1239 account_sequence: canonical_request_atoms(
1240 &account_sequence,
1241 "account_sequence",
1242 true,
1243 )?,
1244 client_order_id,
1245 side,
1246 order_type,
1247 limit_price_atoms: canonical_request_atoms(
1248 &limit_price_atoms,
1249 "limit_price_atoms",
1250 false,
1251 )?,
1252 size_atoms: canonical_request_atoms(&size_atoms, "size_atoms", false)?,
1253 })
1254 }
1255 }
1256}
1257
1258fn normalize_order_client_id(
1259 client_order_id: String,
1260 order_type: PlatformOrderType,
1261) -> Result<String, SdkError> {
1262 let client_order_id = client_order_id.trim().to_owned();
1263 if client_order_id.is_empty()
1264 || client_order_id.len() > 64
1265 || !client_order_id
1266 .bytes()
1267 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1268 || !matches!(
1269 order_type,
1270 PlatformOrderType::GoodUntilCancelled | PlatformOrderType::PostOnly
1271 )
1272 {
1273 return Err(SdkError::InvalidRequest(
1274 "resting order client ID or type is invalid".to_owned(),
1275 ));
1276 }
1277 Ok(client_order_id)
1278}
1279
1280fn order_request_action(request: &PlatformOrderChallengeRequest) -> PlatformOrderAction {
1281 match request {
1282 PlatformOrderChallengeRequest::Place { .. } => PlatformOrderAction::Place,
1283 PlatformOrderChallengeRequest::Cancel { .. } => PlatformOrderAction::Cancel,
1284 PlatformOrderChallengeRequest::CancelAll { .. } => PlatformOrderAction::CancelAll,
1285 PlatformOrderChallengeRequest::Replace { .. } => PlatformOrderAction::Replace,
1286 PlatformOrderChallengeRequest::Batch { .. } => PlatformOrderAction::Batch,
1287 }
1288}
1289
1290fn order_request_owner(request: &PlatformOrderChallengeRequest) -> &str {
1291 match request {
1292 PlatformOrderChallengeRequest::Place { owner_wallet, .. }
1293 | PlatformOrderChallengeRequest::Cancel { owner_wallet, .. }
1294 | PlatformOrderChallengeRequest::CancelAll { owner_wallet, .. }
1295 | PlatformOrderChallengeRequest::Replace { owner_wallet, .. }
1296 | PlatformOrderChallengeRequest::Batch { owner_wallet, .. } => owner_wallet,
1297 }
1298}
1299
1300fn order_request_session(request: &PlatformOrderChallengeRequest) -> &str {
1301 match request {
1302 PlatformOrderChallengeRequest::Place {
1303 session_public_key, ..
1304 }
1305 | PlatformOrderChallengeRequest::Cancel {
1306 session_public_key, ..
1307 }
1308 | PlatformOrderChallengeRequest::CancelAll {
1309 session_public_key, ..
1310 }
1311 | PlatformOrderChallengeRequest::Replace {
1312 session_public_key, ..
1313 }
1314 | PlatformOrderChallengeRequest::Batch {
1315 session_public_key, ..
1316 } => session_public_key,
1317 }
1318}
1319
1320struct OrderAuthorization {
1321 bytes: Vec<u8>,
1322 recent_blockhash: String,
1323 last_valid_block_height: u64,
1324}
1325
1326#[allow(clippy::too_many_arguments)]
1327fn validate_order_place_authorization(
1328 bytes: &[u8],
1329 cursor: &mut usize,
1330 challenge: &PlatformOrderChallengeResponse,
1331 account_sequence: &str,
1332 client_order_id: &str,
1333 side: PlatformTradeSide,
1334 order_type: PlatformOrderType,
1335 limit_price_atoms: &str,
1336 size_atoms: &str,
1337) -> Result<String, SdkError> {
1338 take_u64_eq(
1339 bytes,
1340 cursor,
1341 parse_request_u64(account_sequence, "account_sequence")?,
1342 "order account sequence",
1343 )?;
1344 let client_length = take_u16(bytes, cursor, "client order ID length")? as usize;
1345 if client_length != client_order_id.len() {
1346 return Err(SdkError::InvalidResponse(
1347 "client order ID length changed".to_owned(),
1348 ));
1349 }
1350 take_expected(bytes, cursor, client_order_id.as_bytes(), "client order ID")?;
1351 let actual_side = take_bytes(bytes, cursor, 1, "order side")?[0];
1352 let expected_side = if side == PlatformTradeSide::Buy { 0 } else { 1 };
1353 if actual_side != expected_side {
1354 return Err(SdkError::InvalidResponse("order side changed".to_owned()));
1355 }
1356 let actual_type = take_bytes(bytes, cursor, 1, "order type")?[0];
1357 let expected_type = match order_type {
1358 PlatformOrderType::GoodUntilCancelled => 0,
1359 PlatformOrderType::PostOnly => 3,
1360 PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
1361 return Err(SdkError::InvalidRequest(
1362 "order type is not a resting order".to_owned(),
1363 ));
1364 }
1365 };
1366 if actual_type != expected_type {
1367 return Err(SdkError::InvalidResponse("order type changed".to_owned()));
1368 }
1369 take_u64_eq(
1370 bytes,
1371 cursor,
1372 parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
1373 "order limit price",
1374 )?;
1375 take_u64_eq(
1376 bytes,
1377 cursor,
1378 parse_request_u64(size_atoms, "size_atoms")?,
1379 "order size",
1380 )?;
1381 let order = take_bytes(bytes, cursor, 32, "order identity")?;
1382 Ok(opaque_order_id(&challenge.market_id, order))
1383}
1384
1385fn validate_order_cancel_authorization(
1386 bytes: &[u8],
1387 cursor: &mut usize,
1388 challenge: &PlatformOrderChallengeResponse,
1389 expected_order_id: &str,
1390) -> Result<String, SdkError> {
1391 let order = take_bytes(bytes, cursor, 32, "cancel order identity")?;
1392 let rent_source = take_bytes(bytes, cursor, 1, "cancel rent source")?[0];
1393 if rent_source > 1 {
1394 return Err(SdkError::InvalidResponse(
1395 "cancel rent source is invalid".to_owned(),
1396 ));
1397 }
1398 let order_id = opaque_order_id(&challenge.market_id, order);
1399 if order_id != expected_order_id {
1400 return Err(SdkError::InvalidResponse(
1401 "cancel order identity changed".to_owned(),
1402 ));
1403 }
1404 Ok(order_id)
1405}
1406
1407fn validate_order_authorization(
1408 challenge: &PlatformOrderChallengeResponse,
1409 request: &PlatformOrderChallengeRequest,
1410) -> Result<OrderAuthorization, SdkError> {
1411 let bytes = base64::engine::general_purpose::STANDARD
1412 .decode(challenge.authorization_payload_base64.trim())
1413 .map_err(|_| SdkError::InvalidResponse("order authorization is not base64".to_owned()))?;
1414 let owner = decode_public_key(order_request_owner(request), "owner_wallet")?;
1415 let session = decode_public_key(order_request_session(request), "session_public_key")?;
1416 let mut cursor = 0usize;
1417 take_expected(
1418 &bytes,
1419 &mut cursor,
1420 PUBLIC_ORDER_AUTH_DOMAIN,
1421 "order authorization domain",
1422 )?;
1423 let _market = take_bytes(&bytes, &mut cursor, 32, "order authorization market")?;
1424 take_expected(&bytes, &mut cursor, &owner, "order authorization owner")?;
1425 take_expected(&bytes, &mut cursor, &session, "order authorization session")?;
1426 let action = take_bytes(&bytes, &mut cursor, 1, "order authorization action")?[0];
1427 let expected_action = match order_request_action(request) {
1428 PlatformOrderAction::Place => 0,
1429 PlatformOrderAction::Cancel => 1,
1430 PlatformOrderAction::CancelAll => 2,
1431 PlatformOrderAction::Replace => 3,
1432 PlatformOrderAction::Batch => 4,
1433 };
1434 if action != expected_action || challenge.action != order_request_action(request) {
1435 return Err(SdkError::InvalidResponse(
1436 "order authorization action changed".to_owned(),
1437 ));
1438 }
1439 let mut derived_order_ids = Vec::new();
1440 match request {
1441 PlatformOrderChallengeRequest::Place {
1442 account_sequence,
1443 client_order_id,
1444 side,
1445 order_type,
1446 limit_price_atoms,
1447 size_atoms,
1448 ..
1449 } => {
1450 take_u64_eq(
1451 &bytes,
1452 &mut cursor,
1453 parse_request_u64(account_sequence, "account_sequence")?,
1454 "order account sequence",
1455 )?;
1456 let client_length = take_u16(&bytes, &mut cursor, "client order ID length")? as usize;
1457 if client_length != client_order_id.len() {
1458 return Err(SdkError::InvalidResponse(
1459 "client order ID length changed".to_owned(),
1460 ));
1461 }
1462 take_expected(
1463 &bytes,
1464 &mut cursor,
1465 client_order_id.as_bytes(),
1466 "client order ID",
1467 )?;
1468 let actual_side = take_bytes(&bytes, &mut cursor, 1, "order side")?[0];
1469 let expected_side = if *side == PlatformTradeSide::Buy {
1470 0
1471 } else {
1472 1
1473 };
1474 if actual_side != expected_side {
1475 return Err(SdkError::InvalidResponse("order side changed".to_owned()));
1476 }
1477 let actual_type = take_bytes(&bytes, &mut cursor, 1, "order type")?[0];
1478 let expected_type = match order_type {
1479 PlatformOrderType::GoodUntilCancelled => 0,
1480 PlatformOrderType::PostOnly => 3,
1481 PlatformOrderType::ImmediateOrCancel | PlatformOrderType::FillOrKill => {
1482 return Err(SdkError::InvalidRequest(
1483 "order type is not a resting order".to_owned(),
1484 ));
1485 }
1486 };
1487 if actual_type != expected_type {
1488 return Err(SdkError::InvalidResponse("order type changed".to_owned()));
1489 }
1490 take_u64_eq(
1491 &bytes,
1492 &mut cursor,
1493 parse_request_u64(limit_price_atoms, "limit_price_atoms")?,
1494 "order limit price",
1495 )?;
1496 take_u64_eq(
1497 &bytes,
1498 &mut cursor,
1499 parse_request_u64(size_atoms, "size_atoms")?,
1500 "order size",
1501 )?;
1502 let order = take_bytes(&bytes, &mut cursor, 32, "order identity")?;
1503 derived_order_ids.push(opaque_order_id(&challenge.market_id, order));
1504 }
1505 PlatformOrderChallengeRequest::Cancel { .. }
1506 | PlatformOrderChallengeRequest::CancelAll { .. } => {
1507 let count = usize::from(take_bytes(&bytes, &mut cursor, 1, "cancel order count")?[0]);
1508 if count == 0
1509 || count > 6
1510 || (matches!(request, PlatformOrderChallengeRequest::Cancel { .. }) && count != 1)
1511 {
1512 return Err(SdkError::InvalidResponse(
1513 "cancel order count changed".to_owned(),
1514 ));
1515 }
1516 for index in 0..count {
1517 let order = take_bytes(&bytes, &mut cursor, 32, &format!("cancel order {index}"))?;
1518 let rent_source = take_bytes(
1519 &bytes,
1520 &mut cursor,
1521 1,
1522 &format!("cancel rent source {index}"),
1523 )?[0];
1524 if rent_source > 1 {
1525 return Err(SdkError::InvalidResponse(
1526 "cancel rent source is invalid".to_owned(),
1527 ));
1528 }
1529 derived_order_ids.push(opaque_order_id(&challenge.market_id, order));
1530 }
1531 if let PlatformOrderChallengeRequest::Cancel { order_id, .. } = request {
1532 if derived_order_ids.first() != Some(order_id) {
1533 return Err(SdkError::InvalidResponse(
1534 "cancel order identity changed".to_owned(),
1535 ));
1536 }
1537 }
1538 }
1539 PlatformOrderChallengeRequest::Replace {
1540 order_id,
1541 account_sequence,
1542 client_order_id,
1543 side,
1544 order_type,
1545 limit_price_atoms,
1546 size_atoms,
1547 ..
1548 } => {
1549 derived_order_ids.push(validate_order_cancel_authorization(
1550 &bytes,
1551 &mut cursor,
1552 challenge,
1553 order_id,
1554 )?);
1555 derived_order_ids.push(validate_order_place_authorization(
1556 &bytes,
1557 &mut cursor,
1558 challenge,
1559 account_sequence,
1560 client_order_id,
1561 *side,
1562 *order_type,
1563 limit_price_atoms,
1564 size_atoms,
1565 )?);
1566 }
1567 PlatformOrderChallengeRequest::Batch { operations, .. } => {
1568 let count = usize::from(take_bytes(&bytes, &mut cursor, 1, "batch count")?[0]);
1569 if count == 0 || count > 6 || count != operations.len() {
1570 return Err(SdkError::InvalidResponse(
1571 "order batch count changed".to_owned(),
1572 ));
1573 }
1574 for operation in operations {
1575 let tag = take_bytes(&bytes, &mut cursor, 1, "batch action")?[0];
1576 match operation {
1577 PlatformOrderBatchOperation::Place {
1578 account_sequence,
1579 client_order_id,
1580 side,
1581 order_type,
1582 limit_price_atoms,
1583 size_atoms,
1584 } if tag == 0 => derived_order_ids.push(validate_order_place_authorization(
1585 &bytes,
1586 &mut cursor,
1587 challenge,
1588 account_sequence,
1589 client_order_id,
1590 *side,
1591 *order_type,
1592 limit_price_atoms,
1593 size_atoms,
1594 )?),
1595 PlatformOrderBatchOperation::Cancel { order_id } if tag == 1 => {
1596 derived_order_ids.push(validate_order_cancel_authorization(
1597 &bytes,
1598 &mut cursor,
1599 challenge,
1600 order_id,
1601 )?)
1602 }
1603 PlatformOrderBatchOperation::Replace {
1604 order_id,
1605 account_sequence,
1606 client_order_id,
1607 side,
1608 order_type,
1609 limit_price_atoms,
1610 size_atoms,
1611 } if tag == 3 => {
1612 derived_order_ids.push(validate_order_cancel_authorization(
1613 &bytes,
1614 &mut cursor,
1615 challenge,
1616 order_id,
1617 )?);
1618 derived_order_ids.push(validate_order_place_authorization(
1619 &bytes,
1620 &mut cursor,
1621 challenge,
1622 account_sequence,
1623 client_order_id,
1624 *side,
1625 *order_type,
1626 limit_price_atoms,
1627 size_atoms,
1628 )?);
1629 }
1630 _ => {
1631 return Err(SdkError::InvalidResponse(
1632 "order batch action changed".to_owned(),
1633 ))
1634 }
1635 }
1636 }
1637 }
1638 }
1639 if derived_order_ids != challenge.order_ids {
1640 return Err(SdkError::InvalidResponse(
1641 "order authorization opaque identities changed".to_owned(),
1642 ));
1643 }
1644 let recent_blockhash = bs58::encode(take_bytes(
1645 &bytes,
1646 &mut cursor,
1647 32,
1648 "order authorization blockhash",
1649 )?)
1650 .into_string();
1651 let last_valid_block_height = take_u64(
1652 &bytes,
1653 &mut cursor,
1654 "order authorization last valid block height",
1655 )?;
1656 take_u64_eq(
1657 &bytes,
1658 &mut cursor,
1659 challenge.expires_at_ms,
1660 "order authorization expiry",
1661 )?;
1662 let nonce = take_bytes(&bytes, &mut cursor, 16, "order authorization nonce")?;
1663 if hex::encode(nonce) != challenge.challenge_id[3..] {
1664 return Err(SdkError::InvalidResponse(
1665 "order challenge nonce changed".to_owned(),
1666 ));
1667 }
1668 let _epoch = take_bytes(&bytes, &mut cursor, 16, "order authorization epoch")?;
1669 if cursor != bytes.len() {
1670 return Err(SdkError::InvalidResponse(
1671 "order authorization contains unrecognized fields".to_owned(),
1672 ));
1673 }
1674 Ok(OrderAuthorization {
1675 bytes,
1676 recent_blockhash,
1677 last_valid_block_height,
1678 })
1679}
1680
1681fn validate_order_prepare_binding(
1682 prepared: &PlatformOrderPrepareResponse,
1683 challenge: &PlatformOrderChallengeResponse,
1684 authorization: &OrderAuthorization,
1685) -> Result<(), SdkError> {
1686 if prepared.market_id != challenge.market_id
1687 || prepared.action != challenge.action
1688 || prepared.order_ids != challenge.order_ids
1689 || prepared.recent_blockhash != authorization.recent_blockhash
1690 || prepared.last_valid_block_height != authorization.last_valid_block_height
1691 || prepared.expires_at_ms != challenge.expires_at_ms
1692 {
1693 return Err(SdkError::InvalidResponse(
1694 "prepared order control changed the signed bindings".to_owned(),
1695 ));
1696 }
1697 Ok(())
1698}
1699
1700fn parse_request_u64(value: &str, field: &str) -> Result<u64, SdkError> {
1701 value
1702 .parse::<u64>()
1703 .map_err(|_| SdkError::InvalidRequest(format!("{field} exceeds u64")))
1704}
1705
1706fn take_u16(source: &[u8], cursor: &mut usize, field: &str) -> Result<u16, SdkError> {
1707 let bytes: [u8; 2] = take_bytes(source, cursor, 2, field)?
1708 .try_into()
1709 .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
1710 Ok(u16::from_le_bytes(bytes))
1711}
1712
1713fn opaque_order_id(market_id: &str, order: &[u8]) -> String {
1714 let mut digest = Sha256::new();
1715 digest.update(b"strata-sdk-product:v1\0");
1716 digest.update(b"order");
1717 digest.update([0]);
1718 digest.update(market_id.as_bytes());
1719 digest.update(b":");
1720 digest.update(bs58::encode(order).into_string().as_bytes());
1721 format!("order_{}", hex::encode(&digest.finalize()[..16]))
1722}
1723
1724fn parse_atoms(field: &str, value: &str) -> Result<u64, SdkError> {
1725 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
1726 return Err(SdkError::InvalidResponse(format!(
1727 "{field} must be an unsigned atomic decimal string"
1728 )));
1729 }
1730 value
1731 .parse::<u64>()
1732 .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds the supported range")))
1733}
1734
1735fn valid_public_operation_path(path: &str) -> bool {
1736 let Some(market_id) = path
1737 .strip_prefix("/sonar/markets/")
1738 .and_then(|value| value.strip_suffix("/quote"))
1739 else {
1740 return false;
1741 };
1742 !market_id.is_empty()
1743 && !market_id.starts_with('-')
1744 && !market_id.ends_with('-')
1745 && market_id
1746 .bytes()
1747 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1748}
1749
1750fn validate_quote(
1751 quote: &QuoteResponse,
1752 market_id: &str,
1753 request: &QuoteRequest,
1754 requested_amount: u64,
1755) -> Result<(), SdkError> {
1756 validate_version(quote.schema_version, "e.contract_version)?;
1757 if quote.provider != "Sonar"
1758 || quote.market_id != market_id
1759 || quote.side != request.side
1760 || quote.amount_in_atoms != request.amount_in_atoms
1761 || quote.quote_id.len() != 35
1762 || !quote.quote_id.starts_with("sq_")
1763 || !quote.quote_id[3..]
1764 .bytes()
1765 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1766 || quote.expires_at_ms <= quote.server_time_ms
1767 {
1768 return Err(SdkError::InvalidResponse(
1769 "quote binding or lifetime is invalid".to_owned(),
1770 ));
1771 }
1772
1773 let consumed = parse_atoms("amount_in_consumed_atoms", "e.amount_in_consumed_atoms)?;
1774 let output = parse_atoms("amount_out_atoms", "e.amount_out_atoms)?;
1775 let minimum = parse_atoms("minimum_output_atoms", "e.minimum_output_atoms)?;
1776 parse_atoms("input_fee_atoms", "e.input_fee_atoms)?;
1777 parse_atoms("output_fee_atoms", "e.output_fee_atoms)?;
1778 if consumed > requested_amount || minimum > output {
1779 return Err(SdkError::InvalidResponse(
1780 "quote economics are internally inconsistent".to_owned(),
1781 ));
1782 }
1783 quote
1784 .reference_price
1785 .parse::<f64>()
1786 .ok()
1787 .filter(|value| value.is_finite() && *value > 0.0)
1788 .ok_or_else(|| SdkError::InvalidResponse("reference_price is invalid".to_owned()))?;
1789 quote
1790 .price_impact_pct
1791 .parse::<f64>()
1792 .ok()
1793 .filter(|value| value.is_finite() && *value >= 0.0)
1794 .ok_or_else(|| SdkError::InvalidResponse("price_impact_pct is invalid".to_owned()))?;
1795 Ok(())
1796}
1797
1798struct ExecutionAuthorization {
1799 bytes: Vec<u8>,
1800 recent_blockhash: String,
1801 last_valid_block_height: u64,
1802}
1803
1804fn validate_execution_challenge(
1805 challenge: &ExecutionChallengeResponse,
1806 quote: &QuoteResponse,
1807) -> Result<(), SdkError> {
1808 validate_version(challenge.schema_version, &challenge.contract_version)?;
1809 validate_execution_binding(
1810 &challenge.quote_id,
1811 &challenge.market_id,
1812 challenge.side,
1813 &challenge.amount_in_atoms,
1814 &challenge.minimum_output_atoms,
1815 quote,
1816 )?;
1817 if !valid_handle(&challenge.challenge_id, "sc_")
1818 || challenge.expires_at_ms <= challenge.server_time_ms
1819 || challenge.expires_at_ms > quote.expires_at_ms
1820 {
1821 return Err(SdkError::InvalidResponse(
1822 "execution challenge binding or lifetime is invalid".to_owned(),
1823 ));
1824 }
1825 Ok(())
1826}
1827
1828fn validate_execution_prepare(
1829 prepared: &ExecutionPrepareResponse,
1830 quote: &QuoteResponse,
1831 challenge: &ExecutionChallengeResponse,
1832 authorization: &ExecutionAuthorization,
1833) -> Result<(), SdkError> {
1834 validate_version(prepared.schema_version, &prepared.contract_version)?;
1835 validate_execution_binding(
1836 &prepared.quote_id,
1837 &prepared.market_id,
1838 prepared.side,
1839 &prepared.amount_in_atoms,
1840 &prepared.minimum_output_atoms,
1841 quote,
1842 )?;
1843 if !valid_handle(&prepared.execution_id, "se_")
1844 || prepared.recent_blockhash != authorization.recent_blockhash
1845 || prepared.last_valid_block_height != authorization.last_valid_block_height
1846 || prepared.expires_at_ms > challenge.expires_at_ms
1847 || prepared.transaction_base64.trim().is_empty()
1848 || base64::engine::general_purpose::STANDARD
1849 .decode(prepared.transaction_base64.trim())
1850 .is_err()
1851 {
1852 return Err(SdkError::InvalidResponse(
1853 "prepared execution changed the signed authorization".to_owned(),
1854 ));
1855 }
1856 Ok(())
1857}
1858
1859fn validate_execution_binding(
1860 quote_id: &str,
1861 market_id: &str,
1862 side: QuoteSide,
1863 amount_in_atoms: &str,
1864 minimum_output_atoms: &str,
1865 quote: &QuoteResponse,
1866) -> Result<(), SdkError> {
1867 if quote_id != quote.quote_id
1868 || market_id != quote.market_id
1869 || side != quote.side
1870 || amount_in_atoms != quote.amount_in_atoms
1871 || minimum_output_atoms != quote.minimum_output_atoms
1872 {
1873 return Err(SdkError::InvalidResponse(
1874 "execution does not match the Sonar quote".to_owned(),
1875 ));
1876 }
1877 Ok(())
1878}
1879
1880fn validate_execution_authorization(
1881 challenge: &ExecutionChallengeResponse,
1882 quote: &QuoteResponse,
1883 owner_wallet: &str,
1884 session_public_key: &str,
1885 account_sequence: u64,
1886) -> Result<ExecutionAuthorization, SdkError> {
1887 let bytes = base64::engine::general_purpose::STANDARD
1888 .decode(challenge.authorization_payload_base64.trim())
1889 .map_err(|_| SdkError::InvalidResponse("authorization payload is not base64".to_owned()))?;
1890 let market = decode_public_key("e.market_id, "market_id")?;
1891 let owner = decode_public_key(owner_wallet, "owner_wallet")?;
1892 let session = decode_public_key(session_public_key, "session_public_key")?;
1893 let mut cursor = 0usize;
1894 take_expected(
1895 &bytes,
1896 &mut cursor,
1897 PUBLIC_EXECUTION_AUTH_DOMAIN,
1898 "authorization domain",
1899 )?;
1900 take_expected(&bytes, &mut cursor, &market, "authorization market")?;
1901 take_expected(
1902 &bytes,
1903 &mut cursor,
1904 quote.quote_id.as_bytes(),
1905 "authorization quote",
1906 )?;
1907 take_expected(&bytes, &mut cursor, &owner, "authorization owner")?;
1908 take_expected(&bytes, &mut cursor, &session, "authorization session")?;
1909 let side = take_bytes(&bytes, &mut cursor, 1, "authorization side")?[0];
1910 if side != if quote.side == QuoteSide::Buy { 0 } else { 1 } {
1911 return Err(SdkError::InvalidResponse(
1912 "authorization side changed".to_owned(),
1913 ));
1914 }
1915 take_u64_eq(
1916 &bytes,
1917 &mut cursor,
1918 parse_atoms("amount_in_atoms", "e.amount_in_atoms)?,
1919 "authorization input",
1920 )?;
1921 take_u64_eq(
1922 &bytes,
1923 &mut cursor,
1924 parse_atoms("minimum_output_atoms", "e.minimum_output_atoms)?,
1925 "authorization minimum output",
1926 )?;
1927 take_u64_eq(
1928 &bytes,
1929 &mut cursor,
1930 account_sequence,
1931 "authorization account sequence",
1932 )?;
1933 let _output_balance = take_u64(&bytes, &mut cursor, "authorization output balance")?;
1934 let recent_blockhash = bs58::encode(take_bytes(
1935 &bytes,
1936 &mut cursor,
1937 32,
1938 "authorization blockhash",
1939 )?)
1940 .into_string();
1941 let last_valid_block_height =
1942 take_u64(&bytes, &mut cursor, "authorization last valid block height")?;
1943 take_u64_eq(
1944 &bytes,
1945 &mut cursor,
1946 challenge.expires_at_ms,
1947 "authorization expiry",
1948 )?;
1949 let nonce = take_bytes(&bytes, &mut cursor, 16, "authorization nonce")?;
1950 if hex::encode(nonce) != challenge.challenge_id[3..] {
1951 return Err(SdkError::InvalidResponse(
1952 "authorization challenge nonce changed".to_owned(),
1953 ));
1954 }
1955 let _epoch = take_bytes(&bytes, &mut cursor, 16, "authorization epoch")?;
1956 if cursor != bytes.len() {
1957 return Err(SdkError::InvalidResponse(
1958 "authorization contains unrecognized fields".to_owned(),
1959 ));
1960 }
1961 Ok(ExecutionAuthorization {
1962 bytes,
1963 recent_blockhash,
1964 last_valid_block_height,
1965 })
1966}
1967
1968fn take_expected(
1969 source: &[u8],
1970 cursor: &mut usize,
1971 expected: &[u8],
1972 field: &str,
1973) -> Result<(), SdkError> {
1974 if take_bytes(source, cursor, expected.len(), field)? != expected {
1975 return Err(SdkError::InvalidResponse(format!("{field} changed")));
1976 }
1977 Ok(())
1978}
1979
1980fn take_bytes<'a>(
1981 source: &'a [u8],
1982 cursor: &mut usize,
1983 length: usize,
1984 field: &str,
1985) -> Result<&'a [u8], SdkError> {
1986 let end = cursor
1987 .checked_add(length)
1988 .filter(|end| *end <= source.len())
1989 .ok_or_else(|| SdkError::InvalidResponse(format!("{field} is missing")))?;
1990 let value = &source[*cursor..end];
1991 *cursor = end;
1992 Ok(value)
1993}
1994
1995fn take_u64(source: &[u8], cursor: &mut usize, field: &str) -> Result<u64, SdkError> {
1996 let bytes: [u8; 8] = take_bytes(source, cursor, 8, field)?
1997 .try_into()
1998 .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
1999 Ok(u64::from_le_bytes(bytes))
2000}
2001
2002fn take_u64_eq(
2003 source: &[u8],
2004 cursor: &mut usize,
2005 expected: u64,
2006 field: &str,
2007) -> Result<(), SdkError> {
2008 if take_u64(source, cursor, field)? != expected {
2009 return Err(SdkError::InvalidResponse(format!("{field} changed")));
2010 }
2011 Ok(())
2012}
2013
2014fn decode_public_key(value: &str, field: &str) -> Result<Vec<u8>, SdkError> {
2015 let bytes = bs58::decode(value.trim())
2016 .into_vec()
2017 .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
2018 if bytes.len() != 32 || bs58::encode(&bytes).into_string() != value.trim() {
2019 return Err(SdkError::InvalidRequest(format!(
2020 "{field} must be a canonical 32-byte public key"
2021 )));
2022 }
2023 Ok(bytes)
2024}
2025
2026fn canonical_public_key(value: &str, field: &str) -> Result<String, SdkError> {
2027 decode_public_key(value, field)?;
2028 Ok(value.trim().to_owned())
2029}
2030
2031fn valid_handle(value: &str, prefix: &str) -> bool {
2032 value.len() == prefix.len() + 32
2033 && value.starts_with(prefix)
2034 && value[prefix.len()..]
2035 .bytes()
2036 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2037}
2038
2039fn normalize_idempotency_key(value: &str) -> Result<String, SdkError> {
2040 let value = value.trim();
2041 if value.is_empty()
2042 || value.len() > 64
2043 || !value.bytes().all(|byte| {
2044 byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
2045 })
2046 {
2047 return Err(SdkError::InvalidRequest(
2048 "idempotency key must contain 1-64 URL-safe characters".to_owned(),
2049 ));
2050 }
2051 Ok(value.to_owned())
2052}
2053
2054fn unix_ms() -> Result<u64, SdkError> {
2055 let elapsed = SystemTime::now()
2056 .duration_since(UNIX_EPOCH)
2057 .map_err(|_| SdkError::InvalidRequest("system clock is before Unix epoch".to_owned()))?;
2058 u64::try_from(elapsed.as_millis())
2059 .map_err(|_| SdkError::InvalidRequest("system clock exceeds supported range".to_owned()))
2060}
2061
2062#[cfg(test)]
2063mod tests {
2064 use super::*;
2065 use wiremock::matchers::{body_json, method, path};
2066 use wiremock::{Mock, MockServer, ResponseTemplate};
2067
2068 fn fixture(path: &str) -> serde_json::Value {
2069 let raw = match path {
2070 "action-graph" => strata_public_contract::contract_fixtures::ACTION_GRAPH,
2071 "markets" => strata_public_contract::contract_fixtures::MARKETS,
2072 "quote" => strata_public_contract::contract_fixtures::QUOTE,
2073 "capabilities" => strata_public_contract::contract_fixtures::CAPABILITIES,
2074 "order-challenge" => strata_public_contract::platform::PLATFORM_ORDER_CHALLENGE_FIXTURE,
2075 "order-prepare" => strata_public_contract::platform::PLATFORM_ORDER_PREPARE_FIXTURE,
2076 "order-submit" => strata_public_contract::platform::PLATFORM_ORDER_SUBMIT_FIXTURE,
2077 "order-status" => strata_public_contract::platform::PLATFORM_ORDER_STATUS_FIXTURE,
2078 _ => unreachable!(),
2079 };
2080 serde_json::from_str(raw).unwrap()
2081 }
2082
2083 #[tokio::test]
2084 async fn reads_capabilities_and_quotes_without_internal_metadata() {
2085 let server = MockServer::start().await;
2086 Mock::given(method("GET"))
2087 .and(path("/sonar/capabilities"))
2088 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("capabilities")))
2089 .mount(&server)
2090 .await;
2091 Mock::given(method("GET"))
2092 .and(path("/sonar/markets"))
2093 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
2094 .expect(1)
2095 .mount(&server)
2096 .await;
2097 Mock::given(method("GET"))
2098 .and(path("/sonar/action-graph"))
2099 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("action-graph")))
2100 .expect(1)
2101 .mount(&server)
2102 .await;
2103 Mock::given(method("POST"))
2104 .and(path("/sonar/markets/sol-usdc/quote"))
2105 .and(body_json(serde_json::json!({
2106 "market_id": "11111111111111111111111111111111",
2107 "side": "sell",
2108 "amount_in_atoms": "10000000",
2109 "slippage_bps": 50
2110 })))
2111 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("quote")))
2112 .expect(1)
2113 .mount(&server)
2114 .await;
2115
2116 let client = StrataClient::new(server.uri()).unwrap();
2117 let capabilities = client.capabilities().await.unwrap();
2118 assert!(capabilities
2119 .capabilities
2120 .iter()
2121 .any(|capability| capability.id == "quotes.read"));
2122
2123 let graph = client.action_graph().await.unwrap();
2124 assert_eq!(graph.entry_node, "discover_capabilities");
2125 assert_eq!(graph.authority.permission_source, "external_agent_owner");
2126
2127 let quote = client
2128 .quote(QuoteRequest {
2129 market_id: "SOL/USDC".to_owned(),
2130 side: QuoteSide::Sell,
2131 amount_in_atoms: "10000000".to_owned(),
2132 slippage_bps: 50,
2133 })
2134 .await
2135 .unwrap();
2136 let public = serde_json::to_value(quote).unwrap();
2137 assert!(public.get("quote_id").is_some());
2138 assert!(public.get("unexpected_field").is_none());
2139 }
2140
2141 #[tokio::test]
2142 async fn resting_order_calls_use_only_product_paths_and_external_signatures() {
2143 let server = MockServer::start().await;
2144 let market_id = "market_22222222222222222222222222222222";
2145 let owner_wallet = bs58::encode([1u8; 32]).into_string();
2146 let session_public_key = bs58::encode([2u8; 32]).into_string();
2147 let authorization_signature = bs58::encode([3u8; 64]).into_string();
2148 Mock::given(method("POST"))
2149 .and(path(format!("/v2/markets/{market_id}/orders/challenge")))
2150 .and(body_json(serde_json::json!({
2151 "action": "place",
2152 "owner_wallet": owner_wallet,
2153 "session_public_key": session_public_key,
2154 "account_sequence": "7",
2155 "client_order_id": "agent-order-7",
2156 "side": "buy",
2157 "order_type": "post_only",
2158 "limit_price_atoms": "150000000",
2159 "size_atoms": "1000000"
2160 })))
2161 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-challenge")))
2162 .expect(1)
2163 .mount(&server)
2164 .await;
2165 Mock::given(method("POST"))
2166 .and(path(format!("/v2/markets/{market_id}/orders/prepare")))
2167 .and(body_json(serde_json::json!({
2168 "challenge_id": "oc_11111111111111111111111111111111",
2169 "authorization_signature": authorization_signature
2170 })))
2171 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-prepare")))
2172 .expect(1)
2173 .mount(&server)
2174 .await;
2175 Mock::given(method("POST"))
2176 .and(path(format!("/v2/markets/{market_id}/orders/submit")))
2177 .and(body_json(serde_json::json!({
2178 "order_control_id": "or_44444444444444444444444444444444",
2179 "signed_transaction_base64": "AQIDBA==",
2180 "idempotency_key": "order-attempt-7"
2181 })))
2182 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-submit")))
2183 .expect(1)
2184 .mount(&server)
2185 .await;
2186 Mock::given(method("POST"))
2187 .and(path(format!("/v2/markets/{market_id}/orders/status")))
2188 .and(body_json(serde_json::json!({
2189 "order_control_id": "or_44444444444444444444444444444444",
2190 "idempotency_key": "order-attempt-7"
2191 })))
2192 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("order-status")))
2193 .expect(1)
2194 .mount(&server)
2195 .await;
2196
2197 let client = StrataClient::new(server.uri()).unwrap();
2198 let challenge = client
2199 .order_challenge(
2200 market_id,
2201 PlatformOrderChallengeRequest::Place {
2202 owner_wallet,
2203 session_public_key,
2204 account_sequence: "7".to_owned(),
2205 client_order_id: "agent-order-7".to_owned(),
2206 side: PlatformTradeSide::Buy,
2207 order_type: PlatformOrderType::PostOnly,
2208 limit_price_atoms: "150000000".to_owned(),
2209 size_atoms: "1000000".to_owned(),
2210 },
2211 )
2212 .await
2213 .unwrap();
2214 let prepared = client
2215 .order_prepare(
2216 market_id,
2217 PlatformOrderPrepareRequest {
2218 challenge_id: challenge.challenge_id,
2219 authorization_signature,
2220 },
2221 )
2222 .await
2223 .unwrap();
2224 let receipt = client
2225 .order_submit(
2226 market_id,
2227 PlatformOrderSubmitRequest {
2228 order_control_id: prepared.order_control_id,
2229 signed_transaction_base64: "AQIDBA==".to_owned(),
2230 idempotency_key: "order-attempt-7".to_owned(),
2231 },
2232 )
2233 .await
2234 .unwrap();
2235 assert_eq!(receipt.status, PlatformOrderSubmissionStatus::Submitted);
2236 let status = client
2237 .order_status(
2238 market_id,
2239 PlatformOrderStatusRequest {
2240 order_control_id: receipt.order_control_id,
2241 idempotency_key: "order-attempt-7".to_owned(),
2242 },
2243 )
2244 .await
2245 .unwrap();
2246 assert_eq!(status.status, PlatformOrderControlStatus::Submitting);
2247 }
2248
2249 #[test]
2250 fn order_authorization_parser_binds_every_public_place_field() {
2251 let owner = [1u8; 32];
2252 let session = [2u8; 32];
2253 let order = [3u8; 32];
2254 let nonce = [4u8; 16];
2255 let blockhash = [5u8; 32];
2256 let epoch = [6u8; 16];
2257 let market_id = "market_22222222222222222222222222222222";
2258 let expires_at_ms = 1_786_550_460_000u64;
2259 let request = PlatformOrderChallengeRequest::Place {
2260 owner_wallet: bs58::encode(owner).into_string(),
2261 session_public_key: bs58::encode(session).into_string(),
2262 account_sequence: "7".to_owned(),
2263 client_order_id: "agent-order-7".to_owned(),
2264 side: PlatformTradeSide::Buy,
2265 order_type: PlatformOrderType::PostOnly,
2266 limit_price_atoms: "150000000".to_owned(),
2267 size_atoms: "1000000".to_owned(),
2268 };
2269 let mut payload = Vec::new();
2270 payload.extend_from_slice(PUBLIC_ORDER_AUTH_DOMAIN);
2271 payload.extend_from_slice(&[9u8; 32]);
2272 payload.extend_from_slice(&owner);
2273 payload.extend_from_slice(&session);
2274 payload.push(0);
2275 payload.extend_from_slice(&7u64.to_le_bytes());
2276 payload.extend_from_slice(&("agent-order-7".len() as u16).to_le_bytes());
2277 payload.extend_from_slice(b"agent-order-7");
2278 payload.push(0);
2279 payload.push(3);
2280 payload.extend_from_slice(&150_000_000u64.to_le_bytes());
2281 payload.extend_from_slice(&1_000_000u64.to_le_bytes());
2282 payload.extend_from_slice(&order);
2283 payload.extend_from_slice(&blockhash);
2284 payload.extend_from_slice(&400_000_000u64.to_le_bytes());
2285 payload.extend_from_slice(&expires_at_ms.to_le_bytes());
2286 payload.extend_from_slice(&nonce);
2287 payload.extend_from_slice(&epoch);
2288 let challenge = PlatformOrderChallengeResponse {
2289 schema_version: 2,
2290 contract_version: "2.0".to_owned(),
2291 challenge_id: format!("oc_{}", hex::encode(nonce)),
2292 market_id: market_id.to_owned(),
2293 action: PlatformOrderAction::Place,
2294 order_ids: vec![opaque_order_id(market_id, &order)],
2295 authorization_payload_base64: base64::engine::general_purpose::STANDARD.encode(payload),
2296 server_time_ms: expires_at_ms - 60_000,
2297 expires_at_ms,
2298 };
2299 let authorization = validate_order_authorization(&challenge, &request).unwrap();
2300 assert_eq!(
2301 authorization.recent_blockhash,
2302 bs58::encode(blockhash).into_string()
2303 );
2304 assert_eq!(authorization.last_valid_block_height, 400_000_000);
2305
2306 let mut changed = request;
2307 if let PlatformOrderChallengeRequest::Place { size_atoms, .. } = &mut changed {
2308 *size_atoms = "1000001".to_owned();
2309 }
2310 assert!(validate_order_authorization(&challenge, &changed).is_err());
2311 }
2312
2313 #[test]
2314 fn order_authorization_parser_binds_atomic_batch_order_and_replacement_fields() {
2315 let owner = [1u8; 32];
2316 let session = [2u8; 32];
2317 let cancelled = [3u8; 32];
2318 let replaced = [4u8; 32];
2319 let replacement = [5u8; 32];
2320 let nonce = [6u8; 16];
2321 let blockhash = [7u8; 32];
2322 let market_id = "market_22222222222222222222222222222222";
2323 let expires_at_ms = 1_786_550_460_000u64;
2324 let request = PlatformOrderChallengeRequest::Batch {
2325 owner_wallet: bs58::encode(owner).into_string(),
2326 session_public_key: bs58::encode(session).into_string(),
2327 operations: vec![
2328 PlatformOrderBatchOperation::Cancel {
2329 order_id: opaque_order_id(market_id, &cancelled),
2330 },
2331 PlatformOrderBatchOperation::Replace {
2332 order_id: opaque_order_id(market_id, &replaced),
2333 account_sequence: "8".to_owned(),
2334 client_order_id: "replacement-8".to_owned(),
2335 side: PlatformTradeSide::Sell,
2336 order_type: PlatformOrderType::PostOnly,
2337 limit_price_atoms: "151000000".to_owned(),
2338 size_atoms: "2000000".to_owned(),
2339 },
2340 ],
2341 };
2342 let mut payload = Vec::new();
2343 payload.extend_from_slice(PUBLIC_ORDER_AUTH_DOMAIN);
2344 payload.extend_from_slice(&[9u8; 32]);
2345 payload.extend_from_slice(&owner);
2346 payload.extend_from_slice(&session);
2347 payload.push(4);
2348 payload.push(2);
2349 payload.push(1);
2350 payload.extend_from_slice(&cancelled);
2351 payload.push(1);
2352 payload.push(3);
2353 payload.extend_from_slice(&replaced);
2354 payload.push(0);
2355 payload.extend_from_slice(&8u64.to_le_bytes());
2356 payload.extend_from_slice(&("replacement-8".len() as u16).to_le_bytes());
2357 payload.extend_from_slice(b"replacement-8");
2358 payload.push(1);
2359 payload.push(3);
2360 payload.extend_from_slice(&151_000_000u64.to_le_bytes());
2361 payload.extend_from_slice(&2_000_000u64.to_le_bytes());
2362 payload.extend_from_slice(&replacement);
2363 payload.extend_from_slice(&blockhash);
2364 payload.extend_from_slice(&400_000_000u64.to_le_bytes());
2365 payload.extend_from_slice(&expires_at_ms.to_le_bytes());
2366 payload.extend_from_slice(&nonce);
2367 payload.extend_from_slice(&[8u8; 16]);
2368 let challenge = PlatformOrderChallengeResponse {
2369 schema_version: 2,
2370 contract_version: "2.0".to_owned(),
2371 challenge_id: format!("oc_{}", hex::encode(nonce)),
2372 market_id: market_id.to_owned(),
2373 action: PlatformOrderAction::Batch,
2374 order_ids: vec![
2375 opaque_order_id(market_id, &cancelled),
2376 opaque_order_id(market_id, &replaced),
2377 opaque_order_id(market_id, &replacement),
2378 ],
2379 authorization_payload_base64: base64::engine::general_purpose::STANDARD.encode(payload),
2380 server_time_ms: expires_at_ms - 60_000,
2381 expires_at_ms,
2382 };
2383 validate_order_authorization(&challenge, &request).unwrap();
2384
2385 let mut changed = request;
2386 if let PlatformOrderChallengeRequest::Batch { operations, .. } = &mut changed {
2387 if let PlatformOrderBatchOperation::Replace { size_atoms, .. } = &mut operations[1] {
2388 *size_atoms = "2000001".to_owned();
2389 }
2390 }
2391 assert!(validate_order_authorization(&challenge, &changed).is_err());
2392 }
2393
2394 #[test]
2395 fn rejects_non_http_base_urls() {
2396 assert!(matches!(
2397 StrataClient::new("file:///tmp/contract"),
2398 Err(SdkError::InvalidBaseUrl(_))
2399 ));
2400 }
2401
2402 #[test]
2403 fn accepts_only_product_level_quote_operation_paths() {
2404 assert!(valid_public_operation_path("/sonar/markets/sol-usdc/quote"));
2405 for unsupported_or_ambiguous in [
2406 "/unsupported/build",
2407 "/unsupported/quote",
2408 "/sonar/markets/../quote",
2409 "/sonar/markets/SOL-USDC/quote",
2410 ] {
2411 assert!(!valid_public_operation_path(unsupported_or_ambiguous));
2412 }
2413 }
2414}