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