1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
//! 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
}