Skip to main content

sol_parser_sdk/shredstream/
client.rs

1//! ShredStream 客户端
2//!
3//! `solana_entry::entry::Entry` 在 Agave SDK 中带 `deprecated`(需显式启用不稳定 feature 才消除);
4//! 本模块仍依赖其 bincode 布局解码 Shred 侧 `entries` 负载。
5#![allow(deprecated)]
6
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use crossbeam_queue::ArrayQueue;
12use futures::StreamExt;
13use solana_entry::entry::Entry as SolanaEntry;
14use solana_sdk::message::v0::LoadedAddresses;
15use solana_sdk::transaction::VersionedTransaction;
16use tokio::sync::Mutex;
17use tokio::task::JoinHandle;
18use tonic::metadata::MetadataValue;
19use tonic::transport::{Channel, Endpoint};
20
21use crate::core::now_micros;
22use crate::grpc::types::EventTypeFilter;
23use crate::shredstream::config::ShredStreamConfig;
24use crate::shredstream::proto::{Entry, ShredstreamProxyClient, SubscribeEntriesRequest};
25use crate::DexEvent;
26
27static SHREDSTREAM_DROPPED_EVENTS: AtomicU64 = AtomicU64::new(0);
28
29enum EventSink<'a> {
30    Queue(&'a Arc<ArrayQueue<DexEvent>>),
31    Callback(&'a (dyn Fn(DexEvent) + Send + Sync)),
32}
33
34impl EventSink<'_> {
35    #[inline]
36    fn deliver(&self, event: DexEvent) {
37        match self {
38            EventSink::Queue(queue) => {
39                if queue.push(event).is_err() {
40                    record_shredstream_dropped_event();
41                }
42            }
43            EventSink::Callback(callback) => callback(event),
44        }
45    }
46}
47
48#[inline]
49fn record_shredstream_dropped_event() -> u64 {
50    let dropped = SHREDSTREAM_DROPPED_EVENTS.fetch_add(1, Ordering::Relaxed) + 1;
51    if dropped <= 10 || dropped.is_power_of_two() {
52        log::warn!(
53            target: "sol_parser_sdk::shredstream",
54            "ShredStream event queue is full; dropped event count={}",
55            dropped
56        );
57    }
58    dropped
59}
60
61/// Synchronous ALT lookup from a prewarmed, slot-aware local cache. Called on
62/// the receive task; never perform RPC or blocking I/O here. Address identity
63/// and table activation at the requested slot are the caller's responsibility.
64/// Arguments: transaction, slot, recv_us, tx_index, event_type_filter. The last
65/// three values are the same local timestamp, batch-local index and borrowed
66/// filter passed to the parser. A miss can be handled here before returning Err.
67pub type AddressLookupResolver = dyn Fn(
68        &VersionedTransaction,
69        u64,
70        i64,
71        u64,
72        Option<&EventTypeFilter>,
73    ) -> crate::common::AnyResult<LoadedAddresses>
74    + Send
75    + Sync;
76
77/// ShredStream 客户端
78#[derive(Clone)]
79pub struct ShredStreamClient {
80    endpoint: String,
81    address_lookup_resolver: Option<Arc<AddressLookupResolver>>,
82    config: ShredStreamConfig,
83    subscription_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
84    subscription_lifecycle: Arc<Mutex<()>>,
85}
86
87impl ShredStreamClient {
88    /// 创建新客户端
89    pub async fn new(endpoint: impl Into<String>) -> crate::common::AnyResult<Self> {
90        Self::new_with_config(endpoint, ShredStreamConfig::default()).await
91    }
92
93    /// 使用自定义配置创建客户端
94    pub async fn new_with_config(
95        endpoint: impl Into<String>,
96        config: ShredStreamConfig,
97    ) -> crate::common::AnyResult<Self> {
98        let endpoint = endpoint.into();
99        // 测试连接
100        let _ = Self::connect_client(&endpoint, &config).await?;
101
102        Ok(Self {
103            endpoint,
104            address_lookup_resolver: None,
105            config,
106            subscription_handle: Arc::new(Mutex::new(None)),
107            subscription_lifecycle: Arc::new(Mutex::new(())),
108        })
109    }
110
111    /// Resolve ALT keys from a caller-owned cache before parsing. A cache miss
112    /// returns an error and skips SDK parsing of the transaction. The resolver
113    /// may inspect missing lookup keys or parse the transaction itself first.
114    /// Receives (transaction, slot, recv_us, tx_index, event_type_filter); the
115    /// transaction and filter are borrowed, with no additional cloning.
116    pub fn with_address_lookup_resolver<F>(mut self, resolver: F) -> Self
117    where
118        F: Fn(
119                &VersionedTransaction,
120                u64,
121                i64,
122                u64,
123                Option<&EventTypeFilter>,
124            ) -> crate::common::AnyResult<LoadedAddresses>
125            + Send
126            + Sync
127            + 'static,
128    {
129        self.address_lookup_resolver = Some(Arc::new(resolver));
130        self
131    }
132
133    /// 订阅 DEX 事件(自动重连)
134    ///
135    /// 返回一个队列,事件会被推送到该队列中
136    pub async fn subscribe(&self) -> crate::common::AnyResult<Arc<ArrayQueue<DexEvent>>> {
137        self.subscribe_with_filter(None).await
138    }
139
140    /// 订阅 DEX 事件,并在 ShredStream 热路径中按 SDK 事件类型提前过滤。
141    ///
142    /// 过滤发生在解析分发前,用于低延迟场景避免解析不需要的协议/事件。
143    pub async fn subscribe_with_filter(
144        &self,
145        event_type_filter: Option<EventTypeFilter>,
146    ) -> crate::common::AnyResult<Arc<ArrayQueue<DexEvent>>> {
147        let _lifecycle = self.subscription_lifecycle.lock().await;
148        self.stop_without_lifecycle_lock().await;
149
150        let queue = Arc::new(ArrayQueue::new(100_000));
151        let queue_clone = Arc::clone(&queue);
152
153        let endpoint = self.endpoint.clone();
154        let config = self.config.clone();
155        let resolver = self.address_lookup_resolver.clone();
156
157        let handle = tokio::spawn(async move {
158            let mut delay = config.reconnect_delay_ms;
159            let mut attempts = 0u32;
160
161            loop {
162                if config.max_reconnect_attempts > 0 && attempts >= config.max_reconnect_attempts {
163                    log::error!("Max reconnection attempts reached, giving up");
164                    break;
165                }
166                attempts += 1;
167
168                match Self::stream_events(
169                    &endpoint,
170                    &config,
171                    event_type_filter.as_ref(),
172                    EventSink::Queue(&queue_clone),
173                    resolver.as_deref(),
174                )
175                .await
176                {
177                    Ok(_) => {
178                        log::warn!("ShredStream ended cleanly - reconnecting in {}ms", delay);
179                        tokio::time::sleep(tokio::time::Duration::from_millis(delay.max(1))).await;
180                        delay = config.reconnect_delay_ms;
181                        attempts = 0;
182                    }
183                    Err(e) => {
184                        log::error!("ShredStream error: {} - retry in {}ms", e, delay);
185                        tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
186                        delay = (delay * 2).min(60_000);
187                    }
188                }
189            }
190        });
191
192        *self.subscription_handle.lock().await = Some(handle);
193        Ok(queue)
194    }
195
196    /// 订阅 DEX 事件,并在解析热路径中直接回调事件,避免跨任务队列调度。
197    ///
198    /// 这是最低延迟路径;回调会在 ShredStream 读流任务内执行,应避免阻塞 I/O 或重计算。
199    pub async fn subscribe_with_filter_callback<F>(
200        &self,
201        event_type_filter: Option<EventTypeFilter>,
202        callback: F,
203    ) -> crate::common::AnyResult<()>
204    where
205        F: Fn(DexEvent) + Send + Sync + 'static,
206    {
207        let _lifecycle = self.subscription_lifecycle.lock().await;
208        self.stop_without_lifecycle_lock().await;
209
210        let endpoint = self.endpoint.clone();
211        let config = self.config.clone();
212        let resolver = self.address_lookup_resolver.clone();
213        let callback = Arc::new(callback);
214
215        let handle = tokio::spawn(async move {
216            let mut delay = config.reconnect_delay_ms;
217            let mut attempts = 0u32;
218
219            loop {
220                if config.max_reconnect_attempts > 0 && attempts >= config.max_reconnect_attempts {
221                    log::error!("Max reconnection attempts reached, giving up");
222                    break;
223                }
224                attempts += 1;
225
226                match Self::stream_events_callback(
227                    &endpoint,
228                    &config,
229                    event_type_filter.as_ref(),
230                    callback.clone(),
231                    resolver.as_deref(),
232                )
233                .await
234                {
235                    Ok(_) => {
236                        log::warn!("ShredStream ended cleanly - reconnecting in {}ms", delay);
237                        tokio::time::sleep(tokio::time::Duration::from_millis(delay.max(1))).await;
238                        delay = config.reconnect_delay_ms;
239                        attempts = 0;
240                    }
241                    Err(e) => {
242                        log::error!("ShredStream error: {} - retry in {}ms", e, delay);
243                        tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
244                        delay = (delay * 2).min(60_000);
245                    }
246                }
247            }
248        });
249
250        *self.subscription_handle.lock().await = Some(handle);
251        Ok(())
252    }
253
254    /// 停止订阅
255    pub async fn stop(&self) {
256        let _lifecycle = self.subscription_lifecycle.lock().await;
257        self.stop_without_lifecycle_lock().await;
258    }
259
260    async fn stop_without_lifecycle_lock(&self) {
261        if let Some(handle) = self.subscription_handle.lock().await.take() {
262            handle.abort();
263            let _ = handle.await;
264        }
265    }
266
267    async fn connect_client(
268        endpoint: &str,
269        config: &ShredStreamConfig,
270    ) -> crate::common::AnyResult<ShredstreamProxyClient<Channel>> {
271        let mut builder = Endpoint::from_shared(endpoint.to_string())?;
272        if config.connection_timeout_ms > 0 {
273            builder = builder.connect_timeout(Duration::from_millis(config.connection_timeout_ms));
274        }
275        let channel = builder.connect().await?;
276        Ok(ShredstreamProxyClient::new(channel)
277            .max_decoding_message_size(config.max_decoding_message_size))
278    }
279
280    fn build_subscribe_request(
281        config: &ShredStreamConfig,
282    ) -> Result<tonic::Request<SubscribeEntriesRequest>, String> {
283        let mut request = tonic::Request::new(SubscribeEntriesRequest {});
284        if let Some(token) = config.auth_token.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
285            let mut value = MetadataValue::try_from(token)
286                .map_err(|e| format!("Invalid ShredStream x-token metadata: {e}"))?;
287            value.set_sensitive(true);
288            request.metadata_mut().insert("x-token", value);
289        }
290        Ok(request)
291    }
292
293    /// 核心事件流处理
294    async fn stream_events(
295        endpoint: &str,
296        config: &ShredStreamConfig,
297        event_type_filter: Option<&EventTypeFilter>,
298        sink: EventSink<'_>,
299        resolver: Option<&AddressLookupResolver>,
300    ) -> Result<(), String> {
301        let request = Self::build_subscribe_request(config)?;
302        let mut client = Self::connect_client(endpoint, config).await.map_err(|e| e.to_string())?;
303        let response = if config.request_timeout_ms > 0 {
304            tokio::time::timeout(
305                Duration::from_millis(config.request_timeout_ms),
306                client.subscribe_entries(request),
307            )
308            .await
309            .map_err(|_| {
310                format!(
311                    "ShredStream subscribe request timed out after {}ms",
312                    config.request_timeout_ms
313                )
314            })?
315            .map_err(|e| e.to_string())?
316        } else {
317            client.subscribe_entries(request).await.map_err(|e| e.to_string())?
318        };
319        let mut stream = response.into_inner();
320
321        log::info!("ShredStream connected, receiving entries...");
322
323        let mut events = Vec::with_capacity(4);
324        while let Some(message) = stream.next().await {
325            match message {
326                Ok(entry) => {
327                    Self::process_entry_with_resolver(
328                        entry,
329                        event_type_filter,
330                        &sink,
331                        &mut events,
332                        resolver,
333                    );
334                }
335                Err(e) => {
336                    log::error!("Stream error: {:?}", e);
337                    return Err(e.to_string());
338                }
339            }
340        }
341
342        Ok(())
343    }
344
345    async fn stream_events_callback(
346        endpoint: &str,
347        config: &ShredStreamConfig,
348        event_type_filter: Option<&EventTypeFilter>,
349        callback: Arc<dyn Fn(DexEvent) + Send + Sync>,
350        resolver: Option<&AddressLookupResolver>,
351    ) -> Result<(), String> {
352        Self::stream_events(
353            endpoint,
354            config,
355            event_type_filter,
356            EventSink::Callback(callback.as_ref()),
357            resolver,
358        )
359        .await
360    }
361
362    #[cfg(test)]
363    fn process_entry(
364        entry: Entry,
365        filter: Option<&EventTypeFilter>,
366        sink: &EventSink<'_>,
367        events: &mut Vec<DexEvent>,
368    ) {
369        Self::process_entry_with_resolver(entry, filter, sink, events, None);
370    }
371
372    /// 处理单个 Entry 消息
373    #[inline]
374    fn process_entry_with_resolver(
375        entry: Entry,
376        event_type_filter: Option<&EventTypeFilter>,
377        sink: &EventSink<'_>,
378        events: &mut Vec<DexEvent>,
379        resolver: Option<&AddressLookupResolver>,
380    ) {
381        let slot = entry.slot;
382        let recv_us = now_micros();
383
384        // 反序列化 Entry 数据
385        let entries = match wincode::deserialize::<Vec<SolanaEntry>>(&entry.entries) {
386            Ok(e) => e,
387            Err(e) => {
388                log::debug!("Failed to deserialize entries: {}", e);
389                return;
390            }
391        };
392
393        // 处理每个 Entry 中的交易
394        // Message-local position only: the next Entries message starts at zero again.
395        let mut tx_index = 0u64;
396        for entry in entries {
397            for transaction in entry.transactions.iter() {
398                if transaction.sanitize().is_err() {
399                    log::debug!("Ignoring unsanitized ShredStream transaction");
400                    tx_index += 1;
401                    continue;
402                }
403                events.clear();
404                Self::process_transaction(
405                    transaction,
406                    slot,
407                    recv_us,
408                    tx_index,
409                    event_type_filter,
410                    events,
411                    sink,
412                    resolver,
413                );
414                tx_index += 1;
415            }
416        }
417    }
418
419    /// 处理单个交易
420    #[inline]
421    fn process_transaction(
422        transaction: &solana_sdk::transaction::VersionedTransaction,
423        slot: u64,
424        recv_us: i64,
425        tx_index: u64,
426        event_type_filter: Option<&EventTypeFilter>,
427        events: &mut Vec<DexEvent>,
428        sink: &EventSink<'_>,
429        resolver: Option<&AddressLookupResolver>,
430    ) {
431        Self::parse_transaction_events(
432            transaction,
433            slot,
434            recv_us,
435            tx_index,
436            event_type_filter,
437            events,
438            resolver,
439        );
440
441        for event in events.drain(..) {
442            sink.deliver(event);
443        }
444    }
445
446    #[inline]
447    fn parse_transaction_events(
448        transaction: &solana_sdk::transaction::VersionedTransaction,
449        slot: u64,
450        recv_us: i64,
451        tx_index: u64,
452        event_type_filter: Option<&EventTypeFilter>,
453        events: &mut Vec<DexEvent>,
454        resolver: Option<&AddressLookupResolver>,
455    ) {
456        let Some(&signature) = transaction.signatures.first() else {
457            return;
458        };
459        if transaction.message.address_table_lookups().is_some_and(|lookups| !lookups.is_empty()) {
460            if let Some(resolver) = resolver {
461                let result = resolver(transaction, slot, recv_us, tx_index, event_type_filter)
462                    .and_then(|loaded| {
463                        super::pump_ix::parse_transaction_dex_events_with_loaded_addresses(
464                            transaction,
465                            &loaded.writable,
466                            &loaded.readonly,
467                            signature,
468                            slot,
469                            tx_index,
470                            recv_us,
471                            event_type_filter,
472                            events,
473                        )?;
474                        Ok(())
475                    });
476                if let Err(error) = result {
477                    log::debug!(
478                        "Skipping unresolved ALT transaction {signature} at {slot}: {error}"
479                    );
480                }
481                return;
482            }
483        }
484        // 热路径:`static_account_keys` 零拷贝、`pump_ix` 内不克隆 CompiledInstruction。
485        super::pump_ix::parse_transaction_dex_events_with_filter(
486            transaction,
487            signature,
488            slot,
489            tx_index,
490            recv_us,
491            event_type_filter,
492            events,
493        );
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::core::events::{EventMetadata, PumpFunCreateTokenEvent};
501    use crate::instr::program_ids::PUMPFUN_PROGRAM_ID;
502    use solana_sdk::hash::Hash;
503    use solana_sdk::message::{
504        compiled_instruction::CompiledInstruction, v0, MessageHeader, VersionedMessage,
505    };
506    use solana_sdk::pubkey::Pubkey;
507    use solana_sdk::signature::Signature;
508    use solana_sdk::transaction::VersionedTransaction;
509    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
510
511    #[test]
512    fn subscription_request_includes_only_nonempty_trimmed_auth_token() {
513        for config in [
514            ShredStreamConfig::default(),
515            ShredStreamConfig::low_latency(),
516            ShredStreamConfig::high_throughput(),
517        ] {
518            for (token, expected) in [
519                (None, None),
520                (Some(""), None),
521                (Some(" \t\n "), None),
522                (Some(" test-token "), Some("test-token")),
523            ] {
524                let config =
525                    ShredStreamConfig { auth_token: token.map(str::to_owned), ..config.clone() };
526                let request = ShredStreamClient::build_subscribe_request(&config).unwrap();
527                let header = request.metadata().get("x-token");
528                assert_eq!(header.map(|value| value.to_str().unwrap()), expected);
529                if let Some(header) = header {
530                    assert!(header.is_sensitive());
531                    assert!(!format!("{config:?}").contains("test-token"));
532                }
533            }
534        }
535    }
536
537    #[test]
538    fn subscription_request_rejects_invalid_auth_without_exposing_token() {
539        for token in ["secret\nvalue", "secret\rvalue", "secret\0value", "secret\u{7f}value"] {
540            let config =
541                ShredStreamConfig { auth_token: Some(token.to_owned()), ..Default::default() };
542            let error = ShredStreamClient::build_subscribe_request(&config).unwrap_err();
543            assert!(error.contains("Invalid ShredStream x-token metadata"));
544            assert!(!error.contains("secret"));
545        }
546    }
547
548    fn push_string(data: &mut Vec<u8>, value: &str) {
549        data.extend_from_slice(&(value.len() as u32).to_le_bytes());
550        data.extend_from_slice(value.as_bytes());
551    }
552
553    fn pumpfun_create_data() -> Vec<u8> {
554        let mut data = Vec::new();
555        data.extend_from_slice(&[24, 30, 200, 40, 5, 28, 7, 119]);
556        push_string(&mut data, "Callback Test");
557        push_string(&mut data, "CBT");
558        push_string(&mut data, "https://example.invalid/callback.json");
559        data.extend_from_slice(Pubkey::new_unique().as_ref());
560        data
561    }
562
563    fn pumpfun_create_tx() -> VersionedTransaction {
564        let mut account_keys = (0..10).map(|_| Pubkey::new_unique()).collect::<Vec<_>>();
565        account_keys.push(PUMPFUN_PROGRAM_ID);
566
567        VersionedTransaction {
568            signatures: vec![Signature::default()],
569            message: VersionedMessage::V0(v0::Message {
570                header: MessageHeader {
571                    num_required_signatures: 1,
572                    num_readonly_signed_accounts: 0,
573                    num_readonly_unsigned_accounts: 1,
574                },
575                account_keys,
576                recent_blockhash: Hash::default(),
577                instructions: vec![CompiledInstruction::new_from_raw_parts(
578                    10,
579                    pumpfun_create_data(),
580                    (0..10).collect(),
581                )],
582                address_table_lookups: Vec::new(),
583            }),
584        }
585    }
586
587    fn pumpfun_create_alt_tx() -> (VersionedTransaction, LoadedAddresses) {
588        let mut transaction = pumpfun_create_tx();
589        let VersionedMessage::V0(message) = &mut transaction.message else { unreachable!() };
590        let loaded = LoadedAddresses {
591            writable: message.account_keys.drain(1..10).collect(),
592            readonly: Vec::new(),
593        };
594        message.instructions[0].program_id_index = 1;
595        message.instructions[0].accounts = std::iter::once(0).chain(2..11).collect();
596        message.address_table_lookups = vec![v0::MessageAddressTableLookup {
597            account_key: Pubkey::new_unique(),
598            writable_indexes: (0..9).collect(),
599            readonly_indexes: Vec::new(),
600        }];
601        (transaction, loaded)
602    }
603
604    #[test]
605    fn resolver_receives_parser_context_across_entries_without_cloning_filter() {
606        for filtered in [false, true] {
607            let (transaction, loaded) = pumpfun_create_alt_tx();
608            let filter = Arc::new(EventTypeFilter::include_only(vec![
609                crate::grpc::EventType::PumpFunCreate,
610            ]));
611            let expected_filter = Arc::clone(&filter);
612            let contexts = Arc::new(std::sync::Mutex::new(Vec::new()));
613            let observed = Arc::clone(&contexts);
614            let resolver = move |_: &VersionedTransaction,
615                                 slot: u64,
616                                 recv_us: i64,
617                                 tx_index: u64,
618                                 filter: Option<&EventTypeFilter>| {
619                if filtered {
620                    assert!(std::ptr::eq(filter.unwrap(), expected_filter.as_ref()));
621                } else {
622                    assert!(filter.is_none());
623                }
624                observed.lock().unwrap().push((slot, recv_us, tx_index));
625                Ok(loaded.clone())
626            };
627            let entries = vec![
628                SolanaEntry {
629                    num_hashes: 1,
630                    hash: Hash::default(),
631                    transactions: vec![pumpfun_create_tx(), transaction.clone()],
632                },
633                SolanaEntry {
634                    num_hashes: 1,
635                    hash: Hash::default(),
636                    transactions: vec![transaction],
637                },
638            ];
639            let entry = Entry { slot: 42, entries: wincode::serialize(&entries).unwrap() };
640            let queue = Arc::new(ArrayQueue::new(4));
641            ShredStreamClient::process_entry_with_resolver(
642                entry,
643                filtered.then_some(filter.as_ref()),
644                &EventSink::Queue(&queue),
645                &mut Vec::new(),
646                Some(&resolver),
647            );
648            let contexts = contexts.lock().unwrap();
649            assert_eq!(contexts.len(), 2); // Static transactions never invoke the resolver.
650            let first = queue.pop().unwrap();
651            let recv_us = first.metadata().grpc_recv_us;
652            assert!(recv_us > 0);
653            assert_eq!(first.metadata().tx_index, 0);
654            for (index, event) in [queue.pop().unwrap(), queue.pop().unwrap()].iter().enumerate() {
655                assert!(matches!(event, DexEvent::PumpFunCreate(_)));
656                let metadata = event.metadata();
657                assert_eq!(
658                    contexts[index],
659                    (metadata.slot, metadata.grpc_recv_us, metadata.tx_index)
660                );
661                assert_eq!(metadata.tx_index, index as u64 + 1);
662                assert_eq!(metadata.grpc_recv_us, recv_us);
663            }
664            assert!(queue.pop().is_none());
665        }
666    }
667
668    #[test]
669    fn resolver_can_handle_cache_miss_and_skip_sdk_parsing_without_losing_next_transaction() {
670        let (transaction, _) = pumpfun_create_alt_tx();
671        let expected_lookup = transaction.message.address_table_lookups().unwrap()[0].account_key;
672        let handled = Arc::new(AtomicUsize::new(0));
673        let seen = Arc::clone(&handled);
674        let resolver = move |transaction: &VersionedTransaction,
675                             slot: u64,
676                             recv_us: i64,
677                             tx_index: u64,
678                             filter: Option<&EventTypeFilter>| {
679            assert_eq!(
680                transaction.message.address_table_lookups().unwrap()[0].account_key,
681                expected_lookup
682            );
683            let mut fallback_events = Vec::new();
684            crate::shredstream::pump_ix::parse_transaction_dex_events_with_filter(
685                transaction,
686                transaction.signatures[0],
687                slot,
688                tx_index,
689                recv_us,
690                filter,
691                &mut fallback_events,
692            );
693            assert_eq!(fallback_events.len(), 1);
694            assert!(matches!(fallback_events[0], DexEvent::PumpFunCreate(_)));
695            let metadata = fallback_events[0].metadata();
696            assert_eq!((metadata.slot, metadata.tx_index, metadata.grpc_recv_us), (42, 0, recv_us));
697            seen.fetch_add(1, AtomicOrdering::Relaxed);
698            Err("cache miss handled by caller".into())
699        };
700        let entries = vec![SolanaEntry {
701            num_hashes: 1,
702            hash: Hash::default(),
703            transactions: vec![transaction, pumpfun_create_tx()],
704        }];
705        let entry = Entry { slot: 42, entries: wincode::serialize(&entries).unwrap() };
706        let filter = EventTypeFilter::include_only(vec![crate::grpc::EventType::PumpFunCreate]);
707        let queue = Arc::new(ArrayQueue::new(4));
708        ShredStreamClient::process_entry_with_resolver(
709            entry,
710            Some(&filter),
711            &EventSink::Queue(&queue),
712            &mut Vec::new(),
713            Some(&resolver),
714        );
715        assert_eq!(handled.load(AtomicOrdering::Relaxed), 1);
716        let event = queue.pop().unwrap();
717        assert_eq!(event.metadata().tx_index, 1);
718        assert!(queue.pop().is_none()); // The caller-handled transaction is not parsed again.
719    }
720
721    #[test]
722    fn dropped_counter_increments_without_panicking() {
723        let before = SHREDSTREAM_DROPPED_EVENTS.load(Ordering::Relaxed);
724        let queue = ArrayQueue::new(1);
725
726        queue
727            .push(DexEvent::PumpFunCreate(PumpFunCreateTokenEvent {
728                metadata: EventMetadata::default(),
729                ..Default::default()
730            }))
731            .expect("first push fits");
732
733        if queue
734            .push(DexEvent::PumpFunCreate(PumpFunCreateTokenEvent {
735                metadata: EventMetadata::default(),
736                ..Default::default()
737            }))
738            .is_err()
739        {
740            record_shredstream_dropped_event();
741        }
742
743        assert!(SHREDSTREAM_DROPPED_EVENTS.load(Ordering::Relaxed) > before);
744    }
745
746    #[test]
747    fn callback_path_delivers_events_without_queue() {
748        let entries = vec![SolanaEntry {
749            num_hashes: 1,
750            hash: Hash::default(),
751            transactions: vec![pumpfun_create_tx()],
752        }];
753        let entry = Entry { slot: 42, entries: wincode::serialize(&entries).unwrap() };
754        let count = AtomicUsize::new(0);
755
756        let mut events = Vec::with_capacity(4);
757        ShredStreamClient::process_entry(
758            entry,
759            None,
760            &EventSink::Callback(&|event| {
761                assert!(matches!(event, DexEvent::PumpFunCreate(_)));
762                assert_eq!(event.metadata().slot, 42);
763                count.fetch_add(1, AtomicOrdering::Relaxed);
764            }),
765            &mut events,
766        );
767
768        assert_eq!(count.load(AtomicOrdering::Relaxed), 1);
769    }
770
771    #[test]
772    fn wincode_decodes_solana4_v1_entries() {
773        let entries = vec![SolanaEntry {
774            num_hashes: 1,
775            hash: Hash::new_unique(),
776            transactions: vec![VersionedTransaction {
777                signatures: vec![Signature::from([9; 64])],
778                message: VersionedMessage::V1(solana_sdk::message::v1::Message {
779                    header: MessageHeader { num_required_signatures: 1, ..Default::default() },
780                    config: solana_sdk::message::v1::TransactionConfig::empty()
781                        .with_compute_unit_limit(250_000)
782                        .with_priority_fee(1_500),
783                    lifetime_specifier: Hash::new_unique(),
784                    account_keys: vec![Pubkey::new_unique()],
785                    instructions: Vec::new(),
786                }),
787            }],
788        }];
789        let bytes = wincode::serialize(&entries).expect("serialize V1 Entry");
790        let decoded: Vec<SolanaEntry> = wincode::deserialize(&bytes).expect("decode V1 Entry");
791
792        assert_eq!(decoded, entries);
793    }
794
795    #[test]
796    fn process_entry_rejects_unsanitized_transactions() {
797        let mut transaction = pumpfun_create_tx();
798        let VersionedMessage::V0(message) = &mut transaction.message else {
799            panic!("expected V0 fixture");
800        };
801        message.instructions[0].program_id_index = u8::MAX;
802        let entries = vec![SolanaEntry {
803            num_hashes: 1,
804            hash: Hash::default(),
805            transactions: vec![transaction],
806        }];
807        let entry = Entry { slot: 42, entries: wincode::serialize(&entries).unwrap() };
808        let count = AtomicUsize::new(0);
809        let mut events = Vec::with_capacity(4);
810
811        ShredStreamClient::process_entry(
812            entry,
813            None,
814            &EventSink::Callback(&|_| {
815                count.fetch_add(1, AtomicOrdering::Relaxed);
816            }),
817            &mut events,
818        );
819
820        assert_eq!(count.load(AtomicOrdering::Relaxed), 0);
821    }
822}