vaea-flash-sdk 0.1.0

VAEA Flash — Universal Flash Loan SDK for Solana. Borrow any SPL token atomically in one call.
Documentation
/// VAEA Flash — Local Instruction Builder (Rust)
///
/// Constructs begin_flash and end_flash instructions 100% client-side.
/// Eliminates the HTTP call to /v1/build — saves ~80-100ms per TX.
///
/// Supports ALL 60+ tokens via dynamic registry synced from /v1/capacity.
/// Falls back to 12 core tokens if API is unavailable.
///
/// Zero RPC, zero HTTP at build time.

#[allow(deprecated)]
use solana_sdk::{
    instruction::{AccountMeta, Instruction},
    pubkey::Pubkey,
    system_program,
    sysvar,
};
use sha2::{Sha256, Digest};
use std::collections::HashMap;
use std::sync::RwLock;

use crate::types::{VAEA_PROGRAM_ID, VAEA_API_URL, FlashTier, TokenCapacity};

// ═══════════════════════════════════════════════════════════
//  TokenEntry — stored in the registry
// ═══════════════════════════════════════════════════════════

/// A token entry in the dynamic registry.
#[derive(Debug, Clone)]
pub struct TokenEntry {
    pub symbol: String,
    pub mint: Pubkey,
    pub name: String,
    pub decimals: u8,
    pub max_amount: f64,
    pub max_amount_usd: f64,
    pub source_protocol: String,
    pub route_type: String,
    pub status: String,
    pub logo_uri: Option<String>,
}

// ═══════════════════════════════════════════════════════════
//  Constants
// ═══════════════════════════════════════════════════════════

lazy_static::lazy_static! {
    static ref PROGRAM_ID: Pubkey = VAEA_PROGRAM_ID.parse().unwrap();
    static ref DISC_BEGIN_FLASH: [u8; 8] = anchor_discriminator("begin_flash");
    static ref DISC_END_FLASH: [u8; 8] = anchor_discriminator("end_flash");
    static ref CONFIG_PDA: Pubkey = derive_config();
    static ref FEE_VAULT_PDA: Pubkey = derive_fee_vault();

    /// The dynamic token registry — starts with 12 fallbacks, syncs to 60+
    /// Key: lowercase symbol AND mint base58
    static ref TOKEN_REGISTRY: RwLock<HashMap<String, TokenEntry>> = {
        let mut m = HashMap::new();
        for (sym, mint_str, name, dec) in FALLBACK_TOKENS.iter() {
            if let Ok(mint) = mint_str.parse::<Pubkey>() {
                let entry = TokenEntry {
                    symbol: sym.to_string(),
                    mint,
                    name: name.to_string(),
                    decimals: *dec,
                    max_amount: 0.0,
                    max_amount_usd: 0.0,
                    source_protocol: "unknown".to_string(),
                    route_type: "direct".to_string(),
                    status: "unknown".to_string(),
                    logo_uri: None,
                };
                m.insert(sym.to_lowercase(), entry.clone());
                m.insert(mint_str.to_string(), entry);
            }
        }
        RwLock::new(m)
    };

    static ref REGISTRY_SYNCED: RwLock<bool> = RwLock::new(false);
}

