//! Report renderers (text / markdown / tweet / rokha_app / self-contained
//! HTML) + a durable report store (writes .md + .json + .html artifacts and
//! an index).
use crate::types::{AuditResult, Verdict};
use anyhow::Result;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
/// The live dashboard UI โ also the template for the static per-audit HTML
/// artifact (same pixels, data baked in, zero network).
pub const DASHBOARD_HTML: &str = include_str!("dashboard.html");
fn verdict_str(v: Verdict) -> &'static str {
match v {
Verdict::Avoid => "AVOID",
Verdict::HighRisk => "HIGH RISK",
Verdict::Caution => "CAUTION",
Verdict::Fair => "FAIR",
Verdict::LowRisk => "LOW RISK",
Verdict::Unknown => "UNKNOWN",
}
}
fn verdict_emoji(v: Verdict) -> &'static str {
match v {
Verdict::Avoid => "๐ด",
Verdict::HighRisk => "๐ ",
Verdict::Caution => "๐ก",
Verdict::Fair => "๐ข",
Verdict::LowRisk => "โ
",
Verdict::Unknown => "โช",
}
}
fn sev_emoji(s: crate::types::Severity) -> &'static str {
use crate::types::Severity::*;
match s {
Critical => "๐",
Danger => "๐ด",
Warning => "โ ๏ธ",
Info => "โน๏ธ",
Good => "โ
",
}
}
fn usd(n: Option<f64>) -> String {
match n {
None => "โ".into(),
Some(v) => {
let int = v.round() as i64;
let digits = int.abs().to_string();
let mut s = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
s.push(',');
}
s.push(c);
}
format!("${s}")
}
}
}
pub fn render_text(r: &AuditResult) -> String {
let mut l = vec![];
l.push(format!(
"Solwatch audit โ {} ({})",
r.token.symbol.clone().unwrap_or_else(|| "?".into()),
r.token.name.clone().unwrap_or_else(|| "unknown".into())
));
l.push(format!(" {} ยท Solana", r.address));
l.push(format!(
" {} {} score {}/100",
verdict_emoji(r.verdict),
verdict_str(r.verdict),
r.score
));
l.push(format!(
" mcap {} ยท 24h vol {} ยท holders {}{}",
usd(r.token.market_cap_usd),
usd(r.token.volume_24h_usd),
r.token
.holders_count
.map(|h| h.to_string())
.unwrap_or_else(|| "โ".into()),
r.token
.launchpad
.as_ref()
.map(|l| format!(" ยท via {l}"))
.unwrap_or_default()
));
l.push(String::new());
l.push(" Flags:".into());
for f in &r.flags {
l.push(format!(
" {} {} โ {}",
sev_emoji(f.severity),
f.title,
f.detail
));
}
if !r.warnings.is_empty() {
l.push(String::new());
l.push(format!(" Notes: {}", r.warnings.join("; ")));
}
l.push(String::new());
if let Some(u) = &r.rokha_url {
l.push(format!(" View in Rokha: {u}"));
}
l.push(format!(" scanned {}", r.scanned_at));
l.join("\n")
}
pub fn render_markdown(r: &AuditResult) -> String {
let mut l = vec![];
l.push(format!(
"# {} {} โ {} ({}/100)",
verdict_emoji(r.verdict),
r.token.symbol.clone().unwrap_or_else(|| "?".into()),
verdict_str(r.verdict),
r.score
));
l.push(String::new());
l.push(format!(
"**{}** ยท `{}` ยท Solana{}",
r.token
.name
.clone()
.unwrap_or_else(|| "Unknown token".into()),
r.address,
r.token
.launchpad
.as_ref()
.map(|lp| format!(" ยท launched via {lp}"))
.unwrap_or_default()
));
l.push(String::new());
l.push("| Market cap | 24h volume | Holders | Scanned |".into());
l.push("|---|---|---|---|".into());
l.push(format!(
"| {} | {} | {} | {} |",
usd(r.token.market_cap_usd),
usd(r.token.volume_24h_usd),
r.token
.holders_count
.map(|h| h.to_string())
.unwrap_or_else(|| "โ".into()),
r.scanned_at[..r.scanned_at.len().min(16)].replace('T', " ")
));
l.push(String::new());
l.push("## Findings".into());
l.push(String::new());
for f in &r.flags {
l.push(format!(
"- {} **{}** โ {}",
sev_emoji(f.severity),
f.title,
f.detail
));
}
if let Some(bu) = r.sections.get("bundles") {
if bu
.get("analyzed")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
l.push(String::new());
l.push("## Launch window".into());
l.push(format!(
"{}% of supply sniped by {} wallets ({} in the exact creation slot); snipers still hold {}%. Dev allocation {}%.",
bu.get("pctSupplySniped").map(|v| v.to_string()).unwrap_or_else(|| "โ".into()),
bu.get("snipers").map(|v| v.to_string()).unwrap_or_else(|| "โ".into()),
bu.get("sameSlotBuyers").map(|v| v.to_string()).unwrap_or_else(|| "โ".into()),
bu.get("pctSnipersStillHold").map(|v| v.to_string()).unwrap_or_else(|| "โ".into()),
bu.get("pctDevAllocation").map(|v| v.to_string()).unwrap_or_else(|| "โ".into()),
));
}
}
if let Some(cl) = r
.sections
.pointer("/cluster/clusters")
.and_then(|v| v.as_array())
.filter(|c| !c.is_empty())
{
l.push(String::new());
l.push("## Wallet clusters".into());
l.push(String::new());
l.push("| cluster | wallets | holds now | bought | evidence | proof |".into());
l.push("|---|---|---|---|---|---|".into());
for c in cl {
l.push(format!(
"| #{} | {} | {}% | {}% | {} | {} |",
c.get("id").and_then(|v| v.as_u64()).unwrap_or(0),
c.get("wallets")
.and_then(|w| w.as_array())
.map(|w| w.len())
.unwrap_or(0),
c.get("pct_combined")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
c.get("pct_bought").and_then(|v| v.as_f64()).unwrap_or(0.0),
c.get("reason").and_then(|v| v.as_str()).unwrap_or(""),
if c.get("proof").map(|p| !p.is_null()).unwrap_or(false) {
"โก Jito-PROVEN"
} else {
"โ"
},
));
}
if let Some(ec) = r.sections.pointer("/cluster/effective_concentration") {
if !ec.is_null() {
l.push(String::new());
l.push(format!(
"Effective top-10 (entities): {}% (raw per-wallet: {}%); largest cluster {}%.",
ec.get("top10_clustered_pct")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
ec.get("top10_raw_pct")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
ec.get("largest_cluster_pct")
.and_then(|v| v.as_f64())
.unwrap_or(0.0),
));
}
}
}
if !r.warnings.is_empty() {
l.push(String::new());
l.push(format!("> Notes: {}", r.warnings.join("; ")));
}
l.push(String::new());
if let Some(u) = &r.rokha_url {
l.push(format!("**[View in Rokha โ]({u})**"));
l.push(String::new());
}
l.push(
"_Solwatch ยท real on-chain data ยท a SNAPSHOT of the chain at scan time (flags \
and score move as the chain moves โ compare `flags[]`, not bare scores) ยท \
not financial advice._"
.into(),
);
l.join("\n")
}
pub fn render_tweet(r: &AuditResult) -> String {
let top: Vec<String> = r
.flags
.iter()
.filter(|f| {
matches!(
f.severity,
crate::types::Severity::Critical | crate::types::Severity::Danger
)
})
.take(2)
.map(|f| f.title.replace('๐ฏ', "").trim().to_string())
.collect();
let flag_line = if top.is_empty() {
String::new()
} else {
format!(" {}", top.join("; "))
};
let base = format!(
"{} ${} on Solana โ {} ({}/100).{}",
verdict_emoji(r.verdict),
r.token.symbol.clone().unwrap_or_else(|| "?".into()),
verdict_str(r.verdict),
r.score,
flag_line
);
let tail = format!(
" mcap {}. Audited by @Rokha_ai",
usd(r.token.market_cap_usd)
);
let mut out = format!("{base}{tail}");
if out.chars().count() > 280 {
let keep = 280usize.saturating_sub(tail.chars().count() + 1);
out = format!("{}โฆ{}", base.chars().take(keep).collect::<String>(), tail);
}
out.chars().take(280).collect()
}
/// Build the Rokha APP-view block: the documented, repeatable contract for a
/// rig to get a native render in Rokha's output rail. A rig whose final step
/// outputs `{ "rokha_app": โฆ }` (this JSON already carries it) is render-ready.
pub fn render_rokha_app(r: &AuditResult) -> serde_json::Value {
let mut metrics = vec![];
let mut push = |label: &str, value: String, tone: &str| {
metrics.push(json!({"label": label, "value": value, "tone": tone}));
};
if let Some(m) = r.token.market_cap_usd {
push("Market cap", usd(Some(m)), "neutral");
}
if let Some(v) = r.token.volume_24h_usd {
push("24h volume", usd(Some(v)), "neutral");
}
if let Some(h) = r.token.holders_count {
push("Holders", h.to_string(), "neutral");
}
if let Some(t) = r
.sections
.pointer("/liquidity/totalLiquidityUsd")
.and_then(|v| v.as_f64())
.filter(|t| *t > 0.0)
{
push(
"Liquidity",
usd(Some(t)),
if t < 5000.0 { "warn" } else { "neutral" },
);
}
if let Some(p) = r
.sections
.pointer("/bundles/pctSupplySniped")
.and_then(|v| v.as_f64())
{
push(
"Supply sniped",
format!("{p}%"),
if p >= 40.0 {
"bad"
} else if p >= 15.0 {
"warn"
} else {
"ok"
},
);
}
if let Some(p) = r
.sections
.pointer("/holders/top10Pct")
.and_then(|v| v.as_f64())
{
push(
"Top-10 hold",
format!("{p}%"),
if p >= 60.0 {
"bad"
} else if p >= 30.0 {
"warn"
} else {
"ok"
},
);
}
if let Some(p) = r
.sections
.pointer("/cluster/effective_concentration/largest_cluster_pct")
.and_then(|v| v.as_f64())
.filter(|p| *p > 0.0)
{
push(
"Largest cluster",
format!("{p}%"),
if p >= 20.0 {
"bad"
} else if p >= 10.0 {
"warn"
} else {
"ok"
},
);
}
if let Some(p) = r
.sections
.pointer("/cluster/effective_concentration/top10_clustered_pct")
.and_then(|v| v.as_f64())
.filter(|p| *p > 0.0)
{
push(
"Effective top-10",
format!("{p}%"),
if p >= 60.0 {
"bad"
} else if p >= 30.0 {
"warn"
} else {
"ok"
},
);
}
if let Some(cl) = r
.sections
.pointer("/cluster/clusters")
.and_then(|v| v.as_array())
{
let proven = cl
.iter()
.any(|c| c.get("proof").map(|p| !p.is_null()).unwrap_or(false));
if !cl.is_empty() {
push(
"Jito-proven",
if proven { "yes".into() } else { "no".into() },
if proven { "bad" } else { "neutral" },
);
}
}
if let Some(s) = r
.sections
.pointer("/market/organicScore")
.and_then(|v| v.as_f64())
{
let s = s.round();
push(
"Organic score",
format!("{s}/100"),
if s >= 60.0 {
"ok"
} else if s >= 25.0 {
"warn"
} else {
"bad"
},
);
}
let findings: Vec<String> = r
.flags
.iter()
.map(|f| format!("- {} **{}** โ {}", sev_emoji(f.severity), f.title, f.detail))
.collect();
let mut sections = vec![json!({"heading": "Findings", "markdown": findings.join("\n")})];
if let Some(sn) = r
.sections
.pointer("/bundles/topSnipers")
.and_then(|v| v.as_array())
{
if !sn.is_empty() {
let rows: Vec<_> = sn
.iter()
.map(|s| {
json!([
s.get("owner").and_then(|v| v.as_str()).unwrap_or("?"),
format!("{}%", s.get("pct").and_then(|v| v.as_f64()).unwrap_or(0.0)),
format!(
"{}%",
s.get("heldPct").and_then(|v| v.as_f64()).unwrap_or(0.0)
),
if s.get("sameSlot").and_then(|v| v.as_bool()).unwrap_or(false) {
"yes"
} else {
""
},
])
})
.collect();
sections.push(json!({"heading": "Launch snipers", "table": {
"columns": ["wallet", "bought at launch", "still holds", "same-slot"],
"rows": rows,
}}));
}
}
if let Some(cl) = r
.sections
.pointer("/cluster/clusters")
.and_then(|v| v.as_array())
{
if !cl.is_empty() {
let rows: Vec<_> = cl
.iter()
.map(|c| {
json!([
format!("#{}", c.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
c.get("wallets")
.and_then(|w| w.as_array())
.map(|w| w.len().to_string())
.unwrap_or_default(),
format!(
"{}%",
c.get("pct_combined")
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
),
format!(
"{}%",
c.get("pct_bought").and_then(|v| v.as_f64()).unwrap_or(0.0)
),
c.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
if c.get("proof").map(|p| !p.is_null()).unwrap_or(false) {
"Jito-PROVEN".to_string()
} else {
"".to_string()
},
])
})
.collect();
sections.push(json!({"heading": "Wallet clusters (entities)", "table": {
"columns": ["cluster", "wallets", "holds now", "bought at launch", "evidence", "proof"],
"rows": rows,
}}));
}
}
json!({
"title": format!("Solwatch audit โ ${}", r.token.symbol.clone().unwrap_or_else(|| "?".into())),
"subject": r.address,
"verdict": verdict_str(r.verdict).replace(' ', "_").to_lowercase(),
"score": r.score,
"metrics": metrics,
"sections": sections,
})
}
/// One self-contained HTML file โ the exact dashboard card with this audit's
/// data baked in (inlined CSS/JS, fetch stubbed, no network). This is how a
/// run inside an egress-only sandbox ships its "frontend" OUT as an artifact:
/// the file renders anywhere, including a hard-sandboxed srcdoc iframe.
pub fn render_html(r: &AuditResult) -> String {
// `</` would close the script tag early inside embedded JSON.
let data = serde_json::to_string(r)
.unwrap_or_else(|_| "null".into())
.replace("</", "<\\/");
let stub = format!(
"<script>\nconst __AUDIT = {data};\nwindow.fetch = async (u) => {{\n const s = String(u);\n if (s.includes('/api/audit')) {{\n const m = s.match(/[?&]address=([^&]*)/);\n const a = m ? decodeURIComponent(m[1]) : '';\n const baked = (__AUDIT && __AUDIT.address) || '';\n if (a && a.toLowerCase() !== baked.toLowerCase() && window.parent !== window) {{\n window.parent.postMessage({{ rokha: 'app_action', action: 'run', input: a.slice(0, 4000) }}, '*');\n return {{ json: async () => ({{ __rokha_pending: true }}) }};\n }}\n return {{ json: async () => __AUDIT }};\n }}\n return {{ json: async () => [] }};\n}};\n</script>\n<script>"
);
let html = DASHBOARD_HTML.replacen("<script>", &stub, 1);
html.replacen(
"</script>\n</body>",
"if (__AUDIT && __AUDIT.address) audit(__AUDIT.address);</script>\n</body>",
1,
)
}
// ---- report store ----
pub fn default_report_dir() -> PathBuf {
std::env::var("SOLWATCH_OUT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
std::env::current_dir()
.unwrap_or_default()
.join("solwatch-reports")
})
}
fn safe(s: &str) -> String {
let out: String = s
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(16)
.collect();
if out.is_empty() {
"token".into()
} else {
out
}
}
pub fn write_report(r: &AuditResult, dir: &Path) -> Result<String> {
fs::create_dir_all(dir)?;
let ts = r.scanned_at.replace([':', '.'], "-");
let base = format!(
"{}-{}-{}",
safe(r.token.symbol.as_deref().unwrap_or("token")),
&r.address[..8.min(r.address.len())],
ts
);
let md_file = format!("{base}.md");
fs::write(dir.join(&md_file), render_markdown(r))?;
fs::write(
dir.join(format!("{base}.json")),
serde_json::to_string_pretty(r)?,
)?;
fs::write(dir.join(format!("{base}.html")), render_html(r))?;
let idx_path = dir.join("index.json");
let mut index: Vec<serde_json::Value> = fs::read_to_string(&idx_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
index.insert(0, json!({
"file": md_file, "symbol": r.token.symbol, "address": r.address,
"score": r.score, "verdict": verdict_str(r.verdict).replace(' ', "_"), "scannedAt": r.scanned_at,
}));
index.truncate(500);
fs::write(&idx_path, serde_json::to_string_pretty(&index)?)?;
Ok(md_file)
}
pub fn list_reports(dir: &Path) -> Vec<serde_json::Value> {
fs::read_to_string(dir.join("index.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}