π¦ fyuz-sdk
Async Rust client for the Fyuz bonding-curve launchpad on BNB Smart Chain. Market data, quotes, and unsigned transactions β it never touches your keys.
β οΈ Important β the crate is
fyuz-sdk, but the library you import isfyuz.use FyuzClient; // β crate `fyuz-sdk`, lib name `fyuz` use FyuzClient; // β does not existThis trips up nearly everyone once. It is set by
[lib] name = "fyuz"inCargo.toml, and it is why the rendered API docs at docs.rs/fyuz-sdk are titledfyuz.
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
Or by hand:
[]
= "0.1"
= { = "1", = ["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
use ;
async
Real output, run against https://api.fyuz.fun:
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:
use Duration;
let client = builder
.base_url? // default
.timeout // default
.max_retries // default; 0 disables retrying
.retry_base_delay// default
.max_retry_delay // default; caps Retry-After too
.user_agent
.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 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 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().
get_token_tradesis aPOSTonly because the token address travels in the request body. It is a read, it mutates nothing, and it needs no auth.- Leaderboard
rankis server-assigned, 1-based, already in returned order. Do not re-sort it client side. RoundDetailis flattened on the wire: the receipt fields sit at the top level next topayouts. In Rust they live underdetail.roundvia#[serde(flatten)], sodetail.round.round_idreads theroundIdkey 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()returnsOption<Token>.Nonemeans 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 exceptToken::created_at/updated_at/launched_at/creation_timeandChatMessage::date, which are RFC-3339 strings. The crate carries nochronoortimedependency; 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 oneparsecall.
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.
π± 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 toalloy,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.
use ;
let client = builder
.rpc_url // use your own node in production
.build?;
let token = "0x42322852a918f94186b7dfda2e0e3f4ad3528480";
let spend = parse_units?; // 0.01 BNB, in wei
let quote = client.trade.quote_buy.await?;
println!;
println!; // amount + getFirstBuyFee(token)
let built = client
.trade
.build_buy // 1% β required
.await?;
// built.transaction is ready to sign. Nothing has been sent.
Real output from examples/trade.rs, run against BNB Smart Chain:
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.valueon a buy isamount_in + getFirstBuyFee(token). The fee rides on top of the swap amount and is not deducted from it βquote.value_weiis the number to send. Sending only the amount reverts withInsufficientEthValue().- 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 amaxAmountInunder the real cost, and the swap would revert withMaxInputExceeded(). - Slippage is never chosen for you.
build_buy,build_buy_exact_tokensandbuild_sellall require eitherslippage_bps(100 = 1%) or an explicitlimit_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:
use SellParams;
if client.trade.allowance.await? < amount
let sell = client
.trade
.build_sell
.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 asRevertKind::Pausedwhen the swap is simulated. Call it when you want the reason before asking someone to sign, rather than after their transaction reverted.
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_calls (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.
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:
use Duration;
let client = builder
.rpc_url
.rpc_header? // never sent to the Fyuz API
.rpc_block // pin a height so two quotes compare
.rpc_timeout
.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.
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.
π§― 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:
match client.get_user_profile.await
| 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.
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.
π 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:
use ListTokensParams;
// Token at a timeβ¦
let mut pager = client.token_pages;
while let Some = pager.next_token.await?
// β¦or page at a time.
let mut pager = client.token_pages;
while let Some = pager.next_page.await?
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:
let wire = "123456789012345678901234567890";
let lossy = wire..unwrap;
assert_ne!; // 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:
let bsc = deployment_for_chain_id.expect;
assert_eq!;
// 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 = deployment_for_network.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 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 to run them.
π§ͺ Tests
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 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:
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/, compiled by cargo build --examples:
| Example | What it shows |
|---|---|
quickstart |
Health, chain config, the trending feed, king of the hill |
graduation_watch |
Tokens closest to the $30k graduation threshold |
token_deep_dive |
One token: detail, holders, trades, candles |
export_tokens |
Auto-paginate every token to CSV with exact decimals |
resilient_polling |
Incremental trade polling, retry tuning, error matching |
trade |
Quote a buy and a sell, build the unsigned transactions, handle the approval |
live_smoke |
Minimal check against the production API |
π 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 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.