/// 12 core fallback tokens: (symbol, mint, name, decimals)
const FALLBACK_TOKENS: [(&str, &str, &str, u8); 12] = [
    ("SOL",      "So11111111111111111111111111111111111111112",   "Solana",          9),
    ("USDC",     "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "USD Coin",        6),
    ("USDT",     "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "Tether USD",      6),
    ("JitoSOL",  "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", "Jito Staked SOL", 9),
    ("JupSOL",   "jupSoLaHXQiZZTSfEWMTRRgpnyFm8f6sZdosWBjx93v",  "Jupiter SOL",     9),
    ("JUP",      "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",  "Jupiter",         6),
    ("JLP",      "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4", "Jupiter LP",      6),
    ("cbBTC",    "cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij",  "Coinbase BTC",    8),
    ("mSOL",     "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So",  "Marinade SOL",    9),
    ("bSOL",     "bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1",  "Blaze SOL",       9),
    ("INF",      "5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm", "Infinity",        9),
    ("laineSOL", "LAinEtNLgpmCP9Rvsf5Hn8W6EhNiKLZQti1xfWMLy6X",  "Laine SOL",       9),
];

/// Blacklisted symbols
const BLACKLIST: [&str; 4] = ["EURCV", "FBTC", "USDCV", "wstUSR"];

// ═══════════════════════════════════════════════════════════
//  Token filtering — same logic as frontend
// ═══════════════════════════════════════════════════════════

fn is_valid_token(t: &TokenCapacity) -> bool {
    let sym = &t.symbol;
    // Always keep known core tokens
    if FALLBACK_TOKENS.iter().any(|(s, _, _, _)| s.eq_ignore_ascii_case(sym)) {
        return true;
    }
    // Exclude blacklisted
    if BLACKLIST.contains(&sym.as_str()) { return false; }
    // Exclude SPL Single Pool tokens
    if t.name.starts_with("SPL Single Pool") { return false; }
    // Exclude Kamino LP tokens (kSOL-BSOL etc.)
    if sym.len() > 2 && sym.starts_with('k') && sym.contains('-') {
        let after_k = &sym[1..];
        if after_k.chars().next().map_or(false, |c| c.is_uppercase()) {
            return false;
        }
    }
    // Must have a real name
    if t.name.is_empty() || t.name == "Unknown Token" { return false; }
    // Symbol must be human-readable
    if sym.len() >= 12 || sym.contains("...") { return false; }
    true
}

// ═══════════════════════════════════════════════════════════
//  Registry sync
// ═══════════════════════════════════════════════════════════

/// Update the registry from a vec of TokenCapacity (from /v1/capacity).
/// Called by sync_registry() and by WarmCache on each refresh.
pub fn update_registry_from_capacity(tokens: &[TokenCapacity]) {
    let mut reg = TOKEN_REGISTRY.write().unwrap();
    for t in tokens {
        if !is_valid_token(t) { continue; }
        if let Ok(mint) = t.mint.parse::<Pubkey>() {
            let entry = TokenEntry {
                symbol: t.symbol.clone(),
                mint,
                name: if t.name.is_empty() { t.symbol.clone() } else { t.name.clone() },
                decimals: t.decimals,
                max_amount: t.max_amount,
                max_amount_usd: t.max_amount_usd,
                source_protocol: t.source_protocol.clone(),
                route_type: t.route_type.clone(),
                status: t.status.clone(),
                logo_uri: None,
            };
            reg.insert(t.symbol.to_lowercase(), entry.clone());
            reg.insert(t.mint.clone(), entry);
        }
    }
    *REGISTRY_SYNCED.write().unwrap() = true;
}

/// Sync the token registry from the VAEA API.
pub async fn sync_registry(api_url: Option<&str>) -> usize {
    let url = api_url.unwrap_or(VAEA_API_URL);
    match reqwest::get(&format!("{}/v1/capacity", url)).await {
        Ok(res) => {
            if let Ok(data) = res.json::<crate::types::CapacityResponse>().await {
                update_registry_from_capacity(&data.tokens);
            }
        }
        Err(_) => {}
    }
    let reg = TOKEN_REGISTRY.read().unwrap();
    // Count unique symbols (not mint duplicates)
    let mut seen = std::collections::HashSet::new();
    for entry in reg.values() { seen.insert(entry.symbol.clone()); }
    seen.len()
}

/// Check if registry has been synced
pub fn is_registry_synced() -> bool {
    *REGISTRY_SYNCED.read().unwrap()
}

/// Look up a token by symbol or mint string.
pub fn get_token(token: &str) -> Option<TokenEntry> {
    let reg = TOKEN_REGISTRY.read().unwrap();
    reg.get(&token.to_lowercase()).cloned()
        .or_else(|| reg.get(token).cloned())
}

/// Get all unique tokens in the registry, sorted by USD liquidity.
pub fn get_all_tokens() -> Vec<TokenEntry> {
    let reg = TOKEN_REGISTRY.read().unwrap();
    let mut seen = std::collections::HashSet::new();
    let mut result: Vec<TokenEntry> = Vec::new();
    for entry in reg.values() {
        if seen.insert(entry.symbol.clone()) {
            result.push(entry.clone());
        }
    }
    result.sort_by(|a, b| b.max_amount_usd.partial_cmp(&a.max_amount_usd).unwrap_or(std::cmp::Ordering::Equal));
    result
}

// ═══════════════════════════════════════════════════════════
//  PDA derivation
// ═══════════════════════════════════════════════════════════

fn anchor_discriminator(name: &str) -> [u8; 8] {
    let mut hasher = Sha256::new();
    hasher.update(format!("global:{}", name));
    let result = hasher.finalize();
    let mut disc = [0u8; 8];
    disc.copy_from_slice(&result[..8]);
    disc
}

fn derive_flash_state(payer: &Pubkey, token_mint: &Pubkey) -> Pubkey {
    Pubkey::find_program_address(
        &[b"flash", payer.as_ref(), token_mint.as_ref()],
        &PROGRAM_ID,
    ).0
}

fn derive_config() -> Pubkey {
    Pubkey::find_program_address(&[b"config"], &PROGRAM_ID).0
}

fn derive_fee_vault() -> Pubkey {
    Pubkey::find_program_address(&[b"fee_vault"], &PROGRAM_ID).0
}

// ═══════════════════════════════════════════════════════════
//  Public API
// ═══════════════════════════════════════════════════════════

/// Token identifier — symbol or Pubkey.
pub enum TokenId {
    Symbol(String),
    Mint(Pubkey),
}

impl From<&str> for TokenId {
    fn from(s: &str) -> Self { TokenId::Symbol(s.to_string()) }
}
impl From<Pubkey> for TokenId {
    fn from(p: Pubkey) -> Self { TokenId::Mint(p) }
}

/// Parameters for local instruction building.
pub struct LocalBuildParams {
    pub payer: Pubkey,
    pub token: TokenId,
    pub amount: f64,
    pub tier: FlashTier,
}

/// Result of local instruction building.
pub struct LocalBuildResult {
    pub begin_flash: Instruction,
    pub end_flash: Instruction,
    pub token_mint: Pubkey,
    pub decimals: u8,
    pub expected_fee_native: u64,
}

/// Build begin_flash and end_flash instructions 100% locally.
///
/// **Zero network calls.** ~0.1ms execution time.
/// Supports ALL 60+ tokens when registry is synced.
pub fn local_build(params: LocalBuildParams) -> Result<LocalBuildResult, String> {
    let (token_mint, decimals) = match &params.token {
        TokenId::Symbol(sym) => {
            let entry = get_token(sym)
                .ok_or_else(|| {
                    let available: Vec<String> = get_all_tokens().iter().map(|t| t.symbol.clone()).collect();
                    format!(
                        "Unknown token: {}. Available: {}. Use TokenId::Mint for unlisted tokens, or call sync_registry() first.",
                        sym, available.join(", ")
                    )
                })?;
            (entry.mint, entry.decimals)
        }
        TokenId::Mint(mint) => {
            let entry = get_token(&mint.to_string());
            let decimals = entry.map(|e| e.decimals).unwrap_or(9);
            (*mint, decimals)
        }
    };

    let fee_bps = params.tier.fee_bps();
    let decimals_factor = 10u64.pow(decimals as u32);
    let amount_native = (params.amount * decimals_factor as f64) as u64;
    let expected_fee_native = (amount_native as u128)
        .checked_mul(fee_bps as u128)
        .and_then(|v| v.checked_div(10_000))
        .ok_or_else(|| "Fee calculation overflow".to_string())? as u64;

    let flash_state = derive_flash_state(&params.payer, &token_mint);
    let source_tier = params.tier as u8;

    let mut begin_data = Vec::with_capacity(57);
    begin_data.extend_from_slice(&*DISC_BEGIN_FLASH);
    begin_data.extend_from_slice(token_mint.as_ref());
    begin_data.extend_from_slice(&amount_native.to_le_bytes());
    begin_data.extend_from_slice(&expected_fee_native.to_le_bytes());
    begin_data.push(source_tier);

    let begin_flash = Instruction {
        program_id: *PROGRAM_ID,
        accounts: vec![
            AccountMeta::new(params.payer, true),
            AccountMeta::new(flash_state, false),
            AccountMeta::new_readonly(*CONFIG_PDA, false),
            AccountMeta::new_readonly(sysvar::instructions::id(), false),
            AccountMeta::new_readonly(system_program::id(), false),
        ],
        data: begin_data,
    };

    let mut end_data = Vec::with_capacity(16);
    end_data.extend_from_slice(&*DISC_END_FLASH);
    end_data.extend_from_slice(&amount_native.to_le_bytes());

    let end_flash = Instruction {
        program_id: *PROGRAM_ID,
        accounts: vec![
            AccountMeta::new(params.payer, true),
            AccountMeta::new(flash_state, false),
            AccountMeta::new(*FEE_VAULT_PDA, false),
            AccountMeta::new_readonly(sysvar::instructions::id(), false),
            AccountMeta::new_readonly(system_program::id(), false),
        ],
        data: end_data,
    };

    Ok(LocalBuildResult {
        begin_flash,
        end_flash,
        token_mint,
        decimals,
        expected_fee_native,
    })
}