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
//! 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);
                    // Jupiter's topHoldersPercentage is POOL-INCLUSIVE: it counts
                    // bonding curves and LP vaults as holders. Our live-path
                    // thresholds are calibrated on a pool-EXCLUDED number, so
                    // scoring this against them made every healthy
                    // pre-graduation launchpad token an "Extreme holder
                    // concentration" Danger at weight 30 — the curve holding
                    // ~80% is normal, not a whale. We cannot separate curve from
                    // human here, so we report the number, say plainly what it
                    // does and does not mean, and do NOT spend real score weight
                    // on it. The coverage gap is what carries the uncertainty.
                    let (sev, weight, title) = if p >= 60.0 {
                        (Severity::Warning, 5, "Top holders control a large share (unseparated)")
                    } else {
                        (Severity::Info, 0, "Holder concentration (indexed estimate)")
                    };
                    flags.push(Flag::new(
                        "top10_concentration_indexed",
                        sev,
                        title,
                        format!("Top holders control {p}% of the supply, per Jupiter's index — the RPC refused the live holder list. This figure INCLUDES bonding curves and LP pools, which routinely hold most of a new token's supply, so it is not comparable to a wallet-level concentration read and is not scored as one."),
                        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.
    //
    // BOTH of these fetches are load-bearing for EXCLUSIONS, not just labels.
    // Every exclusion mechanism (incinerator, shared pool authorities, DEX
    // pools, lockers, the vault byte-scan) lives in the classifier loop below,
    // which iterates `unique_owners.zip(owner_accounts)`. On a failure that
    // returned an empty vec, `zip` made the loop body never execute, so every
    // exclusion died at once and the bonding curve was published as
    // "One wallet holds 85% — if it sells, the chart dies". Losing owner
    // resolution means we cannot tell a pool from a person: that is a blind
    // holder read, and it must be reported as one rather than as a whale.
    let ta_addrs: Vec<String> = largest.iter().map(|l| l.token_account.clone()).collect();
    let ta_res = client.multiple_accounts(&ta_addrs).await;
    let owner_resolution_failed = ta_res.is_err();
    let ta_accounts = ta_res.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_res = client.multiple_accounts(&unique_owners).await;
    let classify_failed = owner_res.is_err();
    let owner_accounts = owner_res.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 shared_authorities: HashMap<&str, &str> =
        config::shared_pool_authorities().into_iter().collect();
    let cex_wallets: HashMap<&str, &str> = config::cex_hot_wallets().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);
    }

    // Pool RESERVE VAULT detection — general, cross-launchpad. A large holder
    // whose TOKEN ACCOUNT is referenced inside an indexed pool account's data
    // is that pool's reserve — undistributed curve/AMM supply, NOT a whale.
    // Only pump.fun's curve is derivable offline; every other launchpad
    // (Meteora DBC, Raydium LaunchLab, Bonk/Boop, …) holds its reserve under a
    // system-owned vault-AUTHORITY PDA that matches none of the program/address
    // lists, so without this the curve's 90%+ reserve reads as a dominant whale
    // AND poisons the bundle/cluster passes into a fake "100% sniped / transfer
    // ring" (verified live 2026-07-18 on a Meteora DBC token: the DBC pool
    // account referenced the 92.87% "whale"'s token account as its base vault).
    // We read the pool accounts once and byte-scan them for our top token
    // accounts' pubkeys — no per-program layout knowledge required.
    let mut vault_owners: HashSet<String> = HashSet::new();
    if !ds_pools.is_empty() {
        let pool_list: Vec<String> = ds_pools.iter().cloned().collect();
        if let Ok(pool_datas) = client.accounts_raw(&pool_list).await {
            let blobs: Vec<Vec<u8>> = pool_datas.into_iter().flatten().collect();
            let accounts: Vec<(String, String)> = largest
                .iter()
                .zip(owners.iter())
                .filter_map(|(l, o)| o.clone().map(|owner| (l.token_account.clone(), owner)))
                .collect();
            vault_owners = vault_reserve_owners(&blobs, &accounts);
        }
    }

    // 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 let Some(l) = shared_authorities.get(owner.as_str()) {
            // A Raydium shared vault authority — protocol liquidity, not a whale.
            Some((*l).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 if let Some(l) = pool_labels.get(program_owner) {
            Some((*l).to_string())
        } else if cex_wallets.contains_key(owner.as_str()) {
            // An exchange omnibus/hot wallet holds customer funds, not a
            // position — "one wallet holds 26%, if it sells the chart dies" is
            // false about Binance's custody address. The cluster pass already
            // treats these as stop-nodes; concentration must too.
            Some("exchange (custodial)".to_string())
        } else if vault_owners.contains(owner.as_str()) {
            // Fallback: holds a token account that IS an indexed pool's reserve
            // vault, but matched none of the specific program/address lists
            // (a launchpad vault-authority PDA — Meteora DBC, Raydium
            // LaunchLab, …). The catch-all that keeps a curve reserve from
            // reading as a whale.
            Some("LP reserve vault".to_string())
        } else {
            None
        };
        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());

    // Can we trust WHO these accounts belong to? If either owner-resolution
    // fetch failed, pools/curves/lockers are indistinguishable from humans, so
    // no concentration claim — reassuring OR accusatory — is earned.
    let exclusions_reliable = !owner_resolution_failed && !classify_failed;
    // getTokenLargestAccounts returns at most 20 accounts. A full 20 means the
    // holder tail is invisible, so every figure below is a FLOOR, never a
    // measurement, and can never justify an affirmative all-clear.
    let sample_truncated = largest.len() >= 20;
    let scale = if supply == 0 { None } else { Some(()) };

    if scale.is_none() {
        // Every pct() would be 0.0 via checked_div, which used to render as
        // "Distributed supply" — a total data failure publishing as the most
        // reassuring possible result.
        flags.push(Flag::new(
            "holders_unknown",
            Severity::Warning,
            "Holder concentration could not be verified",
            "Total supply read as zero, so no holder percentage can be computed. Re-run before trusting any concentration figure.".into(),
            5,
        ));
    } else if !exclusions_reliable {
        flags.push(Flag::new(
            "holders_unknown",
            Severity::Warning,
            "Holder concentration could not be verified",
            "The RPC returned the largest token accounts but not their owners, so Solwatch cannot tell a liquidity pool or bonding curve apart from a human wallet. Concentration is NOT reported this run — a curve holding most of the supply is normal and would otherwise read as a whale. Re-run, or set SOLWATCH_RPC_URL to a private RPC.".into(),
            5,
        ));
    } else {
        let qualifier = if sample_truncated {
            " (of the 20 largest token accounts — holders beyond that are not visible to this RPC method, so this is a floor)"
        } else {
            ""
        };
        if top10 >= 60.0 {
            flags.push(Flag::new(
                "top10_concentration",
                Severity::Danger,
                "Extreme holder concentration",
                format!("The 10 biggest non-pool wallets control at least {top10}% of the supply{qualifier} — 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 at least {top10}% of the supply{qualifier}."),
                15,
            ));
        } else {
            // "only X%" is a lower bound presented as a measurement whenever the
            // sample is capped — so under truncation this states the floor and
            // drops to Info rather than an affirmative Good.
            flags.push(Flag::new(
                "top10_concentration",
                if sample_truncated {
                    Severity::Info
                } else {
                    Severity::Good
                },
                if sample_truncated {
                    "No large holders in the visible set"
                } else {
                    "Distributed supply"
                },
                format!("The 10 biggest non-pool wallets control at least {top10}% of the supply{qualifier}."),
                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. Holders outside the 20 largest token accounts are not visible to this RPC method.",
        "sampleTruncated": sample_truncated,
        "exclusionsReliable": exclusions_reliable,
    });

    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` gates the verdict cap downstream. It must mean "we
            // could actually read WHO holds this token", not merely "an RPC
            // call returned" — otherwise a failed owner resolution keeps the
            // cap disengaged while publishing a fabricated whale.
            live_list: exclusions_reliable && supply > 0,
        },
        warning,
    )
}

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

/// Owners whose token account appears verbatim (its 32-byte pubkey) inside one
/// of the given pool-account data blobs — i.e. that token account IS the pool's
/// reserve vault, so its owner is an AMM/launchpad, not a whale. Pure + public
/// so a unit test exercises THIS matcher, not a reimplementation.
///
/// `accounts` = (token_account, owner) for the token's largest accounts; the
/// blobs are the raw bytes of the token's indexed pool accounts. A 32-byte
/// pubkey match is decisive — a collision is astronomically unlikely, and we
/// only ever scan the token's OWN pools, so a real holder is never excluded.
pub fn vault_reserve_owners(blobs: &[Vec<u8>], accounts: &[(String, String)]) -> HashSet<String> {
    let needles: Vec<([u8; 32], &str)> = accounts
        .iter()
        .filter_map(|(ta, owner)| {
            let b = bs58::decode(ta).into_vec().ok()?;
            <[u8; 32]>::try_from(b.as_slice())
                .ok()
                .map(|arr| (arr, owner.as_str()))
        })
        .collect();
    let mut out = HashSet::new();
    for data in blobs {
        if data.len() < 32 {
            continue;
        }
        for (needle, owner) in &needles {
            if data.windows(32).any(|w| w == needle) {
                out.insert((*owner).to_string());
            }
        }
    }
    out
}