solwatch 0.1.18

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, with MISPRICED pairs removed. (The endpoint also returns pairs
    /// where the mint is the quote — those carry the OTHER token's price/FDV
    /// and would poison the math; their 24h volume is captured separately by
    /// `quote_side_volume_24h`, because it is real trading in this token.)
    ///
    /// The outlier filter exists because DexScreener sometimes serves a pair
    /// priced against a token whose own USD price it has wrong, and the error
    /// is enormous rather than marginal: observed live 2026-07-19, JUP's two
    /// Meteora /MET pairs quoted JUP at ~$950 against a true $0.194 (~4,900x),
    /// which alone produced $425M of phantom "liquidity" and a $6.7 TRILLION
    /// FDV next to a $643M market cap in the same report. Summing pairs
    /// blindly means one upstream bad tick becomes our published number.
    pub async fn pairs_for_token(&self, mint: &str) -> Result<PairSet> {
        let all: Vec<DexPair> = self
            .ds(&format!("/latest/dex/tokens/{mint}"))
            .await?
            .into_iter()
            .filter(|p| p.chain_id == DEXSCREENER_CHAIN)
            .collect();

        // Volume from pairs where our mint is the QUOTE side. It is genuine
        // trading in this token (PYUSD trades almost entirely this way — we
        // were under-reporting its 24h volume by ~99%), but these pairs must
        // never donate price or FDV, so they stay out of `pairs`.
        let quote_side_volume_24h: f64 = all
            .iter()
            .filter(|p| p.base_token.address != mint && p.quote_token.address == mint)
            .filter_map(|p| p.volume.h24)
            .sum();

        let mut pairs: Vec<DexPair> = all
            .into_iter()
            .filter(|p| p.base_token.address == mint)
            .collect();

        let (dropped_mispriced, median_price) = drop_mispriced_pairs(&mut pairs);

        pairs.sort_by(|a, b| {
            liq(b)
                .partial_cmp(&liq(a))
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        Ok(PairSet {
            pairs,
            quote_side_volume_24h,
            dropped_mispriced,
            median_price,
        })
    }

    /// 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)
}

/// Remove pairs whose published USD price is wildly out of line with the rest,
/// returning (how many were dropped, the median price used as the reference).
///
/// DexScreener occasionally serves a pair quoted against a token whose own USD
/// price it has wrong, and the error is enormous rather than marginal — so the
/// bad pairs separate cleanly from the good ones. Observed live 2026-07-19:
/// JUP's Meteora /MET pairs quoted JUP at ~$950 against a true $0.194 (~4,900x
/// off), contributing $425M of phantom liquidity and a $6.7T FDV.
///
/// Median, not mean, because the mean is dragged toward the outliers it is
/// supposed to detect. Pure + public so the test exercises THIS code.
pub fn drop_mispriced_pairs(pairs: &mut Vec<DexPair>) -> (usize, Option<f64>) {
    let mut prices: Vec<f64> = pairs
        .iter()
        .filter_map(|p| p.price_usd.as_ref().and_then(|s| s.parse::<f64>().ok()))
        .filter(|p| *p > 0.0)
        .collect();
    prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let median_price = if prices.is_empty() {
        None
    } else {
        Some(prices[prices.len() / 2])
    };

    let before = pairs.len();
    if let Some(med) = median_price.filter(|m| *m > 0.0) {
        // 10x either way. Real cross-venue spread on a thin pool is a few
        // percent; the failure mode here is orders of magnitude, so this
        // threshold cannot discard a legitimately-priced pool.
        pairs.retain(
            |p| match p.price_usd.as_ref().and_then(|s| s.parse::<f64>().ok()) {
                Some(px) if px > 0.0 => {
                    let ratio = if px > med { px / med } else { med / px };
                    ratio <= 10.0
                }
                // No price published (bonding-curve venues) — keep it; it
                // cannot poison a price/FDV read it never contributes to.
                _ => true,
            },
        );
    }
    (before - pairs.len(), median_price)
}

/// The token's pairs after mispricing defence, plus what that defence saw.
/// Callers publish `dropped_mispriced` so a filtered read is never silently
/// presented as the raw truth.
pub struct PairSet {
    /// Base-side pairs, mispriced outliers removed, richest liquidity first.
    pub pairs: Vec<DexPair>,
    /// 24h volume from pairs where this mint is the QUOTE token.
    pub quote_side_volume_24h: f64,
    /// How many base-side pairs were discarded as mispriced.
    pub dropped_mispriced: usize,
    /// Median USD price across base-side pairs — the trusted reference.
    pub median_price: Option<f64>,
}