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