solwatch 0.1.9

Real-data Solana memecoin auditor — rug/freeze/bundle scanner with a 0-100 risk score, plain-English flags, and a live dashboard. Sister tool to Hoodwatch.
Documentation
//! Solwatch CLI — runs in the Rokha sandbox as a single static binary.
//!   solwatch audit <mint> [--json|--md|--fast] [--out <dir>] [--report-html] [--app-html <path>]
//!   solwatch scan [--json]
//!   solwatch watch [--limit N] [--out <dir>] [--report-html]
//!   solwatch tweet <mint>
//!   solwatch serve [--port N] [--out <dir>]

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 {
    /// Full audit of one token mint
    Audit {
        mint: String,
        #[arg(long)]
        json: bool,
        #[arg(long)]
        md: bool,
        /// Emit a self-contained HTML dashboard (data baked in, no network)
        #[arg(long)]
        html: bool,
        /// Skip the slower launch-bundle + copycat + cluster passes
        #[arg(long)]
        fast: bool,
        /// Deeper wallet-cluster pass (more funding hops, bigger RPC budget)
        #[arg(long)]
        deep: bool,
        #[arg(long)]
        out: Option<String>,
        /// Write the self-contained dashboard HTML (data baked in, no network)
        /// to this exact path — the artifact Rokha's APP view iframes.
        #[arg(long)]
        app_html: Option<String>,
        /// Also write the self-contained `.html` dashboard sibling next to the
        /// `.md`/`.json` report in the --out archive (off by default).
        #[arg(long)]
        report_html: bool,
    },
    /// List recently created tokens across Solana launchpads
    Scan {
        #[arg(long)]
        json: bool,
    },
    /// Batch-audit the newest launches
    Watch {
        #[arg(long)]
        limit: Option<usize>,
        #[arg(long)]
        out: Option<String>,
        /// Also write the `.html` dashboard sibling per report (off by default).
        #[arg(long)]
        report_html: bool,
    },
    /// Emit the ≤280-char X post for a token
    Tweet { mint: String },
    /// Launch the live dashboard
    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,
        } => {
            if !is_pubkey(&mint) {
                anyhow::bail!("usage: solwatch audit <mint> [--json] [--md] [--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 r = audit_token(&client, &mint, opts, now_ms, ts).await;
            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) {
                    eprintln!("report written: {f}");
                }
            }
            if let Some(p) = app_html {
                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 {
                println!("{}", serde_json::to_string_pretty(&r)?);
            } else if md {
                println!("{}", render_markdown(&r));
            } else if html {
                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)))
}