1use crate::core::events::{EventMetadata, RawInstructionDexType, TradeParseStatus};
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 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 #[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 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 pub swap_fee_bps: Option<f64>,
54 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
65pub struct StockTokenCatalog {
66 pub tokens: Vec<StockToken>,
67 pub pools: Vec<StockPool>,
68}
69
70impl StockTokenCatalog {
71 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 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 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 pub max_age_us: i64,
161 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#[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 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 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
223pub 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 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 pub fn reset(&mut self) {
296 self.reference = None;
297 self.liquidity.clear();
298 self.last_position.clear();
299 }
300
301 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 pub fn process(&mut self, event: &DexEvent, now_us: i64) -> Option<StockTokenPriceEvent> {
337 if self.pools.is_empty()
338 || event.trade_parse_status() != Some(TradeParseStatus::ExecutionConfirmed)
339 {
340 return None;
341 }
342 let swap = executed_swap(event)?;
343 let metadata = event.metadata();
344 if swap.input_amount == 0
345 || swap.output_amount == 0
346 || !self.fresh(metadata.grpc_recv_us, metadata.slot, metadata, now_us)
347 {
348 return None;
349 }
350 let position = (metadata.slot, metadata.tx_index);
351 if self.last_position.get(&swap.pool).is_some_and(|old| *old > position) {
352 return None;
353 }
354 if swap.pool == self.config.sol_usdc_pool && swap.dex == self.config.sol_usdc_dex {
355 let (sol, usdc) = if swap.input == WSOL && swap.output == USDC {
356 (swap.input_amount, swap.output_amount)
357 } else if swap.input == USDC && swap.output == WSOL {
358 (swap.output_amount, swap.input_amount)
359 } else {
360 return None;
361 };
362 self.reference = Some(SolUsdcReference {
363 pool: swap.pool,
364 usdc_per_sol: usdc as f64 / sol as f64 * 1000.0,
365 metadata: metadata.clone(),
366 });
367 self.last_position.insert(swap.pool, position);
368 return None;
369 }
370 let tracked = self.pools.get(&swap.pool)?;
371 let pool = &tracked.pool;
372 if pool.dex_type != swap.dex {
373 return None;
374 }
375 let token = self.tokens.get(&pool.mint)?;
376 let (stock, quote) = if swap.input == pool.mint && swap.output == pool.quote_mint {
377 (swap.input_amount, swap.output_amount)
378 } else if swap.output == pool.mint && swap.input == pool.quote_mint {
379 (swap.output_amount, swap.input_amount)
380 } else {
381 return None;
382 };
383 let price = quote as f64 / stock as f64 * tracked.quote_scale;
384 if !price.is_finite() || price <= 0.0 {
385 return None;
386 }
387 let reference = self
388 .reference
389 .as_ref()
390 .filter(|r| {
391 (r.metadata.slot, r.metadata.tx_index) < position
394 && self.fresh(r.metadata.grpc_recv_us, r.metadata.slot, metadata, now_us)
395 })
396 .cloned();
397 let (price_sol, price_usdc) = if pool.quote_mint == WSOL {
398 (Some(price), reference.as_ref().map(|r| price * r.usdc_per_sol))
399 } else {
400 (reference.as_ref().map(|r| price / r.usdc_per_sol), Some(price))
401 };
402 let liquidity = self
403 .liquidity
404 .get(&swap.pool)
405 .filter(|l| self.fresh(l.observed_us, l.slot, metadata, now_us))
406 .cloned();
407 self.last_position.insert(swap.pool, position);
408 Some(StockTokenPriceEvent {
409 metadata: metadata.clone(),
410 mint: token.mint,
411 symbol: token.symbol.clone(),
412 pool: pool.pool,
413 dex_type: pool.dex_type,
414 quote_mint: pool.quote_mint,
415 price_in_quote: price,
416 price_sol,
417 price_usdc,
418 basis: StockPriceBasis::ReportedSwapAmounts,
419 stock_amount_raw: stock,
420 quote_amount_raw: quote,
421 sol_usdc_reference: reference,
422 liquidity,
423 })
424 }
425}
426
427struct ExecutedSwap {
428 pool: Pubkey,
429 dex: RawInstructionDexType,
430 input: Pubkey,
431 output: Pubkey,
432 input_amount: u64,
433 output_amount: u64,
434}
435
436fn executed_swap(event: &DexEvent) -> Option<ExecutedSwap> {
437 use RawInstructionDexType::*;
438 let (pool, dex, input, output, input_amount, output_amount) = match event {
439 DexEvent::RaydiumCpmmSwap(e) if e.input_vault_before > 0 && e.output_vault_before > 0 => (
440 e.pool_id,
441 RaydiumCpmm,
442 e.input_token_mint,
443 e.output_token_mint,
444 e.input_amount,
445 e.output_amount,
446 ),
447 DexEvent::RaydiumClmmSwap(e) if e.sqrt_price_x64 > 0 => {
448 let (a, b) =
449 if e.zero_for_one { (e.amount_0, e.amount_1) } else { (e.amount_1, e.amount_0) };
450 (e.pool_state, RaydiumClmm, e.input_mint, e.output_mint, a, b)
451 }
452 DexEvent::OrcaWhirlpoolSwap(e) if e.post_sqrt_price > 0 => {
453 let (a, b) = if e.a_to_b {
454 (e.token_mint_a, e.token_mint_b)
455 } else {
456 (e.token_mint_b, e.token_mint_a)
457 };
458 (e.whirlpool, OrcaWhirlpool, a, b, e.input_amount, e.output_amount)
459 }
460 DexEvent::MeteoraDlmmSwap(e) if matches!(e.event_version, 1 | 2) => {
464 let (a, b) = if e.swap_for_y {
465 (e.token_x_mint, e.token_y_mint)
466 } else {
467 (e.token_y_mint, e.token_x_mint)
468 };
469 (e.pool, MeteoraDlmm, a, b, e.amount_in, e.amount_out)
470 }
471 _ => return None,
472 };
473 Some(ExecutedSwap { pool, dex, input, output, input_amount, output_amount })
474}
475
476pub struct StockPriceSubscription {
479 grpc: YellowstoneGrpc,
480 queue: Arc<ArrayQueue<DexEvent>>,
481 tracker: StockPriceTracker,
482 revision: u64,
483}
484
485impl StockPriceSubscription {
486 pub async fn subscribe(
487 grpc: YellowstoneGrpc,
488 config: StockPriceConfig,
489 ) -> Result<Self, Box<dyn std::error::Error>> {
490 let tracker = StockPriceTracker::new(config).map_err(std::io::Error::other)?;
491 if tracker.pools.is_empty() {
492 return Err("stock price subscription needs configured pools".into());
493 }
494 let mut accounts: Vec<String> = tracker.pools.keys().map(ToString::to_string).collect();
495 accounts.push(tracker.config.sol_usdc_pool.to_string());
496 let filter = EventTypeFilter::include_only(vec![
497 EventType::RaydiumCpmmSwap,
498 EventType::RaydiumClmmSwap,
499 EventType::OrcaWhirlpoolSwap,
500 EventType::MeteoraDlmmSwap,
501 ]);
502 let queue = grpc
503 .subscribe_dex_events(
504 vec![TransactionFilter { account_include: accounts, ..Default::default() }],
505 vec![],
506 Some(filter),
507 )
508 .await?;
509 let revision = grpc.subscription_status().continuity_revision;
510 Ok(Self { grpc, queue, tracker, revision })
511 }
512
513 pub fn update_liquidity(&mut self, snapshot: StockPoolLiquidity) -> Result<(), String> {
514 self.tracker.update_liquidity(snapshot)
515 }
516
517 pub fn pop(&mut self, now_us: i64) -> Option<StockTokenPriceEvent> {
519 let status = self.grpc.subscription_status();
520 if status.continuity_revision != self.revision || !status.connected {
521 self.revision = status.continuity_revision;
522 self.tracker.reset();
523 for _ in 0..self.queue.len() {
524 self.queue.pop();
525 }
526 return None;
527 }
528 for _ in 0..256 {
529 let event = self.queue.pop()?;
530 if let Some(price) = self.tracker.process(&event, now_us) {
531 if self.grpc.subscription_status().continuity_revision != self.revision {
532 self.tracker.reset();
533 return None;
534 }
535 return Some(price);
536 }
537 }
538 None
539 }
540
541 pub async fn stop(&self) {
542 self.grpc.stop().await;
543 }
544}