use crate::chain::{pumpfun_bonding_curve_pda, SolClient};
use crate::config;
use crate::types::{Flag, Severity};
use futures::StreamExt;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
const MAX_PAGES: usize = 12; const SNIPE_WINDOW_SLOTS: u64 = 12; const MAX_TX_FETCHES: usize = 150;
const FRESH_PROBE_TOP: usize = 8;
#[derive(Debug, Clone)]
pub struct BuyerInfo {
pub owner: String,
pub token_accounts: Vec<String>,
pub amount: u128,
pub first_slot: u64,
pub fresh: Option<bool>,
pub held: Option<u128>,
pub signatures: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct LaunchTx {
pub signature: String,
pub slot: u64,
pub fee_payer: String,
pub tx: Value,
}
pub struct BundleAnalysis {
pub section: Value,
pub flags: Vec<Flag>,
pub buyers: Vec<BuyerInfo>,
pub launch_slot: Option<u64>,
pub launch_txs: Vec<LaunchTx>,
pub coverage: f64,
pub dev: Option<String>,
}
impl BundleAnalysis {
fn skipped(note: String, flags: Vec<Flag>) -> Self {
BundleAnalysis {
section: json!({"analyzed": false, "note": note}),
flags,
buyers: vec![],
launch_slot: None,
launch_txs: vec![],
coverage: 0.0,
dev: None,
}
}
}
fn round2(n: f64) -> f64 {
(n * 100.0).round() / 100.0
}
pub async fn analyze(
client: &SolClient,
mint: &str,
supply: u128,
pool_owners: &HashSet<String>,
pool_addresses: &[String],
dev: Option<&str>,
) -> BundleAnalysis {
let mut pool_owners: HashSet<String> = pool_owners.clone();
pool_owners.extend(pool_addresses.iter().cloned());
if let Some(curve) = pumpfun_bonding_curve_pda(mint) {
for tp in [config::SPL_TOKEN, config::TOKEN_2022] {
if let Some(ata) = crate::chain::ata_pda(&curve, mint, tp) {
pool_owners.insert(ata);
}
}
pool_owners.insert(curve);
}
let pool_owners = &pool_owners;
let (oldest_page, truncated) = match client.oldest_signatures(mint, MAX_PAGES).await {
Ok(r) => r,
Err(e) => {
return BundleAnalysis::skipped(
format!("signature history unavailable: {e}"),
vec![Flag::new("bundle_skip", Severity::Info, "Bundle scan skipped",
"Could not read the mint's transaction history (RPC limits) — launch snipe analysis was not performed.".into(), 0)],
);
}
};
if truncated {
return BundleAnalysis::skipped(
format!("token has >{MAX_PAGES}k transactions — launch is old history, snipe analysis skipped"),
vec![Flag::new("bundle_skip", Severity::Info, "Bundle scan skipped",
"This token has a long trading history — launch-window snipe analysis only makes sense for fresh launches.".into(), 0)],
);
}
if oldest_page.is_empty() {
return BundleAnalysis::skipped("no transaction history".into(), vec![]);
}
let launch = oldest_page.last().unwrap().clone();
let launch_slot = launch.slot;
let window_end = launch_slot + SNIPE_WINDOW_SLOTS;
let mut window: Vec<_> = oldest_page
.iter()
.filter(|s| s.slot <= window_end && s.err.is_none())
.collect();
window.reverse(); let total_in_window = window.len();
window.truncate(MAX_TX_FETCHES);
let window_owned: Vec<(String, u64)> = window
.iter()
.map(|s| (s.signature.clone(), s.slot))
.collect();
let fetched: Vec<Option<LaunchTx>> =
futures::stream::iter(window_owned.into_iter().map(|(sig, slot)| async move {
let tx = client.transaction(&sig).await.ok()?;
let fee_payer = tx
.pointer("/transaction/message/accountKeys/0/pubkey")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(LaunchTx {
signature: sig,
slot,
fee_payer,
tx,
})
}))
.buffer_unordered(8)
.collect()
.await;
let launch_txs: Vec<LaunchTx> = fetched.into_iter().flatten().collect();
let tx_parsed = launch_txs.len();
let coverage = if total_in_window == 0 {
0.0
} else {
tx_parsed as f64 / total_in_window as f64
};
let creation_fee_payer = launch_txs
.iter()
.find(|t| t.signature == launch.signature)
.map(|t| t.fee_payer.clone())
.filter(|p| !p.is_empty());
let dev_resolved: Option<String> = dev.map(String::from).or(creation_fee_payer.clone());
let mut buyers: HashMap<String, BuyerInfo> = HashMap::new();
let mut dev_received: u128 = 0;
let mut rows: Vec<(u64, String, u128)> = vec![]; for lt in &launch_txs {
for d in mint_deltas_detailed(<.tx, mint) {
if pool_owners.contains(&d.owner)
|| d.token_account
.as_deref()
.map(|ta| pool_owners.contains(ta))
.unwrap_or(false)
{
continue;
}
let is_dev = dev_resolved.as_deref() == Some(d.owner.as_str());
if is_dev {
dev_received += d.amount;
rows.push((lt.slot, format!("{} (dev)", d.owner), d.amount));
continue;
}
rows.push((lt.slot, d.owner.clone(), d.amount));
let e = buyers.entry(d.owner.clone()).or_insert(BuyerInfo {
owner: d.owner,
token_accounts: vec![],
amount: 0,
first_slot: lt.slot,
fresh: None,
held: None,
signatures: vec![],
});
e.amount += d.amount;
e.first_slot = e.first_slot.min(lt.slot);
if let Some(ta) = d.token_account {
if !e.token_accounts.contains(&ta) {
e.token_accounts.push(ta);
}
}
if !e.signatures.contains(<.signature) {
e.signatures.push(lt.signature.clone());
}
}
}
let mut excluded_pools: Vec<(String, u128, String)> = vec![];
{
let labels: HashMap<&str, &str> = config::pool_program_labels()
.into_iter()
.chain(config::locker_program_labels())
.collect();
let owners: Vec<String> = {
let mut v: Vec<String> = buyers.keys().cloned().collect();
v.sort();
v
};
for chunk in owners.chunks(100) {
if let Ok(accts) = client.multiple_accounts(chunk).await {
for (owner, acc) in chunk.iter().zip(accts.iter()) {
let prog = acc.get("owner").and_then(|o| o.as_str()).unwrap_or("");
if let Some(l) = labels.get(prog) {
if let Some(b) = buyers.remove(owner) {
excluded_pools.push((owner.clone(), b.amount, (*l).to_string()));
}
}
}
}
}
excluded_pools.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
}
let pct = |v: u128| -> Option<f64> {
v.saturating_mul(10000)
.checked_div(supply)
.map(|bp| round2(bp as f64 / 100.0))
};
let snipers = buyers.len();
let same_slot = buyers
.values()
.filter(|b| b.first_slot == launch_slot)
.count();
let total_sniped: u128 = buyers.values().map(|b| b.amount).sum();
let pct_sniped = pct(total_sniped);
let pct_dev = pct(dev_received);
let ta_owner: Vec<(String, String)> = buyers
.values()
.flat_map(|b| {
b.token_accounts
.iter()
.map(|ta| (ta.clone(), b.owner.clone()))
})
.collect();
let mut probe_ok = true;
for chunk in ta_owner.chunks(100) {
let addrs: Vec<String> = chunk.iter().map(|(ta, _)| ta.clone()).collect();
match client.multiple_accounts(&addrs).await {
Ok(accounts) => {
for ((_, owner), acc) in chunk.iter().zip(accounts.iter()) {
let bal: u128 = acc
.pointer("/data/parsed/info/tokenAmount/amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0); if let Some(b) = buyers.get_mut(owner) {
*b.held.get_or_insert(0) += bal;
}
}
}
Err(_) => probe_ok = false,
}
}
let mut ranked: Vec<&mut BuyerInfo> = buyers.values_mut().collect();
ranked.sort_by(|a, b| b.amount.cmp(&a.amount).then(a.owner.cmp(&b.owner)));
let take = ranked.len().min(FRESH_PROBE_TOP);
let probes = ranked.iter().take(take).map(|b| {
let owner = b.owner.clone();
async move {
client
.signatures(&owner, 25, None)
.await
.ok()
.map(|h| h.len() < 25)
}
});
let probe_results = futures::future::join_all(probes).await;
let mut fresh_count = 0usize;
for (b, fresh) in ranked.iter_mut().take(take).zip(probe_results) {
b.fresh = fresh;
if fresh == Some(true) {
fresh_count += 1;
}
}
let fresh_ratio = (take > 0).then(|| round2(fresh_count as f64 / take as f64));
let still_held: Option<u128> = if probe_ok {
Some(ranked.iter().map(|b| b.held.unwrap_or(0)).sum())
} else {
None
};
let pct_held = still_held.and_then(pct);
let mut flags = vec![];
let full_coverage = tx_parsed == total_in_window && total_in_window > 0;
if !full_coverage && total_in_window > 0 {
let cov_pct = (coverage * 100.0).round();
flags.push(Flag::new(
"snipe_coverage",
Severity::Info,
"Launch-window coverage is partial",
format!(
"Only {tx_parsed} of {total_in_window} launch-window transactions were parsed ({cov_pct}%) — the sniped-supply number is a FLOOR, the real figure may be higher."
),
0,
));
}
let held_note = match pct_held {
Some(h) => format!(" (snipers still hold ~{h}%)."),
None => " (still-holding probe unavailable — assume they still hold).".into(),
};
if let Some(ps) = pct_sniped {
let floor = if full_coverage { "" } else { " at least" };
let still_heavy = pct_held.map(|h| h >= 10.0).unwrap_or(true);
if ps >= 40.0 {
flags.push(Flag::new(
"heavy_bundle",
if still_heavy {
Severity::Danger
} else {
Severity::Warning
},
"Heavy launch sniping",
format!(
"{}{ps}% of the supply was bought by {snipers} wallets within ~{SNIPE_WINDOW_SLOTS} slots of launch{held_note}",
if floor.is_empty() { "" } else { "At least " }
),
if still_heavy { 25 } else { 12 },
));
} else if ps >= 15.0 {
flags.push(Flag::new(
"bundle",
Severity::Warning,
"Notable launch sniping",
format!(
"{}{ps}% of the supply was sniped at launch by {snipers} wallets{held_note}",
if floor.is_empty() { "" } else { "At least " }
),
if still_heavy { 12 } else { 6 },
));
} else if full_coverage {
flags.push(Flag::new(
"low_snipe",
Severity::Good,
"Low launch sniping",
format!("Only {ps}% of the supply was sniped at launch."),
0,
));
} else {
flags.push(Flag::new(
"low_snipe",
Severity::Info,
"Low observed launch sniping",
format!("{ps}% of the supply sniped in the transactions we could parse — coverage was partial, so this is a floor, not a clean bill."),
0,
));
}
}
if same_slot >= 4 {
flags.push(Flag::new(
"same_slot_bundle",
Severity::Warning,
"Same-slot buy bundle",
format!("{same_slot} wallets bought in the exact creation slot — a coordinated (likely dev-run) bundle."),
10,
));
}
if let Some(fr) = fresh_ratio {
if fr >= 0.6 && snipers >= 4 {
flags.push(Flag::new(
"fresh_snipers",
Severity::Warning,
"Fresh-wallet snipers",
format!(
"{}% of the top snipers are brand-new wallets — manufactured demand, not real buyers.",
(fr * 100.0).round()
),
8,
));
}
}
if let Some(pd) = pct_dev {
if pd >= 20.0 {
flags.push(Flag::new(
"dev_allocation",
Severity::Danger,
"Dev took a huge launch allocation",
format!("The creator received {pd}% of the supply in the launch window."),
15,
));
} else if pd >= 5.0 {
flags.push(Flag::new(
"dev_allocation",
Severity::Warning,
"Dev bought at launch",
format!("The creator received {pd}% of the supply in the launch window."),
6,
));
}
}
let top_snipers: Vec<_> = ranked
.iter()
.take(FRESH_PROBE_TOP)
.map(|b| {
json!({
"owner": b.owner,
"pct": pct(b.amount).unwrap_or(0.0),
"heldPct": b.held.and_then(pct),
"sameSlot": b.first_slot == launch_slot,
"freshWallet": b.fresh,
})
})
.collect();
let excluded_owners: HashSet<&str> =
excluded_pools.iter().map(|(o, _, _)| o.as_str()).collect();
rows.retain(|(_, owner, _)| !excluded_owners.contains(owner.as_str()));
rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let timeline: Vec<_> = rows
.iter()
.map(|(slot, owner, amount)| {
json!({"slotOffset": slot - launch_slot, "owner": owner, "pct": pct(*amount).unwrap_or(0.0)})
})
.collect();
let section = json!({
"analyzed": true,
"launchSlot": launch_slot,
"launchTime": launch.block_time,
"snipers": snipers,
"sameSlotBuyers": same_slot,
"pctSupplySniped": pct_sniped,
"pctSnipersStillHold": pct_held,
"pctDevAllocation": pct_dev,
"freshWalletRatio": fresh_ratio,
"coverage": round2(coverage),
"topSnipers": top_snipers,
"excludedPools": excluded_pools.iter().map(|(o, a, l)| json!({
"owner": o, "pct": pct(*a), "label": l,
})).collect::<Vec<_>>(),
"timeline": timeline,
"note": format!("Parsed {tx_parsed}/{total_in_window} transactions in slots {launch_slot}–{window_end}. Still-hold covers ALL parsed buyers, not just the top few."),
});
let buyers_out: Vec<BuyerInfo> = {
let mut v: Vec<BuyerInfo> = buyers.into_values().collect();
v.sort_by(|a, b| b.amount.cmp(&a.amount).then(a.owner.cmp(&b.owner)));
v
};
BundleAnalysis {
section,
flags,
buyers: buyers_out,
launch_slot: Some(launch_slot),
launch_txs,
coverage,
dev: dev_resolved,
}
}
#[derive(Debug, Clone)]
pub struct MintDelta {
pub owner: String,
pub token_account: Option<String>,
pub amount: u128,
}
pub fn mint_deltas_detailed(tx: &Value, mint: &str) -> Vec<MintDelta> {
let keys: Vec<String> = tx
.pointer("/transaction/message/accountKeys")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|k| k.get("pubkey").and_then(|p| p.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
let read = |key: &str| -> HashMap<String, (u128, Vec<String>)> {
let mut m: HashMap<String, (u128, Vec<String>)> = HashMap::new();
if let Some(list) = tx
.pointer(&format!("/meta/{key}"))
.and_then(|v| v.as_array())
{
for b in list {
if b.get("mint").and_then(|v| v.as_str()) != Some(mint) {
continue;
}
let Some(owner) = b.get("owner").and_then(|v| v.as_str()) else {
continue;
};
let amt: u128 = b
.pointer("/uiTokenAmount/amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let ta = b
.get("accountIndex")
.and_then(|v| v.as_u64())
.and_then(|i| keys.get(i as usize))
.cloned();
let e = m.entry(owner.to_string()).or_insert((0, vec![]));
e.0 += amt;
if let Some(ta) = ta {
if !e.1.contains(&ta) {
e.1.push(ta);
}
}
}
}
m
};
let pre = read("preTokenBalances");
let post = read("postTokenBalances");
let mut out = vec![];
for (owner, (after, tas)) in post {
let before = pre.get(&owner).map(|(b, _)| *b).unwrap_or(0);
if after > before {
out.push(MintDelta {
owner,
token_account: tas.first().cloned(),
amount: after - before,
});
}
}
out.sort_by(|a, b| a.owner.cmp(&b.owner));
out
}
pub fn mint_deltas(tx: &Value, mint: &str) -> Vec<(String, u128)> {
mint_deltas_detailed(tx, mint)
.into_iter()
.map(|d| (d.owner, d.amount))
.collect()
}