solwatch 0.1.7

Real-data Solana memecoin auditor — rug/freeze/bundle scanner with a 0-100 risk score, plain-English flags, and a live dashboard. Sister tool to Hoodwatch.
Documentation
//! DexScreener client (chain slug `solana`). Gives price, liquidity, FDV, pair
//! age, socials, buy/sell counts and price-change buckets — and powers copycat
//! detection (same symbol, different mint).

use super::SolClient;
use crate::config::{DEXSCREENER_BASE, DEXSCREENER_CHAIN};
use anyhow::Result;
use serde::Deserialize;

#[derive(Debug, Clone, Deserialize, Default)]
pub struct TokenRef {
    pub address: String,
    #[serde(default)]
    pub symbol: String,
    #[serde(default)]
    pub name: String,
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct Liquidity {
    pub usd: Option<f64>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct Socials {
    #[serde(default)]
    pub websites: Vec<serde_json::Value>,
    #[serde(default)]
    pub socials: Vec<serde_json::Value>,
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct TxnBucket {
    #[serde(default)]
    pub buys: u64,
    #[serde(default)]
    pub sells: u64,
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct Txns {
    #[serde(default)]
    pub m5: TxnBucket,
    #[serde(default)]
    pub h1: TxnBucket,
    #[serde(default)]
    pub h6: TxnBucket,
    #[serde(default)]
    pub h24: TxnBucket,
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct PriceChange {
    pub m5: Option<f64>,
    pub h1: Option<f64>,
    pub h6: Option<f64>,
    pub h24: Option<f64>,
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct Volume {
    pub m5: Option<f64>,
    pub h1: Option<f64>,
    pub h6: Option<f64>,
    pub h24: Option<f64>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct DexPair {
    #[serde(rename = "chainId")]
    pub chain_id: String,
    #[serde(rename = "dexId", default)]
    pub dex_id: String,
    #[serde(rename = "pairAddress")]
    pub pair_address: String,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(rename = "baseToken")]
    pub base_token: TokenRef,
    #[serde(rename = "quoteToken", default)]
    pub quote_token: TokenRef,
    #[serde(rename = "priceUsd")]
    pub price_usd: Option<String>,
    pub fdv: Option<f64>,
    #[serde(rename = "marketCap")]
    pub market_cap: Option<f64>,
    pub liquidity: Option<Liquidity>,
    #[serde(default)]
    pub txns: Txns,
    #[serde(rename = "priceChange", default)]
    pub price_change: PriceChange,
    #[serde(default)]
    pub volume: Volume,
    #[serde(rename = "pairCreatedAt")]
    pub pair_created_at: Option<i64>,
    pub info: Option<Socials>,
}

#[derive(Debug, Clone, Deserialize, Default)]
struct PairsResp {
    pairs: Option<Vec<DexPair>>,
}

impl SolClient {
    /// GET with the same retry/backoff posture as the RPC plane. A failure must
    /// surface as Err — NOT an empty pair list: "rate-limited" and "no pair
    /// exists" are different answers, and the second is a hard-fail downstream.
    async fn ds(&self, path: &str) -> Result<Vec<DexPair>> {
        let url = format!("{DEXSCREENER_BASE}{path}");
        let mut last = anyhow::anyhow!("dexscreener {path} failed");
        for attempt in 0..3u32 {
            if attempt > 0 {
                tokio::time::sleep(std::time::Duration::from_millis(400 * attempt as u64)).await;
            }
            match self.http.get(&url).send().await {
                Ok(resp) => {
                    let status = resp.status();
                    if status.as_u16() == 429 || status.is_server_error() {
                        last = anyhow::anyhow!("dexscreener HTTP {status}");
                        continue;
                    }
                    if !status.is_success() {
                        return Err(anyhow::anyhow!("dexscreener HTTP {status}"));
                    }
                    match resp.json::<PairsResp>().await {
                        Ok(r) => return Ok(r.pairs.unwrap_or_default()),
                        Err(e) => {
                            last = anyhow::anyhow!("dexscreener decode: {e}");
                            continue;
                        }
                    }
                }
                Err(e) => last = anyhow::anyhow!("dexscreener: {e}"),
            }
        }
        Err(last)
    }

    /// All Solana pairs where this mint is the BASE token, richest liquidity
    /// first. (The endpoint also returns pairs where the mint is the quote —
    /// those carry the OTHER token's price/FDV and would poison the math.)
    pub async fn pairs_for_token(&self, mint: &str) -> Result<Vec<DexPair>> {
        let mut pairs: Vec<DexPair> = self
            .ds(&format!("/latest/dex/tokens/{mint}"))
            .await?
            .into_iter()
            .filter(|p| p.chain_id == DEXSCREENER_CHAIN && p.base_token.address == mint)
            .collect();
        pairs.sort_by(|a, b| {
            liq(b)
                .partial_cmp(&liq(a))
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        Ok(pairs)
    }

    /// Copycats: same symbol on Solana at a different mint.
    pub async fn find_copycats(&self, symbol: &str, real: &str) -> Result<Vec<DexPair>> {
        let hits = self.ds(&format!("/latest/dex/search?q={symbol}")).await?;
        let mut seen = std::collections::HashSet::new();
        let mut out = vec![];
        for p in hits {
            if p.chain_id != DEXSCREENER_CHAIN {
                continue;
            }
            if !p.base_token.symbol.eq_ignore_ascii_case(symbol) {
                continue;
            }
            if p.base_token.address == real {
                continue;
            }
            if seen.insert(p.base_token.address.clone()) {
                out.push(p);
            }
        }
        out.sort_by(|a, b| {
            liq(b)
                .partial_cmp(&liq(a))
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        Ok(out)
    }
}

pub fn liq(p: &DexPair) -> f64 {
    p.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0)
}