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
//! Offline cluster-engine tests — no network. They exercise the REAL engine
//! pieces (PDA derivation, Jito tip decoder, funding parser, token-account
//! resolution, union-find with evidence weights and stop-node control) with
//! fixtures shaped exactly like live RPC responses.

use serde_json::json;
use solwatch::audit::max_uniform_ratio;
use solwatch::chain::{ata_pda, first_sol_source, is_pubkey, pumpfun_bonding_curve_pda};
use solwatch::checks::bundles::mint_deltas_detailed;
use solwatch::checks::cluster::{ancestors_of, apply_funding_evidence, jito_tip, EvidenceGraph};
use std::collections::HashMap;

const TIP: &str = "96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5";

// ---- PDA derivation (THE curve-exclusion fix) ----

#[test]
fn pumpfun_curve_pda_matches_chain() {
    // Ground truth verified LIVE 2026-07-13: this mint's derived curve PDA
    // exists on mainnet with owner 6EF8r… (the pump.fun program).
    let mint = "6LD2ep3ruTVe7zacGHoMpfMdzVqYBmUSwXRVJRNQpump";
    let curve = pumpfun_bonding_curve_pda(mint).expect("derives");
    assert_eq!(curve, "EMaBKenrqP5CxfVfSP65G9gHjxgphnzZzJj9ujHWLDeU");
    // And its associated token account derives too (belt-and-braces exclusion).
    let ata = ata_pda(&curve, mint, solwatch::config::SPL_TOKEN).expect("ata derives");
    assert_eq!(ata, "8LZx3G25qQtcWmgwrxWFR689ZTDpjSXfwsse4XpWRs2L");
}

#[test]
fn curve_pda_is_deterministic_and_distinct() {
    let a = pumpfun_bonding_curve_pda("DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263").unwrap();
    let b = pumpfun_bonding_curve_pda("EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm").unwrap();
    assert!(is_pubkey(&a) && is_pubkey(&b));
    assert_ne!(a, b);
    assert_eq!(
        a,
        pumpfun_bonding_curve_pda("DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263").unwrap()
    );
    assert!(pumpfun_bonding_curve_pda("not-base58!").is_none());
}

// ---- Jito tip detection ----

fn tip_ins(dest: &str, lamports: u64) -> serde_json::Value {
    json!({"program": "system", "parsed": {"type": "transfer",
        "info": {"source": "payer", "destination": dest, "lamports": lamports}}})
}

#[test]
fn jito_tip_top_level() {
    let tx = json!({"transaction": {"message": {"instructions": [
        {"program": "spl-token", "parsed": {"type": "transfer", "info": {}}},
        tip_ins(TIP, 100_000),
    ]}}});
    assert_eq!(jito_tip(&tx), Some((TIP.to_string(), 100_000)));
}

#[test]
fn jito_tip_inner_cpi() {
    // Tips routed through a program (CPI) MUST still be detected.
    let tx = json!({
        "transaction": {"message": {"instructions": [
            {"programId": "SomeRouter11111111111111111111111111111111"}
        ]}},
        "meta": {"innerInstructions": [
            {"index": 0, "instructions": [ tip_ins(TIP, 5000) ]}
        ]}
    });
    assert_eq!(jito_tip(&tx), Some((TIP.to_string(), 5000)));
}

#[test]
fn jito_tip_ignores_dust_and_strangers() {
    // Below the 1000-lamport floor => not a bundle tip.
    let dust = json!({"transaction": {"message": {"instructions": [tip_ins(TIP, 999)]}}});
    assert_eq!(jito_tip(&dust), None);
    // A transfer to a non-tip account is not a tip.
    let other = json!({"transaction": {"message": {"instructions": [
        tip_ins("SomeRandomWallet1111111111111111111111111111", 100_000)]}}});
    assert_eq!(jito_tip(&other), None);
}

// ---- first-funding parser ----

