Skip to main content

fyuz/
client.rs

1//! The HTTP client: construction, configuration and the retry loop.
2
3use 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
16/// Production base URL, used unless
17/// [`FyuzClientBuilder::base_url`] overrides it.
18pub const DEFAULT_BASE_URL: &str = "https://api.fyuz.fun";
19
20/// Version of this crate, as reported in the `User-Agent`.
21pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23/// Ceiling on any single response body this crate buffers, in bytes.
24///
25/// The largest honest response on this API is a page of tokens, comfortably
26/// under 1 MiB. `reqwest` imposes no limit of its own, so without this a
27/// hostile or broken endpoint can stream until the process runs out of memory —
28/// and the excerpting in `Error` only shortens the *message*, long after the
29/// whole body is already resident.
30pub 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
37/// Query string pairs, in the order they will be encoded.
38pub(crate) type QueryPairs = Vec<(&'static str, String)>;
39
40/// Async client for the Fyuz public REST API.
41///
42/// Cloning is cheap — the underlying `reqwest::Client` and configuration are
43/// shared behind an [`Arc`], and the connection pool is shared with it. Create
44/// one per process and clone it around rather than building a new one per call.
45///
46/// ```no_run
47/// # async fn demo() -> Result<(), fyuz::Error> {
48/// use fyuz::{DiscoverParams, DiscoverTab, FyuzClient};
49///
50/// let client = FyuzClient::new()?;
51///
52/// let trending = client
53///     .discover(&DiscoverParams::new().tab(DiscoverTab::Trending).limit(10))
54///     .await?;
55///
56/// for token in &trending {
57///     println!("{} {:.0}% to graduation", token.symbol, token.graduation_pct);
58/// }
59/// # Ok(())
60/// # }
61/// ```
62#[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    /// Jitter source. A tiny xorshift keeps `rand` out of the dependency list;
75    /// nothing here is security sensitive.
76    rng: AtomicU64,
77    /// JSON-RPC override for the trading layer. `None` falls back to whatever
78    /// `GET /config` publishes for the chain.
79    rpc_url: Option<String>,
80    /// Block tag every `eth_call` is evaluated against. `None` means `"latest"`.
81    rpc_block: Option<String>,
82    /// The transport every [`RpcClient`](crate::RpcClient) in the trading layer
83    /// shares: one connection pool and one header set for every chain, built
84    /// once here rather than per chain on first use.
85    rpc_http: reqwest::Client,
86    /// Memoised `/config` and the per-chain RPC clients.
87    trade_state: TradeState,
88}
89
90impl FyuzClient {
91    /// Build a client pointed at <https://api.fyuz.fun> with the default 30s
92    /// timeout and 3 retries.
93    ///
94    /// ```no_run
95    /// # async fn demo() -> Result<(), fyuz::Error> {
96    /// let client = fyuz::FyuzClient::new()?;
97    /// println!("{}", client.health().await?.status);
98    /// # Ok(())
99    /// # }
100    /// ```
101    pub fn new() -> Result<Self> {
102        Self::builder().build()
103    }
104
105    /// Start configuring a client.
106    ///
107    /// ```no_run
108    /// # fn demo() -> Result<(), fyuz::Error> {
109    /// use std::time::Duration;
110    ///
111    /// let client = fyuz::FyuzClient::builder()
112    ///     .base_url("http://127.0.0.1:8080")?
113    ///     .timeout(Duration::from_secs(5))
114    ///     .max_retries(5)
115    ///     .build()?;
116    /// # let _ = client;
117    /// # Ok(())
118    /// # }
119    /// ```
120    pub fn builder() -> FyuzClientBuilder {
121        FyuzClientBuilder::new()
122    }
123
124    /// The base URL every request is resolved against.
125    pub fn base_url(&self) -> &Url {
126        &self.inner.base_url
127    }
128
129    /// The Distributor endpoints (`/distributor/*`), grouped together.
130    ///
131    /// ```no_run
132    /// # async fn demo() -> Result<(), fyuz::Error> {
133    /// let client = fyuz::FyuzClient::new()?;
134    /// let pot = client.distributor().get_pot().await?;
135    /// match pot.pot_bnb {
136    ///     Some(bnb) => println!("{bnb} BNB in the pot"),
137    ///     // `None` means unknown, never zero.
138    ///     None => println!("pot unavailable right now"),
139    /// }
140    /// # Ok(())
141    /// # }
142    /// ```
143    pub fn distributor(&self) -> Distributor<'_> {
144        Distributor::new(self)
145    }
146
147    /// Bonding-curve trading: quotes, and unsigned buy/sell/approve transactions.
148    ///
149    /// Builds transactions only — it never handles a key. See [`TradeApi`].
150    ///
151    /// ```no_run
152    /// # async fn demo() -> Result<(), fyuz::Error> {
153    /// use fyuz::{parse_units, BuyParams};
154    ///
155    /// let client = fyuz::FyuzClient::new()?;
156    /// let built = client
157    ///     .trade()
158    ///     .build_buy(
159    ///         &BuyParams::new("0x0000000000000000000000000000000000000001", parse_units("0.5", 18)?)
160    ///             .slippage_bps(100),
161    ///     )
162    ///     .await?;
163    /// println!("{}", built.transaction.data);
164    /// # Ok(())
165    /// # }
166    /// ```
167    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    /// Send, retrying 429s, 5xx and transport failures with exponential backoff
219    /// and full jitter. 4xx statuses other than 429 return immediately.
220    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                        // `Retry-After` is the one delay an outside party
240                        // chooses, so it is held to the same ceiling as the
241                        // computed backoff — a hostile or buggy header must not
242                        // be able to park the caller's task. The floor keeps
243                        // `Retry-After: 0` from spinning the retries away with
244                        // no sleep at all.
245                        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            // The status line arrived but the body did not. For this read-only
292            // API every request is safe to repeat, so treat it as retryable.
293            Err(BodyError::Transport(err)) => {
294                return Err(Failure::Retryable {
295                    error: Error::Transport(err),
296                    retry_after,
297                })
298            }
299            // Not retryable: repeating the request gets the same oversized body,
300            // and buffering it again is the thing the ceiling exists to prevent.
301            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    /// Full jitter: sleep a uniform random duration in `[0, cap]`, where `cap`
341    /// doubles per attempt up to `max_retry_delay`.
342    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    /// xorshift64, seeded from the clock at construction time.
357    ///
358    /// The advance is a compare-and-swap, not a load/store pair: clones of one
359    /// client share this state, and two tasks that read it concurrently would
360    /// otherwise compute the same "jitter" and sleep in lockstep — exactly the
361    /// thundering herd the jitter exists to break up.
362    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            // The closure never returns `None`, so `fetch_update` cannot fail.
370            .unwrap_or(1);
371        xorshift(previous)
372    }
373}
374
375/// One step of xorshift64. Zero is a fixed point, which the seed rules out.
376fn xorshift(mut state: u64) -> u64 {
377    state ^= state << 13;
378    state ^= state >> 7;
379    state ^= state << 17;
380    state
381}
382
383/// Why a response body could not be buffered.
384enum BodyError {
385    /// The body passed [`MAX_RESPONSE_BYTES`]; the value is how far the read got.
386    TooLarge(usize),
387    /// The connection failed partway through the body.
388    Transport(reqwest::Error),
389}
390
391/// Read a response body, refusing to buffer more than [`MAX_RESPONSE_BYTES`].
392///
393/// `Content-Length` is checked first so an oversized body costs nothing to
394/// reject, but it is only a hint — a chunked response carries none, and a lying
395/// one is free to send — so the accumulating read is what actually enforces the
396/// bound.
397async 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
414/// Outcome of one HTTP attempt that did not produce a usable body.
415enum Failure {
416    Fatal(Error),
417    Retryable {
418        error: Error,
419        retry_after: Option<Duration>,
420    },
421}
422
423/// Percent-encode one value for use as a single path segment.
424///
425/// Everything outside the RFC 3986 *unreserved* set is escaped, `/` included.
426/// That is the point: [`build_url`] treats `/` as structure, so an unencoded one
427/// inside a caller-supplied address would add a path segment and send the
428/// request to a different endpoint than the method promises.
429///
430/// Encoding happens exactly once, here — [`build_url`] joins the result
431/// verbatim rather than escaping the `%` a second time.
432///
433/// The one thing this cannot express is a segment that *is* `.` or `..`: the
434/// URL standard resolves those away before any escaping is looked at, and every
435/// `%2e` spelling with them. A value of `".."` therefore drops its own segment
436/// rather than travelling as data. It costs the request, not the target of a
437/// neighbouring endpoint, and no address or chain slug is ever spelled that way.
438pub(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
457/// Join a `/`-separated path onto the base URL.
458///
459/// Segments arrive already encoded — the structural parts are ASCII literals in
460/// this crate, and every caller-supplied value goes through [`seg`] first — so
461/// they are joined as they are. `Url::set_path` leaves an existing `%XX` alone
462/// and still escapes anything unsafe that reached it raw.
463fn 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
474/// `Retry-After` in delta-seconds form. The HTTP-date form is ignored, which
475/// falls the caller back onto ordinary exponential backoff.
476fn 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/// Builder for [`FyuzClient`]. Created with [`FyuzClient::builder`].
483#[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    /// A builder with every default in place: production base URL, 30s timeout,
499    /// 3 retries.
500    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    /// Point the client somewhere other than <https://api.fyuz.fun> — a staging
516    /// host, a caching proxy, or a stub server in tests.
517    ///
518    /// A path prefix is respected: `http://gateway/fyuz/` sends `/health` to
519    /// `http://gateway/fyuz/health`.
520    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        // Normalise to a trailing slash so path joining is unambiguous.
543        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    /// Override the `User-Agent`. Defaults to `fyuz-sdk-rust/<version>`.
553    ///
554    /// Identifying your application here is appreciated — it is what lets the
555    /// Fyuz team raise your rate-limit allowance.
556    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
557        self.user_agent = user_agent.into();
558        self
559    }
560
561    /// Per-request timeout, covering connect plus response body. Default 30s.
562    pub fn timeout(mut self, timeout: Duration) -> Self {
563        self.timeout = timeout;
564        self
565    }
566
567    /// How many times to retry a 429, a 5xx or a transport failure. Default 3;
568    /// `0` disables retrying entirely.
569    ///
570    /// One call therefore blocks for at most
571    /// `max_retries × max_retry_delay` of waiting on top of the request time —
572    /// 24s of waiting with the defaults.
573    pub fn max_retries(mut self, max_retries: u32) -> Self {
574        self.max_retries = max_retries;
575        self
576    }
577
578    /// First backoff window. Attempt `n` sleeps a uniform random duration in
579    /// `[0, base * 2^n]`, capped by [`max_retry_delay`](Self::max_retry_delay).
580    /// Default 250ms.
581    pub fn retry_base_delay(mut self, delay: Duration) -> Self {
582        self.retry_base_delay = delay;
583        self
584    }
585
586    /// Ceiling on a single wait between attempts. Default 8s.
587    ///
588    /// This bounds *every* delay the client takes, the computed backoff window
589    /// and a server's `Retry-After` alike. A server that asks for longer than
590    /// this is retried at the ceiling instead, and if it keeps refusing, the
591    /// [`Error::RateLimited`](crate::Error::RateLimited) that comes back still
592    /// carries the server's own
593    /// [`retry_after`](crate::Error::retry_after) for the caller to honour.
594    pub fn max_retry_delay(mut self, delay: Duration) -> Self {
595        self.max_retry_delay = delay;
596        self
597    }
598
599    /// Point the trading layer at a JSON-RPC endpoint of your choosing.
600    ///
601    /// Without this, [`FyuzClient::trade`] uses whatever `GET /config` publishes
602    /// for the chain. That endpoint belongs to the API operator, is shared by
603    /// every caller, and can change without notice — so set your own for
604    /// anything in production.
605    pub fn rpc_url(mut self, url: impl Into<String>) -> Self {
606        self.rpc_url = Some(url.into());
607        self
608    }
609
610    /// Per-call timeout for JSON-RPC requests. Defaults to
611    /// [`DEFAULT_RPC_TIMEOUT`](crate::DEFAULT_RPC_TIMEOUT).
612    pub fn rpc_timeout(mut self, timeout: Duration) -> Self {
613        self.rpc_timeout = Some(timeout);
614        self
615    }
616
617    /// Attach a header to every JSON-RPC request. Call it once per header.
618    ///
619    /// This is how a paid node is reached: most providers authenticate either by
620    /// a key in the path — which [`rpc_url`](Self::rpc_url) already covers — or
621    /// by a header such as `X-API-Key`, which nothing else here could express.
622    /// The header goes only to the RPC endpoint, never to the Fyuz API.
623    ///
624    /// Fails if `name` or `value` is not legal in an HTTP header.
625    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        // The value is deliberately not echoed back: it is usually a credential,
630        // and error messages get logged.
631        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    /// Evaluate every `eth_call` against this block instead of `"latest"` — a
639    /// decimal or `0x`-prefixed height, or a tag the node knows.
640    ///
641    /// Pinning a block is what makes two quotes comparable: `"latest"` moves
642    /// under you between calls, so a buy priced at one height and a sell priced
643    /// at the next are not describing the same curve.
644    pub fn rpc_block(mut self, block: impl Into<String>) -> Self {
645        self.rpc_block = Some(block.into());
646        self
647    }
648
649    /// Build the client.
650    ///
651    /// Fails only if the TLS backend or DNS resolver cannot be initialised.
652    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        // A second transport rather than a second use of the first: the RPC
659        // endpoint has its own timeout, its own headers, and — for anyone who
660        // set `rpc_header` — credentials that must not travel to the Fyuz API.
661        // It is still built once and shared by every chain.
662        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            // xorshift is a fixed point at zero.
673            | 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        // Documented limitation of URL semantics, pinned so it cannot drift into
760        // something worse: `..` loses its own segment, it does not walk up and
761        // pick a different endpoint the way an unencoded `/` used to.
762        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}