solwatch 0.1.18

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
//! Deployer analysis: the dev wallet (from Jupiter's index), its age, how much
//! of the supply it still holds, and how many OTHER tokens it has launched —
//! the serial-rugger signal.

use crate::chain::jupiter::JupToken;
use crate::chain::SolClient;
use crate::types::{Coverage, Flag, Severity};
use serde_json::{json, Value};

pub async fn analyze(
    client: &SolClient,
    jup: Option<&JupToken>,
    dev_fallback: Option<&str>,
    cov: &mut Coverage,
) -> (Value, Vec<Flag>) {
    let mut flags = vec![];
    // Prefer Jupiter's indexed dev; fall back to the creation-tx fee payer
    // (resolved by the bundle pass) when Jupiter hasn't indexed the token, so
    // the wallet-age / fresh-deployer read isn't silently skipped. The
    // serial-deployer + dev-holdings signals below stay Jupiter-only (they need
    // its cross-mint index) and honestly go quiet when Jupiter has no data.
    let dev = jup
        .and_then(|j| j.dev.clone())
        .or_else(|| dev_fallback.map(|s| s.to_string()));
    let dev_mints = jup.and_then(|j| j.audit.dev_mints);
    let dev_balance_pct = jup.and_then(|j| j.audit.dev_balance_percentage);

    let mut wallet_age_days: Option<i64> = None;
    let mut wallet_tx_floor: Option<usize> = None;
    if let Some(d) = &dev {
        // A failed history fetch must NOT read as "no history" — that would let
        // a brand-new deployer wallet publish identically to one we checked and
        // found established. Mark coverage so the verdict cap engages.
        match client.signatures(d, 1000, None).await {
            Err(e) => cov.degrade(format!("deployer wallet history unreadable ({e})")),
            Ok(hist) => {
                wallet_tx_floor = Some(hist.len());
                if hist.len() < 1000 {
                    if let Some(oldest) = hist.last().and_then(|s| s.block_time) {
                        let now = time::OffsetDateTime::now_utc().unix_timestamp();
                        wallet_age_days = Some((now - oldest) / 86_400);
                    }
                }
                if hist.len() <= 5 {
                    flags.push(Flag::new(
                        "fresh_deployer",
                        Severity::Warning,
                        "Fresh deployer wallet",
                        format!(
                        "The creator wallet {} has only {} transactions — no track record at all.",
                        short(d),
                        hist.len()
                    ),
                        5,
                    ));
                }
            }
        }
    }

    if let Some(n) = dev_mints {
        if n >= 1000 {
            // A count this high is a SHARED launchpad/factory authority (e.g.
            // a pump.fun-class creation wallet), not a person — Jupiter's
            // "dev" attribution stops meaning "the creator" here. Flagging it
            // as a serial rugger was a false positive on every graduated
            // launchpad coin (observed live 2026-07-15: "130802 tokens").
            flags.push(Flag::new(
                "factory_authority",
                Severity::Info,
                "Created through a shared launchpad authority",
                format!("The creation wallet has minted {n} tokens — it's a launchpad/factory authority, not an individual creator, so deployer history isn't attributable to a person. Judge the token on its own checks."),
                0,
            ));
        } else if n >= 50 {
            // A COUNT, not a fraud finding. Solwatch has no rug history for
            // these mints — it never checks whether a single prior token was
            // rugged (no LP-pull history, no authority abuse, no price death),
            // and deploy bots / market-making infra / NFT issuers all cross this
            // line legitimately. The old copy ("classic churn-and-rug factory
            // behavior") asserted fraud about a NAMED wallet on the strength of
            // one third-party integer. Report the count; let the reader judge.
            // The weight stays as a base-rate prior, which is what it actually is.
            flags.push(Flag::new(
                "serial_deployer",
                Severity::Warning,
                "High-volume token creator",
                format!(
                    "This creation wallet has minted {n} tokens. High-volume deployers include both rug factories and legitimate bots/launch services — Solwatch has NOT assessed how those tokens performed, so treat this as context, not a finding."
                ),
                12,
            ));
        } else if n >= 10 {
            flags.push(Flag::new(
                "serial_deployer",
                Severity::Warning,
                "Creator has launched many tokens",
                format!("This creator has launched {n} tokens before this one."),
                6,
            ));
        }
    }
    // Jupiter's indexed dev balance. It is a THIRD-PARTY number and can be
    // stale (a dev who has since sold still reads as holding), which is why the
    // copy attributes it — otherwise the report can contradict its own on-chain
    // holder table on the same page and undermine every other figure in it.
    if let Some(p) = dev_balance_pct {
        let p2 = (p * 100.0).round() / 100.0;
        if p >= 20.0 {
            flags.push(Flag::new(
                "dev_holdings",
                Severity::Danger,
                "Dev holds a huge bag",
                format!("Jupiter's index shows the creator holding {p2}% of the supply — one click from a rug. Cross-check the holder table below; index data can lag a sale."),
                15,
            ));
        } else if p >= 10.0 {
            flags.push(Flag::new(
                "dev_holdings",
                Severity::Warning,
                "Dev holds a large bag",
                format!("Jupiter's index shows the creator holding {p2}% of the supply (third-party data — cross-check the holder table)."),
                8,
            ));
        }
    } else if jup.is_none() {
        // Both cross-mint signals (serial deployer + dev holdings) need
        // Jupiter's index. With it down they vanish silently, so a serial
        // rugger with a 45% bag loses ~27 weight points. Mark the gap.
        cov.degrade("deployer history + dev holdings unavailable (Jupiter index)");
    }

    let section = json!({
        "dev": dev,
        "walletAgeDays": wallet_age_days,
        "walletTxCount": wallet_tx_floor.map(|n| if n >= 1000 { json!("1000+") } else { json!(n) }),
        "tokensLaunched": dev_mints,
        "devBalancePct": dev_balance_pct,
    });
    (section, flags)
}

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