#[test]
fn first_sol_source_finds_transfer_and_create() {
    let w = "FreshWallet111111111111111111111111111111111";
    let tx = json!({"transaction": {"message": {"instructions": [
        {"parsed": {"type": "transfer", "info":
            {"source": "FunderA", "destination": w, "lamports": 50_000_000}}}]}}});
    assert_eq!(first_sol_source(&tx, w), Some("FunderA".to_string()));
    // createAccount funds a wallet too; and inner instructions count.
    let tx2 = json!({
        "transaction": {"message": {"instructions": []}},
        "meta": {"innerInstructions": [{"index": 0, "instructions": [
            {"parsed": {"type": "createAccount", "info":
                {"source": "FunderB", "newAccount": w, "lamports": 2_000_000}}}]}]}
    });
    assert_eq!(first_sol_source(&tx2, w), Some("FunderB".to_string()));
    // A transfer to someone ELSE is not our funding.
    let tx3 = json!({"transaction": {"message": {"instructions": [
        {"parsed": {"type": "transfer", "info":
            {"source": "FunderC", "destination": "other", "lamports": 1}}}]}}});
    assert_eq!(first_sol_source(&tx3, w), None);
}

// ---- token-account resolution in the delta decoder ----

#[test]
fn mint_deltas_resolve_token_accounts() {
    let mint = "M1nt111111111111111111111111111111111111111";
    let tx = json!({
        "transaction": {"message": {"accountKeys": [
            {"pubkey": "feePayer"}, {"pubkey": "prog"}, {"pubkey": "curveTa"},
            {"pubkey": "aliceTa"}
        ]}},
        "meta": {
            "preTokenBalances": [
                {"accountIndex": 2, "mint": mint, "owner": "curve", "uiTokenAmount": {"amount": "1000"}}
            ],
            "postTokenBalances": [
                {"accountIndex": 2, "mint": mint, "owner": "curve", "uiTokenAmount": {"amount": "400"}},
                {"accountIndex": 3, "mint": mint, "owner": "alice", "uiTokenAmount": {"amount": "600"}}
            ]
        }
    });
    let d = mint_deltas_detailed(&tx, mint);
    assert_eq!(d.len(), 1);
    assert_eq!(d[0].owner, "alice");
    assert_eq!(d[0].amount, 600);
    // The receiving token account resolves via accountIndex -> accountKeys,
    // so the still-hold probe can getMultipleAccounts it later.
    assert_eq!(d[0].token_account.as_deref(), Some("aliceTa"));
}

// ---- union-find with evidence weights ----

fn wallets(n: usize, prefix: &str) -> Vec<String> {
    (0..n).map(|i| format!("{prefix}{i:04}")).collect()
}

#[test]
fn union_needs_accumulated_weight_of_one() {
    let ws = wallets(3, "w");
    let mut g = EvidenceGraph::new(ws.clone());
    // same_block alone (0.5): no union.
    g.add(&ws[0], &ws[1], "same_block", 0.5, None, Some(1), None);
    assert!(g.clusters().is_empty());
    // + tip heuristic (0.4) = 0.9: still no union.
    g.add(&ws[0], &ws[1], "bundle", 0.4, None, Some(1), None);
    assert!(g.clusters().is_empty());
    // + shared direct funder (1.0) = 1.9: union.
    g.add(&ws[0], &ws[1], "funding", 1.0, None, None, None);
    let c = g.clusters();
    assert_eq!(c, vec![vec![0, 1]]);
    // transfer (1.0) unions alone.
    g.add(
        &ws[1],
        &ws[2],
        "transfer",
        1.0,
        Some("5".into()),
        Some(9),
        None,
    );
    assert_eq!(g.clusters(), vec![vec![0, 1, 2]]);
}

#[test]
fn display_edges_never_union() {
    let ws = wallets(2, "w");
    let mut g = EvidenceGraph::new(ws.clone());
    g.add_display("SomeCex", &ws[0], "funding", 0.0, None);
    g.add_display("SomeCex", &ws[1], "funding", 0.0, None);
    assert!(g.clusters().is_empty());
}

