solwatch 0.1.7

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
//! Shared audit types — wire-compatible with Hoodwatch's AuditResult shape
//! (same score/verdict/flags contract, Solana-native sections).

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Critical,
    Danger,
    Warning,
    Info,
    Good,
}

impl Severity {
    pub fn rank(self) -> u8 {
        match self {
            Severity::Critical => 5,
            Severity::Danger => 4,
            Severity::Warning => 3,
            Severity::Info => 2,
            Severity::Good => 1,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Flag {
    pub id: String,
    pub severity: Severity,
    pub title: String,
    pub detail: String,
    pub weight: i32,
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub hard_fail: bool,
}

impl Flag {
    pub fn new(id: &str, severity: Severity, title: &str, detail: String, weight: i32) -> Self {
        Flag {
            id: id.into(),
            severity,
            title: title.into(),
            detail,
            weight,
            hard_fail: false,
        }
    }
    pub fn hard(mut self) -> Self {
        self.hard_fail = true;
        self
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Verdict {
    Avoid,
    HighRisk,
    Caution,
    Fair,
    LowRisk,
    Unknown,
}

#[derive(Debug, Clone, Serialize, Default)]
pub struct TokenMeta {
    pub name: Option<String>,
    pub symbol: Option<String>,
    pub decimals: Option<u32>,
    #[serde(rename = "totalSupply")]
    pub total_supply: Option<String>,
    #[serde(rename = "holdersCount")]
    pub holders_count: Option<u64>,
    #[serde(rename = "marketCapUsd")]
    pub market_cap_usd: Option<f64>,
    #[serde(rename = "volume24hUsd")]
    pub volume_24h_usd: Option<f64>,
    #[serde(rename = "priceUsd")]
    pub price_usd: Option<f64>,
    #[serde(rename = "tokenProgram")]
    pub token_program: Option<String>,
    pub launchpad: Option<String>,
    #[serde(rename = "createdAt")]
    pub created_at: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AuditResult {
    pub address: String,
    pub chain: &'static str,
    #[serde(rename = "scannedAt")]
    pub scanned_at: String,
    pub token: TokenMeta,
    pub score: i32,
    pub verdict: Verdict,
    pub flags: Vec<Flag>,
    pub sections: serde_json::Value,
    pub warnings: Vec<String>,
    /// "View this in Rokha" link — only present when the owner sets
    /// SOLWATCH_ROKHA_URL (their rig's product page). Never fabricated.
    #[serde(rename = "rokhaUrl", skip_serializing_if = "Option::is_none")]
    pub rokha_url: Option<String>,
    /// The Rokha APP-view block (title/verdict/score/metrics/sections) — a
    /// rig whose final step outputs this JSON gets a native render in Rokha's
    /// output rail, no adapter needed. Null only when the mint is unresolved.
    #[serde(
        rename = "rokha_app",
        skip_serializing_if = "serde_json::Value::is_null"
    )]
    pub rokha_app: serde_json::Value,
}

/// Fold flags into a 0-100 score + verdict (deduction model, hard-fail caps).
/// A honeypot-class flag (non-transferable / frozen-by-default / permanent
/// delegate / confiscatory transfer fee) caps the score at 5; other hard fails
/// (no liquidity) cap it at 20.
pub fn score_flags(flags: &[Flag], token_resolved: bool) -> (i32, Verdict) {
    if !token_resolved {
        return (0, Verdict::Unknown);
    }
    let mut score = 100i32;
    let mut cap = 100i32;
    for f in flags {
        score -= f.weight;
        if f.hard_fail {
            cap = cap.min(if f.severity == Severity::Critical {
                5
            } else {
                20
            });
        }
    }
    score = score.clamp(0, cap);
    let verdict = if score <= 10 {
        Verdict::Avoid
    } else if score < 40 {
        Verdict::HighRisk
    } else if score < 60 {
        Verdict::Caution
    } else if score < 80 {
        Verdict::Fair
    } else {
        Verdict::LowRisk
    };
    (score, verdict)
}