solwatch 0.1.10

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
//! Offline unit tests — no network. They exercise the real decoders and the
//! scoring model with fixtures shaped exactly like live RPC responses.

use serde_json::json;
use solwatch::chain::{is_pubkey, metadata_pda, parse_metaplex};
use solwatch::checks::bundles::mint_deltas;
use solwatch::types::{score_flags, Flag, Severity, Verdict};

const BONK: &str = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263";

#[test]
fn score_clean_token() {
    let flags = vec![
        Flag::new("mint_authority", Severity::Good, "ok", "".into(), 0),
        Flag::new("freeze_authority", Severity::Good, "ok", "".into(), 0),
    ];
    let (score, verdict) = score_flags(&flags, true);
    assert_eq!(score, 100);
    assert_eq!(verdict, Verdict::LowRisk);
}

#[test]
fn score_honeypot_hard_caps_at_5() {
    let flags = vec![Flag::new(
        "non_transferable",
        Severity::Critical,
        "honeypot",
        "".into(),
        60,
    )
    .hard()];
    let (score, verdict) = score_flags(&flags, true);
    assert!(score <= 5, "critical hard fail must cap at 5, got {score}");
    assert_eq!(verdict, Verdict::Avoid);
}

#[test]
fn score_non_critical_hard_caps_at_20() {
    let flags = vec![Flag::new("no_liquidity", Severity::Danger, "x", "".into(), 25).hard()];
    let (score, _) = score_flags(&flags, true);
    assert!(score <= 20);
}

#[test]
fn score_unresolved_token_is_unknown() {
    let (score, verdict) = score_flags(&[], false);
    assert_eq!(score, 0);
    assert_eq!(verdict, Verdict::Unknown);
}

#[test]
fn pubkey_validation() {
    assert!(is_pubkey(BONK));
    assert!(is_pubkey("So11111111111111111111111111111111111111112"));
    assert!(!is_pubkey("0x8e62F281f282686fCa6dCB39288069a93fC23F1c")); // EVM
    assert!(!is_pubkey("notbase58!!!"));
    assert!(!is_pubkey(""));
}

#[test]
fn metadata_pda_derivation() {
    // Verified live 2026-07-13: Metaplex metadata PDA for the BONK mint.
    let pda = metadata_pda(BONK).expect("pda derives");
    assert!(is_pubkey(&pda));
    // Deterministic: same input, same PDA.
    assert_eq!(pda, metadata_pda(BONK).unwrap());
}

#[test]
fn metaplex_parser_roundtrip() {
    // Hand-built borsh Metadata: key, update_auth(32), mint(32),
    // name/symbol/uri (u32-len strings, null-padded), sfbp, no creators,
    // primary_sale=0, is_mutable=1.
    let mut b: Vec<u8> = vec![4];
    b.extend([7u8; 32]); // update authority
    b.extend([9u8; 32]); // mint
    for (s, cap) in [("Bonk", 32), ("BONK", 10), ("https://x/meta.json", 200)] {
        b.extend((cap as u32).to_le_bytes());
        let mut raw = s.as_bytes().to_vec();
        raw.resize(cap, 0);
        b.extend(raw);
    }
    b.extend(100u16.to_le_bytes()); // seller fee bps
    b.push(0); // no creators
    b.push(0); // primary sale
    b.push(1); // is_mutable
    let m = parse_metaplex(&b).expect("parses");
    assert_eq!(m.name, "Bonk");
    assert_eq!(m.symbol, "BONK");
    assert_eq!(m.uri, "https://x/meta.json");
    assert!(m.is_mutable);
    assert_eq!(m.update_authority, bs58_of([7u8; 32]));
}

#[test]
fn metaplex_parser_rejects_garbage() {
    assert!(parse_metaplex(&[]).is_none());
    assert!(parse_metaplex(&[4u8; 40]).is_none());
    // absurd string length must bail, not allocate
    let mut b: Vec<u8> = vec![4];
    b.extend([0u8; 64]);
    b.extend(u32::MAX.to_le_bytes());
    assert!(parse_metaplex(&b).is_none());
}

fn bs58_of(b: [u8; 32]) -> String {
    bs58::encode(b).into_string()
}

#[test]
fn mint_deltas_from_parsed_tx() {
    let mint = "M1nt111111111111111111111111111111111111111";
    let tx = json!({
        "meta": {
            "preTokenBalances": [
                {"accountIndex": 3, "mint": mint, "owner": "curve", "uiTokenAmount": {"amount": "1000"}},
                {"accountIndex": 4, "mint": "OTHER", "owner": "alice", "uiTokenAmount": {"amount": "5"}}
            ],
            "postTokenBalances": [
                {"accountIndex": 3, "mint": mint, "owner": "curve", "uiTokenAmount": {"amount": "400"}},
                {"accountIndex": 5, "mint": mint, "owner": "alice", "uiTokenAmount": {"amount": "600"}},
                {"accountIndex": 4, "mint": "OTHER", "owner": "alice", "uiTokenAmount": {"amount": "9"}}
            ]
        }
    });
    let mut d = mint_deltas(&tx, mint);
    d.sort();
    // alice gained 600 of OUR mint (the OTHER-mint gain is ignored);
    // the curve LOST tokens so it must not appear.
    assert_eq!(d, vec![("alice".to_string(), 600u128)]);
}

#[test]
fn tweet_is_bounded() {
    use solwatch::report::render_tweet;
    use solwatch::types::{AuditResult, TokenMeta};
    let r = AuditResult {
        address: BONK.into(),
        chain: "solana",
        scanned_at: "2026-07-13T00:00:00Z".into(),
        token: TokenMeta {
            symbol: Some("X".repeat(120)),
            market_cap_usd: Some(123456789012345.0),
            ..Default::default()
        },
        score: 3,
        verdict: Verdict::Avoid,
        flags: vec![
            Flag::new(
                "a",
                Severity::Critical,
                &"very long flag title ".repeat(10),
                "d".into(),
                60,
            ),
            Flag::new(
                "b",
                Severity::Danger,
                &"another long one ".repeat(10),
                "d".into(),
                30,
            ),
        ],
        sections: json!({}),
        warnings: vec![],
        rokha_url: None,
        rokha_app: serde_json::Value::Null,
    };
    assert!(render_tweet(&r).chars().count() <= 280);
    // The APP-view block + HTML artifact build from any result without panic.
    let app = solwatch::report::render_rokha_app(&r);
    assert_eq!(app["verdict"], "avoid");
    let html = solwatch::report::render_html(&r);
    assert!(html.contains("__AUDIT"));
    assert!(!html.contains("</script><script>alert")); // json stays inert

    // write_report: .md + .json + index always; .html only on opt-in.
    let dir = std::env::temp_dir().join(format!("solwatch-test-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    let md = solwatch::report::write_report(&r, &dir, false).unwrap();
    let base = md.strip_suffix(".md").unwrap();
    assert!(dir.join(&md).exists());
    assert!(dir.join(format!("{base}.json")).exists());
    assert!(dir.join("index.json").exists());
    assert!(
        !dir.join(format!("{base}.html")).exists(),
        ".html written without --report-html"
    );
    let md = solwatch::report::write_report(&r, &dir, true).unwrap();
    let base = md.strip_suffix(".md").unwrap();
    assert!(dir.join(format!("{base}.html")).exists());
    let _ = std::fs::remove_dir_all(&dir);
}