use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, OnceCell};
use crate::abi::{
address_word, bool_word, decode_bool_at, decode_uint256_at, encode_call, normalize_address,
uint256_word, SELECTOR_ALLOWANCE, SELECTOR_APPROVE, SELECTOR_BALANCE_OF,
SELECTOR_GET_FIRST_BUY_FEE, SELECTOR_GET_MAX_SELLABLE_ETH, SELECTOR_GET_SWAP_OUTPUT,
SELECTOR_PAUSED, SELECTOR_PLATFORM_BUY_FEE_BPS, SELECTOR_SWAP_ETH_FOR_EXACT_TOKENS,
SELECTOR_SWAP_EXACT_ETH_FOR_TOKENS, SELECTOR_SWAP_EXACT_TOKENS_FOR_ETH,
SELECTOR_TOKEN_OWNER_FEE_BPS, SELECTOR_TOKEN_POOLS, U256,
};
use crate::addresses::{deployment_for_chain_id, deployment_for_network, Deployment, DEPLOYMENTS};
use crate::models::ChainInfo;
use crate::revert::{graduated_error, revert_error, revert_selector, SELECTOR_ALREADY_LAUNCHED};
use crate::rpc::{CallOutcome, RpcClient, DEFAULT_BLOCK_TAG};
use crate::{Error, FyuzClient};
const BPS_DIVISOR: u32 = 10_000;
pub const DEFAULT_DEADLINE_SECONDS: u64 = 120;
pub const MAX_DEADLINE_SECONDS: u64 = 365 * 24 * 60 * 60;
pub const DEFAULT_NETWORK: &str = "bsc";
fn invalid(message: impl Into<String>) -> Error {
Error::InvalidArgument {
message: message.into(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsignedTransaction {
pub chain_id: i64,
pub to: String,
pub data: String,
pub value: U256,
pub from: Option<String>,
}
#[derive(Debug, Clone)]
pub struct BuyQuote {
pub network: String,
pub token: String,
pub amount_in_wei: U256,
pub first_buy_fee_wei: U256,
pub value_wei: U256,
pub amount_out_wei: U256,
pub price_impact_bps: u64,
}
#[derive(Debug, Clone)]
pub struct SellQuote {
pub network: String,
pub token: String,
pub amount_in_wei: U256,
pub amount_out_wei: U256,
pub price_impact_bps: u64,
}
#[derive(Debug, Clone)]
pub struct BuiltBuy {
pub transaction: UnsignedTransaction,
pub quote: BuyQuote,
pub limit_wei: U256,
pub deadline: u64,
}
#[derive(Debug, Clone)]
pub struct BuiltSell {
pub transaction: UnsignedTransaction,
pub quote: SellQuote,
pub limit_wei: U256,
pub deadline: u64,
}
#[derive(Debug, Clone, Default)]
struct Limits {
slippage_bps: Option<u32>,
limit_wei: Option<U256>,
deadline: Option<u64>,
deadline_seconds: Option<u64>,
from: Option<String>,
}
macro_rules! limit_builders {
() => {
pub fn slippage_bps(mut self, bps: u32) -> Self {
self.limits.slippage_bps = Some(bps);
self
}
pub fn limit_wei(mut self, limit: U256) -> Self {
self.limits.limit_wei = Some(limit);
self
}
pub fn deadline(mut self, unix_seconds: u64) -> Self {
self.limits.deadline = Some(unix_seconds);
self
}
pub fn deadline_seconds(mut self, seconds: u64) -> Self {
self.limits.deadline_seconds = Some(seconds);
self
}
pub fn from(mut self, address: impl Into<String>) -> Self {
self.limits.from = Some(address.into());
self
}
pub fn network(mut self, network: impl Into<String>) -> Self {
self.network = network.into();
self
}
};
}
#[derive(Debug, Clone)]
pub struct BuyParams {
token: String,
amount_wei: U256,
network: String,
limits: Limits,
}
impl BuyParams {
pub fn new(token: impl Into<String>, amount_wei: U256) -> Self {
Self {
token: token.into(),
amount_wei,
network: DEFAULT_NETWORK.to_string(),
limits: Limits::default(),
}
}
limit_builders!();
}
#[derive(Debug, Clone)]
pub struct BuyExactTokensParams {
token: String,
amount_out_wei: U256,
network: String,
limits: Limits,
}
impl BuyExactTokensParams {
pub fn new(token: impl Into<String>, amount_out_wei: U256) -> Self {
Self {
token: token.into(),
amount_out_wei,
network: DEFAULT_NETWORK.to_string(),
limits: Limits::default(),
}
}
limit_builders!();
}
#[derive(Debug, Clone)]
pub struct SellParams {
token: String,
amount_wei: U256,
network: String,
limits: Limits,
}
impl SellParams {
pub fn new(token: impl Into<String>, amount_wei: U256) -> Self {
Self {
token: token.into(),
amount_wei,
network: DEFAULT_NETWORK.to_string(),
limits: Limits::default(),
}
}
limit_builders!();
}
#[derive(Debug)]
pub struct TradeApi<'a> {
client: &'a FyuzClient,
}
impl<'a> TradeApi<'a> {
pub(crate) fn new(client: &'a FyuzClient) -> Self {
Self { client }
}
pub async fn chain(&self, network: &str) -> Result<ChainInfo, Error> {
if let Some(deployment) = deployment_for_network(network) {
if deployment.api_network.is_none() {
return Ok(chain_from(deployment));
}
}
let chains = self
.client
.trade_state()
.chains
.get_or_try_init(|| async { Ok::<_, Error>(self.client.get_config().await?.chains) })
.await?;
for chain in chains {
if chain.network.as_deref() != Some(network) && chain.chain_id.to_string() != network {
continue;
}
let mut chain = chain.clone();
chain.contract_address = match chain.contract_address.as_deref() {
Some(address) => Some(normalize_address("contractAddress", address)?),
None => deployment_for_chain_id(chain.chain_id).map(|d| d.fyuz.to_string()),
};
if chain.contract_address.is_none() {
return Err(invalid(format!(
"the API did not publish a contract address for {network:?} and this SDK \
has none on file for chain id {}, so no transaction can be built",
chain.chain_id
)));
}
return Ok(chain);
}
if let Some(deployment) = deployment_for_network(network) {
return Ok(chain_from(deployment));
}
let known: Vec<String> = chains
.iter()
.map(|c| c.network.clone().unwrap_or_else(|| c.chain_id.to_string()))
.chain(DEPLOYMENTS.iter().map(|d| d.network.to_string()))
.collect();
Err(invalid(format!(
"unknown network {network:?}; the API serves: {}",
known.join(", ")
)))
}
async fn resolve(&self, network: &str) -> Result<Resolved, Error> {
let chain = self.chain(network).await?;
let contract = chain
.contract_address
.ok_or_else(|| invalid(format!("no contract address is known for {network:?}")))?;
Ok(Resolved {
chain_id: chain.chain_id,
contract,
rpc_url: chain.rpc_url,
})
}
async fn rpc(&self, network: &str) -> Result<Arc<RpcClient>, Error> {
{
let cache = self.client.trade_state().rpcs.lock().await;
if let Some(existing) = cache.get(network) {
return Ok(Arc::clone(existing));
}
}
let chain = self.resolve(network).await?;
let url = self
.client
.rpc_url()
.map(str::to_string)
.or_else(|| chain.rpc_url.clone())
.ok_or_else(|| {
invalid(format!(
"no RPC endpoint for {network:?}: the API published none, and none was \
configured with FyuzClientBuilder::rpc_url"
))
})?;
let client = Arc::new(
RpcClient::with_http(url, self.client.rpc_http().clone())
.block(self.client.rpc_block().unwrap_or(DEFAULT_BLOCK_TAG)),
);
let mut cache = self.client.trade_state().rpcs.lock().await;
Ok(Arc::clone(
cache.entry(network.to_string()).or_insert(client),
))
}
async fn call_contract(&self, network: &str, data: &str) -> Result<CallOutcome, Error> {
let chain = self.resolve(network).await?;
let rpc = self.rpc(network).await?;
rpc.call(&chain.contract, data, None).await
}
pub async fn is_graduated(&self, network: &str, token: &str) -> Result<bool, Error> {
Ok(self.pool_state(network, token).await?.launched)
}
async fn pool_state(&self, network: &str, token: &str) -> Result<PoolState, Error> {
let token = normalize_address("token", token)?;
let data = encode_call(SELECTOR_TOKEN_POOLS, &[address_word(&token)?]);
match self.call_contract(network, &data).await? {
CallOutcome::Ok(result) => Ok(PoolState {
token_reserve: decode_uint256_at(&result, 1)?,
virtual_eth_reserve: decode_uint256_at(&result, 2)?,
virtual_token_reserve: decode_uint256_at(&result, 3)?,
launched: decode_bool_at(&result, 7)?,
}),
CallOutcome::Reverted(revert) => {
Err(revert_error(&revert, &format!("tokenPools({token})")))
}
}
}
pub async fn is_paused(&self, network: &str) -> Result<bool, Error> {
let data = encode_call(SELECTOR_PAUSED, &[]);
match self.call_contract(network, &data).await? {
CallOutcome::Ok(result) => decode_bool_at(&result, 0),
CallOutcome::Reverted(revert) => Err(revert_error(&revert, "paused()")),
}
}
pub async fn first_buy_fee(&self, network: &str, token: &str) -> Result<U256, Error> {
let token = normalize_address("token", token)?;
let data = encode_call(SELECTOR_GET_FIRST_BUY_FEE, &[address_word(&token)?]);
match self.call_contract(network, &data).await? {
CallOutcome::Ok(result) => decode_uint256_at(&result, 0),
CallOutcome::Reverted(revert) => {
Err(revert_error(&revert, &format!("getFirstBuyFee({token})")))
}
}
}
pub async fn max_sellable_wei(&self, network: &str, token: &str) -> Result<U256, Error> {
let token = normalize_address("token", token)?;
let data = encode_call(SELECTOR_GET_MAX_SELLABLE_ETH, &[address_word(&token)?]);
match self.call_contract(network, &data).await? {
CallOutcome::Ok(result) => decode_uint256_at(&result, 0),
CallOutcome::Reverted(revert) => Err(revert_error(
&revert,
&format!("getMaxSellableETH({token})"),
)),
}
}
pub async fn allowance(&self, network: &str, token: &str, owner: &str) -> Result<U256, Error> {
let token = normalize_address("token", token)?;
let owner = normalize_address("owner", owner)?;
let chain = self.resolve(network).await?;
let rpc = self.rpc(network).await?;
let data = encode_call(
SELECTOR_ALLOWANCE,
&[address_word(&owner)?, address_word(&chain.contract)?],
);
let result = rpc
.call_or_error(&token, &data, &format!("allowance({owner})"))
.await?;
decode_uint256_at(&result, 0)
}
pub async fn balance_of(&self, network: &str, token: &str, owner: &str) -> Result<U256, Error> {
let token = normalize_address("token", token)?;
let owner = normalize_address("owner", owner)?;
let rpc = self.rpc(network).await?;
let data = encode_call(SELECTOR_BALANCE_OF, &[address_word(&owner)?]);
let result = rpc
.call_or_error(&token, &data, &format!("balanceOf({owner})"))
.await?;
decode_uint256_at(&result, 0)
}
async fn swap_output(
&self,
network: &str,
token: &str,
amount_in: U256,
is_eth_input: bool,
) -> Result<(U256, u64), Error> {
let data = encode_call(
SELECTOR_GET_SWAP_OUTPUT,
&[
address_word(token)?,
uint256_word(amount_in),
bool_word(is_eth_input),
],
);
match self.call_contract(network, &data).await? {
CallOutcome::Ok(result) => {
let amount_out = decode_uint256_at(&result, 0)?;
let impact = decode_uint256_at(&result, 1)?;
let impact = impact.to_string().parse::<u64>().unwrap_or(u64::MAX);
Ok((amount_out, impact))
}
CallOutcome::Reverted(revert) => {
if revert_selector(&revert).as_deref() == Some(SELECTOR_ALREADY_LAUNCHED) {
return Err(graduated_error(token, None));
}
Err(revert_error(&revert, &format!("getSwapOutput({token})")))
}
}
}
async fn buy_fee_bps(&self, network: &str) -> Result<U256, Error> {
let mut total = U256::ZERO;
for (selector, name) in [
(SELECTOR_PLATFORM_BUY_FEE_BPS, "PLATFORM_BUY_FEE_BPS()"),
(SELECTOR_TOKEN_OWNER_FEE_BPS, "TOKEN_OWNER_FEE_BPS()"),
] {
let part = match self.call_contract(network, selector).await? {
CallOutcome::Ok(result) => decode_uint256_at(&result, 0)?,
CallOutcome::Reverted(revert) => return Err(revert_error(&revert, name)),
};
total = total
.checked_add(part)
.ok_or_else(|| invalid("the contract's buy fees do not fit in a uint256"))?;
}
if total >= U256::from_u64(u64::from(BPS_DIVISOR)) {
return Err(invalid(format!(
"the contract reports a combined buy fee of {total} bps, which is 100% or more — \
refusing to price a buy against it"
)));
}
Ok(total)
}
pub async fn quote_buy(
&self,
network: &str,
token: &str,
amount_wei: U256,
) -> Result<BuyQuote, Error> {
let token = normalize_address("token", token)?;
if amount_wei.is_zero() {
return Err(invalid("amount_wei must be greater than zero"));
}
let (amount_out, price_impact_bps) =
self.swap_output(network, &token, amount_wei, true).await?;
let fee = self.first_buy_fee(network, &token).await?;
let value = amount_wei
.checked_add(fee)
.ok_or_else(|| invalid("amount plus first-buy fee exceeds uint256"))?;
Ok(BuyQuote {
network: network.to_string(),
token,
amount_in_wei: amount_wei,
first_buy_fee_wei: fee,
value_wei: value,
amount_out_wei: amount_out,
price_impact_bps,
})
}
pub async fn quote_sell(
&self,
network: &str,
token: &str,
amount_wei: U256,
) -> Result<SellQuote, Error> {
let token = normalize_address("token", token)?;
if amount_wei.is_zero() {
return Err(invalid("amount_wei must be greater than zero"));
}
let (amount_out, price_impact_bps) =
self.swap_output(network, &token, amount_wei, false).await?;
Ok(SellQuote {
network: network.to_string(),
token,
amount_in_wei: amount_wei,
amount_out_wei: amount_out,
price_impact_bps,
})
}
pub async fn build_buy(&self, params: &BuyParams) -> Result<BuiltBuy, Error> {
let quote = self
.quote_buy(¶ms.network, ¶ms.token, params.amount_wei)
.await?;
let chain = self.resolve(¶ms.network).await?;
let limit = apply_limit(
¶ms.limits,
quote.amount_out_wei,
false,
"min_amount_out",
)?;
let deadline = resolve_deadline(¶ms.limits)?;
Ok(BuiltBuy {
transaction: UnsignedTransaction {
chain_id: chain.chain_id,
to: chain.contract,
data: encode_call(
SELECTOR_SWAP_EXACT_ETH_FOR_TOKENS,
&[
address_word("e.token)?,
uint256_word(quote.amount_in_wei),
uint256_word(limit),
uint256_word(U256::from_u64(deadline)),
],
),
value: quote.value_wei,
from: optional_address(params.limits.from.as_deref())?,
},
quote,
limit_wei: limit,
deadline,
})
}
pub async fn build_buy_exact_tokens(
&self,
params: &BuyExactTokensParams,
) -> Result<BuiltBuy, Error> {
let token = normalize_address("token", ¶ms.token)?;
if params.amount_out_wei.is_zero() {
return Err(invalid("amount_out_wei must be greater than zero"));
}
let pool = self.pool_state(¶ms.network, &token).await?;
if pool.launched {
return Err(graduated_error(&token, None));
}
if pool.virtual_token_reserve.is_zero() {
return Err(invalid(format!(
"no bonding curve exists for {token} on {:?} — check the token address and \
the network",
params.network
)));
}
let fee_bps = self.buy_fee_bps(¶ms.network).await?;
let deliverable = if params.amount_out_wei > pool.token_reserve {
pool.token_reserve
} else {
params.amount_out_wei
};
let cost = buy_cost_wei(&pool, deliverable, fee_bps)?;
let limit = apply_limit(¶ms.limits, cost, true, "max_amount_in")?;
let fee = self.first_buy_fee(¶ms.network, &token).await?;
let chain = self.resolve(¶ms.network).await?;
let deadline = resolve_deadline(¶ms.limits)?;
let value = limit
.checked_add(fee)
.ok_or_else(|| invalid("max input plus first-buy fee exceeds uint256"))?;
let impact = deliverable
.mul_div(
U256::from_u64(u64::from(BPS_DIVISOR)),
pool.virtual_token_reserve,
)
.ok_or_else(|| invalid("this token's price impact does not fit in a uint256"))?;
Ok(BuiltBuy {
transaction: UnsignedTransaction {
chain_id: chain.chain_id,
to: chain.contract,
data: encode_call(
SELECTOR_SWAP_ETH_FOR_EXACT_TOKENS,
&[
address_word(&token)?,
uint256_word(params.amount_out_wei),
uint256_word(limit),
uint256_word(U256::from_u64(deadline)),
],
),
value,
from: optional_address(params.limits.from.as_deref())?,
},
quote: BuyQuote {
network: params.network.clone(),
token,
amount_in_wei: cost,
first_buy_fee_wei: fee,
value_wei: value,
amount_out_wei: deliverable,
price_impact_bps: impact.to_string().parse::<u64>().unwrap_or(u64::MAX),
},
limit_wei: limit,
deadline,
})
}
pub async fn build_sell(&self, params: &SellParams) -> Result<BuiltSell, Error> {
let quote = self
.quote_sell(¶ms.network, ¶ms.token, params.amount_wei)
.await?;
let chain = self.resolve(¶ms.network).await?;
let limit = apply_limit(
¶ms.limits,
quote.amount_out_wei,
false,
"min_amount_out",
)?;
let deadline = resolve_deadline(¶ms.limits)?;
Ok(BuiltSell {
transaction: UnsignedTransaction {
chain_id: chain.chain_id,
to: chain.contract,
data: encode_call(
SELECTOR_SWAP_EXACT_TOKENS_FOR_ETH,
&[
address_word("e.token)?,
uint256_word(quote.amount_in_wei),
uint256_word(limit),
uint256_word(U256::from_u64(deadline)),
],
),
value: U256::ZERO,
from: optional_address(params.limits.from.as_deref())?,
},
quote,
limit_wei: limit,
deadline,
})
}
pub async fn build_approve(
&self,
network: &str,
token: &str,
amount_wei: U256,
) -> Result<UnsignedTransaction, Error> {
let token = normalize_address("token", token)?;
let chain = self.resolve(network).await?;
Ok(UnsignedTransaction {
chain_id: chain.chain_id,
to: token,
data: encode_call(
SELECTOR_APPROVE,
&[address_word(&chain.contract)?, uint256_word(amount_wei)],
),
value: U256::ZERO,
from: None,
})
}
}
#[derive(Debug, Clone)]
struct PoolState {
token_reserve: U256,
virtual_eth_reserve: U256,
virtual_token_reserve: U256,
launched: bool,
}
fn buy_cost_wei(pool: &PoolState, amount_out: U256, fee_bps: U256) -> Result<U256, Error> {
if amount_out.is_zero() || amount_out >= pool.virtual_token_reserve {
return Err(invalid(format!(
"the curve cannot deliver {amount_out} tokens: its virtual token reserve is only {}",
pool.virtual_token_reserve
)));
}
if pool.virtual_eth_reserve.is_zero() {
return Err(invalid(
"the curve for this token holds no virtual reserve, so it cannot be priced",
));
}
let denominator = pool
.virtual_token_reserve
.checked_sub(amount_out)
.expect("amount_out is below the virtual token reserve");
let before_fee = pool
.virtual_eth_reserve
.mul_div(amount_out, denominator)
.and_then(|floor| floor.checked_add(U256::from_u64(1)))
.ok_or_else(|| invalid("pricing this many tokens overflowed uint256"))?;
let divisor = U256::from_u64(u64::from(BPS_DIVISOR));
let net = divisor
.checked_sub(fee_bps)
.filter(|net| !net.is_zero())
.ok_or_else(|| {
invalid(format!(
"a buy fee of {fee_bps} bps leaves nothing to price"
))
})?;
before_fee
.mul_div(divisor, net)
.ok_or_else(|| invalid("grossing this trade up by the buy fee overflowed uint256"))
}
#[derive(Debug, Clone)]
struct Resolved {
chain_id: i64,
contract: String,
rpc_url: Option<String>,
}
fn chain_from(deployment: &Deployment) -> ChainInfo {
ChainInfo {
name: deployment.name.to_string(),
chain_id: deployment.chain_id,
network: Some(deployment.network.to_string()),
contract_address: Some(deployment.fyuz.to_string()),
rpc_url: Some(deployment.rpc_url.to_string()),
currency: Some(deployment.currency.to_string()),
explorer_url: Some(deployment.explorer_url.to_string()),
..ChainInfo::default()
}
}
#[derive(Debug, Default)]
pub(crate) struct TradeState {
pub(crate) chains: OnceCell<Vec<ChainInfo>>,
pub(crate) rpcs: Mutex<HashMap<String, Arc<RpcClient>>>,
}
fn apply_limit(limits: &Limits, quoted: U256, up: bool, field: &str) -> Result<U256, Error> {
if limits.limit_wei.is_some() && limits.slippage_bps.is_some() {
return Err(invalid("pass either slippage_bps or limit_wei, not both"));
}
if let Some(limit) = limits.limit_wei {
return Ok(limit);
}
let Some(bps) = limits.slippage_bps else {
return Err(invalid(format!(
"slippage protection is required: set slippage_bps (100 = 1%) or limit_wei to \
choose {field} yourself. There is no default, because the right tolerance \
depends on the trade and getting it wrong costs you money"
)));
};
if bps >= BPS_DIVISOR {
return Err(invalid(format!(
"slippage_bps must be below 10000, got {bps}"
)));
}
let factor = if up {
BPS_DIVISOR + bps
} else {
BPS_DIVISOR - bps
};
let scaled = quoted
.mul_small(factor)
.ok_or_else(|| invalid("applying slippage overflowed uint256"))?;
Ok(scaled.div_rem_small(BPS_DIVISOR).0)
}
fn resolve_deadline(limits: &Limits) -> Result<u64, Error> {
if let Some(deadline) = limits.deadline {
if deadline == 0 {
return Err(invalid("deadline must be a positive unix timestamp"));
}
return Ok(deadline);
}
let window = limits.deadline_seconds.unwrap_or(DEFAULT_DEADLINE_SECONDS);
if window == 0 {
return Err(invalid("deadline_seconds must be positive"));
}
if window > MAX_DEADLINE_SECONDS {
return Err(invalid(format!(
"deadline_seconds must be at most {MAX_DEADLINE_SECONDS} (one year), got {window}"
)));
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
Ok(now.saturating_add(window))
}
fn optional_address(value: Option<&str>) -> Result<Option<String>, Error> {
value.map(|v| normalize_address("from", v)).transpose()
}