Skip to main content

sol_parser_sdk/
stock_prices.rs

1//! Opt-in stock/ETF execution-price notifications. No HTTP or RPC in this module.
2//! Prices describe reported swap amounts, not executable quotes or stock NAV.
3use crate::core::events::{EventMetadata, RawInstructionDexType};
4use crate::grpc::{EventType, EventTypeFilter, TransactionFilter, YellowstoneGrpc};
5use crate::DexEvent;
6use crossbeam_queue::ArrayQueue;
7use serde::{Deserialize, Serialize};
8use solana_sdk::pubkey::Pubkey;
9use std::{collections::HashMap, sync::Arc};
10
11pub const WSOL: Pubkey = solana_sdk::pubkey!("So11111111111111111111111111111111111111112");
12pub const USDC: Pubkey = solana_sdk::pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
13pub const DEFAULT_SOL_USDC_POOL: Pubkey =
14    solana_sdk::pubkey!("Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE");
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct StockToken {
18    pub mint: Pubkey,
19    pub symbol: String,
20    pub decimals: u8,
21    /// Identity directory, not an assertion about legal issuance or backing.
22    pub catalog_source: String,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct StockPool {
27    pub pool: Pubkey,
28    pub mint: Pubkey,
29    pub quote_mint: Pubkey,
30    pub dex_type: RawInstructionDexType,
31    /// Historical admission evidence, NOT a live fee quote.
32    #[serde(default)]
33    pub catalog_fee_snapshot: Option<StockPoolFeeSnapshot>,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct StockTokenTransferTax {
38    pub basis_points: u16,
39    /// Cap in this mint's raw units. Effective tax depends on transfer size.
40    pub maximum_fee_raw: u64,
41    pub transfer_hook: bool,
42    #[serde(default)]
43    pub transfer_hook_program: Option<String>,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
47pub struct StockPoolFeeSnapshot {
48    pub snapshot_slot: u64,
49    pub epoch: u64,
50    pub unix_timestamp: i64,
51    pub base_swap_fee_bps: f64,
52    /// None for dynamic/unknown rates; the base rate is not the total rate.
53    pub swap_fee_bps: Option<f64>,
54    /// Historical initial active-bin/tick rate, including dynamic fees when known.
55    /// Not a live fee or the average fee for an entire order.
56    #[serde(default)]
57    pub snapshot_start_swap_fee_bps: Option<f64>,
58    pub dynamic_swap_fee: bool,
59    pub stock_transfer_tax: StockTokenTransferTax,
60    pub quote_transfer_tax: StockTokenTransferTax,
61}
62
63/// Empty means disabled; `screened_defaults()` explicitly opts into the catalog.
64#[derive(Clone, Debug, Default, Serialize, Deserialize)]
65pub struct StockTokenCatalog {
66    pub tokens: Vec<StockToken>,
67    pub pools: Vec<StockPool>,
68}
69
70impl StockTokenCatalog {
71    /// Select verified stock-conversion pools from a successful transaction,
72    /// independent of its launch platform, wrapper or other swap legs.
73    /// These are historical identity clues, not a connected path or live quotes.
74    /// Failed transactions and mismatched pool/program/mint identities are excluded.
75    pub fn conversion_legs<'a>(
76        &'a self,
77        route: &'a crate::transaction_route::TransactionRoute,
78    ) -> impl Iterator<Item = &'a crate::transaction_route::RouteSwapLeg> + 'a {
79        use crate::instr::program_ids::*;
80        use crate::transaction_route::SwapProtocol;
81        route.legs.iter().filter(move |leg| {
82            if !route.succeeded {
83                return false;
84            }
85            let dex = match (leg.protocol, leg.program) {
86                (SwapProtocol::RaydiumCpmm, RAYDIUM_CPMM_PROGRAM_ID) => {
87                    RawInstructionDexType::RaydiumCpmm
88                }
89                (SwapProtocol::RaydiumClmm, RAYDIUM_CLMM_PROGRAM_ID) => {
90                    RawInstructionDexType::RaydiumClmm
91                }
92                (SwapProtocol::OrcaWhirlpool, ORCA_WHIRLPOOL_PROGRAM_ID) => {
93                    RawInstructionDexType::OrcaWhirlpool
94                }
95                (SwapProtocol::MeteoraDlmm, METEORA_DLMM_PROGRAM_ID) => {
96                    RawInstructionDexType::MeteoraDlmm
97                }
98                _ => return false,
99            };
100            self.pools.iter().any(|pool| {
101                pool.pool == leg.pool
102                    && pool.dex_type == dex
103                    && ((leg.input_mint == Some(pool.mint)
104                        && leg.output_mint == Some(pool.quote_mint))
105                        || (leg.input_mint == Some(pool.quote_mint)
106                            && leg.output_mint == Some(pool.mint)))
107            })
108        })
109    }
110
111    /// Point-in-time identities screened on 2026-10-20, not live eligibility.
112    pub fn screened_defaults() -> Self {
113        let data: serde_json::Value =
114            serde_json::from_str(include_str!("stock_token_catalog.json"))
115                .expect("bundled stock catalog is tested");
116        let key = |v: &serde_json::Value, field: &str| -> Pubkey {
117            v[field].as_str().unwrap().parse().expect("tested catalog address")
118        };
119        Self {
120            tokens: data["tokens"]
121                .as_array()
122                .unwrap()
123                .iter()
124                .map(|v| StockToken {
125                    mint: key(v, "mint"),
126                    symbol: v["symbol"].as_str().unwrap().into(),
127                    decimals: v["decimals"].as_u64().unwrap() as u8,
128                    catalog_source: v["catalog_source"].as_str().unwrap().into(),
129                })
130                .collect(),
131            pools: data["pools"]
132                .as_array()
133                .unwrap()
134                .iter()
135                .map(|v| StockPool {
136                    pool: key(v, "pool"),
137                    mint: key(v, "mint"),
138                    quote_mint: key(v, "quote_mint"),
139                    dex_type: serde_json::from_value(v["dex_type"].clone()).unwrap(),
140                    catalog_fee_snapshot: serde_json::from_value(v["catalog_fee_snapshot"].clone())
141                        .unwrap(),
142                })
143                .collect(),
144        }
145    }
146
147    /// First screened pool for a mint, ordered by known snapshot-start nominal fees in the
148    /// bundled catalog. Historical preference only; not a live routing quote.
149    pub fn preferred_pool(&self, mint: &Pubkey) -> Option<&StockPool> {
150        self.pools.iter().find(|pool| &pool.mint == mint)
151    }
152}
153
154#[derive(Clone, Debug)]
155pub struct StockPriceConfig {
156    pub catalog: StockTokenCatalog,
157    pub sol_usdc_pool: Pubkey,
158    pub sol_usdc_dex: RawInstructionDexType,
159    /// Local receive-time lifetime for conversion and optional liquidity snapshots.
160    pub max_age_us: i64,
161    /// Also reject cached data more than this many slots behind the trade.
162    pub max_slot_lag: u64,
163}
164
165impl Default for StockPriceConfig {
166    fn default() -> Self {
167        Self {
168            catalog: StockTokenCatalog::default(),
169            sol_usdc_pool: DEFAULT_SOL_USDC_POOL,
170            sol_usdc_dex: RawInstructionDexType::OrcaWhirlpool,
171            max_age_us: 30_000_000,
172            max_slot_lag: 75,
173        }
174    }
175}
176
177/// Optional independently collected TVL/depth, never inferred from CLMM liquidity.
178#[derive(Clone, Debug, Serialize, Deserialize)]
179pub struct StockPoolLiquidity {
180    pub pool: Pubkey,
181    pub slot: u64,
182    pub observed_us: i64,
183    pub tvl_usdc: Option<f64>,
184    pub buy_depth_usdc: Option<f64>,
185    pub sell_depth_usdc: Option<f64>,
186    /// Maximum tolerated price impact in basis points; must be below 10,000.
187    pub depth_max_price_impact_bps: Option<u16>,
188    pub source: String,
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize)]
192pub struct SolUsdcReference {
193    pub pool: Pubkey,
194    pub usdc_per_sol: f64,
195    pub metadata: EventMetadata,
196}
197
198#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
199pub enum StockPriceBasis {
200    /// Ratio of the protocol's reported executed amounts, including its fee semantics.
201    /// Not a fee-adjusted user receipt, pool mid-price, or a quote for another size.
202    ReportedSwapAmounts,
203}
204
205#[derive(Clone, Debug, Serialize, Deserialize)]
206pub struct StockTokenPriceEvent {
207    pub metadata: EventMetadata,
208    pub mint: Pubkey,
209    pub symbol: String,
210    pub pool: Pubkey,
211    pub dex_type: RawInstructionDexType,
212    pub quote_mint: Pubkey,
213    pub price_in_quote: f64,
214    pub price_sol: Option<f64>,
215    pub price_usdc: Option<f64>,
216    pub basis: StockPriceBasis,
217    pub stock_amount_raw: u64,
218    pub quote_amount_raw: u64,
219    pub sol_usdc_reference: Option<SolUsdcReference>,
220    pub liquidity: Option<StockPoolLiquidity>,
221}
222
223/// Stateful, network-free processor; usable with an existing DEX event callback.
224pub struct StockPriceTracker {
225    config: StockPriceConfig,
226    tokens: HashMap<Pubkey, StockToken>,
227    pools: HashMap<Pubkey, TrackedPool>,
228    reference: Option<SolUsdcReference>,
229    liquidity: HashMap<Pubkey, StockPoolLiquidity>,
230    last_position: HashMap<Pubkey, (u64, u64)>,
231}
232
233struct TrackedPool {
234    pool: StockPool,
235    /// Immutable decimal conversion computed once, outside event processing.
236    quote_scale: f64,
237}
238
239fn supported(dex: RawInstructionDexType) -> bool {
240    matches!(
241        dex,
242        RawInstructionDexType::RaydiumCpmm
243            | RawInstructionDexType::RaydiumClmm
244            | RawInstructionDexType::OrcaWhirlpool
245            | RawInstructionDexType::MeteoraDlmm
246    )
247}
248
249impl StockPriceTracker {
250    pub fn new(mut config: StockPriceConfig) -> Result<Self, String> {
251        if config.max_age_us <= 0
252            || !supported(config.sol_usdc_dex)
253            || config.sol_usdc_pool == Pubkey::default()
254        {
255            return Err("invalid price freshness or unsupported reference DEX".into());
256        }
257        let catalog = std::mem::take(&mut config.catalog);
258        let mut tokens = HashMap::with_capacity(catalog.tokens.len());
259        for token in catalog.tokens {
260            if token.mint == Pubkey::default()
261                || token.mint == WSOL
262                || token.mint == USDC
263                || token.decimals > 18
264                || tokens.insert(token.mint, token).is_some()
265            {
266                return Err("invalid or duplicate stock token".into());
267            }
268        }
269        let mut pools = HashMap::with_capacity(catalog.pools.len());
270        for pool in catalog.pools {
271            if !tokens.contains_key(&pool.mint)
272                || ![WSOL, USDC].contains(&pool.quote_mint)
273                || !supported(pool.dex_type)
274                || pool.pool == Pubkey::default()
275                || pool.pool == config.sol_usdc_pool
276                || pools.contains_key(&pool.pool)
277            {
278                return Err("invalid, duplicate or unsupported stock pool".into());
279            }
280            let quote_decimals = if pool.quote_mint == WSOL { 9 } else { 6 };
281            let quote_scale = 10_f64.powi(i32::from(tokens[&pool.mint].decimals) - quote_decimals);
282            pools.insert(pool.pool, TrackedPool { pool, quote_scale });
283        }
284        Ok(Self {
285            config,
286            tokens,
287            pools,
288            reference: None,
289            liquidity: HashMap::new(),
290            last_position: HashMap::new(),
291        })
292    }
293
294    /// Invoke after disconnects, queue loss, or a caller-detected fork rollback.
295    pub fn reset(&mut self) {
296        self.reference = None;
297        self.liquidity.clear();
298        self.last_position.clear();
299    }
300
301    /// Publish externally collected coherent liquidity. No automatic RPC fallback.
302    pub fn update_liquidity(&mut self, snapshot: StockPoolLiquidity) -> Result<(), String> {
303        if !self.pools.contains_key(&snapshot.pool)
304            || snapshot.observed_us <= 0
305            || snapshot.source.trim().is_empty()
306            || snapshot.depth_max_price_impact_bps.is_some_and(|bps| bps >= 10_000)
307            || [snapshot.tvl_usdc, snapshot.buy_depth_usdc, snapshot.sell_depth_usdc]
308                .into_iter()
309                .flatten()
310                .any(|x| !x.is_finite() || x < 0.0)
311            || ((snapshot.buy_depth_usdc.is_some() || snapshot.sell_depth_usdc.is_some())
312                && snapshot.depth_max_price_impact_bps.is_none())
313        {
314            return Err("invalid liquidity snapshot".into());
315        }
316        if let Some(old) = self.liquidity.get(&snapshot.pool) {
317            if snapshot.slot < old.slot || snapshot.observed_us < old.observed_us {
318                return Err("out-of-order liquidity snapshot".into());
319            }
320        }
321        self.liquidity.insert(snapshot.pool, snapshot);
322        Ok(())
323    }
324
325    fn fresh(&self, observed_us: i64, slot: u64, metadata: &EventMetadata, now_us: i64) -> bool {
326        observed_us > 0
327            && observed_us <= now_us
328            && now_us.saturating_sub(observed_us) <= self.config.max_age_us
329            && slot <= metadata.slot
330            && metadata.slot - slot <= self.config.max_slot_lag
331    }
332
333    /// One notification per supported confirmed swap in a configured pool.
334    /// Instruction-only Shred candidates never produce a price. `now_us` must
335    /// use the same wall-clock domain as EventMetadata.grpc_recv_us.
336    pub fn process(&mut self, event: &DexEvent, now_us: i64) -> Option<StockTokenPriceEvent> {
337        if self.pools.is_empty() {
338            return None;
339        }
340        let swap = executed_swap(event)?;
341        let metadata = event.metadata();
342        if swap.input_amount == 0
343            || swap.output_amount == 0
344            || !self.fresh(metadata.grpc_recv_us, metadata.slot, metadata, now_us)
345        {
346            return None;
347        }
348        let position = (metadata.slot, metadata.tx_index);
349        if self.last_position.get(&swap.pool).is_some_and(|old| *old > position) {
350            return None;
351        }
352        if swap.pool == self.config.sol_usdc_pool && swap.dex == self.config.sol_usdc_dex {
353            let (sol, usdc) = if swap.input == WSOL && swap.output == USDC {
354                (swap.input_amount, swap.output_amount)
355            } else if swap.input == USDC && swap.output == WSOL {
356                (swap.output_amount, swap.input_amount)
357            } else {
358                return None;
359            };
360            self.reference = Some(SolUsdcReference {
361                pool: swap.pool,
362                usdc_per_sol: usdc as f64 / sol as f64 * 1000.0,
363                metadata: metadata.clone(),
364            });
365            self.last_position.insert(swap.pool, position);
366            return None;
367        }
368        let tracked = self.pools.get(&swap.pool)?;
369        let pool = &tracked.pool;
370        if pool.dex_type != swap.dex {
371            return None;
372        }
373        let token = self.tokens.get(&pool.mint)?;
374        let (stock, quote) = if swap.input == pool.mint && swap.output == pool.quote_mint {
375            (swap.input_amount, swap.output_amount)
376        } else if swap.output == pool.mint && swap.input == pool.quote_mint {
377            (swap.output_amount, swap.input_amount)
378        } else {
379            return None;
380        };
381        let price = quote as f64 / stock as f64 * tracked.quote_scale;
382        if !price.is_finite() || price <= 0.0 {
383            return None;
384        }
385        let reference = self
386            .reference
387            .as_ref()
388            .filter(|r| {
389                // Metadata has no universal CPI position. A reference from the
390                // same transaction could execute after this swap, so exclude it.
391                (r.metadata.slot, r.metadata.tx_index) < position
392                    && self.fresh(r.metadata.grpc_recv_us, r.metadata.slot, metadata, now_us)
393            })
394            .cloned();
395        let (price_sol, price_usdc) = if pool.quote_mint == WSOL {
396            (Some(price), reference.as_ref().map(|r| price * r.usdc_per_sol))
397        } else {
398            (reference.as_ref().map(|r| price / r.usdc_per_sol), Some(price))
399        };
400        let liquidity = self
401            .liquidity
402            .get(&swap.pool)
403            .filter(|l| self.fresh(l.observed_us, l.slot, metadata, now_us))
404            .cloned();
405        self.last_position.insert(swap.pool, position);
406        Some(StockTokenPriceEvent {
407            metadata: metadata.clone(),
408            mint: token.mint,
409            symbol: token.symbol.clone(),
410            pool: pool.pool,
411            dex_type: pool.dex_type,
412            quote_mint: pool.quote_mint,
413            price_in_quote: price,
414            price_sol,
415            price_usdc,
416            basis: StockPriceBasis::ReportedSwapAmounts,
417            stock_amount_raw: stock,
418            quote_amount_raw: quote,
419            sol_usdc_reference: reference,
420            liquidity,
421        })
422    }
423}
424
425struct ExecutedSwap {
426    pool: Pubkey,
427    dex: RawInstructionDexType,
428    input: Pubkey,
429    output: Pubkey,
430    input_amount: u64,
431    output_amount: u64,
432}
433
434fn executed_swap(event: &DexEvent) -> Option<ExecutedSwap> {
435    use RawInstructionDexType::*;
436    let (pool, dex, input, output, input_amount, output_amount) = match event {
437        DexEvent::RaydiumCpmmSwap(e) if e.input_vault_before > 0 && e.output_vault_before > 0 => (
438            e.pool_id,
439            RaydiumCpmm,
440            e.input_token_mint,
441            e.output_token_mint,
442            e.input_amount,
443            e.output_amount,
444        ),
445        DexEvent::RaydiumClmmSwap(e) if e.sqrt_price_x64 > 0 => {
446            let (a, b) =
447                if e.zero_for_one { (e.amount_0, e.amount_1) } else { (e.amount_1, e.amount_0) };
448            (e.pool_state, RaydiumClmm, e.input_mint, e.output_mint, a, b)
449        }
450        DexEvent::OrcaWhirlpoolSwap(e) if e.post_sqrt_price > 0 => {
451            let (a, b) = if e.a_to_b {
452                (e.token_mint_a, e.token_mint_b)
453            } else {
454                (e.token_mint_b, e.token_mint_a)
455            };
456            (e.whirlpool, OrcaWhirlpool, a, b, e.input_amount, e.output_amount)
457        }
458        // Legacy exact-out instructions place max_input in amount_in. Successful
459        // execution alone cannot turn that limit into a price; require a decoded
460        // binary execution event (version 1/2), not instruction/text fallback.
461        DexEvent::MeteoraDlmmSwap(e) if matches!(e.event_version, 1 | 2) => {
462            let (a, b) = if e.swap_for_y {
463                (e.token_x_mint, e.token_y_mint)
464            } else {
465                (e.token_y_mint, e.token_x_mint)
466            };
467            (e.pool, MeteoraDlmm, a, b, e.amount_in, e.amount_out)
468        }
469        _ => return None,
470    };
471    Some(ExecutedSwap { pool, dex, input, output, input_amount, output_amount })
472}
473
474/// Dedicated opt-in price queue. Use a separate YellowstoneGrpc client: like
475/// subscribe_dex_events, subscribing replaces that client's previous subscription.
476pub struct StockPriceSubscription {
477    grpc: YellowstoneGrpc,
478    queue: Arc<ArrayQueue<DexEvent>>,
479    tracker: StockPriceTracker,
480    revision: u64,
481}
482
483impl StockPriceSubscription {
484    pub async fn subscribe(
485        grpc: YellowstoneGrpc,
486        config: StockPriceConfig,
487    ) -> Result<Self, Box<dyn std::error::Error>> {
488        let tracker = StockPriceTracker::new(config).map_err(std::io::Error::other)?;
489        if tracker.pools.is_empty() {
490            return Err("stock price subscription needs configured pools".into());
491        }
492        let mut accounts: Vec<String> = tracker.pools.keys().map(ToString::to_string).collect();
493        accounts.push(tracker.config.sol_usdc_pool.to_string());
494        let filter = EventTypeFilter::include_only(vec![
495            EventType::RaydiumCpmmSwap,
496            EventType::RaydiumClmmSwap,
497            EventType::OrcaWhirlpoolSwap,
498            EventType::MeteoraDlmmSwap,
499        ]);
500        let queue = grpc
501            .subscribe_dex_events(
502                vec![TransactionFilter { account_include: accounts, ..Default::default() }],
503                vec![],
504                Some(filter),
505            )
506            .await?;
507        let revision = grpc.subscription_status().continuity_revision;
508        Ok(Self { grpc, queue, tracker, revision })
509    }
510
511    pub fn update_liquidity(&mut self, snapshot: StockPoolLiquidity) -> Result<(), String> {
512        self.tracker.update_liquidity(snapshot)
513    }
514
515    /// Bounded drain; call again when None. Clears stale caches after stream gaps.
516    pub fn pop(&mut self, now_us: i64) -> Option<StockTokenPriceEvent> {
517        let status = self.grpc.subscription_status();
518        if status.continuity_revision != self.revision || !status.connected {
519            self.revision = status.continuity_revision;
520            self.tracker.reset();
521            for _ in 0..self.queue.len() {
522                self.queue.pop();
523            }
524            return None;
525        }
526        for _ in 0..256 {
527            let event = self.queue.pop()?;
528            if let Some(price) = self.tracker.process(&event, now_us) {
529                if self.grpc.subscription_status().continuity_revision != self.revision {
530                    self.tracker.reset();
531                    return None;
532                }
533                return Some(price);
534            }
535        }
536        None
537    }
538
539    pub async fn stop(&self) {
540        self.grpc.stop().await;
541    }
542}