Skip to main content

fyuz/
trade.rs

1//! Trading against the bonding curve.
2//!
3//! This module builds **unsigned transactions**. It never sees a private key,
4//! never signs and never broadcasts — it hands you an [`UnsignedTransaction`]
5//! and you pass that to whatever wallet you already have (`alloy`, `ethers-rs`,
6//! a hardware signer, a multisig UI). Signing is the part that needs a crypto
7//! stack, and it is not here.
8//!
9//! ```no_run
10//! # async fn demo() -> Result<(), fyuz::Error> {
11//! use fyuz::{parse_units, BuyParams, FyuzClient};
12//!
13//! let client = FyuzClient::new()?;
14//! let built = client
15//!     .trade()
16//!     .build_buy(
17//!         &BuyParams::new("0x0000000000000000000000000000000000000001", parse_units("0.5", 18)?)
18//!             .slippage_bps(100),
19//!     )
20//!     .await?;
21//!
22//! println!("expecting {} tokens", built.quote.amount_out_wei);
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! Quotes come from the contract, not from arithmetic here: every one is an
28//! `eth_call` to `getSwapOutput`. Reimplementing the curve locally would mean a
29//! second formula that has to be kept in step with an upgradeable contract, and
30//! the day they disagree is the day someone's `min_amount_out` is wrong.
31//!
32//! Slippage is never chosen for you. [`TradeApi::build_buy`] and
33//! [`TradeApi::build_sell`] require either `slippage_bps` or an explicit
34//! `limit_wei`; a default here would be a number picked by someone who cannot
35//! see the trade.
36
37use std::collections::HashMap;
38use std::sync::Arc;
39use std::time::{Duration, SystemTime, UNIX_EPOCH};
40
41use tokio::sync::{Mutex, OnceCell};
42
43use crate::abi::{
44    address_word, bool_word, decode_bool_at, decode_uint256_at, encode_call, normalize_address,
45    uint256_word, SELECTOR_ALLOWANCE, SELECTOR_APPROVE, SELECTOR_BALANCE_OF,
46    SELECTOR_GET_FIRST_BUY_FEE, SELECTOR_GET_MAX_SELLABLE_ETH, SELECTOR_GET_SWAP_OUTPUT,
47    SELECTOR_PAUSED, SELECTOR_PLATFORM_BUY_FEE_BPS, SELECTOR_SWAP_ETH_FOR_EXACT_TOKENS,
48    SELECTOR_SWAP_EXACT_ETH_FOR_TOKENS, SELECTOR_SWAP_EXACT_TOKENS_FOR_ETH,
49    SELECTOR_TOKEN_OWNER_FEE_BPS, SELECTOR_TOKEN_POOLS, U256,
50};
51use crate::addresses::{deployment_for_chain_id, deployment_for_network, Deployment, DEPLOYMENTS};
52use crate::models::ChainInfo;
53use crate::revert::{graduated_error, revert_error, revert_selector, SELECTOR_ALREADY_LAUNCHED};
54use crate::rpc::{CallOutcome, RpcClient, DEFAULT_BLOCK_TAG};
55use crate::{Error, FyuzClient};
56
57/// Basis points in 100%.
58const BPS_DIVISOR: u32 = 10_000;
59
60/// How long a built transaction stays valid when the caller does not say.
61pub const DEFAULT_DEADLINE_SECONDS: u64 = 120;
62
63/// Longest relative deadline this crate will accept, in seconds.
64///
65/// Not a taste judgement: `now + window` on a `u64` panics in a debug build and
66/// wraps in a release one, and `deadline_seconds` is public and takes a `u64`. A
67/// year is far past any honest use, so rejecting beyond it costs nobody
68/// anything and makes the arithmetic unreachable.
69pub const MAX_DEADLINE_SECONDS: u64 = 365 * 24 * 60 * 60;
70
71/// Chain slug used when a call does not name one.
72pub const DEFAULT_NETWORK: &str = "bsc";
73
74fn invalid(message: impl Into<String>) -> Error {
75    Error::InvalidArgument {
76        message: message.into(),
77    }
78}
79
80/// A transaction ready to be signed and sent.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct UnsignedTransaction {
83    /// EIP-155 chain id. Check it before signing — it is what stops a
84    /// BSC-signed transaction replaying elsewhere.
85    pub chain_id: i64,
86    /// Contract to call.
87    pub to: String,
88    /// ABI-encoded calldata, `0x`-prefixed.
89    pub data: String,
90    /// Native value to attach, in wei. Zero for non-payable calls.
91    pub value: U256,
92    /// Sender, when one was supplied. Gas estimation needs it.
93    pub from: Option<String>,
94}
95
96/// What a buy will cost and return, priced against current curve state.
97#[derive(Debug, Clone)]
98pub struct BuyQuote {
99    /// Chain slug this was priced on.
100    pub network: String,
101    /// The token, lower-cased.
102    pub token: String,
103    /// What goes into the curve.
104    pub amount_in_wei: U256,
105    /// `getFirstBuyFee(token)`, charged **on top of** `amount_in_wei`.
106    pub first_buy_fee_wei: U256,
107    /// What `msg.value` must be: `amount_in_wei + first_buy_fee_wei`.
108    pub value_wei: U256,
109    /// Tokens out at the quoted moment.
110    pub amount_out_wei: U256,
111    /// Price impact in basis points, as the contract computes it.
112    pub price_impact_bps: u64,
113}
114
115/// What a sell will return, priced against current curve state.
116#[derive(Debug, Clone)]
117pub struct SellQuote {
118    /// Chain slug this was priced on.
119    pub network: String,
120    /// The token, lower-cased.
121    pub token: String,
122    /// Tokens going in.
123    pub amount_in_wei: U256,
124    /// Native currency out, net of the platform and token-owner fees, which the
125    /// contract takes off the output rather than the input.
126    pub amount_out_wei: U256,
127    /// Price impact in basis points.
128    pub price_impact_bps: u64,
129}
130
131/// A built buy with the quote it was priced from.
132#[derive(Debug, Clone)]
133pub struct BuiltBuy {
134    /// Ready to sign and send.
135    pub transaction: UnsignedTransaction,
136    /// The pricing it was built against.
137    pub quote: BuyQuote,
138    /// The `minAmountOut` (or `maxAmountIn`) actually encoded.
139    pub limit_wei: U256,
140    /// Unix second after which the contract rejects the swap.
141    pub deadline: u64,
142}
143
144/// A built sell with the quote it was priced from.
145#[derive(Debug, Clone)]
146pub struct BuiltSell {
147    /// Ready to sign and send.
148    pub transaction: UnsignedTransaction,
149    /// The pricing it was built against.
150    pub quote: SellQuote,
151    /// The `minAmountOut` actually encoded.
152    pub limit_wei: U256,
153    /// Unix second after which the contract rejects the swap.
154    pub deadline: u64,
155}
156
157/// Slippage and deadline configuration, shared by every build.
158#[derive(Debug, Clone, Default)]
159struct Limits {
160    slippage_bps: Option<u32>,
161    limit_wei: Option<U256>,
162    deadline: Option<u64>,
163    deadline_seconds: Option<u64>,
164    from: Option<String>,
165}
166
167macro_rules! limit_builders {
168    () => {
169        /// Tolerance in basis points — `100` is 1%. Mutually exclusive with
170        /// [`limit_wei`](Self::limit_wei).
171        pub fn slippage_bps(mut self, bps: u32) -> Self {
172            self.limits.slippage_bps = Some(bps);
173            self
174        }
175
176        /// Set `minAmountOut`/`maxAmountIn` exactly, bypassing the tolerance.
177        pub fn limit_wei(mut self, limit: U256) -> Self {
178            self.limits.limit_wei = Some(limit);
179            self
180        }
181
182        /// Absolute deadline, unix seconds. Wins over
183        /// [`deadline_seconds`](Self::deadline_seconds).
184        pub fn deadline(mut self, unix_seconds: u64) -> Self {
185            self.limits.deadline = Some(unix_seconds);
186            self
187        }
188
189        /// Relative deadline in seconds from now. Defaults to 120.
190        pub fn deadline_seconds(mut self, seconds: u64) -> Self {
191            self.limits.deadline_seconds = Some(seconds);
192            self
193        }
194
195        /// Sender address, copied onto the transaction.
196        pub fn from(mut self, address: impl Into<String>) -> Self {
197            self.limits.from = Some(address.into());
198            self
199        }
200
201        /// Chain slug. Defaults to `"bsc"`.
202        pub fn network(mut self, network: impl Into<String>) -> Self {
203            self.network = network.into();
204            self
205        }
206    };
207}
208
209/// Spend an exact amount of native currency on a token.
210#[derive(Debug, Clone)]
211pub struct BuyParams {
212    token: String,
213    amount_wei: U256,
214    network: String,
215    limits: Limits,
216}
217
218impl BuyParams {
219    /// The token to buy, and the native currency to spend (excluding the
220    /// first-buy fee, which the SDK adds to `value` for you).
221    pub fn new(token: impl Into<String>, amount_wei: U256) -> Self {
222        Self {
223            token: token.into(),
224            amount_wei,
225            network: DEFAULT_NETWORK.to_string(),
226            limits: Limits::default(),
227        }
228    }
229
230    limit_builders!();
231}
232
233/// Buy an exact number of tokens, capping the spend.
234#[derive(Debug, Clone)]
235pub struct BuyExactTokensParams {
236    token: String,
237    amount_out_wei: U256,
238    network: String,
239    limits: Limits,
240}
241
242impl BuyExactTokensParams {
243    /// The token, and the exact number of tokens wanted.
244    pub fn new(token: impl Into<String>, amount_out_wei: U256) -> Self {
245        Self {
246            token: token.into(),
247            amount_out_wei,
248            network: DEFAULT_NETWORK.to_string(),
249            limits: Limits::default(),
250        }
251    }
252
253    limit_builders!();
254}
255
256/// Sell tokens for native currency.
257#[derive(Debug, Clone)]
258pub struct SellParams {
259    token: String,
260    amount_wei: U256,
261    network: String,
262    limits: Limits,
263}
264
265impl SellParams {
266    /// The token, and how much of it to sell.
267    pub fn new(token: impl Into<String>, amount_wei: U256) -> Self {
268        Self {
269            token: token.into(),
270            amount_wei,
271            network: DEFAULT_NETWORK.to_string(),
272            limits: Limits::default(),
273        }
274    }
275
276    limit_builders!();
277}
278
279/// Builds unsigned bonding-curve transactions. Reach it through
280/// [`FyuzClient::trade`].
281#[derive(Debug)]
282pub struct TradeApi<'a> {
283    client: &'a FyuzClient,
284}
285
286impl<'a> TradeApi<'a> {
287    pub(crate) fn new(client: &'a FyuzClient) -> Self {
288        Self { client }
289    }
290
291    // ---------------------------------------------------------- chain setup
292
293    /// Chain metadata from `GET /config`: contract address, chain id, RPC.
294    ///
295    /// Fetched once and memoised — it changes about as often as a redeploy.
296    ///
297    /// `contract_address` comes back normalised, and is `Some` or this call
298    /// failed. Where `/config` is silent — it has no `contractAddress` for the
299    /// chain, or does not serve the chain at all, which is the case for BSC
300    /// testnet — [`addresses`](crate::addresses) fills the gap so the build path
301    /// still has 20 verified bytes to encode.
302    pub async fn chain(&self, network: &str) -> Result<ChainInfo, Error> {
303        // A chain the API is known not to serve is answered from the book
304        // without asking it. That is not just a saved round trip: it is what
305        // makes testnet and fork work against a local node with the Fyuz API
306        // unreachable, which is the situation most integration tests run in.
307        if let Some(deployment) = deployment_for_network(network) {
308            if deployment.api_network.is_none() {
309                return Ok(chain_from(deployment));
310            }
311        }
312
313        let chains = self
314            .client
315            .trade_state()
316            .chains
317            .get_or_try_init(|| async { Ok::<_, Error>(self.client.get_config().await?.chains) })
318            .await?;
319
320        for chain in chains {
321            if chain.network.as_deref() != Some(network) && chain.chain_id.to_string() != network {
322                continue;
323            }
324
325            let mut chain = chain.clone();
326            // The one address in this crate that never went through
327            // `normalize_address`: it arrives from the network, and from here it
328            // flows into `transaction.to` and into two `approve`/`allowance`
329            // spender words. A malformed one has to fail here, not silently
330            // become a shifted argument list.
331            chain.contract_address = match chain.contract_address.as_deref() {
332                Some(address) => Some(normalize_address("contractAddress", address)?),
333                None => deployment_for_chain_id(chain.chain_id).map(|d| d.fyuz.to_string()),
334            };
335            if chain.contract_address.is_none() {
336                return Err(invalid(format!(
337                    "the API did not publish a contract address for {network:?} and this SDK \
338                     has none on file for chain id {}, so no transaction can be built",
339                    chain.chain_id
340                )));
341            }
342            return Ok(chain);
343        }
344
345        if let Some(deployment) = deployment_for_network(network) {
346            return Ok(chain_from(deployment));
347        }
348
349        let known: Vec<String> = chains
350            .iter()
351            .map(|c| c.network.clone().unwrap_or_else(|| c.chain_id.to_string()))
352            .chain(DEPLOYMENTS.iter().map(|d| d.network.to_string()))
353            .collect();
354        Err(invalid(format!(
355            "unknown network {network:?}; the API serves: {}",
356            known.join(", ")
357        )))
358    }
359
360    /// A chain reduced to what building a transaction needs, with the launchpad
361    /// address as a plain `String`.
362    ///
363    /// Exists so no call site has to write `unwrap_or_default()` on the
364    /// contract address — which turned "the API sent nothing" into the empty
365    /// string, and then into calldata addressed to nowhere.
366    async fn resolve(&self, network: &str) -> Result<Resolved, Error> {
367        let chain = self.chain(network).await?;
368        let contract = chain
369            .contract_address
370            .ok_or_else(|| invalid(format!("no contract address is known for {network:?}")))?;
371        Ok(Resolved {
372            chain_id: chain.chain_id,
373            contract,
374            rpc_url: chain.rpc_url,
375        })
376    }
377
378    /// The JSON-RPC client for a chain, from the override or `/config`.
379    async fn rpc(&self, network: &str) -> Result<Arc<RpcClient>, Error> {
380        {
381            let cache = self.client.trade_state().rpcs.lock().await;
382            if let Some(existing) = cache.get(network) {
383                return Ok(Arc::clone(existing));
384            }
385        }
386
387        let chain = self.resolve(network).await?;
388        let url = self
389            .client
390            .rpc_url()
391            .map(str::to_string)
392            .or_else(|| chain.rpc_url.clone())
393            .ok_or_else(|| {
394                invalid(format!(
395                    "no RPC endpoint for {network:?}: the API published none, and none was \
396                     configured with FyuzClientBuilder::rpc_url"
397                ))
398            })?;
399
400        // The `reqwest::Client` is the one the builder configured: it carries the
401        // crate's User-Agent, any header the caller attached for an authenticated
402        // node, and — the part that matters under load — a single connection pool
403        // shared with every other chain rather than one per chain.
404        let client = Arc::new(
405            RpcClient::with_http(url, self.client.rpc_http().clone())
406                .block(self.client.rpc_block().unwrap_or(DEFAULT_BLOCK_TAG)),
407        );
408
409        let mut cache = self.client.trade_state().rpcs.lock().await;
410        Ok(Arc::clone(
411            cache.entry(network.to_string()).or_insert(client),
412        ))
413    }
414
415    /// An `eth_call` against the launchpad on `network`.
416    async fn call_contract(&self, network: &str, data: &str) -> Result<CallOutcome, Error> {
417        let chain = self.resolve(network).await?;
418        let rpc = self.rpc(network).await?;
419        rpc.call(&chain.contract, data, None).await
420    }
421
422    // ---------------------------------------------------------------- reads
423
424    /// Whether the token has left the bonding curve.
425    ///
426    /// Reads `tokenPools(token).launched` — the contract's own flag, not the
427    /// indexer's view of it, so it cannot lag behind a graduation that happened
428    /// seconds ago.
429    pub async fn is_graduated(&self, network: &str, token: &str) -> Result<bool, Error> {
430        Ok(self.pool_state(network, token).await?.launched)
431    }
432
433    /// `tokenPools(token)`, reduced to what pricing an exact-tokens buy needs.
434    ///
435    /// The real `token_reserve` is the ceiling on what one buy can be handed;
436    /// the virtual pair is the constant product the curve is priced on. The
437    /// remaining struct members — the real eth reserve, the token and owner
438    /// addresses, the pool type — are not decoded, because nothing here reads
439    /// them and the REST API serves them anyway.
440    async fn pool_state(&self, network: &str, token: &str) -> Result<PoolState, Error> {
441        let token = normalize_address("token", token)?;
442        let data = encode_call(SELECTOR_TOKEN_POOLS, &[address_word(&token)?]);
443
444        match self.call_contract(network, &data).await? {
445            // (uint256 ethReserve, uint256 tokenReserve, uint256 virtualEthReserve,
446            //  uint256 virtualTokenReserve, address token, address owner,
447            //  uint8 poolType, bool launched) — Fyuz.sol PoolInfo, in order.
448            CallOutcome::Ok(result) => Ok(PoolState {
449                token_reserve: decode_uint256_at(&result, 1)?,
450                virtual_eth_reserve: decode_uint256_at(&result, 2)?,
451                virtual_token_reserve: decode_uint256_at(&result, 3)?,
452                launched: decode_bool_at(&result, 7)?,
453            }),
454            CallOutcome::Reverted(revert) => {
455                Err(revert_error(&revert, &format!("tokenPools({token})")))
456            }
457        }
458    }
459
460    /// Whether the launchpad has trading paused.
461    ///
462    /// Not called automatically before a quote: that would double the RPC cost
463    /// of every quote to check a flag that is almost never set, and a paused
464    /// contract already surfaces as a typed [`RevertKind::Paused`](crate::RevertKind)
465    /// when the swap is simulated. Call it when you want the reason *before*
466    /// asking someone to sign, rather than after their transaction reverted.
467    pub async fn is_paused(&self, network: &str) -> Result<bool, Error> {
468        let data = encode_call(SELECTOR_PAUSED, &[]);
469
470        match self.call_contract(network, &data).await? {
471            CallOutcome::Ok(result) => decode_bool_at(&result, 0),
472            CallOutcome::Reverted(revert) => Err(revert_error(&revert, "paused()")),
473        }
474    }
475
476    /// `getFirstBuyFee(token)` in wei — the surcharge on a token's first buy.
477    pub async fn first_buy_fee(&self, network: &str, token: &str) -> Result<U256, Error> {
478        let token = normalize_address("token", token)?;
479        let data = encode_call(SELECTOR_GET_FIRST_BUY_FEE, &[address_word(&token)?]);
480
481        match self.call_contract(network, &data).await? {
482            CallOutcome::Ok(result) => decode_uint256_at(&result, 0),
483            CallOutcome::Reverted(revert) => {
484                Err(revert_error(&revert, &format!("getFirstBuyFee({token})")))
485            }
486        }
487    }
488
489    /// `getMaxSellableETH(token)` — the ceiling one sell may extract.
490    pub async fn max_sellable_wei(&self, network: &str, token: &str) -> Result<U256, Error> {
491        let token = normalize_address("token", token)?;
492        let data = encode_call(SELECTOR_GET_MAX_SELLABLE_ETH, &[address_word(&token)?]);
493
494        match self.call_contract(network, &data).await? {
495            CallOutcome::Ok(result) => decode_uint256_at(&result, 0),
496            CallOutcome::Reverted(revert) => Err(revert_error(
497                &revert,
498                &format!("getMaxSellableETH({token})"),
499            )),
500        }
501    }
502
503    /// ERC-20 `allowance(owner, contract)` — how much the curve may pull.
504    pub async fn allowance(&self, network: &str, token: &str, owner: &str) -> Result<U256, Error> {
505        let token = normalize_address("token", token)?;
506        let owner = normalize_address("owner", owner)?;
507        let chain = self.resolve(network).await?;
508        let rpc = self.rpc(network).await?;
509
510        let data = encode_call(
511            SELECTOR_ALLOWANCE,
512            &[address_word(&owner)?, address_word(&chain.contract)?],
513        );
514        let result = rpc
515            .call_or_error(&token, &data, &format!("allowance({owner})"))
516            .await?;
517        decode_uint256_at(&result, 0)
518    }
519
520    /// ERC-20 `balanceOf(owner)`, in the token's smallest unit.
521    pub async fn balance_of(&self, network: &str, token: &str, owner: &str) -> Result<U256, Error> {
522        let token = normalize_address("token", token)?;
523        let owner = normalize_address("owner", owner)?;
524        let rpc = self.rpc(network).await?;
525
526        let data = encode_call(SELECTOR_BALANCE_OF, &[address_word(&owner)?]);
527        let result = rpc
528            .call_or_error(&token, &data, &format!("balanceOf({owner})"))
529            .await?;
530        decode_uint256_at(&result, 0)
531    }
532
533    // --------------------------------------------------------------- quotes
534
535    /// `getSwapOutput`, with the graduated revert promoted to a typed error.
536    async fn swap_output(
537        &self,
538        network: &str,
539        token: &str,
540        amount_in: U256,
541        is_eth_input: bool,
542    ) -> Result<(U256, u64), Error> {
543        let data = encode_call(
544            SELECTOR_GET_SWAP_OUTPUT,
545            &[
546                address_word(token)?,
547                uint256_word(amount_in),
548                bool_word(is_eth_input),
549            ],
550        );
551
552        match self.call_contract(network, &data).await? {
553            CallOutcome::Ok(result) => {
554                let amount_out = decode_uint256_at(&result, 0)?;
555                let impact = decode_uint256_at(&result, 1)?;
556                let impact = impact.to_string().parse::<u64>().unwrap_or(u64::MAX);
557                Ok((amount_out, impact))
558            }
559            CallOutcome::Reverted(revert) => {
560                if revert_selector(&revert).as_deref() == Some(SELECTOR_ALREADY_LAUNCHED) {
561                    return Err(graduated_error(token, None));
562                }
563                Err(revert_error(&revert, &format!("getSwapOutput({token})")))
564            }
565        }
566    }
567
568    /// `PLATFORM_BUY_FEE_BPS() + TOKEN_OWNER_FEE_BPS()` — the gross-up the
569    /// contract applies to the input side of a buy.
570    ///
571    /// Read, never assumed, and read again on every quote rather than memoised.
572    /// Both are plain storage with owner setters (`setPlatformBuyFeeBps`,
573    /// `setTokenOwnerFeeBps`), and a hardcoded or cached figure that is too low
574    /// produces a `maxAmountIn` under the real cost — which the contract rejects
575    /// with `MaxInputExceeded`, and which the caller would then retry against
576    /// the same stale number forever.
577    async fn buy_fee_bps(&self, network: &str) -> Result<U256, Error> {
578        let mut total = U256::ZERO;
579        for (selector, name) in [
580            (SELECTOR_PLATFORM_BUY_FEE_BPS, "PLATFORM_BUY_FEE_BPS()"),
581            (SELECTOR_TOKEN_OWNER_FEE_BPS, "TOKEN_OWNER_FEE_BPS()"),
582        ] {
583            let part = match self.call_contract(network, selector).await? {
584                CallOutcome::Ok(result) => decode_uint256_at(&result, 0)?,
585                CallOutcome::Reverted(revert) => return Err(revert_error(&revert, name)),
586            };
587            total = total
588                .checked_add(part)
589                .ok_or_else(|| invalid("the contract's buy fees do not fit in a uint256"))?;
590        }
591
592        if total >= U256::from_u64(u64::from(BPS_DIVISOR)) {
593            return Err(invalid(format!(
594                "the contract reports a combined buy fee of {total} bps, which is 100% or more — \
595                 refusing to price a buy against it"
596            )));
597        }
598        Ok(total)
599    }
600
601    /// Price a buy: tokens out for a given amount of native currency in.
602    ///
603    /// Returns an error satisfying [`Error::is_graduated`] when the curve is closed.
604    pub async fn quote_buy(
605        &self,
606        network: &str,
607        token: &str,
608        amount_wei: U256,
609    ) -> Result<BuyQuote, Error> {
610        let token = normalize_address("token", token)?;
611        if amount_wei.is_zero() {
612            return Err(invalid("amount_wei must be greater than zero"));
613        }
614
615        let (amount_out, price_impact_bps) =
616            self.swap_output(network, &token, amount_wei, true).await?;
617        let fee = self.first_buy_fee(network, &token).await?;
618        let value = amount_wei
619            .checked_add(fee)
620            .ok_or_else(|| invalid("amount plus first-buy fee exceeds uint256"))?;
621
622        Ok(BuyQuote {
623            network: network.to_string(),
624            token,
625            amount_in_wei: amount_wei,
626            first_buy_fee_wei: fee,
627            value_wei: value,
628            amount_out_wei: amount_out,
629            price_impact_bps,
630        })
631    }
632
633    /// Price a sell: native currency out for a given amount of tokens in.
634    pub async fn quote_sell(
635        &self,
636        network: &str,
637        token: &str,
638        amount_wei: U256,
639    ) -> Result<SellQuote, Error> {
640        let token = normalize_address("token", token)?;
641        if amount_wei.is_zero() {
642            return Err(invalid("amount_wei must be greater than zero"));
643        }
644
645        let (amount_out, price_impact_bps) =
646            self.swap_output(network, &token, amount_wei, false).await?;
647
648        Ok(SellQuote {
649            network: network.to_string(),
650            token,
651            amount_in_wei: amount_wei,
652            amount_out_wei: amount_out,
653            price_impact_bps,
654        })
655    }
656
657    // --------------------------------------------------------------- builds
658
659    /// Spend an exact amount of native currency on a token.
660    ///
661    /// `transaction.value` already includes the first-buy fee; sending only the
662    /// amount reverts with `InsufficientEthValue`. Excess is refunded on-chain.
663    pub async fn build_buy(&self, params: &BuyParams) -> Result<BuiltBuy, Error> {
664        let quote = self
665            .quote_buy(&params.network, &params.token, params.amount_wei)
666            .await?;
667        let chain = self.resolve(&params.network).await?;
668
669        let limit = apply_limit(
670            &params.limits,
671            quote.amount_out_wei,
672            false,
673            "min_amount_out",
674        )?;
675        let deadline = resolve_deadline(&params.limits)?;
676
677        Ok(BuiltBuy {
678            transaction: UnsignedTransaction {
679                chain_id: chain.chain_id,
680                to: chain.contract,
681                data: encode_call(
682                    SELECTOR_SWAP_EXACT_ETH_FOR_TOKENS,
683                    &[
684                        address_word(&quote.token)?,
685                        uint256_word(quote.amount_in_wei),
686                        uint256_word(limit),
687                        uint256_word(U256::from_u64(deadline)),
688                    ],
689                ),
690                value: quote.value_wei,
691                from: optional_address(params.limits.from.as_deref())?,
692            },
693            quote,
694            limit_wei: limit,
695            deadline,
696        })
697    }
698
699    /// Buy an exact number of tokens, capping what you spend.
700    ///
701    /// Slippage runs the other way here: the limit is a `maxAmountIn`, so
702    /// `slippage_bps` widens the cap rather than lowering a floor.
703    ///
704    /// # Why this one does its own arithmetic
705    ///
706    /// Every other quote in this module is an `eth_call`, for the reason in the
707    /// module note. This one cannot be. `getSwapOutput` answers "what do I get
708    /// for X" in both directions and the contract publishes no view at all for
709    /// "what does Y cost" — `getAmountIn` is internal. Pricing the buy off
710    /// `getSwapOutput(wanted, is_eth_input = false)` is not an approximation of
711    /// the right answer, it is the wrong side of the curve: it divides by
712    /// `virtual_token + wanted` where the buy divides by `virtual_token -
713    /// wanted`, and it subtracts the sell fees where the buy adds the buy fees.
714    /// A `maxAmountIn` derived that way is under the real cost and the swap
715    /// reverts with `MaxInputExceeded`, after the caller has paid the gas.
716    ///
717    /// Searching the forward direction for the inverse — which this used to do —
718    /// gets the side right and the number wrong: a bisection stops inside a
719    /// tolerance band, so it returns a cost above the contract's own to the tune
720    /// of that band, and spends dozens of `eth_call`s doing it. So this mirrors
721    /// `Fyuz.getAmountIn` exactly instead, including its `+1` and its fee
722    /// gross-up, over reserves and fee rates read from the same contract. The
723    /// inputs are still the chain's; only the multiply is local.
724    pub async fn build_buy_exact_tokens(
725        &self,
726        params: &BuyExactTokensParams,
727    ) -> Result<BuiltBuy, Error> {
728        let token = normalize_address("token", &params.token)?;
729        if params.amount_out_wei.is_zero() {
730            return Err(invalid("amount_out_wei must be greater than zero"));
731        }
732
733        let pool = self.pool_state(&params.network, &token).await?;
734        if pool.launched {
735            return Err(graduated_error(&token, None));
736        }
737        if pool.virtual_token_reserve.is_zero() {
738            // `tokenPools` is a mapping, so an unknown token reads back as all
739            // zeros rather than reverting. Saying that outright beats "the curve
740            // cannot deliver N tokens, its reserve is 0", which reads like a
741            // curve that has been bought out.
742            return Err(invalid(format!(
743                "no bonding curve exists for {token} on {:?} — check the token address and \
744                 the network",
745                params.network
746            )));
747        }
748        let fee_bps = self.buy_fee_bps(&params.network).await?;
749
750        // The contract clamps the request to what the curve still holds *before*
751        // it prices anything (`_swapETHForExactTokens`), so price what will
752        // actually be filled. Pricing the full request would quote a number the
753        // swap never uses.
754        let deliverable = if params.amount_out_wei > pool.token_reserve {
755            pool.token_reserve
756        } else {
757            params.amount_out_wei
758        };
759        let cost = buy_cost_wei(&pool, deliverable, fee_bps)?;
760
761        let limit = apply_limit(&params.limits, cost, true, "max_amount_in")?;
762        let fee = self.first_buy_fee(&params.network, &token).await?;
763        let chain = self.resolve(&params.network).await?;
764        let deadline = resolve_deadline(&params.limits)?;
765        // swapETHForExactTokens wants msg.value >= maxAmountIn + firstBuyFee and
766        // refunds whatever the swap did not need, so the ceiling is what has to
767        // be funded, not the quote.
768        let value = limit
769            .checked_add(fee)
770            .ok_or_else(|| invalid("max input plus first-buy fee exceeds uint256"))?;
771
772        // `checkPriceImpact` measures the output against the virtual token
773        // reserve, which is the same ratio `getSwapOutput` reports for a buy.
774        let impact = deliverable
775            .mul_div(
776                U256::from_u64(u64::from(BPS_DIVISOR)),
777                pool.virtual_token_reserve,
778            )
779            .ok_or_else(|| invalid("this token's price impact does not fit in a uint256"))?;
780
781        Ok(BuiltBuy {
782            transaction: UnsignedTransaction {
783                chain_id: chain.chain_id,
784                to: chain.contract,
785                data: encode_call(
786                    SELECTOR_SWAP_ETH_FOR_EXACT_TOKENS,
787                    &[
788                        address_word(&token)?,
789                        uint256_word(params.amount_out_wei),
790                        uint256_word(limit),
791                        uint256_word(U256::from_u64(deadline)),
792                    ],
793                ),
794                value,
795                from: optional_address(params.limits.from.as_deref())?,
796            },
797            quote: BuyQuote {
798                network: params.network.clone(),
799                token,
800                amount_in_wei: cost,
801                first_buy_fee_wei: fee,
802                value_wei: value,
803                amount_out_wei: deliverable,
804                price_impact_bps: impact.to_string().parse::<u64>().unwrap_or(u64::MAX),
805            },
806            limit_wei: limit,
807            deadline,
808        })
809    }
810
811    /// Sell tokens for native currency.
812    ///
813    /// The contract pulls the tokens with `transferFrom`, so an ERC-20 approval
814    /// must already be in place — check [`TradeApi::allowance`] and send
815    /// [`TradeApi::build_approve`] first, or the swap reverts inside the token.
816    pub async fn build_sell(&self, params: &SellParams) -> Result<BuiltSell, Error> {
817        let quote = self
818            .quote_sell(&params.network, &params.token, params.amount_wei)
819            .await?;
820        let chain = self.resolve(&params.network).await?;
821
822        let limit = apply_limit(
823            &params.limits,
824            quote.amount_out_wei,
825            false,
826            "min_amount_out",
827        )?;
828        let deadline = resolve_deadline(&params.limits)?;
829
830        Ok(BuiltSell {
831            transaction: UnsignedTransaction {
832                chain_id: chain.chain_id,
833                to: chain.contract,
834                data: encode_call(
835                    SELECTOR_SWAP_EXACT_TOKENS_FOR_ETH,
836                    &[
837                        address_word(&quote.token)?,
838                        uint256_word(quote.amount_in_wei),
839                        uint256_word(limit),
840                        uint256_word(U256::from_u64(deadline)),
841                    ],
842                ),
843                value: U256::ZERO,
844                from: optional_address(params.limits.from.as_deref())?,
845            },
846            quote,
847            limit_wei: limit,
848            deadline,
849        })
850    }
851
852    /// ERC-20 `approve`, letting the curve pull tokens for a sell.
853    ///
854    /// Pass [`U256::MAX`] to approve once and forever — it saves a transaction
855    /// per sell, at the cost of a standing allowance against an upgradeable
856    /// contract.
857    pub async fn build_approve(
858        &self,
859        network: &str,
860        token: &str,
861        amount_wei: U256,
862    ) -> Result<UnsignedTransaction, Error> {
863        let token = normalize_address("token", token)?;
864        let chain = self.resolve(network).await?;
865
866        Ok(UnsignedTransaction {
867            chain_id: chain.chain_id,
868            to: token,
869            data: encode_call(
870                SELECTOR_APPROVE,
871                &[address_word(&chain.contract)?, uint256_word(amount_wei)],
872            ),
873            value: U256::ZERO,
874            from: None,
875        })
876    }
877}
878
879/// The part of `tokenPools(token)` an exact-tokens buy is priced against.
880#[derive(Debug, Clone)]
881struct PoolState {
882    /// The real token balance the curve still holds — the ceiling on one buy.
883    token_reserve: U256,
884    /// `x` in `x * y = k`.
885    virtual_eth_reserve: U256,
886    /// `y` in `x * y = k`.
887    virtual_token_reserve: U256,
888    /// The graduation flag: once true every swap entrypoint reverts.
889    launched: bool,
890}
891
892/// What `swapETHForExactTokens` will charge for `amount_out` tokens.
893///
894/// `Fyuz.getAmountIn(amountOut, virtualEthReserve, virtualTokenReserve,
895/// PLATFORM_BUY_FEE_BPS + TOKEN_OWNER_FEE_BPS)`, to the wei — including the `+1`
896/// the contract adds after its floor division, which is what stops a rounded-down
897/// input landing one token short of what was asked for.
898fn buy_cost_wei(pool: &PoolState, amount_out: U256, fee_bps: U256) -> Result<U256, Error> {
899    if amount_out.is_zero() || amount_out >= pool.virtual_token_reserve {
900        // getAmountIn's own InvalidOutput guard: at or beyond the virtual
901        // reserve the denominator is zero or negative and the curve can never
902        // deliver, however much is offered for it.
903        return Err(invalid(format!(
904            "the curve cannot deliver {amount_out} tokens: its virtual token reserve is only {}",
905            pool.virtual_token_reserve
906        )));
907    }
908    if pool.virtual_eth_reserve.is_zero() {
909        return Err(invalid(
910            "the curve for this token holds no virtual reserve, so it cannot be priced",
911        ));
912    }
913
914    let denominator = pool
915        .virtual_token_reserve
916        .checked_sub(amount_out)
917        .expect("amount_out is below the virtual token reserve");
918    let before_fee = pool
919        .virtual_eth_reserve
920        .mul_div(amount_out, denominator)
921        .and_then(|floor| floor.checked_add(U256::from_u64(1)))
922        .ok_or_else(|| invalid("pricing this many tokens overflowed uint256"))?;
923
924    let divisor = U256::from_u64(u64::from(BPS_DIVISOR));
925    let net = divisor
926        .checked_sub(fee_bps)
927        .filter(|net| !net.is_zero())
928        .ok_or_else(|| {
929            invalid(format!(
930                "a buy fee of {fee_bps} bps leaves nothing to price"
931            ))
932        })?;
933    before_fee
934        .mul_div(divisor, net)
935        .ok_or_else(|| invalid("grossing this trade up by the buy fee overflowed uint256"))
936}
937
938/// A chain with its launchpad address already validated.
939#[derive(Debug, Clone)]
940struct Resolved {
941    chain_id: i64,
942    contract: String,
943    rpc_url: Option<String>,
944}
945
946/// A [`ChainInfo`] built from the compile-time address book.
947///
948/// Only the fields the trading layer reads are populated. The rest stay `None`
949/// rather than being invented: this is a deployment record, not a copy of what
950/// `/config` would have said, and a caller reading `target_market_cap` off it
951/// should get "unknown" rather than a number from a stale constant.
952fn chain_from(deployment: &Deployment) -> ChainInfo {
953    ChainInfo {
954        name: deployment.name.to_string(),
955        chain_id: deployment.chain_id,
956        network: Some(deployment.network.to_string()),
957        contract_address: Some(deployment.fyuz.to_string()),
958        rpc_url: Some(deployment.rpc_url.to_string()),
959        currency: Some(deployment.currency.to_string()),
960        explorer_url: Some(deployment.explorer_url.to_string()),
961        ..ChainInfo::default()
962    }
963}
964
965/// Per-client trading state: the memoised `/config` and the RPC clients.
966#[derive(Debug, Default)]
967pub(crate) struct TradeState {
968    pub(crate) chains: OnceCell<Vec<ChainInfo>>,
969    pub(crate) rpcs: Mutex<HashMap<String, Arc<RpcClient>>>,
970}
971
972/// Turn a quote plus a tolerance into the limit that goes on-chain.
973///
974/// `up` is which way the tolerance moves the number: `false` for a
975/// `minAmountOut` floor, `true` for a `maxAmountIn` ceiling.
976fn apply_limit(limits: &Limits, quoted: U256, up: bool, field: &str) -> Result<U256, Error> {
977    if limits.limit_wei.is_some() && limits.slippage_bps.is_some() {
978        return Err(invalid("pass either slippage_bps or limit_wei, not both"));
979    }
980    if let Some(limit) = limits.limit_wei {
981        return Ok(limit);
982    }
983
984    let Some(bps) = limits.slippage_bps else {
985        return Err(invalid(format!(
986            "slippage protection is required: set slippage_bps (100 = 1%) or limit_wei to \
987             choose {field} yourself. There is no default, because the right tolerance \
988             depends on the trade and getting it wrong costs you money"
989        )));
990    };
991    if bps >= BPS_DIVISOR {
992        return Err(invalid(format!(
993            "slippage_bps must be below 10000, got {bps}"
994        )));
995    }
996
997    let factor = if up {
998        BPS_DIVISOR + bps
999    } else {
1000        BPS_DIVISOR - bps
1001    };
1002    let scaled = quoted
1003        .mul_small(factor)
1004        .ok_or_else(|| invalid("applying slippage overflowed uint256"))?;
1005    Ok(scaled.div_rem_small(BPS_DIVISOR).0)
1006}
1007
1008/// Absolute deadline in unix seconds, from whichever form the caller used.
1009fn resolve_deadline(limits: &Limits) -> Result<u64, Error> {
1010    if let Some(deadline) = limits.deadline {
1011        if deadline == 0 {
1012            return Err(invalid("deadline must be a positive unix timestamp"));
1013        }
1014        return Ok(deadline);
1015    }
1016    let window = limits.deadline_seconds.unwrap_or(DEFAULT_DEADLINE_SECONDS);
1017    if window == 0 {
1018        return Err(invalid("deadline_seconds must be positive"));
1019    }
1020    if window > MAX_DEADLINE_SECONDS {
1021        return Err(invalid(format!(
1022            "deadline_seconds must be at most {MAX_DEADLINE_SECONDS} (one year), got {window}"
1023        )));
1024    }
1025    let now = SystemTime::now()
1026        .duration_since(UNIX_EPOCH)
1027        .unwrap_or(Duration::ZERO)
1028        .as_secs();
1029    // Bounded above, so this cannot wrap — saturating anyway costs nothing and
1030    // means a clock that has run away does not decide whether this panics.
1031    Ok(now.saturating_add(window))
1032}
1033
1034fn optional_address(value: Option<&str>) -> Result<Option<String>, Error> {
1035    value.map(|v| normalize_address("from", v)).transpose()
1036}