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
//! Audit orchestrator. Runs every check against live chain data, folds the
//! flags into a 0-100 score + verdict, and returns a structured result.

use crate::chain::SolClient;
use crate::checks::{bundles, cluster, deployer, holders, liquidity, mint};
use crate::types::{score_flags, AuditResult, Flag, Severity, TokenMeta};
use serde_json::json;

#[derive(Default, Clone, Copy)]
pub struct AuditOptions {
    pub skip_bundles: bool,
    pub skip_copycats: bool,
    /// Deeper cluster pass: more funding hops probed, bigger lookup budget.
    pub deep: bool,
}

pub async fn audit_token(
    client: &SolClient,
    mint_addr: &str,
    opts: AuditOptions,
    now_ms: i64,
    scanned_at: String,
) -> AuditResult {
    let mut warnings = vec![];

    // Prologue: the four fetches are independent — run them concurrently.
    let (mint_res, meta_res, jup_res, liq) = futures::join!(
        client.mint_info(mint_addr),
        client.metaplex_metadata(mint_addr),
        client.jupiter_token(mint_addr),
        liquidity::analyze(client, mint_addr, now_ms),
    );
    let mint_info = match mint_res {
        Ok(m) => m,
        Err(e) => {
            warnings.push(format!("mint lookup: {e}"));
            None
        }
    };
    let meta = meta_res
        .map_err(|e| warnings.push(format!("metadata: {e}")))
        .ok()
        .flatten();
    let jup = jup_res
        .map_err(|e| warnings.push(format!("jupiter: {e}")))
        .ok()
        .flatten();
    if let Some(e) = &liq.fetch_error {
        warnings.push(format!("liquidity (dexscreener): {e}"));
    }

    let Some(mint_info) = mint_info else {
        // No mint account = not a token (or a dead RPC) — honest UNKNOWN.
        let (score, verdict) = score_flags(&[], false);
        return AuditResult {
            address: mint_addr.to_string(),
            chain: crate::config::CHAIN,
            scanned_at,
            token: TokenMeta::default(),
            score,
            verdict,
            flags: vec![],
            sections: json!({}),
            warnings,
            rokha_url: crate::config::rokha_link(),
            rokha_app: serde_json::Value::Null,
        };
    };

    let mint_an = mint::analyze(&mint_info, meta.as_ref());
    let supply = mint_info.supply;

    // Holder pass (needs the DexScreener pool list to label LP accounts).
    let jup_top10 = jup.as_ref().and_then(|j| j.audit.top_holders_percentage);
    let (holder_an, holder_warn) =
        holders::analyze(client, mint_addr, supply, &liq.pool_addresses, jup_top10).await;
    if let Some(w) = holder_warn {
        warnings.push(w);
    }

    // Parallel: deployer, bundles, copycats.
    let dev = jup.as_ref().and_then(|j| j.dev.clone());
    let symbol = mint_an
        .symbol
        .clone()
        .or_else(|| jup.as_ref().and_then(|j| j.symbol.clone()));
    let deployer_fut = deployer::analyze(client, jup.as_ref());
    let bundles_fut = async {
        if opts.skip_bundles {
            bundles::BundleAnalysis {
                section: json!({"analyzed": false, "note": "skipped (--fast)"}),
                flags: vec![],
                buyers: vec![],
                launch_slot: None,
                launch_txs: vec![],
                coverage: 0.0,
                dev: dev.clone(),
            }
        } else {
            bundles::analyze(
                client,
                mint_addr,
                supply,
                &holder_an.pool_owners,
                &liq.pool_addresses,
                dev.as_deref(),
            )
            .await
        }
    };
    let copycats_fut = async {
        match (&symbol, opts.skip_copycats) {
            (Some(s), false) => client.find_copycats(s, mint_addr).await.unwrap_or_default(),
            _ => vec![],
        }
    };
    let ((deployer_section, deployer_flags), bundle_an, copycats) =
        futures::join!(deployer_fut, bundles_fut, copycats_fut);
    let bundles_section = bundle_an.section.clone();
    let bundles_flags = bundle_an.flags.clone();

    // Cluster pass — funding graph + transfers + same-slot + Jito, folded
    // into the shared cluster-graph contract. Skipped under --fast.
    let (cluster_section, cluster_flags, cluster_supersedes) = if opts.skip_bundles {
        (
            json!({"schema": 1, "scope": {"holders_analyzed": 0, "snipers_analyzed": 0,
                "funder_hops": 0, "coverage": "unavailable", "note": "skipped (--fast)"},
                "nodes": [], "edges": [], "clusters": [], "effective_concentration": serde_json::Value::Null}),
            vec![],
            false,
        )
    } else {
        let out = cluster::analyze(
            client,
            mint_addr,
            cluster::ClusterInputs {
                holders: &holder_an.ranked,
                buyers: &bundle_an.buyers,
                dev: bundle_an.dev.as_deref(),
                excluded: &holder_an.pool_owners,
                supply,
                launch_slot: bundle_an.launch_slot,
                launch_txs: &bundle_an.launch_txs,
                launch_coverage: bundle_an.coverage,
                holders_live: holder_an.live_list,
            },
            opts.deep,
        )
        .await;
        (out.section, out.flags, out.supersedes_raw_top10)
    };

    // LP-safety posture per venue.
    let mut lp_flags = vec![];
    let launchpad = jup.as_ref().and_then(|j| j.launchpad.clone());
    match liq.top_dex.as_deref() {
        Some("pumpfun") => lp_flags.push(Flag::new(
            "bonding_curve",
            Severity::Info,
            "Still on the bonding curve",
            "Trading happens on pump.fun's bonding curve — there is no LP to pull yet, but the token is extremely early and most never graduate.".into(),
            3,
        )),
        Some("pumpswap") => lp_flags.push(Flag::new(
            "lp_locked",
            Severity::Good,
            "LP locked by protocol (PumpSwap)",
            "Graduated pump.fun token — the liquidity is protocol-owned and cannot be pulled by the dev.".into(),
            0,
        )),
        Some(_) if liq.pair_count > 0 => {
            let deep = liq.total_liquidity_usd >= 100_000.0;
            let via = launchpad
                .as_deref()
                .map(|l| format!(" (launched via {l})"))
                .unwrap_or_default();
            lp_flags.push(Flag::new(
                "lp_lock_unverified",
                if deep { Severity::Info } else { Severity::Warning },
                "LP lock unverified",
                format!("Solwatch could not confirm the LP is burned or locked{via} — in principle the dev could pull it. Verify before trusting."),
                if deep { 3 } else { 10 },
            ));
        }
        _ => {}
    }

    // Market-quality reads: Solwatch's own bot-volume heuristics (from data
    // already fetched) + Jupiter's third-party organic score, labeled as such.
    let mut market_flags = vec![];
    // Heuristic 1 — uniform launch-buy sizes: an army of bots buying near-
    // identical amounts is manufactured demand, not a crowd.
    if bundle_an.buyers.len() >= 5 {
        let amounts: Vec<u128> = bundle_an.buyers.iter().map(|b| b.amount).collect();
        let uniform = max_uniform_ratio(&amounts);
        if uniform >= 0.6 {
            market_flags.push(Flag::new(
                "uniform_buys",
                Severity::Warning,
                "Launch buys are suspiciously uniform",
                format!(
                    "{}% of the launch-window buys are near-identical sizes — scripted wallets, not organic buyers.",
                    (uniform * 100.0).round()
                ),
                8,
            ));
        }
    }
    // Heuristic 2 — volume way out of proportion to the holder base.
    if let (Some(vol), Some(holders_n)) = (
        liq.section
            .pointer("/pairs/0/volume/h24")
            .and_then(|v| v.as_f64()),
        jup.as_ref().and_then(|j| j.holder_count),
    ) {
        if holders_n >= 20 && vol / holders_n as f64 > 50_000.0 {
            market_flags.push(Flag::new(
                "wash_volume",
                Severity::Warning,
                "Volume disproportionate to holders",
                format!(
                    "${:.0}k of 24h volume across only {holders_n} holders (~${:.0}k per holder) — heavy wash/bot trading is the usual cause.",
                    vol / 1000.0,
                    vol / holders_n as f64 / 1000.0
                ),
                6,
            ));
        }
    }
    if let Some(j) = &jup {
        match (j.organic_score, j.organic_score_label.as_deref()) {
            (Some(s), Some("low")) if liq.pair_count > 0 => market_flags.push(Flag::new(
                "low_organic",
                Severity::Warning,
                "Volume looks botted (Jupiter score)",
                format!(
                    "Jupiter's third-party organic score is {} / 100 — most of the trading volume doesn't look like real humans.",
                    s.round()
                ),
                8,
            )),
            (Some(s), Some("high")) => market_flags.push(Flag::new(
                "organic_volume",
                Severity::Good,
                "Organic trading activity (Jupiter score)",
                format!("Jupiter's third-party organic score is {} / 100 — real trader activity.", s.round()),
                0,
            )),
            _ => {}
        }
        if j.is_verified == Some(true) {
            market_flags.push(Flag::new(
                "verified",
                Severity::Good,
                "Community-verified token",
                "Listed on Jupiter's verified list.".into(),
                0,
            ));
        }
    }

    // Copycats.
    let mut copycat_flags = vec![];
    if !copycats.is_empty() {
        let ids: Vec<String> = copycats
            .iter()
            .take(3)
            .map(|c| c.base_token.address[..8.min(c.base_token.address.len())].to_string())
            .collect();
        copycat_flags.push(Flag::new(
            "copycats",
            Severity::Info,
            &format!("{} same-symbol token(s) on Solana", copycats.len()),
            format!(
                "Impersonators exist — double-check you hold {mint_addr}. Copycats: {}.",
                ids.join(", ")
            ),
            2,
        ));
    }

    // Assemble. When the clustered top-10 read is authoritative (full
    // coverage), it SUPERSEDES the raw per-wallet top-10 flag — never both.
    let mut holder_flags = holder_an.flags;
    if cluster_supersedes {
        holder_flags.retain(|f| f.id != "top10_concentration");
    }
    let mut flags: Vec<Flag> = vec![];
    flags.extend(mint_an.flags);
    flags.extend(holder_flags);
    flags.extend(liq.flags);
    flags.extend(deployer_flags);
    flags.extend(bundles_flags);
    flags.extend(cluster_flags);
    flags.extend(lp_flags);
    flags.extend(market_flags);
    flags.extend(copycat_flags);
    if let Some(p) = client.rpc_pressure() {
        warnings.push(p);
    }

    let (score, verdict) = score_flags(&flags, true);
    flags.sort_by_key(|f| std::cmp::Reverse(f.severity.rank()));

    let top_pair = liq
        .section
        .pointer("/pairs/0")
        .cloned()
        .unwrap_or(json!(null));
    let token_meta = TokenMeta {
        name: mint_an
            .name
            .clone()
            .or_else(|| jup.as_ref().and_then(|j| j.name.clone())),
        symbol,
        decimals: Some(mint_info.decimals),
        total_supply: Some(mint_info.supply_raw.clone()),
        holders_count: jup.as_ref().and_then(|j| j.holder_count),
        market_cap_usd: jup
            .as_ref()
            .and_then(|j| j.mcap)
            .or_else(|| liq.section.pointer("/fdvUsd").and_then(|v| v.as_f64())),
        volume_24h_usd: top_pair.pointer("/volume/h24").and_then(|v| v.as_f64()),
        price_usd: jup
            .as_ref()
            .and_then(|j| j.usd_price)
            .or_else(|| top_pair.pointer("/priceUsd").and_then(|v| v.as_f64())),
        token_program: Some(mint_info.program.clone()),
        launchpad,
        created_at: jup.as_ref().and_then(|j| j.created_at.clone()),
    };

    let copycats_section: Vec<_> = copycats
        .iter()
        .take(5)
        .map(|c| {
            json!({
                "mint": c.base_token.address,
                "liquidityUsd": c.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0),
                "pair": c.pair_address,
            })
        })
        .collect();
    let market_section = jup.as_ref().map(|j| {
        json!({
            "organicScore": j.organic_score,
            "organicScoreLabel": j.organic_score_label,
            "isVerified": j.is_verified,
            "tags": j.tags,
            "icon": j.icon,
        })
    });

    let mut result = AuditResult {
        address: mint_addr.to_string(),
        chain: crate::config::CHAIN,
        scanned_at,
        token: token_meta,
        score,
        verdict,
        flags,
        sections: json!({
            "mint": mint_an.section,
            "holders": holder_an.section,
            "liquidity": liq.section,
            "bundles": bundles_section,
            "cluster": cluster_section,
            "deployer": deployer_section,
            "market": market_section,
            "copycats": copycats_section,
        }),
        warnings,
        rokha_url: crate::config::rokha_link(),
        rokha_app: serde_json::Value::Null,
    };
    result.rokha_app = crate::report::render_rokha_app(&result);
    result
}

/// Largest fraction of values clustered within ±1% of a common size (public
/// for the unit tests). 1.0 = every buy is the same size.
pub fn max_uniform_ratio(amounts: &[u128]) -> f64 {
    if amounts.is_empty() {
        return 0.0;
    }
    let mut best = 0usize;
    for &a in amounts {
        if a == 0 {
            continue;
        }
        let lo = a - a / 100;
        let hi = a + a / 100;
        let n = amounts.iter().filter(|&&x| x >= lo && x <= hi).count();
        best = best.max(n);
    }
    best as f64 / amounts.len() as f64
}