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,
pub pool_owners: HashSet<String>,
pub ranked: Vec<(String, u128)>,
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) => {
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,
);
}
};
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();
if let Some(curve) = crate::chain::pumpfun_bonding_curve_pda(mint) {
ds_pools.insert(curve);
}
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) {
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)
};
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![]; 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..])
}
}