<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.
[](https://crates.io/crates/fyuz-sdk)
[](https://crates.io/crates/fyuz-sdk)
[](https://docs.rs/fyuz-sdk)
[](LICENSE)
[](https://www.rust-lang.org)
[](#-why-this-sdk)
[](#-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:
| π **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
| `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
| `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()`.
| `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
| `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),
}
```
| `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();
```
| 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`:
| `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`:
| [`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:
| π¦ 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).