#[test]
fn cex_hub_must_not_merge_everything() {
    // 500 wallets first-funded by one CEX hot wallet + 5 wallets funded by a
    // private funder. The ring's shared PRIVATE funder unions on its own
    // (1.0); the CEX crowd gets funding evidence of ZERO through the
    // stop-node and must NOT merge even once same-slot co-buys land — with a
    // hub, 500 same-slot copy-traders would otherwise become "one entity".
    let hub = "CexHotWallet11111111111111111111111111111111".to_string();
    let funder = "PrivateFunder1111111111111111111111111111111".to_string();
    let mut all = wallets(500, "cexuser");
    let ring = wallets(5, "ring");
    all.extend(ring.clone());
    let mut g = EvidenceGraph::new(all.clone());
    let mut funder_cache: HashMap<String, Option<String>> = HashMap::new();
    for w in &all[..500] {
        funder_cache.insert(w.clone(), Some(hub.clone()));
    }
    for w in &ring {
        funder_cache.insert(w.clone(), Some(funder.clone()));
    }
    let chain_parent = HashMap::new();
    let mut funder_stop = HashMap::new();
    funder_stop.insert(hub.clone(), true); // CEX = stop-node
    funder_stop.insert(funder.clone(), false);
    apply_funding_evidence(&mut g, &all, &funder_cache, &chain_parent, &funder_stop);
    // Shared PRIVATE direct funder alone (1.0) unions the ring immediately;
    // the CEX crowd (zero-weight stop-node edges) stays un-merged.
    let after_funding = g.clusters();
    assert_eq!(
        after_funding.len(),
        1,
        "the private ring unions on funding alone"
    );
    assert_eq!(after_funding[0].len(), 5);
    // Same-slot co-buys land on a sample of the CEX crowd and on the ring.
    for pair in all[..40].windows(2) {
        g.add(&pair[0], &pair[1], "same_block", 0.5, None, Some(3), None);
    }
    for pair in ring.windows(2) {
        g.add(&pair[0], &pair[1], "same_block", 0.5, None, Some(3), None);
    }
    let clusters = g.clusters();
    assert_eq!(
        clusters.len(),
        1,
        "only the private-funder ring may cluster"
    );
    assert_eq!(clusters[0].len(), 5);
    let members: Vec<&str> = clusters[0].iter().map(|&i| all[i].as_str()).collect();
    assert!(members.iter().all(|m| m.starts_with("ring")));
    // The CEX hub must NOT appear as a funding edge at all: a shared exchange
    // hot wallet is the null hypothesis, and drawing it fanned out to the crowd
    // paints a fake coordinated cluster. The relationship lives in the wallet's
    // first_funder, not the graph.
    assert!(
        !g.edges.iter().any(|e| e.from == hub && e.kind == "funding"),
        "CEX/hub funder must never be drawn as a funding edge"
    );
}

#[test]
fn unknown_funder_stop_status_fails_safe() {
    // A funder with NO stop-status entry must be treated as a stop-node
    // (fail-closed against over-merge when probes ran out of budget).
    let ws = wallets(4, "w");
    let mut g = EvidenceGraph::new(ws.clone());
    let mut funder_cache = HashMap::new();
    for w in &ws {
        funder_cache.insert(w.clone(), Some("UnprobedFunder".to_string()));
    }
    apply_funding_evidence(&mut g, &ws, &funder_cache, &HashMap::new(), &HashMap::new());
    assert!(g.clusters().is_empty());
}

