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