use crate::chain::SolClient;
use crate::checks::{bundles, cluster, deployer, holders, liquidity, mint};
use crate::types::{score_flags, AuditResult, Flag, Severity, TokenMeta};
use serde_json::json;
#[derive(Default, Clone, Copy)]
pub struct AuditOptions {
pub skip_bundles: bool,
pub skip_copycats: bool,
pub deep: bool,
}
pub async fn audit_token(
client: &SolClient,
mint_addr: &str,
opts: AuditOptions,
now_ms: i64,
scanned_at: String,
) -> AuditResult {
let mut warnings = vec![];
let (mint_res, meta_res, jup_res, liq) = futures::join!(
client.mint_info(mint_addr),
client.metaplex_metadata(mint_addr),
client.jupiter_token(mint_addr),
liquidity::analyze(client, mint_addr, now_ms),
);
let mint_info = match mint_res {
Ok(m) => m,
Err(e) => {
warnings.push(format!("mint lookup: {e}"));
None
}
};
let meta = meta_res
.map_err(|e| warnings.push(format!("metadata: {e}")))
.ok()
.flatten();
let jup = jup_res
.map_err(|e| warnings.push(format!("jupiter: {e}")))
.ok()
.flatten();
if let Some(e) = &liq.fetch_error {
warnings.push(format!("liquidity (dexscreener): {e}"));
}
let Some(mint_info) = mint_info else {
let (score, verdict) = score_flags(&[], false);
return AuditResult {
address: mint_addr.to_string(),
chain: crate::config::CHAIN,
scanned_at,
token: TokenMeta::default(),
score,
verdict,
flags: vec![],
sections: json!({}),
warnings,
rokha_url: crate::config::rokha_link(),
rokha_app: serde_json::Value::Null,
};
};
let mint_an = mint::analyze(&mint_info, meta.as_ref());
let supply = mint_info.supply;
let jup_top10 = jup.as_ref().and_then(|j| j.audit.top_holders_percentage);
let (holder_an, holder_warn) =
holders::analyze(client, mint_addr, supply, &liq.pool_addresses, jup_top10).await;
if let Some(w) = holder_warn {
warnings.push(w);
}
let dev = jup.as_ref().and_then(|j| j.dev.clone());
let symbol = mint_an
.symbol
.clone()
.or_else(|| jup.as_ref().and_then(|j| j.symbol.clone()));
let bundles_fut = async {
if opts.skip_bundles {
bundles::BundleAnalysis {
section: json!({"analyzed": false, "note": "skipped (--fast)"}),
flags: vec![],
buyers: vec![],
launch_slot: None,
launch_txs: vec![],
coverage: 0.0,
dev: dev.clone(),
}
} else {
bundles::analyze(
client,
mint_addr,
supply,
&holder_an.pool_owners,
&liq.pool_addresses,
dev.as_deref(),
)
.await
}
};
let copycats_fut = async {
match (&symbol, opts.skip_copycats) {
(Some(s), false) => client.find_copycats(s, mint_addr).await.unwrap_or_default(),
_ => vec![],
}
};
let (bundle_an, copycats) = futures::join!(bundles_fut, copycats_fut);
let (deployer_section, deployer_flags) =
deployer::analyze(client, jup.as_ref(), bundle_an.dev.as_deref()).await;
let bundles_section = bundle_an.section.clone();
let bundles_flags = bundle_an.flags.clone();
let (cluster_section, cluster_flags, cluster_supersedes) = if opts.skip_bundles {
(
json!({"schema": 1, "scope": {"holders_analyzed": 0, "snipers_analyzed": 0,
"funder_hops": 0, "coverage": "unavailable", "note": "skipped (--fast)"},
"nodes": [], "edges": [], "clusters": [], "effective_concentration": serde_json::Value::Null}),
vec![],
false,
)
} else {
let out = cluster::analyze(
client,
mint_addr,
cluster::ClusterInputs {
holders: &holder_an.ranked,
buyers: &bundle_an.buyers,
dev: bundle_an.dev.as_deref(),
excluded: &holder_an.pool_owners,
supply,
launch_slot: bundle_an.launch_slot,
launch_txs: &bundle_an.launch_txs,
launch_coverage: bundle_an.coverage,
holders_live: holder_an.live_list,
},
opts.deep,
)
.await;
(out.section, out.flags, out.supersedes_raw_top10)
};
let mut lp_flags = vec![];
let launchpad = jup.as_ref().and_then(|j| j.launchpad.clone());
match liq.top_dex.as_deref() {
Some("pumpfun") => lp_flags.push(Flag::new(
"bonding_curve",
Severity::Info,
"Still on the bonding curve",
"Trading happens on pump.fun's bonding curve — there is no LP to pull yet, but the token is extremely early and most never graduate.".into(),
3,
)),
Some("pumpswap") => lp_flags.push(Flag::new(
"lp_locked",
Severity::Good,
"LP locked by protocol (PumpSwap)",
"Graduated pump.fun token — the liquidity is protocol-owned and cannot be pulled by the dev.".into(),
0,
)),
Some(_) if liq.pair_count > 0 => {
let deep = liq.total_liquidity_usd >= 100_000.0;
let via = launchpad
.as_deref()
.map(|l| format!(" (launched via {l})"))
.unwrap_or_default();
lp_flags.push(Flag::new(
"lp_lock_unverified",
if deep { Severity::Info } else { Severity::Warning },
"LP lock unverified",
format!("Solwatch could not confirm the LP is burned or locked{via} — in principle the dev could pull it. Verify before trusting."),
if deep { 3 } else { 10 },
));
}
_ => {}
}
let mut market_flags = vec![];
if bundle_an.buyers.len() >= 5 {
let amounts: Vec<u128> = bundle_an.buyers.iter().map(|b| b.amount).collect();
let uniform = max_uniform_ratio(&amounts);
if uniform >= 0.6 {
market_flags.push(Flag::new(
"uniform_buys",
Severity::Warning,
"Launch buys are suspiciously uniform",
format!(
"{}% of the launch-window buys are near-identical sizes — scripted wallets, not organic buyers.",
(uniform * 100.0).round()
),
8,
));
}
}
if let (Some(vol), Some(holders_n)) = (
liq.section
.pointer("/pairs/0/volume/h24")
.and_then(|v| v.as_f64()),
jup.as_ref().and_then(|j| j.holder_count),
) {
if holders_n >= 20 && vol / holders_n as f64 > 50_000.0 {
market_flags.push(Flag::new(
"wash_volume",
Severity::Warning,
"Volume disproportionate to holders",
format!(
"${:.0}k of 24h volume across only {holders_n} holders (~${:.0}k per holder) — heavy wash/bot trading is the usual cause.",
vol / 1000.0,
vol / holders_n as f64 / 1000.0
),
6,
));
}
}
if let Some(j) = &jup {
match (j.organic_score, j.organic_score_label.as_deref()) {
(Some(s), Some("low")) if liq.pair_count > 0 => market_flags.push(Flag::new(
"low_organic",
Severity::Warning,
"Volume looks botted (Jupiter score)",
format!(
"Jupiter's third-party organic score is {} / 100 — most of the trading volume doesn't look like real humans.",
s.round()
),
8,
)),
(Some(s), Some("high")) => market_flags.push(Flag::new(
"organic_volume",
Severity::Good,
"Organic trading activity (Jupiter score)",
format!("Jupiter's third-party organic score is {} / 100 — real trader activity.", s.round()),
0,
)),
_ => {}
}
if j.is_verified == Some(true) {
market_flags.push(Flag::new(
"verified",
Severity::Good,
"Community-verified token",
"Listed on Jupiter's verified list.".into(),
0,
));
}
}
let mut copycat_flags = vec![];
if !copycats.is_empty() {
let ids: Vec<String> = copycats
.iter()
.take(3)
.map(|c| c.base_token.address[..8.min(c.base_token.address.len())].to_string())
.collect();
copycat_flags.push(Flag::new(
"copycats",
Severity::Info,
&format!("{} same-symbol token(s) on Solana", copycats.len()),
format!(
"Impersonators exist — double-check you hold {mint_addr}. Copycats: {}.",
ids.join(", ")
),
2,
));
}
let mut holder_flags = holder_an.flags;
if cluster_supersedes {
holder_flags.retain(|f| f.id != "top10_concentration");
}
let mut flags: Vec<Flag> = vec![];
flags.extend(mint_an.flags);
flags.extend(holder_flags);
flags.extend(liq.flags);
flags.extend(deployer_flags);
flags.extend(bundles_flags);
flags.extend(cluster_flags);
flags.extend(lp_flags);
flags.extend(market_flags);
flags.extend(copycat_flags);
let rpc_pressure = client.rpc_pressure();
let coverage_blind = !holder_an.live_list || rpc_pressure.is_some();
if let Some(p) = rpc_pressure {
warnings.push(p);
}
if coverage_blind {
flags.push(Flag::new(
"insufficient_coverage",
Severity::Warning,
"Incomplete scan — concentration not fully verified",
"RPC limits meant Solwatch couldn't fully read holder concentration / coordinated wallets this run. The deterministic mint checks (mint & freeze authority, Token-2022 traps) ARE complete — but treat the score as a ceiling, not an all-clear, and re-run for the full picture.".into(),
0,
));
}
let (mut score, mut verdict) = score_flags(&flags, true);
if coverage_blind && score > 79 {
score = 79;
verdict = crate::types::verdict_from_score(score);
}
flags.sort_by_key(|f| std::cmp::Reverse(f.severity.rank()));
let top_pair = liq
.section
.pointer("/pairs/0")
.cloned()
.unwrap_or(json!(null));
let volume_24h_total = liq
.section
.pointer("/pairs")
.and_then(|v| v.as_array())
.map(|ps| {
ps.iter()
.filter_map(|p| p.pointer("/volume/h24").and_then(|v| v.as_f64()))
.sum::<f64>()
})
.filter(|v| *v > 0.0);
let token_meta = TokenMeta {
name: mint_an
.name
.clone()
.or_else(|| jup.as_ref().and_then(|j| j.name.clone())),
symbol,
decimals: Some(mint_info.decimals),
total_supply: Some(mint_info.supply_raw.clone()),
holders_count: jup.as_ref().and_then(|j| j.holder_count),
market_cap_usd: jup
.as_ref()
.and_then(|j| j.mcap)
.or_else(|| liq.section.pointer("/fdvUsd").and_then(|v| v.as_f64())),
volume_24h_usd: volume_24h_total
.or_else(|| top_pair.pointer("/volume/h24").and_then(|v| v.as_f64())),
price_usd: jup
.as_ref()
.and_then(|j| j.usd_price)
.or_else(|| top_pair.pointer("/priceUsd").and_then(|v| v.as_f64())),
token_program: Some(mint_info.program.clone()),
launchpad,
created_at: jup.as_ref().and_then(|j| j.created_at.clone()),
};
let copycats_section: Vec<_> = copycats
.iter()
.take(5)
.map(|c| {
json!({
"mint": c.base_token.address,
"liquidityUsd": c.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0),
"pair": c.pair_address,
})
})
.collect();
let market_section = jup.as_ref().map(|j| {
json!({
"organicScore": j.organic_score,
"organicScoreLabel": j.organic_score_label,
"isVerified": j.is_verified,
"tags": j.tags,
"icon": j.icon,
})
});
let mut result = AuditResult {
address: mint_addr.to_string(),
chain: crate::config::CHAIN,
scanned_at,
token: token_meta,
score,
verdict,
flags,
sections: json!({
"mint": mint_an.section,
"holders": holder_an.section,
"liquidity": liq.section,
"bundles": bundles_section,
"cluster": cluster_section,
"deployer": deployer_section,
"market": market_section,
"copycats": copycats_section,
}),
warnings,
rokha_url: crate::config::rokha_link(),
rokha_app: serde_json::Value::Null,
};
result.rokha_app = crate::report::render_rokha_app(&result);
result
}
pub fn max_uniform_ratio(amounts: &[u128]) -> f64 {
if amounts.is_empty() {
return 0.0;
}
let mut best = 0usize;
for &a in amounts {
if a == 0 {
continue;
}
let lo = a - a / 100;
let hi = a + a / 100;
let n = amounts.iter().filter(|&&x| x >= lo && x <= hi).count();
best = best.max(n);
}
best as f64 / amounts.len() as f64
}