solwatch 0.1.15

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
//! Mint-level security — the Solana honeypot analog. On EVM you have to
//! simulate a sell; on Solana the rug levers are readable directly off the
//! mint account: an active mint authority (infinite supply), an active freeze
//! authority (your tokens can be frozen mid-trade — the classic Solana
//! honeypot), and the Token-2022 extensions (transfer fees = sell tax,
//! permanent delegate = confiscation, transfer hooks = programmable blocks,
//! non-transferable / default-frozen = outright honeypots).

use crate::chain::{MetaplexMeta, MintInfo};
use crate::config;
use crate::types::{Flag, Severity};
use serde_json::{json, Value};

pub struct MintAnalysis {
    pub flags: Vec<Flag>,
    pub section: Value,
    pub name: Option<String>,
    pub symbol: Option<String>,
}

fn ext<'a>(mint: &'a MintInfo, name: &str) -> Option<&'a Value> {
    mint.extensions
        .iter()
        .find(|e| e.get("extension").and_then(|x| x.as_str()) == Some(name))
}

pub fn analyze(mint: &MintInfo, meta: Option<&MetaplexMeta>) -> MintAnalysis {
    let mut flags = vec![];
    let is_2022 = mint.program == config::TOKEN_2022;

    // --- Authorities (both token programs) ---
    match &mint.mint_authority {
        Some(a) => flags.push(Flag::new(
            "mint_authority",
            Severity::Danger,
            "Mint authority still active",
            format!(
                "The wallet {} can create unlimited new tokens at any time, diluting every holder to zero. Safe tokens revoke this.",
                short(a)
            ),
            30,
        )),
        None => flags.push(Flag::new(
            "mint_authority",
            Severity::Good,
            "Mint authority revoked",
            "No one can ever create more of this token — the supply is fixed.".into(),
            0,
        )),
    }
    match &mint.freeze_authority {
        Some(a) => flags.push(
            Flag::new(
                "freeze_authority",
                Severity::Danger,
                "Freeze authority active — sell-block risk",
                format!(
                    "The wallet {} can FREEZE any holder's tokens, instantly blocking them from selling. This is the #1 Solana honeypot lever.",
                    short(a)
                ),
                25,
            )
            .hard(),
        ),
        None => flags.push(Flag::new(
            "freeze_authority",
            Severity::Good,
            "Freeze authority revoked",
            "No one can freeze your tokens — you can always sell.".into(),
            0,
        )),
    }

    // --- Token-2022 extensions ---
    let mut ext_summary = vec![];
    if is_2022 {
        if ext(mint, "nonTransferable").is_some() {
            ext_summary.push("nonTransferable");
            flags.push(
                Flag::new(
                    "non_transferable",
                    Severity::Critical,
                    "🍯 Token is NON-TRANSFERABLE",
                    "The mint is flagged non-transferable — you can receive it but never send or sell it. This is a honeypot by construction.".into(),
                    60,
                )
                .hard(),
            );
        }
        if let Some(e) = ext(mint, "defaultAccountState") {
            let state = e
                .pointer("/state/accountState")
                .and_then(|s| s.as_str())
                .unwrap_or("");
            if state == "frozen" {
                ext_summary.push("defaultAccountState=frozen");
                flags.push(
                    Flag::new(
                        "default_frozen",
                        Severity::Critical,
                        "🍯 New accounts start FROZEN",
                        "Every buyer's token account is frozen by default — you cannot sell unless the team manually unfreezes you. Honeypot mechanics.".into(),
                        60,
                    )
                    .hard(),
                );
            }
        }
        if let Some(e) = ext(mint, "permanentDelegate") {
            let d = e
                .pointer("/state/delegate")
                .and_then(|s| s.as_str())
                .unwrap_or("?");
            ext_summary.push("permanentDelegate");
            flags.push(
                Flag::new(
                    "permanent_delegate",
                    Severity::Critical,
                    "🍯 Permanent delegate can seize tokens",
                    format!(
                        "The address {} is a permanent delegate — it can transfer or burn tokens OUT of any holder's wallet without permission.",
                        short(d)
                    ),
                    60,
                )
                .hard(),
            );
        }
        if let Some(e) = ext(mint, "transferHook") {
            let prog = e.pointer("/state/programId").and_then(|s| s.as_str());
            if let Some(p) = prog.filter(|p| !p.is_empty()) {
                ext_summary.push("transferHook");
                flags.push(Flag::new(
                    "transfer_hook",
                    Severity::Danger,
                    "Transfer hook — programmable transfer control",
                    format!(
                        "Every transfer calls custom program {} which can reject sells for any reason (allowlists, trading hours, honeypot switches). Trust requires reading that program.",
                        short(p)
                    ),
                    20,
                ));
            }
        }
        if let Some(e) = ext(mint, "transferFeeConfig") {
            let bps = e
                .pointer("/state/newerTransferFee/transferFeeBasisPoints")
                .or_else(|| e.pointer("/state/olderTransferFee/transferFeeBasisPoints"))
                .and_then(|b| b.as_u64())
                .unwrap_or(0);
            let pct = bps as f64 / 100.0;
            if bps > 0 {
                ext_summary.push("transferFee");
            }
            if pct >= 25.0 {
                flags.push(
                    Flag::new(
                        "transfer_fee",
                        Severity::Critical,
                        "🍯 Confiscatory transfer tax",
                        format!("A {pct}% fee is skimmed from EVERY transfer — selling costs you {pct}% off the top. Effectively a honeypot."),
                        40,
                    )
                    .hard(),
                );
            } else if pct >= 10.0 {
                flags.push(Flag::new(
                    "transfer_fee",
                    Severity::Danger,
                    "High transfer tax",
                    format!("A {pct}% fee is taken from every transfer (buys AND sells)."),
                    20,
                ));
            } else if bps > 0 {
                flags.push(Flag::new(
                    "transfer_fee",
                    Severity::Warning,
                    "Transfer tax",
                    format!("A {pct}% fee is taken from every transfer."),
                    8,
                ));
            }
            // A 0% fee today is only safe if the fee AUTHORITY is also revoked —
            // otherwise the controller can raise the tax to as much as 100% at
            // any time. A revoked authority serializes as JSON null (→ None).
            if bps == 0 {
                if let Some(a) = e
                    .pointer("/state/transferFeeConfigAuthority")
                    .and_then(|s| s.as_str())
                    .filter(|s| !s.is_empty())
                {
                    flags.push(Flag::new(
                        "transfer_fee_authority",
                        Severity::Warning,
                        "Transfer fee can be switched on",
                        format!(
                            "The fee is 0% today, but {} still controls it and can raise the transfer tax — up to 100% — at any time. Safe tokens revoke the fee authority.",
                            short(a)
                        ),
                        8,
                    ));
                }
            }
        }
        if ext(mint, "mintCloseAuthority").is_some() {
            ext_summary.push("mintCloseAuthority");
            flags.push(Flag::new(
                "mint_close_authority",
                Severity::Warning,
                "Mint can be closed",
                "A close authority exists on the mint — an unusual power for a community token."
                    .into(),
                5,
            ));
        }
        // Pausable (Token-2022): a pause authority can halt EVERY transfer of the
        // token at once — a global freeze equivalent to freeze authority, and a
        // honeypot lever. Currently-paused = active honeypot; a live authority =
        // the lever exists. Missing/None authority = the power is revoked.
        if let Some(e) = ext(mint, "pausableConfig") {
            let paused = e
                .pointer("/state/paused")
                .and_then(|b| b.as_bool())
                .unwrap_or(false);
            let authority = e
                .pointer("/state/authority")
                .and_then(|s| s.as_str())
                .filter(|s| !s.is_empty());
            if paused {
                ext_summary.push("paused");
                flags.push(
                    Flag::new(
                        "paused",
                        Severity::Critical,
                        "🍯 All transfers are PAUSED",
                        "Every transfer of this token is currently paused — nobody can sell until the team unpauses. Honeypot mechanics.".into(),
                        60,
                    )
                    .hard(),
                );
            } else if let Some(a) = authority {
                ext_summary.push("pausable");
                flags.push(
                    Flag::new(
                        "pausable",
                        Severity::Danger,
                        "Transfers can be paused",
                        format!(
                            "The wallet {} can pause ALL transfers at any moment — a global freeze that blocks every holder from selling. Equivalent to a freeze authority.",
                            short(a)
                        ),
                        25,
                    )
                    .hard(),
                );
            }
        }
    }

    // --- Metadata (Metaplex for classic SPL; tokenMetadata extension for 2022) ---
    let (mut name, mut symbol, mut mutable, mut update_auth) = (None, None, None, None);
    if let Some(m) = meta {
        name = Some(m.name.clone()).filter(|s| !s.is_empty());
        symbol = Some(m.symbol.clone()).filter(|s| !s.is_empty());
        mutable = Some(m.is_mutable);
        update_auth = Some(m.update_authority.clone());
    } else if let Some(e) = ext(mint, "tokenMetadata") {
        name = e
            .pointer("/state/name")
            .and_then(|s| s.as_str())
            .map(String::from);
        symbol = e
            .pointer("/state/symbol")
            .and_then(|s| s.as_str())
            .map(String::from);
        update_auth = e
            .pointer("/state/updateAuthority")
            .and_then(|s| s.as_str())
            .map(String::from);
        mutable = update_auth.as_ref().map(|_| true);
    }
    if mutable == Some(true) {
        flags.push(Flag::new(
            "mutable_metadata",
            Severity::Info,
            "Metadata can still be changed",
            "The team can rename the token or swap its image/links at any time.".into(),
            2,
        ));
    }

    let section = json!({
        "tokenProgram": mint.program,
        "isToken2022": is_2022,
        "decimals": mint.decimals,
        "supply": mint.supply_raw,
        "mintAuthority": mint.mint_authority,
        "freezeAuthority": mint.freeze_authority,
        "extensions": ext_summary,
        "metadata": {
            "name": name,
            "symbol": symbol,
            "isMutable": mutable,
            "updateAuthority": update_auth,
        },
    });

    MintAnalysis {
        flags,
        section,
        name,
        symbol,
    }
}

fn short(a: &str) -> String {
    if a.len() < 12 {
        a.to_string()
    } else {
        format!("{}{}", &a[..4], &a[a.len() - 4..])
    }
}