solwatch 0.1.5

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
//! Liquidity analysis from DexScreener: total liquidity, liquidity-vs-FDV,
//! pair age, socials, buy/sell pressure and price-change buckets (dashboard
//! chart fodder), plus the LP-safety posture per venue.

use crate::chain::dexscreener::{liq, DexPair};
use crate::chain::SolClient;
use crate::types::{Flag, Severity};
use serde_json::json;

pub struct LiquidityAnalysis {
    pub pool_addresses: Vec<String>,
    pub total_liquidity_usd: f64,
    pub pair_count: usize,
    pub top_dex: Option<String>,
    pub flags: Vec<Flag>,
    pub section: serde_json::Value,
    pub fetch_error: Option<String>,
}

fn round2(n: f64) -> f64 {
    (n * 100.0).round() / 100.0
}

pub async fn analyze(client: &SolClient, mint: &str, now_ms: i64) -> LiquidityAnalysis {
    let mut flags = vec![];
    // "API failed" and "no pairs" are different answers: an empty Ok means the
    // token genuinely has no indexed pair (hard-fail); an Err means we don't
    // know — soft flag + warning, never hard-fail.
    let (pairs, fetch_error): (Vec<DexPair>, Option<String>) =
        match client.pairs_for_token(mint).await {
            Ok(p) => (p, None),
            Err(e) => (vec![], Some(e.to_string())),
        };

    if let Some(err) = &fetch_error {
        flags.push(Flag::new(
            "liquidity_unknown",
            Severity::Warning,
            "Liquidity could not be verified",
            format!("DexScreener unreachable ({err}) — liquidity, FDV ratio and pair age were not assessed. Re-run before trusting."),
            5,
        ));
        return LiquidityAnalysis {
            pool_addresses: vec![],
            total_liquidity_usd: 0.0,
            pair_count: 0,
            top_dex: None,
            flags,
            section: json!({"fetchError": err, "pairs": []}),
            fetch_error,
        };
    }

    let total: f64 = round2(pairs.iter().map(liq).sum());
    let top = pairs.first();
    let fdv = top.and_then(|p| p.fdv.or(p.market_cap));
    let liq_to_fdv = fdv.filter(|f| *f > 0.0).map(|f| round2(total / f * 100.0));
    let has_socials = top
        .and_then(|p| p.info.as_ref())
        .map(|i| !i.socials.is_empty() || !i.websites.is_empty())
        .unwrap_or(false);

    if pairs.is_empty() {
        flags.push(
            Flag::new(
                "no_liquidity",
                Severity::Danger,
                "No DEX liquidity found",
                "No tradable pair is indexed for this mint — it cannot be bought or sold on a DEX right now.".into(),
                25,
            )
            .hard(),
        );
    } else {
        let depth_reported = pairs
            .iter()
            .any(|p| p.liquidity.as_ref().and_then(|l| l.usd).is_some());
        if !depth_reported {
            // DexScreener indexes the pair but reports no pool depth — typical
            // for bonding-curve venues. "Unreported" is not "zero"; say so.
            flags.push(Flag::new(
                "liquidity_unreported",
                Severity::Info,
                "Pool depth not reported",
                "DexScreener doesn't publish liquidity for this venue (bonding curve) — assume it is tiny.".into(),
                3,
            ));
        } else if total < 5000.0 {
            flags.push(Flag::new(
                "low_liquidity",
                Severity::Danger,
                "Very low liquidity",
                format!(
                    "Only ${} of total liquidity — the pool can be drained or the price wrecked with pocket change.",
                    fmt(total)
                ),
                20,
            ));
        } else if total < 25000.0 {
            flags.push(Flag::new(
                "low_liquidity",
                Severity::Warning,
                "Low liquidity",
                format!(
                    "${} total liquidity — exits get expensive fast.",
                    fmt(total)
                ),
                8,
            ));
        }
        if let Some(r) = liq_to_fdv {
            // Only meaningful for thin tokens — large caps keep most of their
            // depth on CEXes, and unreported (bonding-curve) depth reads as 0.
            if depth_reported && r < 2.0 && total < 150_000.0 {
                flags.push(Flag::new(
                    "liq_fdv_ratio",
                    Severity::Warning,
                    "Liquidity tiny vs market cap",
                    format!("Liquidity is only {r}% of the market cap — the price is mostly on paper and exit is hard."),
                    10,
                ));
            }
        }
        let youngest = pairs
            .iter()
            .filter_map(|p| p.pair_created_at)
            .map(|c| (now_ms - c) / 3_600_000)
            .min();
        if let Some(h) = youngest {
            if h < 6 {
                flags.push(Flag::new(
                    "very_new",
                    Severity::Warning,
                    "Brand-new pair",
                    format!("The main pair is ~{h}h old — most rugs happen in the first hours."),
                    6,
                ));
            }
        }
        if !has_socials {
            flags.push(Flag::new(
                "no_socials",
                Severity::Info,
                "No socials listed",
                "No website/X/Telegram on the DexScreener profile.".into(),
                3,
            ));
        }
        // Sell-pressure read from the top pair's 24h txns.
        if let Some(p) = top {
            let (b, s) = (p.txns.h24.buys, p.txns.h24.sells);
            if b + s >= 50 && s > 0 && b as f64 / s as f64 > 5.0 {
                flags.push(Flag::new(
                    "buy_sell_imbalance",
                    Severity::Warning,
                    "Buys massively outnumber sells",
                    format!("{b} buys vs {s} sells in 24h — either nobody CAN sell (test a tiny sell first) or bots are painting volume."),
                    8,
                ));
            }
        }
    }

    let section = json!({
        "totalLiquidityUsd": total,
        "fdvUsd": fdv,
        "liqToFdvPct": liq_to_fdv,
        "hasSocials": has_socials,
        "pairs": pairs.iter().take(8).map(|p| json!({
            "dex": p.dex_id,
            "labels": p.labels,
            "pairAddress": p.pair_address,
            "quoteSymbol": p.quote_token.symbol,
            "liquidityUsd": liq(p),
            "priceUsd": p.price_usd.as_ref().and_then(|s| s.parse::<f64>().ok()),
            "ageHours": p.pair_created_at.map(|c| (now_ms - c) / 3_600_000),
            "txns24h": {"buys": p.txns.h24.buys, "sells": p.txns.h24.sells},
            "txns1h": {"buys": p.txns.h1.buys, "sells": p.txns.h1.sells},
            "priceChange": {"m5": p.price_change.m5, "h1": p.price_change.h1, "h6": p.price_change.h6, "h24": p.price_change.h24},
            "volume": {"m5": p.volume.m5, "h1": p.volume.h1, "h6": p.volume.h6, "h24": p.volume.h24},
        })).collect::<Vec<_>>(),
    });

    LiquidityAnalysis {
        pool_addresses: pairs.iter().map(|p| p.pair_address.clone()).collect(),
        total_liquidity_usd: total,
        pair_count: pairs.len(),
        top_dex: pairs.first().map(|p| p.dex_id.clone()),
        flags,
        section,
        fetch_error: None,
    }
}

fn fmt(n: f64) -> String {
    let mut s = String::new();
    let int = n.round() as i64;
    let digits = int.abs().to_string();
    for (i, c) in digits.chars().enumerate() {
        if i > 0 && (digits.len() - i) % 3 == 0 {
            s.push(',');
        }
        s.push(c);
    }
    s
}