Skip to main content

vaea_flash_sdk/
local_builder.rs

1/// VAEA Flash — Local Instruction Builder (Rust)
2///
3/// Constructs begin_flash and end_flash instructions 100% client-side.
4/// Eliminates the HTTP call to /v1/build — saves ~80-100ms per TX.
5///
6/// Supports ALL 60+ tokens via dynamic registry synced from /v1/capacity.
7/// Falls back to 12 core tokens if API is unavailable.
8///
9/// Zero RPC, zero HTTP at build time.
10
11#[allow(deprecated)]
12use solana_sdk::{
13    instruction::{AccountMeta, Instruction},
14    pubkey::Pubkey,
15    system_program,
16    sysvar,
17};
18use sha2::{Sha256, Digest};
19use std::collections::HashMap;
20use std::sync::RwLock;
21
22use crate::types::{VAEA_PROGRAM_ID, VAEA_API_URL, FlashTier, TokenCapacity};
23
24// ═══════════════════════════════════════════════════════════
25//  TokenEntry — stored in the registry
26// ═══════════════════════════════════════════════════════════
27
28/// A token entry in the dynamic registry.
29#[derive(Debug, Clone)]
30pub struct TokenEntry {
31    pub symbol: String,
32    pub mint: Pubkey,
33    pub name: String,
34    pub decimals: u8,
35    pub max_amount: f64,
36    pub max_amount_usd: f64,
37    pub source_protocol: String,
38    pub route_type: String,
39    pub status: String,
40    pub logo_uri: Option<String>,
41}
42
43// ═══════════════════════════════════════════════════════════
44//  Constants
45// ═══════════════════════════════════════════════════════════
46
47lazy_static::lazy_static! {
48    static ref PROGRAM_ID: Pubkey = VAEA_PROGRAM_ID.parse().unwrap();
49    static ref DISC_BEGIN_FLASH: [u8; 8] = anchor_discriminator("begin_flash");
50    static ref DISC_END_FLASH: [u8; 8] = anchor_discriminator("end_flash");
51    static ref CONFIG_PDA: Pubkey = derive_config();
52    static ref FEE_VAULT_PDA: Pubkey = derive_fee_vault();
53
54    /// The dynamic token registry — starts with 12 fallbacks, syncs to 60+
55    /// Key: lowercase symbol AND mint base58
56    static ref TOKEN_REGISTRY: RwLock<HashMap<String, TokenEntry>> = {
57        let mut m = HashMap::new();
58        for (sym, mint_str, name, dec) in FALLBACK_TOKENS.iter() {
59            if let Ok(mint) = mint_str.parse::<Pubkey>() {
60                let entry = TokenEntry {
61                    symbol: sym.to_string(),
62                    mint,
63                    name: name.to_string(),
64                    decimals: *dec,
65                    max_amount: 0.0,
66                    max_amount_usd: 0.0,
67                    source_protocol: "unknown".to_string(),
68                    route_type: "direct".to_string(),
69                    status: "unknown".to_string(),
70                    logo_uri: None,
71                };
72                m.insert(sym.to_lowercase(), entry.clone());
73                m.insert(mint_str.to_string(), entry);
74            }
75        }
76        RwLock::new(m)
77    };
78
79    static ref REGISTRY_SYNCED: RwLock<bool> = RwLock::new(false);
80}
81
82/// 12 core fallback tokens: (symbol, mint, name, decimals)
83const FALLBACK_TOKENS: [(&str, &str, &str, u8); 12] = [
84    ("SOL",      "So11111111111111111111111111111111111111112",   "Solana",          9),
85    ("USDC",     "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "USD Coin",        6),
86    ("USDT",     "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "Tether USD",      6),
87    ("JitoSOL",  "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", "Jito Staked SOL", 9),
88    ("JupSOL",   "jupSoLaHXQiZZTSfEWMTRRgpnyFm8f6sZdosWBjx93v",  "Jupiter SOL",     9),
89    ("JUP",      "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",  "Jupiter",         6),
90    ("JLP",      "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4", "Jupiter LP",      6),
91    ("cbBTC",    "cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij",  "Coinbase BTC",    8),
92    ("mSOL",     "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So",  "Marinade SOL",    9),
93    ("bSOL",     "bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1",  "Blaze SOL",       9),
94    ("INF",      "5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm", "Infinity",        9),
95    ("laineSOL", "LAinEtNLgpmCP9Rvsf5Hn8W6EhNiKLZQti1xfWMLy6X",  "Laine SOL",       9),
96];
97
98/// Blacklisted symbols
99const BLACKLIST: [&str; 4] = ["EURCV", "FBTC", "USDCV", "wstUSR"];
100
101// ═══════════════════════════════════════════════════════════
102//  Token filtering — same logic as frontend
103// ═══════════════════════════════════════════════════════════
104
105fn is_valid_token(t: &TokenCapacity) -> bool {
106    let sym = &t.symbol;
107    // Always keep known core tokens
108    if FALLBACK_TOKENS.iter().any(|(s, _, _, _)| s.eq_ignore_ascii_case(sym)) {
109        return true;
110    }
111    // Exclude blacklisted
112    if BLACKLIST.contains(&sym.as_str()) { return false; }
113    // Exclude SPL Single Pool tokens
114    if t.name.starts_with("SPL Single Pool") { return false; }
115    // Exclude Kamino LP tokens (kSOL-BSOL etc.)
116    if sym.len() > 2 && sym.starts_with('k') && sym.contains('-') {
117        let after_k = &sym[1..];
118        if after_k.chars().next().map_or(false, |c| c.is_uppercase()) {
119            return false;
120        }
121    }
122    // Must have a real name
123    if t.name.is_empty() || t.name == "Unknown Token" { return false; }
124    // Symbol must be human-readable
125    if sym.len() >= 12 || sym.contains("...") { return false; }
126    true
127}
128
129// ═══════════════════════════════════════════════════════════
130//  Registry sync
131// ═══════════════════════════════════════════════════════════
132
133/// Update the registry from a vec of TokenCapacity (from /v1/capacity).
134/// Called by sync_registry() and by WarmCache on each refresh.
135pub fn update_registry_from_capacity(tokens: &[TokenCapacity]) {
136    let mut reg = TOKEN_REGISTRY.write().unwrap();
137    for t in tokens {
138        if !is_valid_token(t) { continue; }
139        if let Ok(mint) = t.mint.parse::<Pubkey>() {
140            let entry = TokenEntry {
141                symbol: t.symbol.clone(),
142                mint,
143                name: if t.name.is_empty() { t.symbol.clone() } else { t.name.clone() },
144                decimals: t.decimals,
145                max_amount: t.max_amount,
146                max_amount_usd: t.max_amount_usd,
147                source_protocol: t.source_protocol.clone(),
148                route_type: t.route_type.clone(),
149                status: t.status.clone(),
150                logo_uri: None,
151            };
152            reg.insert(t.symbol.to_lowercase(), entry.clone());
153            reg.insert(t.mint.clone(), entry);
154        }
155    }
156    *REGISTRY_SYNCED.write().unwrap() = true;
157}
158
159/// Sync the token registry from the VAEA API.
160pub async fn sync_registry(api_url: Option<&str>) -> usize {
161    let url = api_url.unwrap_or(VAEA_API_URL);
162    match reqwest::get(&format!("{}/v1/capacity", url)).await {
163        Ok(res) => {
164            if let Ok(data) = res.json::<crate::types::CapacityResponse>().await {
165                update_registry_from_capacity(&data.tokens);
166            }
167        }
168        Err(_) => {}
169    }
170    let reg = TOKEN_REGISTRY.read().unwrap();
171    // Count unique symbols (not mint duplicates)
172    let mut seen = std::collections::HashSet::new();
173    for entry in reg.values() { seen.insert(entry.symbol.clone()); }
174    seen.len()
175}
176
177/// Check if registry has been synced
178pub fn is_registry_synced() -> bool {
179    *REGISTRY_SYNCED.read().unwrap()
180}
181
182/// Look up a token by symbol or mint string.
183pub fn get_token(token: &str) -> Option<TokenEntry> {
184    let reg = TOKEN_REGISTRY.read().unwrap();
185    reg.get(&token.to_lowercase()).cloned()
186        .or_else(|| reg.get(token).cloned())
187}
188
189/// Get all unique tokens in the registry, sorted by USD liquidity.
190pub fn get_all_tokens() -> Vec<TokenEntry> {
191    let reg = TOKEN_REGISTRY.read().unwrap();
192    let mut seen = std::collections::HashSet::new();
193    let mut result: Vec<TokenEntry> = Vec::new();
194    for entry in reg.values() {
195        if seen.insert(entry.symbol.clone()) {
196            result.push(entry.clone());
197        }
198    }
199    result.sort_by(|a, b| b.max_amount_usd.partial_cmp(&a.max_amount_usd).unwrap_or(std::cmp::Ordering::Equal));
200    result
201}
202
203// ═══════════════════════════════════════════════════════════
204//  PDA derivation
205// ═══════════════════════════════════════════════════════════
206
207fn anchor_discriminator(name: &str) -> [u8; 8] {
208    let mut hasher = Sha256::new();
209    hasher.update(format!("global:{}", name));
210    let result = hasher.finalize();
211    let mut disc = [0u8; 8];
212    disc.copy_from_slice(&result[..8]);
213    disc
214}
215
216fn derive_flash_state(payer: &Pubkey, token_mint: &Pubkey) -> Pubkey {
217    Pubkey::find_program_address(
218        &[b"flash", payer.as_ref(), token_mint.as_ref()],
219        &PROGRAM_ID,
220    ).0
221}
222
223fn derive_config() -> Pubkey {
224    Pubkey::find_program_address(&[b"config"], &PROGRAM_ID).0
225}
226
227fn derive_fee_vault() -> Pubkey {
228    Pubkey::find_program_address(&[b"fee_vault"], &PROGRAM_ID).0
229}
230
231// ═══════════════════════════════════════════════════════════
232//  Public API
233// ═══════════════════════════════════════════════════════════
234
235/// Token identifier — symbol or Pubkey.
236pub enum TokenId {
237    Symbol(String),
238    Mint(Pubkey),
239}
240
241impl From<&str> for TokenId {
242    fn from(s: &str) -> Self { TokenId::Symbol(s.to_string()) }
243}
244impl From<Pubkey> for TokenId {
245    fn from(p: Pubkey) -> Self { TokenId::Mint(p) }
246}
247
248/// Parameters for local instruction building.
249pub struct LocalBuildParams {
250    pub payer: Pubkey,
251    pub token: TokenId,
252    pub amount: f64,
253    pub tier: FlashTier,
254}
255
256/// Result of local instruction building.
257pub struct LocalBuildResult {
258    pub begin_flash: Instruction,
259    pub end_flash: Instruction,
260    pub token_mint: Pubkey,
261    pub decimals: u8,
262    pub expected_fee_native: u64,
263}
264
265/// Build begin_flash and end_flash instructions 100% locally.
266///
267/// **Zero network calls.** ~0.1ms execution time.
268/// Supports ALL 60+ tokens when registry is synced.
269pub fn local_build(params: LocalBuildParams) -> Result<LocalBuildResult, String> {
270    let (token_mint, decimals) = match &params.token {
271        TokenId::Symbol(sym) => {
272            let entry = get_token(sym)
273                .ok_or_else(|| {
274                    let available: Vec<String> = get_all_tokens().iter().map(|t| t.symbol.clone()).collect();
275                    format!(
276                        "Unknown token: {}. Available: {}. Use TokenId::Mint for unlisted tokens, or call sync_registry() first.",
277                        sym, available.join(", ")
278                    )
279                })?;
280            (entry.mint, entry.decimals)
281        }
282        TokenId::Mint(mint) => {
283            let entry = get_token(&mint.to_string());
284            let decimals = entry.map(|e| e.decimals).unwrap_or(9);
285            (*mint, decimals)
286        }
287    };
288
289    let fee_bps = params.tier.fee_bps();
290    let decimals_factor = 10u64.pow(decimals as u32);
291    let amount_native = (params.amount * decimals_factor as f64) as u64;
292    let expected_fee_native = (amount_native as u128)
293        .checked_mul(fee_bps as u128)
294        .and_then(|v| v.checked_div(10_000))
295        .ok_or_else(|| "Fee calculation overflow".to_string())? as u64;
296
297    let flash_state = derive_flash_state(&params.payer, &token_mint);
298    let source_tier = params.tier as u8;
299
300    let mut begin_data = Vec::with_capacity(57);
301    begin_data.extend_from_slice(&*DISC_BEGIN_FLASH);
302    begin_data.extend_from_slice(token_mint.as_ref());
303    begin_data.extend_from_slice(&amount_native.to_le_bytes());
304    begin_data.extend_from_slice(&expected_fee_native.to_le_bytes());
305    begin_data.push(source_tier);
306
307    let begin_flash = Instruction {
308        program_id: *PROGRAM_ID,
309        accounts: vec![
310            AccountMeta::new(params.payer, true),
311            AccountMeta::new(flash_state, false),
312            AccountMeta::new_readonly(*CONFIG_PDA, false),
313            AccountMeta::new_readonly(sysvar::instructions::id(), false),
314            AccountMeta::new_readonly(system_program::id(), false),
315        ],
316        data: begin_data,
317    };
318
319    let mut end_data = Vec::with_capacity(16);
320    end_data.extend_from_slice(&*DISC_END_FLASH);
321    end_data.extend_from_slice(&amount_native.to_le_bytes());
322
323    let end_flash = Instruction {
324        program_id: *PROGRAM_ID,
325        accounts: vec![
326            AccountMeta::new(params.payer, true),
327            AccountMeta::new(flash_state, false),
328            AccountMeta::new(*FEE_VAULT_PDA, false),
329            AccountMeta::new_readonly(sysvar::instructions::id(), false),
330            AccountMeta::new_readonly(system_program::id(), false),
331        ],
332        data: end_data,
333    };
334
335    Ok(LocalBuildResult {
336        begin_flash,
337        end_flash,
338        token_mint,
339        decimals,
340        expected_fee_native,
341    })
342}