1use async_trait::async_trait;
7use base64::Engine as _;
8use reqwest::{StatusCode, Url};
9use serde::de::DeserializeOwned;
10use std::collections::HashSet;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12use strata_public_contract::{ErrorResponse, CONTRACT_MAJOR, CONTRACT_VERSION};
13use thiserror::Error;
14
15pub use strata_public_contract::{
16 ActionAuthorityModel, ActionEdge, ActionGraph, ActionNode, ActionNodeKind, ActionOperation,
17 CapabilityCatalog, CapabilityDescriptor, CapabilityRisk, CapabilityStability,
18 ExecutionChallengeRequest, ExecutionChallengeResponse, ExecutionPrepareRequest,
19 ExecutionPrepareResponse, ExecutionStatus, ExecutionSubmitRequest, ExecutionSubmitResponse,
20 Market, MarketsResponse, McpExposure, QuoteRequest, QuoteResponse, QuoteSide,
21 DEFAULT_SLIPPAGE_BPS,
22};
23
24pub const DEFAULT_API_BASE: &str = "https://api.stratabook.app";
25const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
26const PUBLIC_EXECUTION_AUTH_DOMAIN: &[u8] = b"strata-sonar-execution:v1\0";
27
28#[async_trait]
29pub trait SessionSigner: Send + Sync {
30 fn public_key(&self) -> &str;
32
33 async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, String>;
35
36 async fn sign_transaction(&self, transaction_base64: &str) -> Result<String, String>;
38}
39
40#[derive(Debug)]
41pub struct ExecutionVerificationContext<'a> {
42 pub quote: &'a QuoteResponse,
43 pub challenge: &'a ExecutionChallengeResponse,
44 pub prepared: &'a ExecutionPrepareResponse,
45 pub owner_wallet: &'a str,
46 pub session_public_key: &'a str,
47}
48
49#[async_trait]
50pub trait ExecutionVerifier: Send + Sync {
51 async fn verify(&self, context: &ExecutionVerificationContext<'_>) -> Result<(), String>;
54}
55
56#[derive(Debug, Error)]
57pub enum SdkError {
58 #[error("invalid API base URL: {0}")]
59 InvalidBaseUrl(String),
60 #[error("invalid request: {0}")]
61 InvalidRequest(String),
62 #[error("market is not available: {0}")]
63 MarketNotFound(String),
64 #[error("operation is not available for market: {0}")]
65 OperationUnavailable(String),
66 #[error("Strata API error {status} ({code}): {message}")]
67 Api {
68 status: StatusCode,
69 code: String,
70 message: String,
71 retryable: bool,
72 },
73 #[error("invalid public contract response: {0}")]
74 InvalidResponse(String),
75 #[error("session signer rejected the operation: {0}")]
76 Signer(String),
77 #[error("prepared transaction was rejected: {0}")]
78 Verification(String),
79 #[error(transparent)]
80 Transport(#[from] reqwest::Error),
81}
82
83#[derive(Clone, Debug)]
84pub struct StrataClient {
85 base_url: Url,
86 http: reqwest::Client,
87}
88
89impl StrataClient {
90 pub fn production() -> Result<Self, SdkError> {
91 Self::new(DEFAULT_API_BASE)
92 }
93
94 pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
95 Self::with_timeout(base_url, DEFAULT_TIMEOUT)
96 }
97
98 pub fn with_timeout(base_url: impl AsRef<str>, timeout: Duration) -> Result<Self, SdkError> {
99 if timeout.is_zero() {
100 return Err(SdkError::InvalidRequest(
101 "timeout must be greater than zero".to_owned(),
102 ));
103 }
104 let base_url = normalize_base_url(base_url.as_ref())?;
105 let http = reqwest::Client::builder().timeout(timeout).build()?;
106 Ok(Self { base_url, http })
107 }
108
109 pub async fn capabilities(&self) -> Result<CapabilityCatalog, SdkError> {
110 let catalog: CapabilityCatalog = self.get("sonar/capabilities", &[]).await?;
111 validate_version(catalog.schema_version, &catalog.contract_version)?;
112
113 let mut ids = HashSet::new();
114 if catalog
115 .capabilities
116 .iter()
117 .any(|capability| !ids.insert(capability.id.as_str()))
118 {
119 return Err(SdkError::InvalidResponse(
120 "capability IDs must be unique".to_owned(),
121 ));
122 }
123 Ok(catalog)
124 }
125
126 pub async fn action_graph(&self) -> Result<ActionGraph, SdkError> {
129 let graph: ActionGraph = self.get("sonar/action-graph", &[]).await?;
130 validate_action_graph(&graph)?;
131 Ok(graph)
132 }
133
134 pub async fn markets(&self) -> Result<MarketsResponse, SdkError> {
135 let markets: MarketsResponse = self.get("sonar/markets", &[]).await?;
136 validate_version(markets.schema_version, &markets.contract_version)?;
137 Ok(markets)
138 }
139
140 pub async fn quote(&self, request: QuoteRequest) -> Result<QuoteResponse, SdkError> {
142 let amount_in = parse_atoms("amount_in_atoms", &request.amount_in_atoms)?;
143 if amount_in == 0 {
144 return Err(SdkError::InvalidRequest(
145 "amount_in_atoms must be greater than zero".to_owned(),
146 ));
147 }
148 if request.slippage_bps > 1_000 {
149 return Err(SdkError::InvalidRequest(
150 "slippage_bps must be between 0 and 1,000".to_owned(),
151 ));
152 }
153
154 let markets = self.markets().await?;
155 let market = markets
156 .markets
157 .iter()
158 .find(|market| {
159 market.label.eq_ignore_ascii_case(&request.market_id)
160 || market.market_pda.as_deref() == Some(request.market_id.as_str())
161 })
162 .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
163 if !market.ready {
164 return Err(SdkError::OperationUnavailable(market.label.clone()));
165 }
166 let market_pda = market
167 .market_pda
168 .as_deref()
169 .ok_or_else(|| SdkError::MarketNotFound(request.market_id.clone()))?;
170 let quote_path = market
171 .quote_path
172 .as_deref()
173 .filter(|path| valid_public_operation_path(path))
174 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
175 let wire = QuoteRequest {
176 market_id: market_pda.to_owned(),
177 side: request.side,
178 amount_in_atoms: request.amount_in_atoms.clone(),
179 slippage_bps: request.slippage_bps,
180 };
181 let quote: QuoteResponse = self.post(quote_path, &wire).await?;
182 validate_quote("e, market_pda, &request, amount_in)?;
183 Ok(quote)
184 }
185
186 pub async fn execution_challenge(
189 &self,
190 market: &str,
191 request: ExecutionChallengeRequest,
192 ) -> Result<ExecutionChallengeResponse, SdkError> {
193 if !valid_handle(&request.quote_id, "sq_") {
194 return Err(SdkError::InvalidRequest("quote_id is invalid".to_owned()));
195 }
196 let request = ExecutionChallengeRequest {
197 quote_id: request.quote_id,
198 owner_wallet: canonical_public_key(&request.owner_wallet, "owner_wallet")?,
199 session_public_key: canonical_public_key(
200 &request.session_public_key,
201 "session_public_key",
202 )?,
203 account_sequence: parse_atoms("account_sequence", &request.account_sequence)?
204 .to_string(),
205 };
206 let execution_path = self.execution_path(market).await?;
207 let challenge: ExecutionChallengeResponse = self
208 .post(&format!("{execution_path}/challenge"), &request)
209 .await?;
210 validate_version(challenge.schema_version, &challenge.contract_version)?;
211 if !valid_handle(&challenge.challenge_id, "sc_") || challenge.quote_id != request.quote_id {
212 return Err(SdkError::InvalidResponse(
213 "execution challenge does not match the requested quote".to_owned(),
214 ));
215 }
216 Ok(challenge)
217 }
218
219 pub async fn execution_prepare(
222 &self,
223 market: &str,
224 request: ExecutionPrepareRequest,
225 ) -> Result<ExecutionPrepareResponse, SdkError> {
226 if !valid_handle(&request.challenge_id, "sc_") {
227 return Err(SdkError::InvalidRequest(
228 "challenge_id is invalid".to_owned(),
229 ));
230 }
231 let signature = bs58::decode(request.authorization_signature.trim())
232 .into_vec()
233 .map_err(|_| {
234 SdkError::InvalidRequest("authorization_signature must be base58".to_owned())
235 })?;
236 if signature.len() != 64
237 || bs58::encode(&signature).into_string() != request.authorization_signature.trim()
238 {
239 return Err(SdkError::InvalidRequest(
240 "authorization_signature must be a canonical Ed25519 signature".to_owned(),
241 ));
242 }
243 let request = ExecutionPrepareRequest {
244 challenge_id: request.challenge_id,
245 authorization_signature: bs58::encode(signature).into_string(),
246 };
247 let execution_path = self.execution_path(market).await?;
248 let prepared: ExecutionPrepareResponse = self
249 .post(&format!("{execution_path}/prepare"), &request)
250 .await?;
251 validate_version(prepared.schema_version, &prepared.contract_version)?;
252 if !valid_handle(&prepared.execution_id, "se_") {
253 return Err(SdkError::InvalidResponse(
254 "prepared execution ID is invalid".to_owned(),
255 ));
256 }
257 Ok(prepared)
258 }
259
260 pub async fn execution_submit(
263 &self,
264 market: &str,
265 request: ExecutionSubmitRequest,
266 ) -> Result<ExecutionSubmitResponse, SdkError> {
267 if !valid_handle(&request.execution_id, "se_") {
268 return Err(SdkError::InvalidRequest(
269 "execution_id is invalid".to_owned(),
270 ));
271 }
272 let transaction = request.signed_transaction_base64.trim();
273 let decoded = base64::engine::general_purpose::STANDARD
274 .decode(transaction)
275 .map_err(|_| {
276 SdkError::InvalidRequest(
277 "signed_transaction_base64 must be canonical base64".to_owned(),
278 )
279 })?;
280 if decoded.is_empty()
281 || base64::engine::general_purpose::STANDARD.encode(&decoded) != transaction
282 {
283 return Err(SdkError::InvalidRequest(
284 "signed_transaction_base64 must be canonical base64".to_owned(),
285 ));
286 }
287 let request = ExecutionSubmitRequest {
288 execution_id: request.execution_id,
289 signed_transaction_base64: transaction.to_owned(),
290 idempotency_key: normalize_idempotency_key(&request.idempotency_key)?,
291 };
292 let execution_path = self.execution_path(market).await?;
293 let submitted: ExecutionSubmitResponse = self
294 .post(&format!("{execution_path}/submit"), &request)
295 .await?;
296 validate_version(submitted.schema_version, &submitted.contract_version)?;
297 if submitted.execution_id != request.execution_id
298 || submitted.status != ExecutionStatus::Submitted
299 || submitted.signature.trim().is_empty()
300 {
301 return Err(SdkError::InvalidResponse(
302 "execution receipt does not match the submitted transaction".to_owned(),
303 ));
304 }
305 Ok(submitted)
306 }
307
308 pub async fn execute_quote<S, V>(
312 &self,
313 quote: &QuoteResponse,
314 owner_wallet: &str,
315 account_sequence: u64,
316 signer: &S,
317 verifier: &V,
318 idempotency_key: Option<&str>,
319 ) -> Result<ExecutionSubmitResponse, SdkError>
320 where
321 S: SessionSigner + ?Sized,
322 V: ExecutionVerifier + ?Sized,
323 {
324 validate_version(quote.schema_version, "e.contract_version)?;
325 let now_ms = unix_ms()?;
326 if quote.expires_at_ms <= now_ms {
327 return Err(SdkError::InvalidRequest("quote has expired".to_owned()));
328 }
329 let owner_wallet = canonical_public_key(owner_wallet, "owner_wallet")?;
330 let session_public_key = canonical_public_key(signer.public_key(), "session_public_key")?;
331 let markets = self.markets().await?;
332 let market = markets
333 .markets
334 .iter()
335 .find(|market| market.market_pda.as_deref() == Some(quote.market_id.as_str()))
336 .ok_or_else(|| SdkError::MarketNotFound(quote.market_id.clone()))?;
337 let quote_path = market
338 .quote_path
339 .as_deref()
340 .filter(|path| valid_public_operation_path(path))
341 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
342 let execution_path = format!(
343 "{}/execution",
344 quote_path
345 .strip_suffix("/quote")
346 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
347 );
348 let challenge: ExecutionChallengeResponse = self
349 .post(
350 &format!("{execution_path}/challenge"),
351 &ExecutionChallengeRequest {
352 quote_id: quote.quote_id.clone(),
353 owner_wallet: owner_wallet.clone(),
354 session_public_key: session_public_key.clone(),
355 account_sequence: account_sequence.to_string(),
356 },
357 )
358 .await?;
359 validate_execution_challenge(&challenge, quote)?;
360 let authorization = validate_execution_authorization(
361 &challenge,
362 quote,
363 &owner_wallet,
364 &session_public_key,
365 account_sequence,
366 )?;
367 let signature = signer
368 .sign_message(&authorization.bytes)
369 .await
370 .map_err(SdkError::Signer)?;
371 if signature.len() != 64 {
372 return Err(SdkError::InvalidResponse(
373 "session authorization signature must contain 64 bytes".to_owned(),
374 ));
375 }
376 let prepared: ExecutionPrepareResponse = self
377 .post(
378 &format!("{execution_path}/prepare"),
379 &ExecutionPrepareRequest {
380 challenge_id: challenge.challenge_id.clone(),
381 authorization_signature: bs58::encode(signature).into_string(),
382 },
383 )
384 .await?;
385 validate_execution_prepare(&prepared, quote, &challenge, &authorization)?;
386 verifier
387 .verify(&ExecutionVerificationContext {
388 quote,
389 challenge: &challenge,
390 prepared: &prepared,
391 owner_wallet: &owner_wallet,
392 session_public_key: &session_public_key,
393 })
394 .await
395 .map_err(SdkError::Verification)?;
396 let signed_transaction = signer
397 .sign_transaction(&prepared.transaction_base64)
398 .await
399 .map_err(SdkError::Signer)?;
400 base64::engine::general_purpose::STANDARD
401 .decode(signed_transaction.trim())
402 .map_err(|_| {
403 SdkError::InvalidResponse(
404 "session signer returned an invalid base64 transaction".to_owned(),
405 )
406 })?;
407 let idempotency_key =
408 normalize_idempotency_key(idempotency_key.unwrap_or(&prepared.execution_id))?;
409 let submitted: ExecutionSubmitResponse = self
410 .post(
411 &format!("{execution_path}/submit"),
412 &ExecutionSubmitRequest {
413 execution_id: prepared.execution_id.clone(),
414 signed_transaction_base64: signed_transaction,
415 idempotency_key,
416 },
417 )
418 .await?;
419 validate_version(submitted.schema_version, &submitted.contract_version)?;
420 if submitted.execution_id != prepared.execution_id
421 || submitted.status != ExecutionStatus::Submitted
422 || submitted.signature.trim().is_empty()
423 {
424 return Err(SdkError::InvalidResponse(
425 "execution receipt does not match the prepared transaction".to_owned(),
426 ));
427 }
428 Ok(submitted)
429 }
430
431 async fn get<T: DeserializeOwned>(
432 &self,
433 path: &str,
434 query: &[(&str, &str)],
435 ) -> Result<T, SdkError> {
436 let mut url = self.base_url.join(path).map_err(|error| {
437 SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
438 })?;
439 url.query_pairs_mut().extend_pairs(query.iter().copied());
440
441 let response = self
442 .http
443 .get(url)
444 .header(reqwest::header::ACCEPT, "application/json")
445 .send()
446 .await?;
447 let status = response.status();
448 let bytes = response.bytes().await?;
449 if !status.is_success() {
450 return match serde_json::from_slice::<ErrorResponse>(&bytes) {
451 Ok(error) => Err(SdkError::Api {
452 status,
453 code: error.error.code,
454 message: error.error.message,
455 retryable: error.error.retryable,
456 }),
457 Err(_) => Err(SdkError::Api {
458 status,
459 code: "request_failed".to_owned(),
460 message: "Strata could not complete the request.".to_owned(),
461 retryable: status.is_server_error(),
462 }),
463 };
464 }
465 serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
466 }
467
468 async fn post<T: DeserializeOwned, B: serde::Serialize>(
469 &self,
470 path: &str,
471 body: &B,
472 ) -> Result<T, SdkError> {
473 let url = self.base_url.join(path).map_err(|error| {
474 SdkError::InvalidBaseUrl(format!("could not join public operation: {error}"))
475 })?;
476 let response = self
477 .http
478 .post(url)
479 .header(reqwest::header::ACCEPT, "application/json")
480 .json(body)
481 .send()
482 .await?;
483 let status = response.status();
484 let bytes = response.bytes().await?;
485 if !status.is_success() {
486 return match serde_json::from_slice::<ErrorResponse>(&bytes) {
487 Ok(error) => Err(SdkError::Api {
488 status,
489 code: error.error.code,
490 message: error.error.message,
491 retryable: error.error.retryable,
492 }),
493 Err(_) => Err(SdkError::Api {
494 status,
495 code: "request_failed".to_owned(),
496 message: "Strata could not complete the request.".to_owned(),
497 retryable: status.is_server_error(),
498 }),
499 };
500 }
501 serde_json::from_slice(&bytes).map_err(|error| SdkError::InvalidResponse(error.to_string()))
502 }
503
504 async fn execution_path(&self, requested_market: &str) -> Result<String, SdkError> {
505 let markets = self.markets().await?;
506 let market = markets
507 .markets
508 .iter()
509 .find(|market| {
510 market.label.eq_ignore_ascii_case(requested_market.trim())
511 || market.market_pda.as_deref() == Some(requested_market.trim())
512 })
513 .ok_or_else(|| SdkError::MarketNotFound(requested_market.to_owned()))?;
514 if !market.ready {
515 return Err(SdkError::OperationUnavailable(market.label.clone()));
516 }
517 let quote_path = market
518 .quote_path
519 .as_deref()
520 .filter(|path| valid_public_operation_path(path))
521 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?;
522 Ok(format!(
523 "{}/execution",
524 quote_path
525 .strip_suffix("/quote")
526 .ok_or_else(|| SdkError::OperationUnavailable(market.label.clone()))?
527 ))
528 }
529}
530
531fn normalize_base_url(value: &str) -> Result<Url, SdkError> {
532 let mut normalized = value.trim().to_owned();
533 if !normalized.ends_with('/') {
534 normalized.push('/');
535 }
536 let url =
537 Url::parse(&normalized).map_err(|error| SdkError::InvalidBaseUrl(error.to_string()))?;
538 if !matches!(url.scheme(), "http" | "https") || url.cannot_be_a_base() {
539 return Err(SdkError::InvalidBaseUrl(
540 "URL must use http or https and include a host".to_owned(),
541 ));
542 }
543 Ok(url)
544}
545
546fn validate_action_graph(graph: &ActionGraph) -> Result<(), SdkError> {
547 validate_version(graph.schema_version, &graph.contract_version)?;
548 if graph.graph_version != "1.0"
549 || graph.authority.permission_source != "external_agent_owner"
550 || graph.authority.signing_location != "external"
551 || graph.authority.accepts_private_keys
552 {
553 return Err(SdkError::InvalidResponse(
554 "unsupported action graph authority model".to_owned(),
555 ));
556 }
557 let ids = graph
558 .nodes
559 .iter()
560 .map(|node| node.id.as_str())
561 .collect::<HashSet<_>>();
562 if ids.len() != graph.nodes.len() || !ids.contains(graph.entry_node.as_str()) {
563 return Err(SdkError::InvalidResponse(
564 "action graph node IDs are invalid".to_owned(),
565 ));
566 }
567 if graph.edges.iter().any(|edge| {
568 !ids.contains(edge.from.as_str())
569 || !ids.contains(edge.to.as_str())
570 || edge.condition.trim().is_empty()
571 }) {
572 return Err(SdkError::InvalidResponse(
573 "action graph contains an invalid edge".to_owned(),
574 ));
575 }
576 Ok(())
577}
578
579fn validate_version(schema_version: u16, contract_version: &str) -> Result<(), SdkError> {
580 if schema_version != CONTRACT_MAJOR || contract_version != CONTRACT_VERSION {
581 return Err(SdkError::InvalidResponse(format!(
582 "unsupported contract {contract_version} (schema {schema_version})"
583 )));
584 }
585 Ok(())
586}
587
588fn parse_atoms(field: &str, value: &str) -> Result<u64, SdkError> {
589 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
590 return Err(SdkError::InvalidResponse(format!(
591 "{field} must be an unsigned atomic decimal string"
592 )));
593 }
594 value
595 .parse::<u64>()
596 .map_err(|_| SdkError::InvalidResponse(format!("{field} exceeds the supported range")))
597}
598
599fn valid_public_operation_path(path: &str) -> bool {
600 let Some(market_id) = path
601 .strip_prefix("/sonar/markets/")
602 .and_then(|value| value.strip_suffix("/quote"))
603 else {
604 return false;
605 };
606 !market_id.is_empty()
607 && !market_id.starts_with('-')
608 && !market_id.ends_with('-')
609 && market_id
610 .bytes()
611 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
612}
613
614fn validate_quote(
615 quote: &QuoteResponse,
616 market_id: &str,
617 request: &QuoteRequest,
618 requested_amount: u64,
619) -> Result<(), SdkError> {
620 validate_version(quote.schema_version, "e.contract_version)?;
621 if quote.provider != "Sonar"
622 || quote.market_id != market_id
623 || quote.side != request.side
624 || quote.amount_in_atoms != request.amount_in_atoms
625 || quote.quote_id.len() != 35
626 || !quote.quote_id.starts_with("sq_")
627 || !quote.quote_id[3..]
628 .bytes()
629 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
630 || quote.expires_at_ms <= quote.server_time_ms
631 {
632 return Err(SdkError::InvalidResponse(
633 "quote binding or lifetime is invalid".to_owned(),
634 ));
635 }
636
637 let consumed = parse_atoms("amount_in_consumed_atoms", "e.amount_in_consumed_atoms)?;
638 let output = parse_atoms("amount_out_atoms", "e.amount_out_atoms)?;
639 let minimum = parse_atoms("minimum_output_atoms", "e.minimum_output_atoms)?;
640 parse_atoms("input_fee_atoms", "e.input_fee_atoms)?;
641 parse_atoms("output_fee_atoms", "e.output_fee_atoms)?;
642 if consumed > requested_amount || minimum > output {
643 return Err(SdkError::InvalidResponse(
644 "quote economics are internally inconsistent".to_owned(),
645 ));
646 }
647 quote
648 .reference_price
649 .parse::<f64>()
650 .ok()
651 .filter(|value| value.is_finite() && *value > 0.0)
652 .ok_or_else(|| SdkError::InvalidResponse("reference_price is invalid".to_owned()))?;
653 quote
654 .price_impact_pct
655 .parse::<f64>()
656 .ok()
657 .filter(|value| value.is_finite() && *value >= 0.0)
658 .ok_or_else(|| SdkError::InvalidResponse("price_impact_pct is invalid".to_owned()))?;
659 Ok(())
660}
661
662struct ExecutionAuthorization {
663 bytes: Vec<u8>,
664 recent_blockhash: String,
665 last_valid_block_height: u64,
666}
667
668fn validate_execution_challenge(
669 challenge: &ExecutionChallengeResponse,
670 quote: &QuoteResponse,
671) -> Result<(), SdkError> {
672 validate_version(challenge.schema_version, &challenge.contract_version)?;
673 validate_execution_binding(
674 &challenge.quote_id,
675 &challenge.market_id,
676 challenge.side,
677 &challenge.amount_in_atoms,
678 &challenge.minimum_output_atoms,
679 quote,
680 )?;
681 if !valid_handle(&challenge.challenge_id, "sc_")
682 || challenge.expires_at_ms <= challenge.server_time_ms
683 || challenge.expires_at_ms > quote.expires_at_ms
684 {
685 return Err(SdkError::InvalidResponse(
686 "execution challenge binding or lifetime is invalid".to_owned(),
687 ));
688 }
689 Ok(())
690}
691
692fn validate_execution_prepare(
693 prepared: &ExecutionPrepareResponse,
694 quote: &QuoteResponse,
695 challenge: &ExecutionChallengeResponse,
696 authorization: &ExecutionAuthorization,
697) -> Result<(), SdkError> {
698 validate_version(prepared.schema_version, &prepared.contract_version)?;
699 validate_execution_binding(
700 &prepared.quote_id,
701 &prepared.market_id,
702 prepared.side,
703 &prepared.amount_in_atoms,
704 &prepared.minimum_output_atoms,
705 quote,
706 )?;
707 if !valid_handle(&prepared.execution_id, "se_")
708 || prepared.recent_blockhash != authorization.recent_blockhash
709 || prepared.last_valid_block_height != authorization.last_valid_block_height
710 || prepared.expires_at_ms > challenge.expires_at_ms
711 || prepared.transaction_base64.trim().is_empty()
712 || base64::engine::general_purpose::STANDARD
713 .decode(prepared.transaction_base64.trim())
714 .is_err()
715 {
716 return Err(SdkError::InvalidResponse(
717 "prepared execution changed the signed authorization".to_owned(),
718 ));
719 }
720 Ok(())
721}
722
723fn validate_execution_binding(
724 quote_id: &str,
725 market_id: &str,
726 side: QuoteSide,
727 amount_in_atoms: &str,
728 minimum_output_atoms: &str,
729 quote: &QuoteResponse,
730) -> Result<(), SdkError> {
731 if quote_id != quote.quote_id
732 || market_id != quote.market_id
733 || side != quote.side
734 || amount_in_atoms != quote.amount_in_atoms
735 || minimum_output_atoms != quote.minimum_output_atoms
736 {
737 return Err(SdkError::InvalidResponse(
738 "execution does not match the Sonar quote".to_owned(),
739 ));
740 }
741 Ok(())
742}
743
744fn validate_execution_authorization(
745 challenge: &ExecutionChallengeResponse,
746 quote: &QuoteResponse,
747 owner_wallet: &str,
748 session_public_key: &str,
749 account_sequence: u64,
750) -> Result<ExecutionAuthorization, SdkError> {
751 let bytes = base64::engine::general_purpose::STANDARD
752 .decode(challenge.authorization_payload_base64.trim())
753 .map_err(|_| SdkError::InvalidResponse("authorization payload is not base64".to_owned()))?;
754 let market = decode_public_key("e.market_id, "market_id")?;
755 let owner = decode_public_key(owner_wallet, "owner_wallet")?;
756 let session = decode_public_key(session_public_key, "session_public_key")?;
757 let mut cursor = 0usize;
758 take_expected(
759 &bytes,
760 &mut cursor,
761 PUBLIC_EXECUTION_AUTH_DOMAIN,
762 "authorization domain",
763 )?;
764 take_expected(&bytes, &mut cursor, &market, "authorization market")?;
765 take_expected(
766 &bytes,
767 &mut cursor,
768 quote.quote_id.as_bytes(),
769 "authorization quote",
770 )?;
771 take_expected(&bytes, &mut cursor, &owner, "authorization owner")?;
772 take_expected(&bytes, &mut cursor, &session, "authorization session")?;
773 let side = take_bytes(&bytes, &mut cursor, 1, "authorization side")?[0];
774 if side != if quote.side == QuoteSide::Buy { 0 } else { 1 } {
775 return Err(SdkError::InvalidResponse(
776 "authorization side changed".to_owned(),
777 ));
778 }
779 take_u64_eq(
780 &bytes,
781 &mut cursor,
782 parse_atoms("amount_in_atoms", "e.amount_in_atoms)?,
783 "authorization input",
784 )?;
785 take_u64_eq(
786 &bytes,
787 &mut cursor,
788 parse_atoms("minimum_output_atoms", "e.minimum_output_atoms)?,
789 "authorization minimum output",
790 )?;
791 take_u64_eq(
792 &bytes,
793 &mut cursor,
794 account_sequence,
795 "authorization account sequence",
796 )?;
797 let _output_balance = take_u64(&bytes, &mut cursor, "authorization output balance")?;
798 let recent_blockhash = bs58::encode(take_bytes(
799 &bytes,
800 &mut cursor,
801 32,
802 "authorization blockhash",
803 )?)
804 .into_string();
805 let last_valid_block_height =
806 take_u64(&bytes, &mut cursor, "authorization last valid block height")?;
807 take_u64_eq(
808 &bytes,
809 &mut cursor,
810 challenge.expires_at_ms,
811 "authorization expiry",
812 )?;
813 let nonce = take_bytes(&bytes, &mut cursor, 16, "authorization nonce")?;
814 if hex::encode(nonce) != challenge.challenge_id[3..] {
815 return Err(SdkError::InvalidResponse(
816 "authorization challenge nonce changed".to_owned(),
817 ));
818 }
819 let _epoch = take_bytes(&bytes, &mut cursor, 16, "authorization epoch")?;
820 if cursor != bytes.len() {
821 return Err(SdkError::InvalidResponse(
822 "authorization contains unrecognized fields".to_owned(),
823 ));
824 }
825 Ok(ExecutionAuthorization {
826 bytes,
827 recent_blockhash,
828 last_valid_block_height,
829 })
830}
831
832fn take_expected(
833 source: &[u8],
834 cursor: &mut usize,
835 expected: &[u8],
836 field: &str,
837) -> Result<(), SdkError> {
838 if take_bytes(source, cursor, expected.len(), field)? != expected {
839 return Err(SdkError::InvalidResponse(format!("{field} changed")));
840 }
841 Ok(())
842}
843
844fn take_bytes<'a>(
845 source: &'a [u8],
846 cursor: &mut usize,
847 length: usize,
848 field: &str,
849) -> Result<&'a [u8], SdkError> {
850 let end = cursor
851 .checked_add(length)
852 .filter(|end| *end <= source.len())
853 .ok_or_else(|| SdkError::InvalidResponse(format!("{field} is missing")))?;
854 let value = &source[*cursor..end];
855 *cursor = end;
856 Ok(value)
857}
858
859fn take_u64(source: &[u8], cursor: &mut usize, field: &str) -> Result<u64, SdkError> {
860 let bytes: [u8; 8] = take_bytes(source, cursor, 8, field)?
861 .try_into()
862 .map_err(|_| SdkError::InvalidResponse(format!("{field} is invalid")))?;
863 Ok(u64::from_le_bytes(bytes))
864}
865
866fn take_u64_eq(
867 source: &[u8],
868 cursor: &mut usize,
869 expected: u64,
870 field: &str,
871) -> Result<(), SdkError> {
872 if take_u64(source, cursor, field)? != expected {
873 return Err(SdkError::InvalidResponse(format!("{field} changed")));
874 }
875 Ok(())
876}
877
878fn decode_public_key(value: &str, field: &str) -> Result<Vec<u8>, SdkError> {
879 let bytes = bs58::decode(value.trim())
880 .into_vec()
881 .map_err(|_| SdkError::InvalidRequest(format!("{field} must be base58")))?;
882 if bytes.len() != 32 || bs58::encode(&bytes).into_string() != value.trim() {
883 return Err(SdkError::InvalidRequest(format!(
884 "{field} must be a canonical 32-byte public key"
885 )));
886 }
887 Ok(bytes)
888}
889
890fn canonical_public_key(value: &str, field: &str) -> Result<String, SdkError> {
891 decode_public_key(value, field)?;
892 Ok(value.trim().to_owned())
893}
894
895fn valid_handle(value: &str, prefix: &str) -> bool {
896 value.len() == prefix.len() + 32
897 && value.starts_with(prefix)
898 && value[prefix.len()..]
899 .bytes()
900 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
901}
902
903fn normalize_idempotency_key(value: &str) -> Result<String, SdkError> {
904 let value = value.trim();
905 if value.is_empty()
906 || value.len() > 64
907 || !value.bytes().all(|byte| {
908 byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
909 })
910 {
911 return Err(SdkError::InvalidRequest(
912 "idempotency key must contain 1-64 URL-safe characters".to_owned(),
913 ));
914 }
915 Ok(value.to_owned())
916}
917
918fn unix_ms() -> Result<u64, SdkError> {
919 let elapsed = SystemTime::now()
920 .duration_since(UNIX_EPOCH)
921 .map_err(|_| SdkError::InvalidRequest("system clock is before Unix epoch".to_owned()))?;
922 u64::try_from(elapsed.as_millis())
923 .map_err(|_| SdkError::InvalidRequest("system clock exceeds supported range".to_owned()))
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use wiremock::matchers::{body_json, method, path};
930 use wiremock::{Mock, MockServer, ResponseTemplate};
931
932 fn fixture(path: &str) -> serde_json::Value {
933 let raw = match path {
934 "action-graph" => strata_public_contract::contract_fixtures::ACTION_GRAPH,
935 "markets" => strata_public_contract::contract_fixtures::MARKETS,
936 "quote" => strata_public_contract::contract_fixtures::QUOTE,
937 "capabilities" => strata_public_contract::contract_fixtures::CAPABILITIES,
938 _ => unreachable!(),
939 };
940 serde_json::from_str(raw).unwrap()
941 }
942
943 #[tokio::test]
944 async fn reads_capabilities_and_quotes_without_internal_metadata() {
945 let server = MockServer::start().await;
946 Mock::given(method("GET"))
947 .and(path("/sonar/capabilities"))
948 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("capabilities")))
949 .mount(&server)
950 .await;
951 Mock::given(method("GET"))
952 .and(path("/sonar/markets"))
953 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("markets")))
954 .expect(1)
955 .mount(&server)
956 .await;
957 Mock::given(method("GET"))
958 .and(path("/sonar/action-graph"))
959 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("action-graph")))
960 .expect(1)
961 .mount(&server)
962 .await;
963 Mock::given(method("POST"))
964 .and(path("/sonar/markets/sol-usdc/quote"))
965 .and(body_json(serde_json::json!({
966 "market_id": "11111111111111111111111111111111",
967 "side": "sell",
968 "amount_in_atoms": "10000000",
969 "slippage_bps": 50
970 })))
971 .respond_with(ResponseTemplate::new(200).set_body_json(fixture("quote")))
972 .expect(1)
973 .mount(&server)
974 .await;
975
976 let client = StrataClient::new(server.uri()).unwrap();
977 let capabilities = client.capabilities().await.unwrap();
978 assert!(capabilities
979 .capabilities
980 .iter()
981 .any(|capability| capability.id == "quotes.read"));
982
983 let graph = client.action_graph().await.unwrap();
984 assert_eq!(graph.entry_node, "discover_capabilities");
985 assert_eq!(graph.authority.permission_source, "external_agent_owner");
986
987 let quote = client
988 .quote(QuoteRequest {
989 market_id: "SOL/USDC".to_owned(),
990 side: QuoteSide::Sell,
991 amount_in_atoms: "10000000".to_owned(),
992 slippage_bps: 50,
993 })
994 .await
995 .unwrap();
996 let public = serde_json::to_value(quote).unwrap();
997 assert!(public.get("quote_id").is_some());
998 assert!(public.get("unexpected_field").is_none());
999 }
1000
1001 #[test]
1002 fn rejects_non_http_base_urls() {
1003 assert!(matches!(
1004 StrataClient::new("file:///tmp/contract"),
1005 Err(SdkError::InvalidBaseUrl(_))
1006 ));
1007 }
1008
1009 #[test]
1010 fn accepts_only_product_level_quote_operation_paths() {
1011 assert!(valid_public_operation_path("/sonar/markets/sol-usdc/quote"));
1012 for unsupported_or_ambiguous in [
1013 "/unsupported/build",
1014 "/unsupported/quote",
1015 "/sonar/markets/../quote",
1016 "/sonar/markets/SOL-USDC/quote",
1017 ] {
1018 assert!(!valid_public_operation_path(unsupported_or_ambiguous));
1019 }
1020 }
1021}