#[test]
fn shared_grand_funder_is_weak_evidence() {
    // A -> f1 -> gf, B -> f2 -> gf: shared ancestor gives 0.5 — NOT a union
    // on its own, but unions once same-slot evidence lands on top.
    let ws = wallets(2, "w");
    let mut g = EvidenceGraph::new(ws.clone());
    let mut funder_cache = HashMap::new();
    funder_cache.insert(ws[0].clone(), Some("f1".to_string()));
    funder_cache.insert(ws[1].clone(), Some("f2".to_string()));
    let mut chain_parent = HashMap::new();
    chain_parent.insert("f1".to_string(), "gf".to_string());
    chain_parent.insert("f2".to_string(), "gf".to_string());
    let mut funder_stop = HashMap::new();
    for f in ["f1", "f2", "gf"] {
        funder_stop.insert(f.to_string(), false);
    }
    apply_funding_evidence(&mut g, &ws, &funder_cache, &chain_parent, &funder_stop);
    assert!(
        g.clusters().is_empty(),
        "0.5 ancestor evidence must not union"
    );
    g.add(&ws[0], &ws[1], "same_block", 0.5, None, Some(7), None);
    assert_eq!(g.clusters(), vec![vec![0, 1]]);
}

#[test]
fn ancestors_stop_at_stop_nodes() {
    let mut funder_cache = HashMap::new();
    funder_cache.insert("w".to_string(), Some("f1".to_string()));
    let mut chain_parent = HashMap::new();
    chain_parent.insert("f1".to_string(), "cex".to_string());
    chain_parent.insert("cex".to_string(), "beyond".to_string());
    let mut funder_stop = HashMap::new();
    funder_stop.insert("f1".to_string(), false);
    funder_stop.insert("cex".to_string(), true);
    let anc = ancestors_of("w", &funder_cache, &chain_parent, &funder_stop, 3);
    // The walk includes the stop-node itself but never goes past it.
    assert_eq!(anc, vec!["f1".to_string(), "cex".to_string()]);
}

#[test]
fn oversized_clusters_are_discarded() {
    // A transfer chain across 1001 wallets = one >1000 component => merge
    // error by definition, dropped.
    let ws = wallets(1001, "x");
    let mut g = EvidenceGraph::new(ws.clone());
    for i in 0..1000 {
        g.add(&ws[i], &ws[i + 1], "transfer", 1.0, None, None, None);
    }
    assert!(g.clusters().is_empty());
}

// ---- bot-volume heuristic ----

#[test]
fn uniform_buy_ratio() {
    // 6 near-identical buys out of 8 => 0.75.
    let amounts = vec![
        1_000_000u128,
        1_000_100,
        999_950,
        1_000_000,
        1_000_005,
        999_999,
        50_000,
        73_000_000,
    ];
    let r = max_uniform_ratio(&amounts);
    assert!((r - 0.75).abs() < 1e-9, "got {r}");
    assert_eq!(max_uniform_ratio(&[]), 0.0);
    // organic-looking spread stays low
    let organic = vec![100u128, 5_000, 42_000, 1_000_000, 9_000_000];
    assert!(max_uniform_ratio(&organic) <= 0.4);
}

#[test]
fn linked_groups_surface_sub_threshold_evidence() {
    use std::collections::HashSet;
    // 0-1 transfer (1.0) = a merged cluster; 2-3 same-slot only (0.5) =
    // linked BELOW the bar; 4 is unlinked. The below-bar pair must surface
    // as a linked group, the clustered pair must not reappear there.
    let ws = wallets(5, "w");
    let mut g = EvidenceGraph::new(ws.clone());
    g.add(&ws[0], &ws[1], "transfer", 1.0, None, None, None);
    g.add(&ws[2], &ws[3], "same_block", 0.5, None, Some(3), None);
    let clusters = g.clusters();
    assert_eq!(clusters, vec![vec![0, 1]]);
    let clustered: HashSet<usize> = clusters.iter().flatten().copied().collect();
    let linked = g.linked_groups(&clustered);
    assert_eq!(
        linked,
        vec![vec![2, 3]],
        "sub-threshold pair surfaces as a lead"
    );
}