1use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use reqwest::header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
8use reqwest::{Method, StatusCode, Url};
9use serde::de::DeserializeOwned;
10
11use crate::api::distributor::Distributor;
12use crate::error::{message_from_body, truncate, Error, Result};
13use crate::rpc::DEFAULT_RPC_TIMEOUT;
14use crate::trade::{TradeApi, TradeState};
15
16pub const DEFAULT_BASE_URL: &str = "https://api.fyuz.fun";
19
20pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23pub const MAX_RESPONSE_BYTES: usize = 1 << 20;
31
32const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
33const DEFAULT_MAX_RETRIES: u32 = 3;
34const DEFAULT_RETRY_BASE_DELAY: Duration = Duration::from_millis(250);
35const DEFAULT_MAX_RETRY_DELAY: Duration = Duration::from_secs(8);
36
37pub(crate) type QueryPairs = Vec<(&'static str, String)>;
39
40#[derive(Debug, Clone)]
63pub struct FyuzClient {
64 inner: Arc<Inner>,
65}
66
67#[derive(Debug)]
68struct Inner {
69 http: reqwest::Client,
70 base_url: Url,
71 max_retries: u32,
72 retry_base_delay: Duration,
73 max_retry_delay: Duration,
74 rng: AtomicU64,
77 rpc_url: Option<String>,
80 rpc_block: Option<String>,
82 rpc_http: reqwest::Client,
86 trade_state: TradeState,
88}
89
90impl FyuzClient {
91 pub fn new() -> Result<Self> {
102 Self::builder().build()
103 }
104
105 pub fn builder() -> FyuzClientBuilder {
121 FyuzClientBuilder::new()
122 }
123
124 pub fn base_url(&self) -> &Url {
126 &self.inner.base_url
127 }
128
129 pub fn distributor(&self) -> Distributor<'_> {
144 Distributor::new(self)
145 }
146
147 pub fn trade(&self) -> TradeApi<'_> {
168 TradeApi::new(self)
169 }
170
171 pub(crate) fn rpc_url(&self) -> Option<&str> {
172 self.inner.rpc_url.as_deref()
173 }
174
175 pub(crate) fn rpc_block(&self) -> Option<&str> {
176 self.inner.rpc_block.as_deref()
177 }
178
179 pub(crate) fn rpc_http(&self) -> &reqwest::Client {
180 &self.inner.rpc_http
181 }
182
183 pub(crate) fn trade_state(&self) -> &TradeState {
184 &self.inner.trade_state
185 }
186
187 pub(crate) async fn get_json<T: DeserializeOwned>(
188 &self,
189 path: &str,
190 query: QueryPairs,
191 ) -> Result<T> {
192 self.request(Method::GET, path, query, None).await
193 }
194
195 pub(crate) async fn post_json<T: DeserializeOwned>(
196 &self,
197 path: &str,
198 query: QueryPairs,
199 body: serde_json::Value,
200 ) -> Result<T> {
201 self.request(Method::POST, path, query, Some(body)).await
202 }
203
204 async fn request<T: DeserializeOwned>(
205 &self,
206 method: Method,
207 path: &str,
208 query: QueryPairs,
209 body: Option<serde_json::Value>,
210 ) -> Result<T> {
211 let bytes = self.send_with_retry(method, path, query, body).await?;
212 serde_json::from_slice(&bytes).map_err(|e| Error::Decode {
213 message: e.to_string(),
214 body: truncate(String::from_utf8_lossy(&bytes).trim(), 256),
215 })
216 }
217
218 async fn send_with_retry(
221 &self,
222 method: Method,
223 path: &str,
224 query: QueryPairs,
225 body: Option<serde_json::Value>,
226 ) -> Result<Vec<u8>> {
227 let url = build_url(&self.inner.base_url, path);
228 let mut attempt: u32 = 0;
229
230 loop {
231 match self.attempt(&method, &url, &query, body.as_ref()).await {
232 Ok(bytes) => return Ok(bytes),
233 Err(Failure::Fatal(err)) => return Err(err),
234 Err(Failure::Retryable { error, retry_after }) => {
235 if attempt >= self.inner.max_retries {
236 return Err(error);
237 }
238 let delay = match retry_after {
239 Some(after) => after
246 .max(self.inner.retry_base_delay)
247 .min(self.inner.max_retry_delay),
248 None => self.backoff(attempt),
249 };
250 tokio::time::sleep(delay).await;
251 attempt += 1;
252 }
253 }
254 }
255 }
256
257 async fn attempt(
258 &self,
259 method: &Method,
260 url: &Url,
261 query: &QueryPairs,
262 body: Option<&serde_json::Value>,
263 ) -> std::result::Result<Vec<u8>, Failure> {
264 let mut request = self.inner.http.request(method.clone(), url.clone());
265 if !query.is_empty() {
266 request = request.query(query);
267 }
268 if let Some(body) = body {
269 request = request.json(body);
270 }
271
272 let response = match request.send().await {
273 Ok(response) => response,
274 Err(err) => {
275 let retryable = err.is_timeout() || err.is_connect() || err.is_request();
276 return Err(if retryable {
277 Failure::Retryable {
278 error: Error::Transport(err),
279 retry_after: None,
280 }
281 } else {
282 Failure::Fatal(Error::Transport(err))
283 });
284 }
285 };
286
287 let status = response.status();
288 let retry_after = parse_retry_after(response.headers());
289 let bytes = match read_capped(response).await {
290 Ok(bytes) => bytes,
291 Err(BodyError::Transport(err)) => {
294 return Err(Failure::Retryable {
295 error: Error::Transport(err),
296 retry_after,
297 })
298 }
299 Err(BodyError::TooLarge(seen)) => {
302 return Err(Failure::Fatal(Error::Api {
303 status: status.as_u16(),
304 message: format!(
305 "response body is at least {seen} bytes, past the \
306 {MAX_RESPONSE_BYTES}-byte ceiling"
307 ),
308 }))
309 }
310 };
311
312 if status.is_success() {
313 return Ok(bytes);
314 }
315
316 let message = message_from_body(&bytes, status);
317
318 if status == StatusCode::TOO_MANY_REQUESTS {
319 return Err(Failure::Retryable {
320 error: Error::RateLimited {
321 message,
322 retry_after,
323 },
324 retry_after,
325 });
326 }
327
328 let error = Error::Api {
329 status: status.as_u16(),
330 message,
331 };
332
333 Err(if status.is_server_error() {
334 Failure::Retryable { error, retry_after }
335 } else {
336 Failure::Fatal(error)
337 })
338 }
339
340 fn backoff(&self, attempt: u32) -> Duration {
343 let factor = 1u32 << attempt.min(16);
344 let cap = self
345 .inner
346 .retry_base_delay
347 .saturating_mul(factor)
348 .min(self.inner.max_retry_delay);
349 let cap_ms = cap.as_millis() as u64;
350 if cap_ms == 0 {
351 return Duration::ZERO;
352 }
353 Duration::from_millis(self.next_random() % (cap_ms + 1))
354 }
355
356 fn next_random(&self) -> u64 {
363 let previous = self
364 .inner
365 .rng
366 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |state| {
367 Some(xorshift(state))
368 })
369 .unwrap_or(1);
371 xorshift(previous)
372 }
373}
374
375fn xorshift(mut state: u64) -> u64 {
377 state ^= state << 13;
378 state ^= state >> 7;
379 state ^= state << 17;
380 state
381}
382
383enum BodyError {
385 TooLarge(usize),
387 Transport(reqwest::Error),
389}
390
391async fn read_capped(mut response: reqwest::Response) -> std::result::Result<Vec<u8>, BodyError> {
398 if let Some(declared) = response.content_length() {
399 if declared > MAX_RESPONSE_BYTES as u64 {
400 return Err(BodyError::TooLarge(declared as usize));
401 }
402 }
403
404 let mut body: Vec<u8> = Vec::new();
405 while let Some(chunk) = response.chunk().await.map_err(BodyError::Transport)? {
406 if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
407 return Err(BodyError::TooLarge(body.len() + chunk.len()));
408 }
409 body.extend_from_slice(&chunk);
410 }
411 Ok(body)
412}
413
414enum Failure {
416 Fatal(Error),
417 Retryable {
418 error: Error,
419 retry_after: Option<Duration>,
420 },
421}
422
423pub(crate) fn seg(value: &str) -> String {
439 const HEX: &[u8; 16] = b"0123456789ABCDEF";
440
441 let mut encoded = String::with_capacity(value.len());
442 for byte in value.bytes() {
443 match byte {
444 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
445 encoded.push(byte as char)
446 }
447 _ => {
448 encoded.push('%');
449 encoded.push(HEX[usize::from(byte >> 4)] as char);
450 encoded.push(HEX[usize::from(byte & 0x0f)] as char);
451 }
452 }
453 }
454 encoded
455}
456
457fn build_url(base: &Url, path: &str) -> Url {
464 let mut url = base.clone();
465 let mut joined = url.path().trim_end_matches('/').to_string();
466 for segment in path.split('/').filter(|s| !s.is_empty()) {
467 joined.push('/');
468 joined.push_str(segment);
469 }
470 url.set_path(&joined);
471 url
472}
473
474fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
477 let raw = headers.get(RETRY_AFTER)?.to_str().ok()?;
478 let seconds: u64 = raw.trim().parse().ok()?;
479 Some(Duration::from_secs(seconds))
480}
481
482#[derive(Debug, Clone)]
484pub struct FyuzClientBuilder {
485 base_url: Url,
486 user_agent: String,
487 timeout: Duration,
488 max_retries: u32,
489 retry_base_delay: Duration,
490 max_retry_delay: Duration,
491 rpc_url: Option<String>,
492 rpc_timeout: Option<Duration>,
493 rpc_block: Option<String>,
494 rpc_headers: HeaderMap,
495}
496
497impl FyuzClientBuilder {
498 pub fn new() -> Self {
501 Self {
502 base_url: Url::parse(DEFAULT_BASE_URL).expect("DEFAULT_BASE_URL is a valid URL"),
503 user_agent: format!("fyuz-sdk-rust/{VERSION}"),
504 timeout: DEFAULT_TIMEOUT,
505 max_retries: DEFAULT_MAX_RETRIES,
506 retry_base_delay: DEFAULT_RETRY_BASE_DELAY,
507 max_retry_delay: DEFAULT_MAX_RETRY_DELAY,
508 rpc_url: None,
509 rpc_timeout: None,
510 rpc_block: None,
511 rpc_headers: HeaderMap::new(),
512 }
513 }
514
515 pub fn base_url(mut self, base_url: &str) -> Result<Self> {
521 let mut parsed = Url::parse(base_url).map_err(|e| Error::InvalidBaseUrl {
522 url: base_url.to_string(),
523 message: e.to_string(),
524 })?;
525
526 if parsed.cannot_be_a_base() {
527 return Err(Error::InvalidBaseUrl {
528 url: base_url.to_string(),
529 message: "URL cannot be used as a base".to_string(),
530 });
531 }
532 if !matches!(parsed.scheme(), "http" | "https") {
533 return Err(Error::InvalidBaseUrl {
534 url: base_url.to_string(),
535 message: format!(
536 "unsupported scheme {:?}, expected http or https",
537 parsed.scheme()
538 ),
539 });
540 }
541
542 if !parsed.path().ends_with('/') {
544 let path = format!("{}/", parsed.path());
545 parsed.set_path(&path);
546 }
547
548 self.base_url = parsed;
549 Ok(self)
550 }
551
552 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
557 self.user_agent = user_agent.into();
558 self
559 }
560
561 pub fn timeout(mut self, timeout: Duration) -> Self {
563 self.timeout = timeout;
564 self
565 }
566
567 pub fn max_retries(mut self, max_retries: u32) -> Self {
574 self.max_retries = max_retries;
575 self
576 }
577
578 pub fn retry_base_delay(mut self, delay: Duration) -> Self {
582 self.retry_base_delay = delay;
583 self
584 }
585
586 pub fn max_retry_delay(mut self, delay: Duration) -> Self {
595 self.max_retry_delay = delay;
596 self
597 }
598
599 pub fn rpc_url(mut self, url: impl Into<String>) -> Self {
606 self.rpc_url = Some(url.into());
607 self
608 }
609
610 pub fn rpc_timeout(mut self, timeout: Duration) -> Self {
613 self.rpc_timeout = Some(timeout);
614 self
615 }
616
617 pub fn rpc_header(mut self, name: &str, value: &str) -> Result<Self> {
626 let name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| Error::InvalidArgument {
627 message: format!("{name:?} is not a valid header name: {e}"),
628 })?;
629 let value = HeaderValue::from_str(value).map_err(|e| Error::InvalidArgument {
632 message: format!("the value for header {name:?} is not a valid header value: {e}"),
633 })?;
634 self.rpc_headers.insert(name, value);
635 Ok(self)
636 }
637
638 pub fn rpc_block(mut self, block: impl Into<String>) -> Self {
645 self.rpc_block = Some(block.into());
646 self
647 }
648
649 pub fn build(self) -> Result<FyuzClient> {
653 let http = reqwest::Client::builder()
654 .user_agent(&self.user_agent)
655 .timeout(self.timeout)
656 .build()?;
657
658 let rpc_http = reqwest::Client::builder()
663 .user_agent(&self.user_agent)
664 .timeout(self.rpc_timeout.unwrap_or(DEFAULT_RPC_TIMEOUT))
665 .default_headers(self.rpc_headers)
666 .build()?;
667
668 let seed = SystemTime::now()
669 .duration_since(UNIX_EPOCH)
670 .map(|d| d.as_nanos() as u64)
671 .unwrap_or(0x9E37_79B9_7F4A_7C15)
672 | 1;
674
675 Ok(FyuzClient {
676 inner: Arc::new(Inner {
677 http,
678 base_url: self.base_url,
679 max_retries: self.max_retries,
680 retry_base_delay: self.retry_base_delay,
681 max_retry_delay: self.max_retry_delay,
682 rng: AtomicU64::new(seed),
683 rpc_url: self.rpc_url,
684 rpc_block: self.rpc_block,
685 rpc_http,
686 trade_state: TradeState::default(),
687 }),
688 })
689 }
690}
691
692impl Default for FyuzClientBuilder {
693 fn default() -> Self {
694 Self::new()
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 fn base(url: &str) -> Url {
703 FyuzClientBuilder::new().base_url(url).unwrap().base_url
704 }
705
706 #[test]
707 fn joins_paths_onto_a_bare_host() {
708 assert_eq!(
709 build_url(&base("http://127.0.0.1:8080"), "/tokens/king").as_str(),
710 "http://127.0.0.1:8080/tokens/king"
711 );
712 }
713
714 #[test]
715 fn joins_paths_onto_a_prefixed_base() {
716 assert_eq!(
717 build_url(&base("http://gateway/fyuz"), "/distributor/rounds/7").as_str(),
718 "http://gateway/fyuz/distributor/rounds/7"
719 );
720 }
721
722 #[test]
723 fn percent_encodes_path_segments() {
724 let url = build_url(&base("https://api.fyuz.fun"), "/wallet/0x00 ff");
725 assert_eq!(url.as_str(), "https://api.fyuz.fun/wallet/0x00%20ff");
726 }
727
728 #[test]
729 fn seg_escapes_everything_outside_the_unreserved_set() {
730 assert_eq!(
731 seg("0x1111111111111111111111111111111111111111"),
732 "0x1111111111111111111111111111111111111111"
733 );
734 assert_eq!(seg("-._~"), "-._~", "unreserved punctuation stays literal");
735 assert_eq!(seg("a/b"), "a%2Fb", "a slash must not become structure");
736 assert_eq!(seg("a?b=c#d"), "a%3Fb%3Dc%23d");
737 assert_eq!(seg("0x00 ff"), "0x00%20ff");
738 assert_eq!(seg("%2F"), "%252F", "a pre-escaped slash is data too");
739 assert_eq!(seg("é"), "%C3%A9", "non-ASCII is escaped per UTF-8 byte");
740 }
741
742 #[test]
743 fn a_slash_in_an_argument_cannot_retarget_the_request() {
744 let base = base("https://api.fyuz.fun");
745
746 let url = build_url(&base, &format!("/wallet/{}", seg("../distributor/stats")));
747 assert_eq!(
748 url.path(),
749 "/wallet/..%2Fdistributor%2Fstats",
750 "the argument must stay one segment"
751 );
752
753 let url = build_url(&base, &format!("/tier/{}", seg("0xabc/../../health")));
754 assert_eq!(url.path(), "/tier/0xabc%2F..%2F..%2Fhealth");
755 }
756
757 #[test]
758 fn a_bare_dot_segment_collapses_rather_than_reaching_a_sibling_endpoint() {
759 let url = build_url(
763 &base("https://api.fyuz.fun"),
764 &format!("/wallet/{}", seg("..")),
765 );
766 assert_eq!(url.path(), "/");
767 assert_eq!(seg(".."), "..");
768 }
769
770 #[test]
771 fn build_url_does_not_escape_an_already_escaped_segment_twice() {
772 let url = build_url(
773 &base("https://api.fyuz.fun"),
774 &format!("/wallet/{}", seg("a b")),
775 );
776 assert_eq!(url.as_str(), "https://api.fyuz.fun/wallet/a%20b");
777 }
778
779 #[test]
780 fn rejects_non_http_base_urls() {
781 let err = FyuzClientBuilder::new()
782 .base_url("ftp://example.com")
783 .unwrap_err();
784 assert!(matches!(err, Error::InvalidBaseUrl { .. }));
785 let err = FyuzClientBuilder::new().base_url("not a url").unwrap_err();
786 assert!(matches!(err, Error::InvalidBaseUrl { .. }));
787 }
788
789 #[test]
790 fn parses_retry_after_seconds() {
791 let mut headers = reqwest::header::HeaderMap::new();
792 headers.insert(RETRY_AFTER, "3".parse().unwrap());
793 assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(3)));
794
795 headers.insert(
796 RETRY_AFTER,
797 "Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
798 );
799 assert_eq!(parse_retry_after(&headers), None);
800 }
801
802 #[test]
803 fn backoff_stays_within_the_cap() {
804 let client = FyuzClientBuilder::new()
805 .retry_base_delay(Duration::from_millis(100))
806 .max_retry_delay(Duration::from_millis(400))
807 .build()
808 .unwrap();
809
810 for attempt in 0..6 {
811 let delay = client.backoff(attempt);
812 assert!(delay <= Duration::from_millis(400), "{delay:?}");
813 }
814 assert!(client.backoff(0) <= Duration::from_millis(100));
815 }
816}