solwatch 0.1.14

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);
}

#[test]
fn raydium_shared_vault_authorities_excluded() {
    // Both are real Raydium vault authorities that are NOT program-owned
    // (AMM v4 authority has no account; CPMM authority is system-owned), so
    // they must be excluded by ADDRESS or a Raydium pool ranks as a whale.
    let auth: std::collections::HashMap<&str, &str> = solwatch::config::shared_pool_authorities()
        .into_iter()
        .collect();
    assert!(auth.contains_key("5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j"));
    assert!(auth.contains_key("GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL"));
}

// ---- New data-quality guards (mint-level honeypot levers + tweet honesty) ----

fn t2022_mint(extensions: Vec<serde_json::Value>) -> solwatch::chain::MintInfo {
    solwatch::chain::MintInfo {
        program: solwatch::config::TOKEN_2022.into(),
        decimals: 6,
        supply: 1_000_000,
        supply_raw: "1000000".into(),
        mint_authority: None,
        freeze_authority: None,
        extensions,
    }
}

#[test]
fn pausable_authority_is_hard_danger() {
    let m = t2022_mint(vec![json!({
        "extension": "pausableConfig",
        "state": {"authority": "Pause111111111111111111111111111111111111111", "paused": false}
    })]);
    let a = solwatch::checks::mint::analyze(&m, None);
    let f = a
        .flags
        .iter()
        .find(|f| f.id == "pausable")
        .expect("pausable flag");
    assert_eq!(f.severity, Severity::Danger);
    assert!(
        f.hard_fail,
        "a live pause authority must hard-cap the score"
    );
}

#[test]
fn paused_now_is_critical_honeypot() {
    let m = t2022_mint(vec![json!({
        "extension": "pausableConfig",
        "state": {"authority": "Pause111111111111111111111111111111111111111", "paused": true}
    })]);
    let a = solwatch::checks::mint::analyze(&m, None);
    let f = a
        .flags
        .iter()
        .find(|f| f.id == "paused")
        .expect("paused flag");
    assert_eq!(f.severity, Severity::Critical);
    assert!(f.hard_fail);
    // No separate "pausable" flag when it's already paused.
    assert!(a.flags.iter().all(|f| f.id != "pausable"));
}

#[test]
fn revoked_pause_authority_is_silent() {
    let m = t2022_mint(vec![json!({
        "extension": "pausableConfig",
        "state": {"authority": serde_json::Value::Null, "paused": false}
    })]);
    let a = solwatch::checks::mint::analyze(&m, None);
    assert!(a
        .flags
        .iter()
        .all(|f| f.id != "pausable" && f.id != "paused"));
}

#[test]
fn zero_fee_with_live_authority_flags() {
    let m = t2022_mint(vec![json!({
        "extension": "transferFeeConfig",
        "state": {
            "transferFeeConfigAuthority": "Fee1111111111111111111111111111111111111111",
            "newerTransferFee": {"transferFeeBasisPoints": 0}
        }
    })]);
    let a = solwatch::checks::mint::analyze(&m, None);
    assert!(
        a.flags
            .iter()
            .any(|f| f.id == "transfer_fee_authority" && f.severity == Severity::Warning),
        "0% fee with a live authority must warn it can be switched on"
    );
    // The fee itself is 0 → no active-fee flag.
    assert!(a.flags.iter().all(|f| f.id != "transfer_fee"));
}

#[test]
fn zero_fee_revoked_authority_is_silent() {
    let m = t2022_mint(vec![json!({
        "extension": "transferFeeConfig",
        "state": {
            "transferFeeConfigAuthority": serde_json::Value::Null,
            "newerTransferFee": {"transferFeeBasisPoints": 0}
        }
    })]);
    let a = solwatch::checks::mint::analyze(&m, None);
    assert!(a.flags.iter().all(|f| f.id != "transfer_fee_authority"));
}

#[test]
fn tweet_marks_partial_scan_on_reassuring_verdict() {
    use solwatch::report::render_tweet;
    use solwatch::types::{AuditResult, TokenMeta};
    let mk = |verdict, coverage: &str| AuditResult {
        address: BONK.into(),
        chain: "solana",
        scanned_at: "2026-07-16T00:00:00Z".into(),
        token: TokenMeta {
            symbol: Some("PONS".into()),
            market_cap_usd: Some(500_000.0),
            ..Default::default()
        },
        score: 85,
        verdict,
        flags: vec![],
        sections: json!({"cluster": {"scope": {"coverage": coverage}}}),
        warnings: vec![],
        rokha_url: None,
        rokha_app: serde_json::Value::Null,
    };
    // Reassuring verdict + non-full coverage → the caveat must ride the tweet.
    assert!(render_tweet(&mk(Verdict::LowRisk, "partial")).contains("partial scan"));
    // Full coverage → no caveat.
    assert!(!render_tweet(&mk(Verdict::LowRisk, "full")).contains("partial scan"));
    // A risk verdict already warns; no caveat noise.
    assert!(!render_tweet(&mk(Verdict::Avoid, "partial")).contains("partial scan"));
}

#[test]
fn coverage_ceiling_downgrades_low_risk_to_fair() {
    use solwatch::types::verdict_from_score;
    // A clean token scores high; a blind scan caps the score at 79 → FAIR,
    // so an un-verifiable concentration read can never post as LOW RISK.
    assert_eq!(verdict_from_score(88), Verdict::LowRisk);
    assert_eq!(verdict_from_score(79), Verdict::Fair);
    // Risk bands are untouched by the mapping.
    assert_eq!(verdict_from_score(35), Verdict::HighRisk);
    assert_eq!(verdict_from_score(5), Verdict::Avoid);
}