use crate::chain::SolClient;
use crate::checks::{bundles, cluster, deployer, holders, liquidity, mint};
use crate::types::{score_flags, AuditResult, Coverage, 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 mut cov = Coverage::new();
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,
coverage_gaps: vec!["mint account unresolved — no checks could run".into()],
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()));
if opts.skip_bundles {
cov.degrade("launch-window bundle + wallet-cluster passes skipped (--fast)");
}
if opts.skip_copycats {
cov.degrade("copycat/impersonator scan skipped (--fast)");
}
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) => match client.find_copycats(s, mint_addr).await {
Ok(c) => (c, None),
Err(e) => (vec![], Some(format!("copycat scan (dexscreener): {e}"))),
},
_ => (vec![], None),
}
};
let (bundle_an, (copycats, copycat_err)) = futures::join!(bundles_fut, copycats_fut);
if let Some(e) = copycat_err {
warnings.push(e);
cov.degrade("copycat/impersonator scan failed");
}
let (deployer_section, deployer_flags) =
deployer::analyze(client, jup.as_ref(), bundle_an.dev.as_deref(), &mut cov).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": 2, "scope": {"wallets_analyzed": 0, "holders_analyzed": 0,
"snipers_analyzed": 0, "funder_hops": 0, "transfer_probes": 0,
"transfer_txs_sampled": 0, "coverage": "unavailable",
"limits": ["the wallet-link pass did not run at all (--fast)"],
"note": "skipped (--fast)"},
"findings": [], "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 PumpSwap pool's liquidity is protocol-owned and cannot be pulled by the dev. Any OTHER pool this token trades in is not covered by that protection.".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("/totalVolume24hUsd")
.and_then(|v| v.as_f64())
.filter(|v| *v > 0.0),
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();
if !holder_an.live_list {
cov.degrade("live holder concentration unreadable");
}
if let Some(p) = rpc_pressure {
cov.degrade("RPC throttling reduced scan coverage");
warnings.push(p);
}
if cluster_section
.pointer("/scope/coverage")
.and_then(|v| v.as_str())
.is_some_and(|c| c != "full" && c != "unavailable")
{
cov.degrade("wallet-cluster analysis was partial");
}
let coverage_blind = cov.is_blind();
if coverage_blind {
flags.push(Flag::new(
"insufficient_coverage",
Severity::Warning,
"Incomplete scan — treat the score as a ceiling",
format!(
"Solwatch could not complete every check this run: {}. The deterministic mint checks (mint & freeze authority, Token-2022 traps) ARE complete and are unaffected — but unknown is not the same as safe, so treat the score as a ceiling rather than an all-clear and re-run for the full picture.",
cov.summary()
),
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("/totalVolume24hUsd")
.and_then(|v| v.as_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: liq
.section
.pointer("/firstPairCreatedAtMs")
.and_then(|v| v.as_i64())
.and_then(|ms| {
time::OffsetDateTime::from_unix_timestamp(ms / 1000)
.ok()
.and_then(|t| {
t.format(&time::format_description::well_known::Rfc3339)
.ok()
})
})
.or_else(|| 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,
coverage_gaps: cov.gaps().to_vec(),
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
}