use anyhow::Result;
use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Json, Router,
};
use clap::{Parser, Subcommand};
use serde_json::json;
use solwatch::audit::{audit_token, AuditOptions};
use solwatch::chain::{is_pubkey, SolClient};
use solwatch::report::{
default_report_dir, list_reports, render_html, render_markdown, render_text, render_tweet,
write_report, DASHBOARD_HTML,
};
use solwatch::scan::scan_new_launches;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Parser)]
#[command(name = "solwatch", version, about = "Solana memecoin auditor")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Audit {
mint: String,
#[arg(long)]
json: bool,
#[arg(long)]
md: bool,
#[arg(long)]
html: bool,
#[arg(long)]
fast: bool,
#[arg(long)]
deep: bool,
#[arg(long)]
out: Option<String>,
#[arg(long)]
app_html: Option<String>,
#[arg(long)]
report_html: bool,
#[arg(long)]
agent: bool,
},
Scan {
#[arg(long)]
json: bool,
},
Watch {
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
out: Option<String>,
#[arg(long)]
report_html: bool,
},
Tweet { mint: String },
Serve {
#[arg(long)]
port: Option<u16>,
#[arg(long)]
out: Option<String>,
},
}
fn now() -> (i64, String) {
let n = OffsetDateTime::now_utc();
(
n.unix_timestamp() * 1000,
n.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
)
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("solwatch error: {e}");
std::process::exit(1);
}
}
async fn run() -> Result<()> {
let cli = Cli::parse();
let client = SolClient::new();
match cli.cmd {
Cmd::Audit {
mint,
json,
md,
html,
fast,
deep,
out,
app_html,
report_html,
agent,
} => {
if !is_pubkey(&mint) {
anyhow::bail!("usage: solwatch audit <mint> [--json] [--md] [--agent] [--fast] [--deep] [--out <dir>] [--report-html] [--app-html <path>] — <mint> must be a base58 Solana address");
}
let (now_ms, ts) = now();
let opts = AuditOptions {
skip_bundles: fast,
skip_copycats: fast,
deep: deep && !fast,
};
let mut r = audit_token(&client, &mint, opts, now_ms, ts).await;
if agent {
r.rokha_app = serde_json::Value::Null;
}
let out_dir = out
.map(PathBuf::from)
.or_else(|| std::env::var("SOLWATCH_OUT").ok().map(PathBuf::from));
if let Some(dir) = out_dir {
if let Ok(f) = write_report(&r, &dir, report_html && !agent) {
eprintln!("report written: {f}");
}
}
if let Some(p) = app_html.filter(|_| !agent) {
let p = PathBuf::from(p);
if let Some(parent) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&p, render_html(&r))?;
eprintln!("app html written: {}", p.display());
}
if json || agent {
println!("{}", serde_json::to_string_pretty(&r)?);
} else if md {
println!("{}", render_markdown(&r));
} else if html && !agent {
println!("{}", render_html(&r));
} else {
println!("{}", render_text(&r));
}
}
Cmd::Tweet { mint } => {
if !is_pubkey(&mint) {
anyhow::bail!("usage: solwatch tweet <mint>");
}
let (now_ms, ts) = now();
let r = audit_token(&client, &mint, AuditOptions::default(), now_ms, ts).await;
println!("{}", render_tweet(&r));
}
Cmd::Scan { json } => {
let launches = scan_new_launches(&client).await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(
&json!({"count": launches.len(), "launches": launches})
)?
);
} else {
println!("Fresh Solana launches: {}", launches.len());
for l in launches.iter().take(40) {
println!(
" {} {} via {} holders {} mcap ${}",
l.symbol.clone().unwrap_or_else(|| "?".into()),
l.mint,
l.launchpad.clone().unwrap_or_else(|| "?".into()),
l.holder_count.unwrap_or(0),
l.mcap_usd.unwrap_or(0.0).round()
);
}
}
}
Cmd::Watch {
limit,
out,
report_html,
} => {
let limit = limit.unwrap_or(5);
let launches = scan_new_launches(&client).await?;
let out_dir = out
.map(PathBuf::from)
.or_else(|| std::env::var("SOLWATCH_OUT").ok().map(PathBuf::from));
let mut results = vec![];
for l in launches.iter().take(limit) {
let (now_ms, ts) = now();
let r = audit_token(&client, &l.mint, AuditOptions::default(), now_ms, ts).await;
if let Some(dir) = &out_dir {
let _ = write_report(&r, dir, report_html);
}
results.push(r);
}
println!(
"{}",
serde_json::to_string_pretty(
&json!({"audited": results.len(), "results": results})
)?
);
}
Cmd::Serve { port, out } => {
let port = port
.or_else(|| {
std::env::var("SOLWATCH_PORT")
.ok()
.and_then(|s| s.parse().ok())
})
.unwrap_or(7565);
let dir = out.map(PathBuf::from).unwrap_or_else(default_report_dir);
serve(port, dir).await?;
}
}
Ok(())
}
#[derive(Clone)]
struct AppState {
client: Arc<SolClient>,
report_dir: PathBuf,
}
async fn serve(port: u16, report_dir: PathBuf) -> Result<()> {
let state = AppState {
client: Arc::new(SolClient::new()),
report_dir,
};
let app = Router::new()
.route("/", get(|| async { Html(DASHBOARD_HTML) }))
.route("/api/health", get(health))
.route("/api/audit", get(api_audit))
.route("/api/scan", get(api_scan))
.route("/api/reports", get(api_reports))
.with_state(state);
let addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("\n ☀ Solwatch dashboard live → http://{addr}\n Open it to audit tokens, watch fresh launches, and browse saved reports.\n (Ctrl-C to stop)\n");
axum::serve(listener, app).await?;
Ok(())
}
async fn health(State(s): State<AppState>) -> impl IntoResponse {
Json(json!({"ok": true, "chain": "solana", "reportDir": s.report_dir.to_string_lossy()}))
}
async fn api_audit(
State(s): State<AppState>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let mint = q.get("address").cloned().unwrap_or_default();
if !is_pubkey(&mint) {
return Json(json!({"error": "invalid mint address"}));
}
let fast = q.get("fast").map(|v| v == "1").unwrap_or(false);
let deep = q.get("deep").map(|v| v == "1").unwrap_or(false);
let (now_ms, ts) = now();
let opts = AuditOptions {
skip_bundles: fast,
skip_copycats: fast,
deep: deep && !fast,
};
let r = audit_token(&s.client, &mint, opts, now_ms, ts).await;
let _ = write_report(&r, &s.report_dir, false);
Json(serde_json::to_value(&r).unwrap_or(json!({"error": "serialize"})))
}
async fn api_scan(State(s): State<AppState>) -> impl IntoResponse {
let launches = scan_new_launches(&s.client).await.unwrap_or_default();
Json(json!({"count": launches.len(), "launches": launches}))
}
async fn api_reports(State(s): State<AppState>) -> impl IntoResponse {
Json(json!(list_reports(&s.report_dir)))
}