Metadata-Version: 2.4
Name: fyuz-sdk
Version: 0.1.1
Summary: Typed, dependency-free Python client for the Fyuz public API (bonding-curve launchpad on BNB Smart Chain)
Project-URL: Homepage, https://fyuz.fun
Project-URL: API, https://api.fyuz.fun
Project-URL: Repository, https://github.com/fyuz-launchpad/fyuz-sdk
Project-URL: Issues, https://github.com/fyuz-launchpad/fyuz-sdk/issues
Author: Fyuz
License: MIT
License-File: LICENSE
Keywords: bnb,bonding-curve,bsc,defi,fyuz,launchpad,pancakeswap
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">

# 🐍 fyuz-sdk (Python)

**Typed, zero-dependency Python client for the [Fyuz](https://fyuz.fun) bonding-curve launchpad on BNB Smart Chain.**

[![PyPI](https://img.shields.io/badge/pypi-fyuz--sdk%200.1.1-3775A9?logo=pypi&logoColor=white)](https://pypi.org/project/fyuz-sdk/)
[![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/)
[![Runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)](#-why-this-sdk)
[![Typed](https://img.shields.io/badge/typed-py.typed-blue)](https://peps.python.org/pep-0561/)
[![Tests](https://img.shields.io/badge/tests-177%20offline%20%2B%20fork-brightgreen)](#-development)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)

[Homepage](https://fyuz.fun) · [API](https://api.fyuz.fun) · [Repository](https://github.com/fyuz-launchpad/fyuz-sdk) · [TypeScript](../typescript) · [Go](../go) · [Rust](../rust)

</div>

---

Fyuz tokens trade on an **internal bonding curve** until they reach a $30,000 market cap, then
*graduate* into a PancakeSwap V2 pair. Before graduation there is no DEX pool anywhere on chain, so
this API is the only source of price, liquidity and curve state for a pre-graduation token. This
package wraps every one of its 28 read-only endpoints in frozen dataclasses, and builds **unsigned**
bonding-curve transactions your own wallet signs. Standard library only — `urllib`, `json`,
`dataclasses`. No `requests`, no `httpx`, no `pydantic`, no `web3`.

---

## 📦 Install

```bash
pip install fyuz-sdk          # import name: fyuz
```

From a checkout of this repository:

```bash
pip install /path/to/fyuz/sdk/python
```

Requires **Python 3.9+**. Ships a `py.typed` marker, so mypy and Pyright see the annotations.

## ⚡ Quickstart

```python
from fyuz import FyuzClient

with FyuzClient() as fyuz:
    print(fyuz.health().status)                    # 'ok'

    # One dense row per token: cap, volume, holders, graduation progress.
    for t in fyuz.discover(tab="trending", limit=3):
        print(f"{t.symbol:<8} ${t.marketcap:>10,.0f}  {t.graduation_pct:5.1f}% to graduation")

    # Full curve state for one token — prices stay exact decimal strings.
    detail = fyuz.get_token("bsc", "0x42322852a918f94186b7dfda2e0e3f4ad3528480")
    token = detail.token_details
    print(token.token_symbol, token.price, token.graduated)
```

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

```text
ok
PROGI    $     4,473   14.9% to graduation
TUTACO   $     4,473   14.9% to graduation
SRIRA    $     4,473   14.9% to graduation
KIMKACHU 7.853031083E-9 False
```

## 🏆 Why this SDK

| | |
|---|---|
| 🧬 **Four SDKs, one behaviour** | TypeScript, Go, Python and Rust ship the same API and the same semantics. Pick a language, not a feature set. |
| 🔬 **Fork-tested against the real contract** | Not just stubs: `../scripts/fork-test.sh` forks BNB Smart Chain and runs all four suites against the deployed Fyuz proxy `0x33a98bef6496684a8dac83734d9ceb0cefc7019c`. |
| 🧊 **One behaviour, checked field by field** | Each SDK writes a conformance record on the fork, and `../scripts/fork-test.sh` compares every leaf field across all four — same quotes, same calldata, same decoded pool state, wei for wei. |
| 🎯 **Quotes checked on the real curve** | Every quote is verified against `getSwapOutput` on chain. Exact-tokens pricing was verified correct **to the wei** by bisecting the deployed contract. |
| 💥 **Reverts classified from real revert data** | The error table is driven by payloads the live contract returned — `SwapExpired` `0x2b32713d`, `SlippageExceeded` `0x8199f5f3` — not by fixtures someone typed. |
| 🧪 **Hermetic offline suite** | 177 Python tests, no network required (164 TypeScript, 100 Go, 130 Rust + 47 doctests alongside). |
| 🔐 **Never signs, never broadcasts** | The SDK builds `{chainId, to, data, value}` and stops. Your key never enters this package — which is also why it needs no crypto dependency. |
| 🔄 **No release needed for a proxy upgrade** | Contract address, chain id and RPC come from `GET /config` at runtime. A new chain or an upgraded proxy needs no new SDK. |

## 🎓 What "graduation" means

`pair_address` is `None` and no DEX pool exists anywhere until a token graduates at a $30,000 market
cap. Nothing on PancakeSwap, DexScreener or any aggregator will show a pre-graduation token, because
it does not exist there yet.

`pool_type` is `1` for PancakeSwap V2, `2` for V3 and `3` for V4 — direct launches, which skip the
bonding curve entirely, also report `3`, and are told apart by `launched_at` / `graduated` rather
than by this field. The API types it as a plain integer with no enum, so treat the set as open and
always handle an unrecognised value.

`network` is the chain slug. The API serves BNB Smart Chain (`"bsc"`, chain 56) and Robinhood Chain
(chain 4663); every token today is on BSC.

## 🔢 Precision: decimal fields are strings, and they stay strings

Token amounts, prices, market caps and every `*_wei` value arrive as **decimal strings**, because
18-decimal values and `uint256` wei amounts do not survive a round trip through a 64-bit float:

```python
>>> float("123456789.123456789123456789")
123456789.12345679          # four digits gone, silently
```

This SDK keeps them as `str` in the models and **never** parses them into `float`. When you need
arithmetic, use the opt-in helpers — they go through `decimal.Decimal` and are exact:

```python
from fyuz import to_decimal, wei_to_bnb

mcap = to_decimal(token.marketcap)            # Decimal('4566.671076655321500000')
paid = wei_to_bnb(stats.total_paid_wei)       # Decimal BNB, no rounding
```

Aggregates computed server-side — `volume_usd`, `points`, `reward_eth`, `pot_bnb`, everything on
`DiscoverToken`, `Portfolio`, `WalletStats` and the analytics endpoints — are genuine `float` values
on the wire and are typed as `float` here.

Timestamps: `Trade.date`, `KingReign.started_at`, `RoundReceipt.time_end` and friends are `int` UNIX
**seconds**. `Token.created_at` / `updated_at` / `launched_at` / `creation_time` and
`ChatMessage.date` are RFC-3339 strings. Convert either with `from_unix()` / `parse_datetime()`.

| Helper | Purpose |
|---|---|
| `to_decimal(str)` | Decimal-string field → exact `Decimal` |
| `wei_to_bnb(str)` | Wei decimal string → exact `Decimal` BNB |
| `parse_datetime(str)` | RFC-3339 field → aware `datetime` |
| `from_unix(int)` | UNIX-seconds field → aware UTC `datetime` |

All four pass `None` straight through, so nullable fields stay nullable.

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

Several fields are nullable and their `None` means **unknown**, not zero — usually because an RPC
read failed or an on-chain settlement has not been indexed yet. Rendering them as `0` is a
correctness bug:

| Field | `None` means |
|---|---|
| `Pot.pot_bnb`, `Pot.total_points` | RPC unreachable / no Distributor configured — hide the figure |
| `Claimable.claimable_wei` | Unknown — hide the banner, do not say "nothing owed" |
| `TokenDetail.curve_holding` | The chain read failed — the curve balance is unknown |
| `RoundReceipt.pot_wei` / `distributed_wei` / `winner_amount_wei` | Settlement not indexed yet |
| `Token.pair_address`, `Token.launched_at` | The token has not graduated |
| `KingReign.ended_at` | The reign is still running |

## 📚 API surface

All 28 endpoints are unauthenticated and read-only.

<details>
<summary><b>Market data, trades and analytics</b> — 12 methods</summary>

| Method | Endpoint | Returns |
|---|---|---|
| `health()` | `GET /health` | `HealthStatus` |
| `get_config()` | `GET /config` | `ChainConfig` |
| `discover(...)` | `GET /discover` | `List[DiscoverToken]` |
| `list_tokens(...)` | `GET /tokens` | `TokenPage` |
| `iter_tokens(...)` | `GET /tokens` (all pages) | `Iterator[Token]` |
| `get_king()` | `GET /tokens/king` | `Optional[Token]` |
| `get_token(network, token_address, ...)` | `GET /tokens/{network}/{tokenAddress}` | `TokenDetail` |
| `get_recent_trades(...)` | `GET /trades/recent` | `RecentTrades` |
| `get_token_trades(token_address, ...)` | `POST /trades` | `List[Trade]` |
| `get_chart_data(token_address, interval, from_ts, to_ts, ...)` | `GET /trades/getChartData` | `List[Candle]` |
| `get_token_analytics(network, address)` | `GET /analytics/token/{network}/{address}` | `TokenAnalytics` |
| `get_top_traders(network, address)` | `GET /analytics/top-traders/{network}/{address}` | `List[TopTrader]` |

`POST /trades` is a POST only because the token address travels in the body. It is unauthenticated,
read-only and safe to poll.

</details>

<details>
<summary><b>Wallets, leaderboards and rewards</b> — 10 methods</summary>

| Method | Endpoint | Returns |
|---|---|---|
| `get_wallet(address)` | `GET /wallet/{address}` | `WalletStats` |
| `get_portfolio(address)` | `GET /portfolio/{address}` | `Portfolio` |
| `get_user_leaderboard(limit=...)` | `GET /users/leaderboard` | `List[LeaderboardEntry]` |
| `get_user_profile(address)` | `GET /users/profile/{address}` | `UserProfile` |
| `get_top_holders(count, ...)` | `GET /users/top/{count}` | `List[TopHolderEntry]` |
| `get_kings_history()` | `GET /kings/history` | `List[KingReign]` |
| `get_season()` | `GET /season` | `Season` |
| `get_referral_leaderboard(limit=...)` | `GET /referrals/leaderboard` | `List[ReferralLeaderEntry]` |
| `get_tier(address)` | `GET /tier/{address}` | `TierInfo` |
| `get_rewards(address)` | `GET /rewards/{address}` | `Rewards` |

</details>

<details>
<summary><b>Distributor</b> — <code>client.distributor.*</code>, 7 methods</summary>

Fyuz streams a 0.3% leaderboard fee into a Distributor contract. Every round it pays 90% of the pot
pro-rata by points and hands the remainder to a Chainlink-VRF-picked winner.

| Method | Endpoint | Returns |
|---|---|---|
| `distributor.get_stats()` | `GET /distributor/stats` | `PayoutStats` |
| `distributor.get_pot()` | `GET /distributor/pot` | `Pot` |
| `distributor.get_shares(...)` | `GET /distributor/shares` | `Shares` |
| `distributor.list_rounds(...)` | `GET /distributor/rounds` | `List[RoundReceipt]` |
| `distributor.get_round(round_id)` | `GET /distributor/rounds/{id}` | `RoundDetail` |
| `distributor.get_payouts(address)` | `GET /distributor/payouts/{address}` | `AddressPayouts` |
| `distributor.get_claimable(address)` | `GET /distributor/claimable/{address}` | `Claimable` |

</details>

<details>
<summary><b>Offline address book</b> — <code>fyuz_address</code>, <code>chain_addresses</code>, <code>known_chains</code></summary>

`GET /config` stays the authority at runtime. The address book is for what that lookup cannot do:
a verified address before any server is reachable, or when you have deliberately decided not to
trust the API for the `to` of a transaction that moves money.

```python
from fyuz import fyuz_address, chain_addresses, known_chains, BSC

fyuz_address(56)                 # '0x33a98bef6496684a8dac83734d9ceb0cefc7019c'
chain_addresses("bsc").rpc_url   # 'https://bsc-dataseed.bnbchain.org'
BSC.distributor                  # '0x2c88255f6a80296a3aa8c328e5ff308d74615c7b'
[c.network for c in known_chains()]
```

Every address is lower-cased, matching what `normalize_address()` produces, so a plain `==` against
SDK output is correct. `tests/test_addresses.py` asserts these constants against
`shared/addresses/`, so the two cannot drift.

</details>

## 📄 Pagination

`list_tokens()` returns one page plus `token_count`. `iter_tokens()` walks every page lazily — pages
are fetched on demand, so breaking out of the loop stops the requests:

```python
for token in fyuz.iter_tokens(search_word="pepe", page_size=100):
    if token.graduated:
        print(token.token_symbol, token.pair_address)
```

Iteration ends at the first of: a page shorter than `page_size`, an empty page, `token_count` being
reached, or `max_pages` (default `MAX_ITER_PAGES = 10_000`) requests. `page_size` is clamped to
`MAX_SERVER_PAGE_SIZE = 100`, because the server clamps it too and every page would then look short.

## 💱 Trading — unsigned transactions only

`client.trade` builds **unsigned** bonding-curve transactions. It never sees a private key, never
signs and never broadcasts — you hand `{chainId, to, data, value}` to web3.py, eth-account, a
hardware signer or a multisig UI, and that library owns the key. That is *also* why this package
still has zero runtime dependencies: signing is the part that needs a crypto library, and it is not
here.

```python
from fyuz import FyuzClient, format_units, parse_units

TOKEN = "0x42322852a918f94186b7dfda2e0e3f4ad3528480"

# The RPC /config publishes belongs to the API operator and is shared by every
# caller — pass your own for anything real.
with FyuzClient(rpc_url="https://bsc-dataseed.bnbchain.org") as fyuz:
    quote = fyuz.trade.quote_buy(TOKEN, parse_units("0.01"))     # 0.01 BNB
    print("tokens out ", format_units(quote.amount_out_wei))
    print("msg.value  ", format_units(quote.value_wei), "BNB")   # amount + first-buy fee
    print("impact     ", quote.price_impact_bps, "bps")

    # slippage_bps is required — there is no default, because the right
    # tolerance depends on the trade and a wrong one costs you money.
    built = fyuz.trade.build_buy(TOKEN, parse_units("0.01"), slippage_bps=100)
    print("to         ", built.transaction.to)
    print("chainId    ", built.transaction.chain_id)
    print("data       ", built.transaction.data[:74] + "...")
    print("minOut     ", format_units(built.limit_wei))
```

Real output, quoted against the live curve:

```text
tokens out  1259164.617412410713942325
msg.value   0.01 BNB
impact      11 bps
to          0x33A98BeF6496684a8daC83734D9CEB0CEFC7019c
chainId     56
data        0x6bf05b0100000000000000000000000042322852a918f94186b7dfda2e0e3f4ad3528480...
minOut      1246572.971238286606802901
```

Then sign and send it yourself:

```python
w3.eth.send_transaction(built.transaction.as_dict())   # your wallet, your key
```

Selling needs an ERC-20 approval first, because the contract pulls the tokens with `transferFrom`:

```python
if fyuz.trade.allowance(TOKEN, owner) < amount_wei:
    approve = fyuz.trade.build_approve(TOKEN, amount_wei=amount_wei)
    w3.eth.send_transaction(approve.as_dict())

sell = fyuz.trade.build_sell(TOKEN, amount_wei, slippage_bps=150)
```

### 💸 Fees, and why `msg.value` is bigger than you asked for

`transaction.value` already includes `getFirstBuyFee(token)`: the fee **rides on top of** the swap
amount, so `msg.value = amount_in + first_buy_fee`. Sending only `amount_in` reverts with
`InsufficientEthValue()`. Any excess is refunded by the contract.

The buy fee is `PLATFORM_BUY_FEE_BPS` (80) + `TOKEN_OWNER_FEE_BPS` (20) = **100 bps**. Both are
owner-settable storage variables, so the SDK reads them from the chain rather than assuming them.

A token that has graduated raises `FyuzTokenGraduatedError` instead of building a transaction that
would revert.

<details>
<summary><b><code>client.trade</code> reference</b> — 13 methods</summary>

| Method | Does |
|---|---|
| `chain(network="bsc")` | Chain metadata from `GET /config`, falling back to the offline address book → `ChainInfo` |
| `quote_buy(token, amount_wei, ...)` | Price an exact-BNB buy via `getSwapOutput` → `BuyQuote` |
| `quote_sell(token, amount_wei, ...)` | Price a sell via `getSwapOutput` → `SellQuote` |
| `quote_buy_exact_tokens(token, amount_out_wei, ...)` | Price buying an exact number of tokens (mirrors the contract's `getAmountIn`) → `BuyQuote` |
| `build_buy(token, amount_wei, *, slippage_bps \| limit_wei, ...)` | `swapExactETHForTokens` → `BuiltTrade` |
| `build_buy_exact_tokens(token, amount_out_wei, *, slippage_bps \| limit_wei, ...)` | `swapETHForExactTokens`; the limit is a `max_amount_in` → `BuiltTrade` |
| `build_sell(token, amount_wei, *, slippage_bps \| limit_wei, ...)` | `swapExactTokensForETH` → `BuiltTrade` |
| `build_approve(token, *, amount_wei \| unlimited=True, ...)` | ERC-20 `approve` for the curve → `UnsignedTransaction` |
| `is_graduated(token, ...)` | `tokenPools(token).launched` — the contract's own flag, not the indexer's → `bool` |
| `first_buy_fee(token, ...)` | `getFirstBuyFee(token)` in wei → `int` |
| `max_sellable_wei(token, ...)` | `getMaxSellableETH(token)` → `int` |
| `allowance(token, owner, ...)` | ERC-20 `allowance(owner, curve)` → `int` |
| `balance_of(token, owner, ...)` | ERC-20 `balanceOf(owner)` → `int` |

Amount helpers, so no float ever touches a wei value:

| Helper | Purpose |
|---|---|
| `parse_units("0.5")` | Whole units → wei `int` (`500000000000000000`) |
| `format_units(wei)` | Wei → exact whole-unit string |
| `parse_amount(name, value)` | Validate a base-10 wei `str`/`int`; refuses decimal points |
| `normalize_address(name, value)` | Validate a 20-byte address, lower-cased |
| `UINT256_MAX` | `2**256 - 1`, the unlimited-approval amount |

Lower-level pieces, exported for callers who want them: `RPCClient`, `CallResult`,
`DEFAULT_RPC_TIMEOUT`, `revert_error()`, `revert_selector()`, `UnsignedTransaction`, `BuyQuote`,
`SellQuote`, `BuiltTrade`, `DEFAULT_DEADLINE_SECONDS` (120), `DEFAULT_NETWORK` (`"bsc"`).

</details>

## 🚨 Errors

```python
from fyuz import FyuzClient, FyuzRateLimitError, FyuzNotFoundError, FyuzAPIError, FyuzError

with FyuzClient() as fyuz:
    try:
        profile = fyuz.get_user_profile("0xdead...")
    except FyuzNotFoundError:
        profile = None
    except FyuzRateLimitError as exc:          # 429, after retries were exhausted
        print("slow down", exc.retry_after)
    except FyuzAPIError as exc:                # any other non-2xx
        print(exc.status, exc.message)         # message from the {"error": ...} envelope
    except FyuzError:                          # transport / decode / argument failures
        raise
```

| Exception | Raised when |
|---|---|
| `FyuzError` | Base class for everything below |
| `FyuzInvalidArgumentError` | An argument cannot produce a valid request — bad address, decimal string where wei was expected, `None` for a path segment. Also a `ValueError`, so `except ValueError` still works |
| `FyuzAPIError` | Non-2xx response; carries `.status`, `.message`, `.body`, `.retry_after` |
| `FyuzRateLimitError` | `429`, after retries; subclass of `FyuzAPIError` |
| `FyuzNotFoundError` | `404`; subclass of `FyuzAPIError` |
| `FyuzConnectionError` | DNS/refused/reset, after retries |
| `FyuzTimeoutError` | The request was still in flight when `timeout` expired; subclass of `FyuzConnectionError` |
| `FyuzDecodeError` | Response body was not the JSON shape the endpoint promises |
| `FyuzRPCError` | A JSON-RPC node errored or answered with something that was not a JSON-RPC response; carries `.method`, `.code`, `.data` |
| `FyuzContractRevertError` | An `eth_call` reverted; carries `.kind`, `.error_name`, `.selector`, `.retryable`, `.data` |
| `FyuzTokenGraduatedError` | The bonding curve is closed; subclass of `FyuzContractRevertError` |

`except FyuzError` really does catch everything, including argument validation. Catch
`FyuzRateLimitError` *before* `FyuzAPIError`, and `FyuzTimeoutError` before `FyuzConnectionError` —
they are subclasses.

<details>
<summary><b>Contract reverts, classified</b> — <code>exc.kind</code> and what to do about it</summary>

A reverted `eth_call` comes back as a bare 4-byte selector; the SDK maps it to an actionable error.
Reverts marked retryable are worth re-quoting and retrying.

| Selector | `error_name` | `kind` | Retryable |
|---|---|---|---|
| `0xcfa6d878` | `AlreadyLaunched()` | `graduated` | no |
| `0x9c8787c0` | `PoolDoesNotExist()` | `no_pool` | no |
| `0x8199f5f3` | `SlippageExceeded()` | `slippage` | yes |
| `0x2b32713d` | `SwapExpired()` | `expired` | yes |
| `0xe96d2afe` | `MaxPriceImpactExceeded()` | `price_impact` | no |
| `0x9bca8aec` | `SellAmountTooLarge()` | `sell_too_large` | no |
| `0x1985c735` | `InsufficientEthValue()` | `insufficient_value` | no |
| `0xe4455cae` | `InsufficientTokenBalance()` | `insufficient_balance` | no |
| `0xf8b3bb61` | `InsufficientInput()` | `insufficient_input` | no |
| `0xbb55fd27` | `InsufficientLiquidity()` | `insufficient_liquidity` | no |
| `0x1f2a2005` | `ZeroAmount()` | `zero_amount` | no |
| `0x1087e109` | `StalePriceFeed()` | `stale_feed` | yes |
| `0xd93c0665` | `EnforcedPause()` | `paused` | yes |
| `0xde9d3c88` | `MaxInputExceeded()` | `max_input` | yes |
| `0x98f73609` | `InvalidOutput()` | `invalid_output` | no |
| `0xcd4e6167` | `FeeTooHigh()` | `fee_too_high` | no |

`Error(string)` and `Panic(uint256)` are decoded too, and an unrecognised selector is reported
verbatim rather than swallowed — a later contract version will add errors this table does not know.

</details>

## 🔁 Rate limits and retries

The API rate-limits **per client IP at 120 requests/minute** and answers `429` with
`{"error": "Too many requests"}`.

The client retries `429`, `5xx` and transport failures automatically, with exponential backoff and
full jitter (`sleep = uniform(0, min(cap, 0.25 * 2**n))`). A `Retry-After` header, when present,
raises the computed backoff — it can lengthen the wait but never shorten it, so `Retry-After: 0`
cannot spin the retries away — and is capped at 60s. It is honoured on a `503` from a draining load
balancer just as much as on a `429`. **No other 4xx is ever retried.**

```python
FyuzClient(
    "https://api.fyuz.fun",
    timeout=30.0,          # seconds, per request
    max_retries=3,         # 0 disables retrying entirely
    backoff_initial=0.25,
    backoff_max=8.0,
    user_agent="my-indexer/1.0",   # default: fyuz-sdk-python/0.1.1
    headers={"X-Trace": "..."},    # extra headers on every API request
    rpc_url="https://my-node.example",   # for client.trade
)
```

`timeout` bounds one attempt, not the whole call: with retries the worst case is
`timeout * (max_retries + 1)` plus backoff. Lower both to bound a call.

## 🧭 Notes for indexers and data partners

- `GET /discover` is the highest-signal single call: market cap, 24h volume, buy/sell counts,
  holders, price change and graduation progress per token.
- `get_recent_trades(latest_trade_id=...)` polls the platform-wide feed incrementally — pass back the
  highest `Trade.id` you have seen.
- `rank` on every leaderboard is a server-assigned 1-based ordinal in returned order. Do not re-sort
  client-side; the server's ordering is what the payout logic uses.
- Response models ignore unknown fields, so a server-side addition will never break a deployed
  client. Collection fields are tuples (the models are frozen); endpoints returning a bare JSON array
  give you a `list`.
- The client does **not** pool connections: `urllib.request` opens a fresh TCP connection and TLS
  handshake per call, where the Go, Rust and TypeScript clients reuse one. That is the price of the
  zero-dependency guarantee, and on a tight poll loop it is the dominant cost.

## 🧑‍🍳 Examples

Runnable programs in [`examples/`](examples):

| Example | What it shows |
|---|---|
| [`01_quickstart.py`](examples/01_quickstart.py) | Health, chain config, the trending feed, king of the hill |
| [`02_graduation_watch.py`](examples/02_graduation_watch.py) | Tokens closest to the $30k graduation threshold |
| [`03_token_deep_dive.py`](examples/03_token_deep_dive.py) | One token: detail, holders, trades, hourly candles |
| [`04_export_tokens.py`](examples/04_export_tokens.py) | Auto-paginate every token to CSV, summing with `Decimal` |
| [`05_resilient_polling.py`](examples/05_resilient_polling.py) | Incremental trade polling, retry tuning, error classification |
| [`06_trade.py`](examples/06_trade.py) | Quote a buy and a sell, build the unsigned transactions, handle the approval |

```bash
PYTHONPATH=src python3 examples/01_quickstart.py
PYTHONPATH=src python3 examples/03_token_deep_dive.py 0x42322852a918f94186b7dfda2e0e3f4ad3528480
PYTHONPATH=src python3 examples/04_export_tokens.py dog > tokens.csv
BSC_RPC_URL=https://bsc-dataseed.bnbchain.org PYTHONPATH=src python3 examples/06_trade.py
```

(`PYTHONPATH=src` is only for running against a checkout; after `pip install fyuz-sdk` they run
as-is.) The same six exist in the TypeScript, Go and Rust clients — see [`../README.md`](../README.md).

## 🛠️ Development

Tests are **stdlib `unittest`**. There is no pytest, and nothing to install to run them.

```bash
cd sdk/python

# 177 hermetic tests against a local stub HTTP server, never the network.
# tests/ joins src/ on the path: the suite imports its stub server and payload
# fixtures as top-level modules.
PYTHONPATH=src:tests python3 -m unittest discover -s tests -t tests

# Type check (strict) and lint
pip install -e ".[dev]"
mypy
ruff check src tests examples
ruff format --check src tests examples

# Build the distributions
pip install build
python3 -m build
```

`tests/test_conformance.py` additionally reads [`../shared/test-vectors`](../shared) from disk — the
same fixtures drive the TypeScript, Go and Rust suites, so a change to the wire contract fails all
four at once.

```bash
../scripts/test-all.sh      # every SDK, offline
../scripts/fork-test.sh     # fork BSC, run everything against the live contract
```

`fork-test.sh` spins up an anvil fork of BNB Smart Chain and points all four suites at the real
deployed proxy. Each writes a conformance record, and the script flattens all four and compares them
leaf field by leaf field — it prints `all 4 SDKs agree on every field`, or the exact divergence.
Without `FYUZ_FORK_RPC` set, the fork tests skip and the suite stays offline and hermetic.

## 👨‍👩‍👧‍👦 The SDK family

Same API, same behaviour, four languages:

| Language | Install | Runtime dependencies |
|---|---|---|
| 🐍 Python | `pip install fyuz-sdk` | **none** — stdlib `urllib` |
| 🟦 TypeScript | `pnpm add fyuz-sdk` | **none** — native `fetch` |
| 🐹 Go | `go get github.com/fyuz-launchpad/fyuz-sdk/go` | **none** — `net/http` |
| 🦀 Rust | `cargo add fyuz-sdk` | reqwest, serde, serde_json, thiserror, tokio — deliberate |

## 🔗 Links

- 🌐 Homepage — <https://fyuz.fun>
- 🔌 API — <https://api.fyuz.fun>
- 📦 Repository — <https://github.com/fyuz-launchpad/fyuz-sdk>

## 📜 License

MIT — see [LICENSE](LICENSE).
