fyuz-sdk 0.1.1

Async Rust client for the Fyuz public REST API β€” bonding-curve token market data on BNB Smart Chain.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
<div align="center">

# πŸ¦€ fyuz-sdk

**Async Rust client for the [Fyuz](https://fyuz.fun) bonding-curve launchpad on BNB Smart Chain.**
Market data, quotes, and unsigned transactions β€” it never touches your keys.

[![crates.io](https://img.shields.io/badge/crates.io-fyuz--sdk-e6a04b?logo=rust&logoColor=white)](https://crates.io/crates/fyuz-sdk)
[![version](https://img.shields.io/badge/version-0.1.1-blue)](https://crates.io/crates/fyuz-sdk)
[![docs.rs](https://img.shields.io/badge/docs.rs-fyuz--sdk-4d76ae?logo=docsdotrs&logoColor=white)](https://docs.rs/fyuz-sdk)
[![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![rust](https://img.shields.io/badge/rust-1.75%2B-b7410e?logo=rust&logoColor=white)](https://www.rust-lang.org)
[![tests](https://img.shields.io/badge/tests-130%20%2B%2047%20doctests-brightgreen)](#-why-this-sdk)
[![unsigned only](https://img.shields.io/badge/keys-never%20touched-critical)](#-trading-unsigned-transactions-only)

</div>

---

> ⚠️ **Important β€” the crate is `fyuz-sdk`, but the library you import is `fyuz`.**
> ```rust
> use fyuz::FyuzClient;   // βœ…  crate `fyuz-sdk`, lib name `fyuz`
> use fyuz_sdk::FyuzClient; // ❌ does not exist
> ```
> This trips up nearly everyone once. It is set by `[lib] name = "fyuz"` in
> [`Cargo.toml`]Cargo.toml, and it is why the rendered API docs at
> [docs.rs/fyuz-sdk]https://docs.rs/fyuz-sdk are titled `fyuz`.

Fyuz is a bonding-curve token launchpad on **BNB Smart Chain (chain 56)**. This crate is a typed,
`async`/`tokio` client for its public REST API β€” 28 unauthenticated endpoints covering discovery,
tokens, trades, candles, analytics, wallets, leaderboards and the fee Distributor β€” plus a trading
layer that prices swaps against the live contract and hands you **unsigned** transactions to sign
with whatever wallet you already have. Every response is a named struct, every decimal amount stays
an exact string, and nothing here holds a private key.

---

## πŸ“¦ Install

```bash
cargo add fyuz-sdk
cargo add tokio --features macros,rt-multi-thread
```

Or by hand:

```toml
[dependencies]
fyuz-sdk = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

| | |
|---|---|
| πŸ“¦ Crate | `fyuz-sdk` |
| πŸ¦€ Import as | `use fyuz::…` |
| πŸ“– Docs | <https://docs.rs/fyuz-sdk> |
| 🌐 API | `https://api.fyuz.fun` |
| 🧰 MSRV | Rust 1.75 |
| πŸ”“ Auth | none β€” every endpoint here is public and safe to poll |

## πŸš€ Quickstart

```rust
use fyuz::{DiscoverParams, DiscoverTab, FyuzClient};

#[tokio::main]
async fn main() -> Result<(), fyuz::Error> {
    let client = FyuzClient::new()?;

    let trending = client
        .discover(&DiscoverParams::new().tab(DiscoverTab::Trending).limit(5))
        .await?;

    for t in &trending {
        let venue = if t.launched { "PancakeSwap" } else { "bonding curve" };
        println!(
            "{:<8} ${:>10.0}  {:>5.1}% to graduation  ({venue})",
            t.symbol, t.marketcap, t.graduation_pct
        );
    }

    Ok(())
}
```

Real output, run against `https://api.fyuz.fun`:

```text
PROGI    $      4473   14.9% to graduation  (bonding curve)
TUTACO   $      4473   14.9% to graduation  (bonding curve)
SRIRA    $      4473   14.9% to graduation  (bonding curve)
CROIS    $      4473   14.9% to graduation  (bonding curve)
SLOIN    $      4472   14.9% to graduation  (bonding curve)
```

`FyuzClient` is cheap to clone β€” the `reqwest::Client`, its connection pool and all configuration sit
behind an `Arc`. Build one per process and clone it around. Every knob is optional:

```rust
use std::time::Duration;

let client = fyuz::FyuzClient::builder()
    .base_url("https://api.fyuz.fun")?           // default
    .timeout(Duration::from_secs(30))            // default
    .max_retries(3)                              // default; 0 disables retrying
    .retry_base_delay(Duration::from_millis(250))// default
    .max_retry_delay(Duration::from_secs(8))     // default; caps Retry-After too
    .user_agent("acme-indexer/2.1 (ops@acme.example)")
    .build()?;
```

## πŸŽ“ The one thing to understand first

A Fyuz token trades on an **internal bonding curve** until it reaches a **$30,000 market cap**, at
which point it *graduates* into a PancakeSwap V2 pair.

**Before graduation there is no DEX pool anywhere.** `pair_address` is `None`, there is no pair to
quote, no aggregator has a price, and no on-chain pool read will give you one. For a pre-graduation
token **this API is the only source of price, liquidity and volume data.** If you are building an
indexer, a bot or a portfolio tracker, that fact decides your architecture.

`graduation_pct` (on `DiscoverToken`) and `progress` (on `Token`) tell you how close a token is to
that moment. `pool_type` is `1` for a PancakeSwap V2 pair, `2` for V3 and `3` for V4 β€” direct
launches, which skip the bonding curve entirely, also report `3`. The API types the field as a plain
integer with no enum, so treat the set as open. `network` is currently `"bsc"`.

## βœ… Why this SDK

There are four Fyuz SDKs β€” TypeScript, Go, Python and Rust. They are not four independent guesses at
the same API. They are checked against each other and against the real contract:

| βœ”οΈ | What is actually verified |
|---|---|
| πŸ” **Field-for-field agreement across languages** | All four SDKs write a conformance record from the same fork and it agrees on **every field** β€” same quotes, same calldata, same decoded pool state. [`scripts/fork-test.sh`]../scripts/fork-test.sh flattens the four records and compares them leaf by leaf; any divergence fails the run. |
| ⛓️ **Fork-tested against the deployed contract** | Not stubs: [`scripts/fork-test.sh`]../scripts/fork-test.sh forks BNB Smart Chain and runs the suites against the live Fyuz proxy `0x33a98…7019c`. |
| πŸ’° **Quotes checked against the curve** | Every buy/sell quote is compared with `getSwapOutput` on the real curve, and exact-tokens pricing was verified correct **to the wei** by bisecting the deployed contract. |
| 🧨 **Reverts classified from real revert data** | The revert table is driven by bytes the contract actually returned β€” `SwapExpired` `0x2b32713d`, `SlippageExceeded` `0x8199f5f3` β€” not by selectors someone typed. |
| πŸ§ͺ **Hermetic offline suite** | `cargo test` runs **130 tests + 47 doctests** with no network at all. TypeScript 164, Go 100, Python 177 do the same. |
| πŸ” **Never signs, never broadcasts** | The trading layer builds unsigned transactions and stops. No key material enters this crate at any point. |

## πŸ“š API surface

All methods are `async` and return `fyuz::Result<T>`. Optional query parameters live in per-endpoint
params structs with chainable setters (`DiscoverParams::new().tab(..).limit(..)`); anything you do
not set is simply not sent, and the server applies its own default.

### Market data

| Method | HTTP | Returns |
|---|---|---|
| `health()` | `GET /health` | `HealthStatus` |
| `get_config()` | `GET /config` | `ChainConfig` |
| `discover(&DiscoverParams)` | `GET /discover` | `Vec<DiscoverToken>` |
| `list_tokens(&ListTokensParams)` | `GET /tokens` | `TokenPage` |
| `token_pages(&ListTokensParams)` | `GET /tokens` (all pages) | `TokenPager` |
| `get_king()` | `GET /tokens/king` | `Option<Token>` |
| `get_token(network, address, &TokenDetailParams)` | `GET /tokens/{network}/{tokenAddress}` | `TokenDetail` |
| `get_recent_trades(&RecentTradesParams)` | `GET /trades/recent` | `RecentTrades` |
| `get_token_trades(address, &TokenTradesParams)` | `POST /trades` | `Vec<Trade>` |
| `get_chart_data(&ChartDataParams)` | `GET /trades/getChartData` | `Vec<Candle>` |
| `get_token_analytics(network, address)` | `GET /analytics/token/{network}/{address}` | `TokenAnalytics` |
| `get_top_traders(network, address)` | `GET /analytics/top-traders/{network}/{address}` | `Vec<TopTrader>` |

### Wallets, profiles and leaderboards

| Method | HTTP | Returns |
|---|---|---|
| `get_wallet(address)` | `GET /wallet/{address}` | `WalletStats` |
| `get_portfolio(address)` | `GET /portfolio/{address}` | `Portfolio` |
| `get_user_leaderboard(Option<limit>)` | `GET /users/leaderboard` | `Vec<LeaderboardEntry>` |
| `get_user_profile(address)` | `GET /users/profile/{address}` | `UserProfile` |
| `get_top_holders(count, &TopHoldersParams)` | `GET /users/top/{count}` | `Vec<TopHolderEntry>` |
| `get_kings_history()` | `GET /kings/history` | `Vec<KingReign>` |
| `get_season()` | `GET /season` | `Season` |
| `get_referral_leaderboard(Option<limit>)` | `GET /referrals/leaderboard` | `Vec<ReferralLeaderEntry>` |
| `get_tier(address)` | `GET /tier/{address}` | `TierInfo` |
| `get_rewards(address)` | `GET /rewards/{address}` | `Rewards` |

### Distributor β€” the fee payback ledger

Reached through `client.distributor()`.

| Method | HTTP | Returns |
|---|---|---|
| `get_stats()` | `GET /distributor/stats` | `PayoutStats` |
| `get_pot()` | `GET /distributor/pot` | `Pot` |
| `get_shares(&SharesParams)` | `GET /distributor/shares` | `Shares` |
| `list_rounds(&RoundsParams)` | `GET /distributor/rounds` | `Vec<RoundReceipt>` |
| `get_round(id)` | `GET /distributor/rounds/{id}` | `RoundDetail` |
| `get_payouts(address)` | `GET /distributor/payouts/{address}` | `AddressPayouts` |
| `get_claimable(address)` | `GET /distributor/claimable/{address}` | `Claimable` |

Endpoints that can answer `404`: `get_token`, `get_token_analytics`, `get_user_profile`,
`distributor().get_round()`. Check with `Error::is_not_found()`.

<details>
<summary>πŸ“ <b>Endpoint notes worth reading once</b></summary>

- **`get_token_trades` is a `POST`** only because the token address travels in the request body. It
  is a read, it mutates nothing, and it needs no auth.
- **Leaderboard `rank` is server-assigned**, 1-based, already in returned order. Do not re-sort it
  client side.
- **`RoundDetail` is flattened on the wire**: the receipt fields sit at the top level next to
  `payouts`. In Rust they live under `detail.round` via `#[serde(flatten)]`, so `detail.round.round_id`
  reads the `roundId` key the API actually sends.
- **`get_config()` currently lists two chains**: BNB Smart Chain (56), which has every token, and
  Robinhood Chain (4663), which has none yet. All trading and market data in practice is chain 56.
- **`get_king()` returns `Option<Token>`.** `None` means the hill is empty β€” a real state, not an
  error: a token leaves the moment it graduates, so between a graduation and the next launch there
  is no king. The `{"king": …}` envelope is unwrapped for you.
- **Timestamps are UNIX seconds (`i64`)** everywhere except `Token::created_at` / `updated_at` /
  `launched_at` / `creation_time` and `ChatMessage::date`, which are RFC-3339 strings. The crate
  carries no `chrono` or `time` dependency; parse those with whatever date library you already use.
  This is the one convenience the TypeScript, Go and Python SDKs have that this one deliberately
  does not β€” picking a date crate for you would put a major version of it into every downstream
  build to save one `parse` call.

</details>

<details>
<summary>🚫 <b>What is deliberately missing</b></summary>

Only the public, unauthenticated surface is implemented. The API-key-gated routes have no client
methods β€” including `POST /distributor/rounds`, which shares a path with `distributor().list_rounds()`
but is internal to the round-runner, and the authenticated `/users`, `/chats`, `/watchlist`,
`/stream`, `/paper`, `/creator`, `/airdrop` and `/referrals/{address}` routes.

</details>

## πŸ’± Trading: unsigned transactions only

`client.trade()` prices bonding-curve swaps against the live contract and builds transactions.

> πŸ“ **Note β€” it never signs and never broadcasts.** You get an `UnsignedTransaction`
> `{ chain_id, to, data, value, from }` and hand it to `alloy`, `ethers-rs`, a hardware signer or a
> multisig UI. That library owns the key; this crate never sees one. That is a security property,
> not a missing feature.

```rust
use fyuz::{format_units, parse_units, BuyParams, FyuzClient};

let client = FyuzClient::builder()
    .rpc_url(std::env::var("BSC_RPC_URL")?)   // use your own node in production
    .build()?;

let token = "0x42322852a918f94186b7dfda2e0e3f4ad3528480";
let spend = parse_units("0.01", 18)?;         // 0.01 BNB, in wei

let quote = client.trade().quote_buy("bsc", token, spend).await?;
println!("{} tokens out", format_units(quote.amount_out_wei, 18));
println!("msg.value {}", quote.value_wei);    // amount + getFirstBuyFee(token)

let built = client
    .trade()
    .build_buy(&BuyParams::new(token, spend).slippage_bps(100))  // 1% β€” required
    .await?;
// built.transaction is ready to sign. Nothing has been sent.
```

Real output from [`examples/trade.rs`](examples/trade.rs), run against BNB Smart Chain:

```text
chain      BNB Smart Chain (56)
contract   0x33a98bef6496684a8dac83734d9ceb0cefc7019c

buy quote
  spending       0.01 BNB
  first-buy fee  0 BNB
  msg.value      0.01 BNB  <- includes the fee
  tokens out     1259164.617412410713942325
  price impact   0.11%

buy transaction (unsigned)
  chainId        56
  to             0x33a98bef6496684a8dac83734d9ceb0cefc7019c
  value          10000000000000000
  data           0x6bf05b0100000000000000000000000042322852a918f94186b7dfda2e0e3f4ad3528480…
  minAmountOut   1246572.971238286606802901 tokens
```

### 🧾 Trading API

| Method | What it does |
|---|---|
| `chain(network)` | `ChainInfo` from `GET /config` β€” contract address, chain id, RPC. Memoised. |
| `quote_buy(network, token, amount_wei)` | `BuyQuote`: tokens out, first-buy fee, required `msg.value`, price impact |
| `quote_sell(network, token, amount_wei)` | `SellQuote`: native out net of fees, price impact |
| `build_buy(&BuyParams)` | `BuiltBuy` β€” spend an exact amount of BNB |
| `build_buy_exact_tokens(&BuyExactTokensParams)` | `BuiltBuy` β€” get an exact number of tokens, capping the spend |
| `build_sell(&SellParams)` | `BuiltSell` β€” sell tokens for BNB |
| `build_approve(network, token, amount_wei)` | `UnsignedTransaction` β€” the ERC-20 approval a sell needs |
| `is_graduated(network, token)` | Reads `tokenPools(token).launched` β€” the contract's flag, not the indexer's view |
| `is_paused(network)` | Reads the launchpad's `paused()` flag |
| `first_buy_fee(network, token)` | `getFirstBuyFee(token)` in wei |
| `max_sellable_wei(network, token)` | `getMaxSellableETH(token)` β€” the ceiling on one sell |
| `allowance(network, token, owner)` | ERC-20 `allowance(owner, launchpad)` |
| `balance_of(network, token, owner)` | ERC-20 `balanceOf(owner)` |

### πŸ’Έ Fees, slippage and the things that revert

- **`msg.value` on a buy is `amount_in + getFirstBuyFee(token)`.** The fee rides *on top of* the
  swap amount and is not deducted from it β€” `quote.value_wei` is the number to send. Sending only
  the amount reverts with `InsufficientEthValue()`.
- **The buy fee is `PLATFORM_BUY_FEE_BPS` (80) + `TOKEN_OWNER_FEE_BPS` (20) = 100 bps.** The SDK
  never hardcodes those: both are plain storage with owner setters, so they are read from the
  contract on every exact-tokens quote. A stale figure would produce a `maxAmountIn` under the real
  cost, and the swap would revert with `MaxInputExceeded()`.
- **Slippage is never chosen for you.** `build_buy`, `build_buy_exact_tokens` and `build_sell` all
  require either `slippage_bps` (100 = 1%) or an explicit `limit_wei`. A default here would be a
  number picked by someone who cannot see the trade.
- **Selling needs an approval first** β€” the contract pulls tokens with `transferFrom`:

```rust
use fyuz::SellParams;

if client.trade().allowance("bsc", token, owner).await? < amount {
    let approve = client.trade().build_approve("bsc", token, amount).await?;
    // sign and send `approve`, wait for it, then continue
}
let sell = client
    .trade()
    .build_sell(&SellParams::new(token, amount).slippage_bps(150))
    .await?;
```

- **A graduated token fails fast.** Quoting or building for one returns an error satisfying
  `Error::is_graduated()` instead of a transaction that would burn gas to revert.
- **`is_paused()` is not called automatically.** That would double the RPC cost of every quote to
  check a flag that is almost never set, and a paused contract already surfaces as
  `RevertKind::Paused` when the swap is simulated. Call it when you want the reason *before* asking
  someone to sign, rather than after their transaction reverted.

<details>
<summary>πŸ” <b>Why <code>build_buy_exact_tokens</code> costs more calls</b></summary>

Every other quote in this crate is a single `eth_call`, because reimplementing the curve locally
would mean a second formula to keep in step with an upgradeable contract. This one cannot be:
`getSwapOutput` answers "what do I get for X" in both directions, and the contract publishes no view
for "what does Y cost" β€” `getAmountIn` is `internal`.

So `build_buy_exact_tokens` mirrors `Fyuz.getAmountIn` exactly β€” including its `+1` after the floor
division and its fee gross-up β€” over reserves and fee rates read from the same contract in the same
breath. That is four `eth_call`s (`tokenPools`, both fee constants, `getFirstBuyFee`) against
`build_buy`'s two. Only the multiply is local; every input is the chain's.

Pricing it off the *sell* side instead would be one call and the wrong answer: it divides by
`virtual_token + wanted` where the buy divides by `virtual_token βˆ’ wanted`, and it subtracts the sell
fees where the buy adds the buy fees.

</details>

<details>
<summary>πŸ”Œ <b>Reaching a private node</b></summary>

The JSON-RPC transport takes headers and a pinned block, and shares one connection pool across every
chain. Headers set here go **only** to the RPC endpoint, never to the Fyuz API:

```rust
use std::time::Duration;

let client = fyuz::FyuzClient::builder()
    .rpc_url("https://bsc.example-provider.io/v1")
    .rpc_header("x-api-key", &std::env::var("RPC_KEY")?)?  // never sent to the Fyuz API
    .rpc_block("0x6c5b1e8")                                // pin a height so two quotes compare
    .rpc_timeout(Duration::from_secs(5))
    .build()?;
```

Without `rpc_url`, the trading layer uses whatever `GET /config` publishes. That endpoint belongs to
the API operator, is shared by every caller and can change without notice β€” set your own for
anything in production.

</details>

<details>
<summary>πŸ”’ <b><code>U256</code>, and why not <code>u128</code></b></summary>

Amounts use `fyuz::U256`, a big-endian `[u8; 32]` with exactly the operations this crate performs
(`from_dec_str`, `checked_add`, `checked_sub`, `mul_div`, `div_rem_small`, `to_hex_word`, …).
`u128` would not do: an ERC-20 allowance is `2^256 βˆ’ 1` for every wallet that ever approved
unlimited spending, and that is the most ordinary value there is.

`parse_units("0.5", 18)` and `format_units(value, 18)` convert to and from human decimal strings
without ever going through `f64`.

</details>

## 🧯 Errors and rate limiting

The API allows **120 requests per minute per IP** and answers `429` with `{"error": "Too many
requests"}` past that.

The client retries `429`, `5xx` and transport failures automatically with exponential backoff and
**full jitter**, honouring `Retry-After` when the server sends one β€” bounded by `max_retry_delay`
(default 8s) in both directions, so a hostile or buggy header cannot park your task. Other `4xx`
statuses are **never** retried. Once the retries are spent you get a typed error:

```rust
match client.get_user_profile(address).await {
    Ok(profile) => { /* … */ }
    Err(e) if e.is_rate_limited() => {
        // A distinct variant precisely so backpressure can match on it.
        eprintln!("throttled, retry after {:?}", e.retry_after());
    }
    Err(e) if e.is_not_found() => eprintln!("no such profile"),
    Err(fyuz::Error::Api { status, message }) => eprintln!("HTTP {status}: {message}"),
    Err(e) => return Err(e),
}
```

| Variant | Raised when |
|---|---|
| `Api { status, message }` | non-2xx other than 429; `message` is the server's `{"error": …}` text |
| `RateLimited { message, retry_after }` | 429 survived every retry |
| `Transport(reqwest::Error)` | DNS, connection, TLS or timeout β€” no HTTP response at all |
| `Decode { message, body }` | 2xx arrived but did not deserialise; `body` is the first 256 bytes |
| `InvalidBaseUrl { url, message }` | `base_url` was not a usable http/https base |
| `InvalidArgument { message }` | bad address, zero amount, or a build with no slippage protection |
| `ContractRevert { kind, message, error_name, selector, data, retryable, token_address }` | the contract rejected a *simulated* call β€” costs no gas |
| `Rpc { method, message, code, data }` | the JSON-RPC node returned an error object |
| `RpcTransport { method, source }` | the RPC endpoint was unreachable |

Helpers: `status()`, `is_rate_limited()`, `is_not_found()`, `is_graduated()`, `is_retryable_revert()`,
`is_timeout()`, `retry_after()`. `Error` is `#[non_exhaustive]` β€” match with a `_` arm.

<details>
<summary>🧨 <b>Revert classification</b></summary>

A reverted `eth_call` comes back as a bare 4-byte selector. The mapping from `0xcfa6d878` to "the
token graduated, trade the DEX pair" exists nowhere on-chain, so this crate carries the table β€”
checked against `shared/test-vectors/trade-errors.json`, which all four SDKs share.

`RevertKind` variants: `Graduated`, `NoPool`, `Slippage`, `Expired`, `PriceImpact`, `SellTooLarge`,
`InsufficientValue`, `InsufficientBalance`, `InsufficientInput`, `InsufficientLiquidity`,
`ZeroAmount`, `StaleFeed`, `Paused`, `RevertString`, `Unknown`.

An unrecognised selector is reported verbatim rather than swallowed β€” a later contract version will
add errors this table does not know about, and having the selector in the message is at least
something to search for.

</details>

## πŸ“„ Auto-pagination

`GET /tokens` is the one paginated endpoint. `token_pages()` walks it and stops once the server's
`tokenCount` is covered β€” or a page comes back empty, so it terminates even if the total shifts
underneath you:

```rust
use fyuz::ListTokensParams;

// Token at a time…
let mut pager = client.token_pages(&ListTokensParams::new().page_size(100));
while let Some(token) = pager.next_token().await? {
    println!("{}", token.token_symbol);
}

// …or page at a time.
let mut pager = client.token_pages(&ListTokensParams::new());
while let Some(page) = pager.next_page().await? {
    println!("{} rows of {:?}", page.len(), pager.total_count());
}
```

`TokenPager` also offers `collect_all()` and `fetched_count()`. It is a plain async iterator rather
than a `futures::Stream`: the crate has no `futures` dependency, and `while let Some(..)` reads the
same.

## πŸ”’ Decimal strings are strings. Keep them that way.

Token balances, prices, market caps and **every `*_wei` field** come back as decimal strings and stay
`String` in this crate's models. An 18-decimal token amount, or a `uint256` wei total, does not
survive `f64`:

```rust
let wire = "123456789012345678901234567890";
let lossy = wire.parse::<f64>().unwrap();
assert_ne!(format!("{lossy:.0}"), wire);  // silently wrong
```

If you need arithmetic, parse at the edge of *your* code with a big-integer or decimal type
(`rust_decimal`, `bigdecimal`, `primitive_types::U256`). This SDK deliberately does not choose one
for you and never parses these into `f64` behind your back.

Aggregates the server already computed in floating point β€” `volume_usd`, `points`, `reward_eth`,
`pot_bnb`, PnL, percentages β€” arrive as JSON numbers and are modelled as `f64`.
`RoundReceipt::vrf_random` is a decimal string too (a `uint256` VRF word), not hex and not a number.

### ⚠️ `None` is not `0`

`Pot::pot_bnb`, `Pot::total_points`, `Claimable::claimable_wei`, `TokenDetail::curve_holding` and the
nullable `*_wei` fields on `RoundReceipt` are `None` when the value is **unknown** β€” the RPC was
unreachable, or the settlement is not indexed yet. Rendering `0` there tells a user something false.
Hide the figure instead.

## πŸ“ Deployed addresses

Trading reads its contract address and `rpcUrl` from `GET /config` **at runtime**, so a proxy upgrade
or a new chain needs no SDK release. That stays the authority. The verified proxy addresses also ship
as compile-time constants for the cases `/config` cannot cover:

```rust
let bsc = fyuz::deployment_for_chain_id(56).expect("mainnet is in the book");
assert_eq!(bsc.fyuz, "0x33a98bef6496684a8dac83734d9ceb0cefc7019c");

// Or by slug, or by a chain id spelled as a string β€” the same forms the trading
// layer accepts as a `network` everywhere else.
let testnet = fyuz::deployment_for_network("bsc-testnet").unwrap();
```

| Chain | Id | Fyuz proxy | Served by `/config` |
|---|---|---|---|
| BNB Smart Chain | 56 | `0x33a98bef6496684a8dac83734d9ceb0cefc7019c` | βœ… |
| BNB Smart Chain Testnet | 97 | `0x24febdcc41c804873dbbfe703cd9d2d4b771c236` | ❌ β€” book only |

The book is used in exactly two cases: **BSC testnet**, which the public API does not serve at all
(so fork and testnet work with the Fyuz API unreachable), and a `/config` response that lists a chain
with **no `contractAddress`**. Everywhere else `/config` wins, and the address it publishes is
validated as 20 bytes on the way in, so a malformed one fails at `chain()` rather than becoming a
shifted argument list in calldata.

Constants are transcribed from [`../shared/addresses`](../shared/addresses) rather than parsed at
runtime: a published crate has no sibling `shared/` directory, and `include_str!` plus a parse would
turn a typo into a runtime panic instead of a compile error. `tests/addresses.rs` asserts every
constant against both that directory and `shared/test-vectors/addresses.json`, so the transcription
cannot drift silently.

## πŸ“¦ Dependencies

The TypeScript, Go and Python SDKs have **zero** runtime dependencies. Rust has five, and that is a
deliberate choice rather than an oversight β€” `async` HTTP with TLS, and JSON into typed structs, are
not in `std`:

| Crate | Why |
|---|---|
| `reqwest` | HTTP. `default-features = false` + **`rustls`**, so the crate builds with **no OpenSSL headers** on the box β€” the single biggest source of "it does not build in CI/Alpine/cross-compile". `http2` and `system-proxy` keep behaviour on par with reqwest's defaults behind a corporate proxy, without dragging in `native-tls` or `charset`. |
| `serde` + `serde_json` | Response models and the error envelope |
| `thiserror` | The `Error` enum |
| `tokio` | `time` **only** β€” retry backoff sleeps. `default-features = false`, so you pick your own runtime features. |

Dev-dependencies (a stub HTTP server on a plain `TcpListener`, no `wiremock`) reach nobody depending
on the published crate.

> The published crate **excludes `tests/`**. Those suites read fixtures from `../../shared`, which
> lives above the crate root and cannot be packaged β€” shipping the tests would ship files that do not
> compile. Clone [the repository]https://github.com/fyuz-launchpad/fyuz-sdk to run them.

## πŸ§ͺ Tests

```bash
cargo test                    # 130 tests + 47 doctests, no network
cargo test --test integration
```

The default suite never touches the network. `tests/common/mod.rs` is a stub HTTP server on a plain
`tokio::net::TcpListener` bound to `127.0.0.1:0`. It covers the happy path, 429-then-success retry,
5xx retry, "4xx is never retried", error-envelope parsing, decimal-string round-tripping without
precision loss, `null` vs `0`, the flattened `RoundDetail`, query/body encoding and auto-pagination.

`tests/conformance.rs` pulls in [`../shared/test-vectors`](../shared) with `include_str!` β€” the same
fixtures drive the TypeScript, Go and Python suites, so a change to the wire contract fails all four
at once. `tests/trade.rs` covers calldata and revert vectors; `tests/addresses.rs` covers the address
book against both files that describe it.

Against the real chain, from this crate's directory:

```bash
../scripts/fork-test.sh           # all four SDKs against a BSC fork
../scripts/fork-test.sh rust      # just this one
../scripts/test-all.sh            # every SDK, offline
```

`tests/fork.rs` skips itself when `FYUZ_FORK_RPC` is unset, which is what keeps `cargo test`
hermetic. Nothing in it compares the SDK with itself: each expectation is built by hand, posted to
the node as plain JSON, and sliced from the 32-byte answer β€” *then* the SDK is asked the same
question.

## πŸ§‘β€πŸ’» Examples

Runnable programs in [`examples/`](examples), compiled by `cargo build --examples`:

| Example | What it shows |
|---|---|
| [`quickstart`]examples/quickstart.rs | Health, chain config, the trending feed, king of the hill |
| [`graduation_watch`]examples/graduation_watch.rs | Tokens closest to the $30k graduation threshold |
| [`token_deep_dive`]examples/token_deep_dive.rs | One token: detail, holders, trades, candles |
| [`export_tokens`]examples/export_tokens.rs | Auto-paginate every token to CSV with exact decimals |
| [`resilient_polling`]examples/resilient_polling.rs | Incremental trade polling, retry tuning, error matching |
| [`trade`]examples/trade.rs | Quote a buy and a sell, build the unsigned transactions, handle the approval |
| [`live_smoke`]examples/live_smoke.rs | Minimal check against the production API |

```bash
cargo run --example quickstart
cargo run --example token_deep_dive -- 0x42322852a918f94186b7dfda2e0e3f4ad3528480
cargo run --example export_tokens -- dog > tokens.csv
cargo run --example trade -- 0x42322852a918f94186b7dfda2e0e3f4ad3528480
```

## 🌍 The other three SDKs

Same API, same behaviour, same conformance record:

| Language | Install | Import | Runtime deps |
|---|---|---|---|
| πŸ¦€ Rust | `cargo add fyuz-sdk` | `use fyuz::…` | reqwest, serde, serde_json, thiserror, tokio |
| 🟦 TypeScript | `pnpm add fyuz-sdk` | `fyuz-sdk` | none |
| 🐍 Python | `pip install fyuz-sdk` | `import fyuz` | none |
| 🐹 Go | `go get github.com/fyuz-launchpad/fyuz-sdk/go` | `fyuz` | none |

See [`../README.md`](../README.md) for the side-by-side.

## πŸ”— Links

- 🏠 Homepage β€” <https://fyuz.fun>
- 🌐 API β€” <https://api.fyuz.fun>
- πŸ“– Docs β€” <https://docs.rs/fyuz-sdk>
- πŸ’Ύ Repository β€” <https://github.com/fyuz-launchpad/fyuz-sdk>

## πŸ“œ License

MIT β€” see [LICENSE](LICENSE).