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
//! Holder-distribution analysis. Takes the top-20 largest token accounts,
//! resolves each one's OWNER wallet, then classifies owners: liquidity pools
//! and bonding curves (owner account held by a known AMM program), burn
//! addresses, and program-owned accounts are excluded from concentration math.

use crate::chain::{LargeAccount, SolClient};
use crate::config;
use crate::types::{Flag, Severity};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};

pub struct HolderAnalysis {
    pub flags: Vec<Flag>,
    pub section: Value,
    /// Owner wallets classified as pools/curves/lockers/burns — reused by the
    /// bundle + cluster passes (excluded from buyer math and never clustered).
    pub pool_owners: HashSet<String>,
    /// Ranked non-pool holders (owner, raw amount) — the cluster pass input.
    pub ranked: Vec<(String, u128)>,
    /// Whether the live per-wallet holder list was actually read (false when
    /// the RPC refused it and we fell back to Jupiter's aggregate number).
    pub live_list: bool,
}

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

pub async fn analyze(
    client: &SolClient,
    mint: &str,
    supply: u128,
    pool_addresses: &[String],
    jup_top10: Option<f64>,
) -> (HolderAnalysis, Option<String>) {
    let mut flags = vec![];
    let mut warning = None;

    let largest: Vec<LargeAccount> = match client.largest_accounts(mint).await {
        Ok(l) => l,
        Err(e) => {
            // The public RPC hard-blocks getTokenLargestAccounts for most
            // callers. Fall back to Jupiter's indexed top-holders number so
            // the concentration read survives (labeled with its source).
            warning = Some(format!("holders (getTokenLargestAccounts): {e}"));
            match jup_top10 {
                Some(p) => {
                    let p = round2(p);
                    let (sev, weight, title) = if p >= 60.0 {
                        (Severity::Danger, 30, "Extreme holder concentration")
                    } else if p >= 30.0 {
                        (Severity::Warning, 15, "High holder concentration")
                    } else {
                        (Severity::Good, 0, "Distributed supply")
                    };
                    flags.push(Flag::new(
                        "top10_concentration",
                        sev,
                        title,
                        format!("Top holders control {p}% of the supply (via Jupiter's index — the RPC refused the live holder list)."),
                        weight,
                    ));
                }
                None => flags.push(Flag::new(
                    "holders_unknown",
                    Severity::Warning,
                    "Holder concentration could not be verified",
                    "The RPC would not return the largest token accounts (rate limit) and no indexed fallback exists — re-run, or set SOLWATCH_RPC_URL to a private RPC.".into(),
                    5,
                )),
            }
            return (
                HolderAnalysis {
                    flags,
                    section: json!({"fetchError": true, "top10Pct": jup_top10.map(round2), "source": "jupiter", "top": [], "excluded": []}),
                    pool_owners: HashSet::new(),
                    ranked: vec![],
                    live_list: false,
                },
                warning,
            );
        }
    };

    // Resolve token-account owners, then classify each owner account.
    let ta_addrs: Vec<String> = largest.iter().map(|l| l.token_account.clone()).collect();
    let ta_accounts = client
        .multiple_accounts(&ta_addrs)
        .await
        .unwrap_or_default();
    let mut owners: Vec<Option<String>> = vec![None; largest.len()];
    for (i, acc) in ta_accounts.iter().enumerate() {
        owners[i] = acc
            .pointer("/data/parsed/info/owner")
            .and_then(|o| o.as_str())
            .map(String::from);
    }
    let unique_owners: Vec<String> = {
        let mut seen = HashSet::new();
        owners
            .iter()
            .flatten()
            .filter(|o| seen.insert((*o).clone()))
            .cloned()
            .collect()
    };
    let owner_accounts = client
        .multiple_accounts(&unique_owners)
        .await
        .unwrap_or_default();
    let pool_labels: HashMap<&str, &str> = config::pool_program_labels().into_iter().collect();
    let locker_labels: HashMap<&str, &str> = config::locker_program_labels().into_iter().collect();
    let mut ds_pools: HashSet<String> = pool_addresses.iter().cloned().collect();
    // The DERIVED pump.fun bonding curve — excluded even when nothing has
    // indexed the pair yet (pure math, no API dependency).
    if let Some(curve) = crate::chain::pumpfun_bonding_curve_pda(mint) {
        ds_pools.insert(curve);
    }

    // owner wallet -> (label if pool/program/locker, is_program_owned)
    let mut owner_class: HashMap<String, (Option<String>, bool)> = HashMap::new();
    for (owner, acc) in unique_owners.iter().zip(owner_accounts.iter()) {
        let program_owner = acc.get("owner").and_then(|o| o.as_str()).unwrap_or("");
        let label = if owner == config::INCINERATOR {
            Some("burn (incinerator)".to_string())
        } else if ds_pools.contains(owner.as_str()) {
            Some("LP pool".to_string())
        } else if let Some(l) = locker_labels.get(program_owner) {
            // A team lock / vesting escrow is not a whale.
            Some((*l).to_string())
        } else {
            pool_labels.get(program_owner).map(|l| (*l).to_string())
        };
        let program_owned = !program_owner.is_empty() && program_owner != config::SYSTEM_PROGRAM;
        owner_class.insert(owner.clone(), (label, program_owned));
    }

    let pct = |v: u128| -> f64 {
        v.saturating_mul(10000)
            .checked_div(supply)
            .map(|bp| round2(bp as f64 / 100.0))
            .unwrap_or(0.0)
    };

    // Aggregate by owner (one whale can hold several token accounts).
    let mut by_owner: HashMap<String, u128> = HashMap::new();
    for (la, owner) in largest.iter().zip(owners.iter()) {
        let key = owner.clone().unwrap_or_else(|| la.token_account.clone());
        *by_owner.entry(key).or_insert(0) += la.amount;
    }
    let mut ranked: Vec<(String, u128, bool)> = vec![]; // (owner, amount, program_owned)
    let mut excluded: Vec<(String, u128, String)> = vec![];
    let mut pool_owners = HashSet::new();
    for (owner, amount) in by_owner {
        let (label, program_owned) = owner_class.get(&owner).cloned().unwrap_or((None, false));
        match label {
            Some(l) => {
                pool_owners.insert(owner.clone());
                excluded.push((owner, amount, l));
            }
            None => ranked.push((owner, amount, program_owned)),
        }
    }
    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
    excluded.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));

    let top10: f64 = round2(ranked.iter().take(10).map(|r| pct(r.1)).sum());

    if top10 >= 60.0 {
        flags.push(Flag::new(
            "top10_concentration",
            Severity::Danger,
            "Extreme holder concentration",
            format!("The 10 biggest non-pool wallets control {top10}% of the supply — a handful of people can crash the price at will."),
            30,
        ));
    } else if top10 >= 30.0 {
        flags.push(Flag::new(
            "top10_concentration",
            Severity::Warning,
            "High holder concentration",
            format!("The 10 biggest non-pool wallets control {top10}% of the supply."),
            15,
        ));
    } else {
        flags.push(Flag::new(
            "top10_concentration",
            Severity::Good,
            "Distributed supply",
            format!("The 10 biggest non-pool wallets control only {top10}% of the supply."),
            0,
        ));
    }
    if let Some((owner, amount, program_owned)) = ranked.first() {
        let p = pct(*amount);
        if p >= 25.0 && !program_owned {
            flags.push(Flag::new(
                "single_whale",
                Severity::Danger,
                "Single wallet dominates",
                format!(
                    "One wallet ({}) holds {p}% of the supply — if it sells, the chart dies.",
                    short(owner)
                ),
                15,
            ));
        }
    }

    let section = json!({
        "top10Pct": top10,
        "top": ranked.iter().take(15).map(|(o, a, prog)| json!({
            "owner": o, "pct": pct(*a), "programOwned": prog,
        })).collect::<Vec<_>>(),
        "excluded": excluded.iter().map(|(o, a, l)| json!({
            "owner": o, "pct": pct(*a), "label": l,
        })).collect::<Vec<_>>(),
        "note": "Owner wallets of the 20 largest token accounts; pools, bonding curves, lockers and burns excluded.",
    });

    let ranked_out: Vec<(String, u128)> = ranked.iter().map(|(o, a, _)| (o.clone(), *a)).collect();
    (
        HolderAnalysis {
            flags,
            section,
            pool_owners,
            ranked: ranked_out,
            live_list: true,
        },
        warning,
    )
}

fn short(a: &str) -> String {
    if a.len() < 12 {
        a.to_string()
    } else {
        format!("{}{}", &a[..4], &a[a.len() - 4..])
    }
}