1#![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
61pub 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#[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 pub async fn new(endpoint: impl Into<String>) -> crate::common::AnyResult<Self> {
90 Self::new_with_config(endpoint, ShredStreamConfig::default()).await
91 }
92
93 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 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 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 pub async fn subscribe(&self) -> crate::common::AnyResult<Arc<ArrayQueue<DexEvent>>> {
137 self.subscribe_with_filter(None).await
138 }
139
140 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 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 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 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 #[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 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 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 #[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 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); 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()); }
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}