1use crate::core::events::DexEvent;
7use crate::grpc::types::EventTypeFilter;
8use crate::instr::read_pubkey_fast;
9use crate::transaction_cost::{parse_yellowstone_transaction_cost, TransactionCost};
10use smallvec::SmallVec;
11use solana_client::rpc_client::RpcClient;
12use solana_client::rpc_config::{RpcTransactionConfig, UiTransactionEncoding};
13use solana_client::rpc_response::{
14 EncodedTransaction, UiInstruction, UiTransactionStatusMeta, UiTransactionTokenBalance,
15};
16use solana_sdk::pubkey::Pubkey;
17use solana_sdk::signature::Signature;
18use solana_transaction_status::{
19 option_serializer::OptionSerializer, EncodedConfirmedTransactionWithStatusMeta,
20};
21use std::collections::HashMap;
22use yellowstone_grpc_proto::prelude::{
23 CompiledInstruction, InnerInstruction, InnerInstructions, Message, MessageAddressTableLookup,
24 MessageHeader, TokenBalance, Transaction, TransactionError, TransactionStatusMeta,
25 UiTokenAmount,
26};
27
28pub fn parse_transaction_from_rpc(
50 rpc_client: &RpcClient,
51 signature: &Signature,
52 filter: Option<&EventTypeFilter>,
53) -> Result<Vec<DexEvent>, ParseError> {
54 let config = RpcTransactionConfig {
56 encoding: Some(UiTransactionEncoding::Base64),
57 commitment: None,
58 max_supported_transaction_version: Some(1),
59 };
60
61 let rpc_tx = rpc_client.get_transaction_with_config(signature, config).map_err(|e| {
62 let msg = e.to_string();
63 if msg.contains("invalid type: null") && msg.contains("EncodedConfirmedTransactionWithStatusMeta") {
64 ParseError::RpcError(format!(
65 "Transaction not found (RPC returned null). Common causes: 1) Transaction is too old and pruned (use an archive RPC). 2) Wrong network or invalid signature. Try SOLANA_RPC_URL with an archive endpoint (e.g. Helius, QuickNode) or a more recent tx. Original: {}",
66 msg
67 ))
68 } else {
69 ParseError::RpcError(msg)
70 }
71 })?;
72
73 parse_rpc_transaction(&rpc_tx, filter)
74}
75
76pub fn parse_rpc_transaction(
93 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
94 filter: Option<&EventTypeFilter>,
95) -> Result<Vec<DexEvent>, ParseError> {
96 if rpc_tx.transaction.meta.as_ref().is_some_and(|meta| meta.err.is_some()) {
99 return Ok(Vec::new());
100 }
101 let (grpc_meta, grpc_tx) = convert_rpc_to_grpc_for_parsing(rpc_tx)?;
102 let signature = extract_grpc_signature(&grpc_tx)?;
103 parse_converted_rpc_transaction(rpc_tx, grpc_meta, grpc_tx, signature, filter)
104}
105
106#[derive(Debug)]
108pub struct ParsedRpcTransaction {
109 pub events: Vec<DexEvent>,
110 pub cost: TransactionCost,
111 pub signature: Signature,
112}
113
114pub fn parse_rpc_transaction_with_cost(
116 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
117 filter: Option<&EventTypeFilter>,
118) -> Result<ParsedRpcTransaction, ParseError> {
119 let (grpc_meta, grpc_tx) = convert_rpc_to_grpc_for_parsing(rpc_tx)?;
120 let signature = extract_grpc_signature(&grpc_tx)?;
121 let cost = parse_yellowstone_transaction_cost(&grpc_tx, &grpc_meta)
122 .ok_or_else(|| ParseError::MissingField("transaction.message".to_string()))?;
123 let events = parse_converted_rpc_transaction(rpc_tx, grpc_meta, grpc_tx, signature, filter)?;
124 Ok(ParsedRpcTransaction { events, cost, signature })
125}
126
127pub fn parse_rpc_transaction_cost_with_signature(
129 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
130) -> Result<(TransactionCost, Signature), ParseError> {
131 let (grpc_meta, grpc_tx) = convert_rpc_to_grpc_for_parsing(rpc_tx)?;
132 let signature = extract_grpc_signature(&grpc_tx)?;
133 let cost = parse_yellowstone_transaction_cost(&grpc_tx, &grpc_meta)
134 .ok_or_else(|| ParseError::MissingField("transaction.message".to_string()))?;
135 Ok((cost, signature))
136}
137
138fn extract_grpc_signature(transaction: &Transaction) -> Result<Signature, ParseError> {
139 transaction
140 .signatures
141 .first()
142 .ok_or_else(|| ParseError::MissingField("transaction.signatures[0]".to_string()))
143 .and_then(|bytes| {
144 Signature::try_from(bytes.as_slice()).map_err(|error| {
145 ParseError::ConversionError(format!("Invalid transaction signature: {error}"))
146 })
147 })
148}
149
150fn parse_converted_rpc_transaction(
151 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
152 grpc_meta: TransactionStatusMeta,
153 grpc_tx: Transaction,
154 signature: Signature,
155 filter: Option<&EventTypeFilter>,
156) -> Result<Vec<DexEvent>, ParseError> {
157 if grpc_meta.err.is_some() {
160 return Ok(Vec::new());
161 }
162 let slot = rpc_tx.slot;
164 let block_time_us = rpc_tx.block_time.map(|t| t * 1_000_000);
165 let grpc_recv_us = std::time::SystemTime::now()
166 .duration_since(std::time::UNIX_EPOCH)
167 .unwrap_or_default()
168 .as_micros() as i64;
169
170 let grpc_tx_opt = Some(grpc_tx);
172
173 let mut program_invokes: HashMap<Pubkey, Vec<(i32, i32)>> = HashMap::new();
174
175 if let Some(ref tx) = grpc_tx_opt {
176 if let Some(ref msg) = tx.message {
177 let keys_len = msg.account_keys.len();
178 let writable_len = grpc_meta.loaded_writable_addresses.len();
179 let get_key = |i: usize| -> Option<&Vec<u8>> {
180 if i < keys_len {
181 msg.account_keys.get(i)
182 } else if i < keys_len + writable_len {
183 grpc_meta.loaded_writable_addresses.get(i - keys_len)
184 } else {
185 grpc_meta.loaded_readonly_addresses.get(i - keys_len - writable_len)
186 }
187 };
188
189 for (i, ix) in msg.instructions.iter().enumerate() {
190 let pid = get_key(ix.program_id_index as usize)
191 .map_or(Pubkey::default(), |k| read_pubkey_fast(k));
192 if crate::grpc::program_ids::needs_invoke_context(&pid) {
193 program_invokes.entry(pid).or_default().push((i as i32, -1));
194 }
195 }
196
197 for inner in &grpc_meta.inner_instructions {
198 let outer_idx = inner.index as usize;
199 for (j, inner_ix) in inner.instructions.iter().enumerate() {
200 let pid = get_key(inner_ix.program_id_index as usize)
201 .map_or(Pubkey::default(), |k| read_pubkey_fast(k));
202 if crate::grpc::program_ids::needs_invoke_context(&pid) {
203 program_invokes.entry(pid).or_default().push((outer_idx as i32, j as i32));
204 }
205 }
206 }
207 }
208 }
209
210 let log_messages = rpc_log_messages(rpc_tx);
211 let is_created_buy = false;
214
215 let instr_events =
217 crate::grpc::instruction_parser::parse_instructions_enhanced_with_created_buy(
218 &grpc_meta,
219 &grpc_tx_opt,
220 signature,
221 slot,
222 0, block_time_us,
224 grpc_recv_us,
225 filter,
226 is_created_buy,
227 );
228
229 struct ActiveProgram<'a> {
231 encoded: &'a str,
232 pubkey: Pubkey,
233 position: (i32, i32),
234 }
235
236 let mut active_program_stack: SmallVec<[ActiveProgram<'_>; 8]> = SmallVec::new();
237 let mut log_events = Vec::new();
238 let mut outer_index = -1;
239 let mut inner_index = -1;
240
241 for log in log_messages {
242 if let Some((pid, depth)) = crate::logs::optimized_matcher::parse_invoke_info(log) {
243 let pk = crate::grpc::program_ids::known_program_id(pid).unwrap_or_default();
244 active_program_stack.truncate(depth - 1);
245 if depth == 1 {
246 outer_index = crate::grpc::yellowstone_tx_parse::next_logged_outer_index(
247 &grpc_tx_opt,
248 outer_index,
249 );
250 inner_index = -1;
251 } else {
252 inner_index += 1;
253 }
254 active_program_stack.push(ActiveProgram {
255 encoded: pid,
256 pubkey: pk,
257 position: (outer_index, inner_index),
258 });
259 }
260
261 if let Some(mut event) = crate::logs::parse_log_with_program_id(
262 log,
263 signature,
264 slot,
265 0, block_time_us,
267 grpc_recv_us,
268 filter,
269 is_created_buy,
270 None,
271 active_program_stack.last().map(|active| &active.pubkey),
272 ) {
273 if let Some(active) = active_program_stack.last() {
274 if let Ok(outer_index) = usize::try_from(active.position.0) {
275 crate::core::common_filler::mark_trade_execution_source(
276 &mut event,
277 outer_index,
278 &grpc_meta,
279 &grpc_tx_opt,
280 );
281 }
282 }
283 if matches!(&event, DexEvent::RaydiumAmmV4Swap(_)) {
285 let mut scoped = crate::core::invoke_context::InvokeContext::default();
286 if let Some(active) = active_program_stack.last() {
287 scoped.push(active.pubkey, active.position);
288 }
289 crate::core::account_dispatcher::fill_accounts_with_invoke_context(
290 &mut event,
291 &grpc_meta,
292 &grpc_tx_opt,
293 &scoped,
294 );
295 } else {
296 crate::core::account_dispatcher::fill_accounts_with_owned_keys(
297 &mut event,
298 &grpc_meta,
299 &grpc_tx_opt,
300 &program_invokes,
301 );
302 }
303
304 crate::core::common_filler::fill_data(
306 &mut event,
307 &grpc_meta,
308 &grpc_tx_opt,
309 &program_invokes,
310 );
311
312 log_events.push(event);
313 }
314
315 if let Some(pid) = crate::logs::optimized_matcher::parse_program_complete_info(log) {
316 if let Some(pos) = active_program_stack.iter().rposition(|active| active.encoded == pid)
317 {
318 active_program_stack.truncate(pos);
319 }
320 }
321 }
322
323 let mut events = merge_log_and_instruction_events(log_events, instr_events);
324 crate::core::common_filler::materialize_trade_execution_sources(
325 &mut events,
326 &grpc_meta,
327 &grpc_tx_opt,
328 );
329 crate::core::common_filler::fill_pump_creation_context(&mut events, &grpc_meta, &grpc_tx_opt);
330 fill_rpc_event_metadata(&mut events, rpc_tx, &grpc_meta, &grpc_tx_opt);
331 Ok(events)
332}
333
334fn fill_rpc_event_metadata(
335 events: &mut [DexEvent],
336 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
337 meta: &TransactionStatusMeta,
338 transaction: &Option<Transaction>,
339) {
340 if let Some(rpc_meta) = rpc_tx.transaction.meta.as_ref() {
341 for event in events.iter_mut() {
342 fill_rpc_token_balances(event, rpc_meta, meta, transaction);
343 }
344 }
345 crate::grpc::transaction_meta::fill_recent_blockhash(events, transaction);
346}
347
348#[inline]
349fn fill_rpc_token_balances(
350 event: &mut DexEvent,
351 rpc_meta: &UiTransactionStatusMeta,
352 meta: &TransactionStatusMeta,
353 transaction: &Option<Transaction>,
354) {
355 let trade = match event {
356 DexEvent::PumpFunTrade(event)
357 | DexEvent::PumpFunBuy(event)
358 | DexEvent::PumpFunSell(event)
359 | DexEvent::PumpFunBuyExactSolIn(event) => event,
360 _ => return,
361 };
362
363 if let Some(user_index) = rpc_account_index(transaction, meta, &trade.user) {
364 trade.sol_balance = rpc_meta.post_balances.get(user_index).copied();
365 }
366
367 if trade.associated_user == Pubkey::default() {
368 return;
369 }
370
371 let matches_account = |balance: &UiTransactionTokenBalance| {
372 rpc_account_key(transaction, meta, balance.account_index as usize)
373 .is_some_and(|key| key.as_slice() == trade.associated_user.as_ref())
374 };
375
376 if let OptionSerializer::Some(balances) = &rpc_meta.post_token_balances {
377 if let Some(balance) = balances.iter().find(|balance| matches_account(balance)) {
378 trade.token_balance = balance.ui_token_amount.amount.parse().ok();
379 return;
380 }
381 }
382
383 if let OptionSerializer::Some(balances) = &rpc_meta.pre_token_balances {
384 if balances.iter().any(matches_account) {
385 trade.token_balance = Some(0);
386 }
387 }
388}
389
390#[inline]
391fn rpc_account_index(
392 transaction: &Option<Transaction>,
393 meta: &TransactionStatusMeta,
394 account: &Pubkey,
395) -> Option<usize> {
396 if *account == Pubkey::default() {
397 return None;
398 }
399
400 let message = transaction.as_ref()?.message.as_ref()?;
401 message
402 .account_keys
403 .iter()
404 .chain(&meta.loaded_writable_addresses)
405 .chain(&meta.loaded_readonly_addresses)
406 .position(|key| key.as_slice() == account.as_ref())
407}
408
409#[inline]
410fn rpc_account_key<'a>(
411 transaction: &'a Option<Transaction>,
412 meta: &'a TransactionStatusMeta,
413 index: usize,
414) -> Option<&'a Vec<u8>> {
415 let message = transaction.as_ref()?.message.as_ref()?;
416 let static_len = message.account_keys.len();
417 let writable_len = meta.loaded_writable_addresses.len();
418
419 if index < static_len {
420 message.account_keys.get(index)
421 } else if index < static_len + writable_len {
422 meta.loaded_writable_addresses.get(index - static_len)
423 } else {
424 meta.loaded_readonly_addresses.get(index - static_len - writable_len)
425 }
426}
427
428#[inline]
429fn rpc_log_messages(rpc_tx: &EncodedConfirmedTransactionWithStatusMeta) -> &[String] {
430 let Some(meta) = rpc_tx.transaction.meta.as_ref() else { return &[] };
431 match &meta.log_messages {
432 OptionSerializer::Some(messages) => messages,
433 _ => &[],
434 }
435}
436
437fn merge_log_and_instruction_events(
438 log_events: Vec<DexEvent>,
439 instr_events: Vec<DexEvent>,
440) -> Vec<DexEvent> {
441 crate::grpc::log_instr_dedup::dedupe_log_instruction_events(log_events, instr_events)
442}
443
444#[derive(Debug)]
446pub enum ParseError {
447 RpcError(String),
448 ConversionError(String),
449 MissingField(String),
450}
451
452impl std::fmt::Display for ParseError {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 match self {
455 ParseError::RpcError(msg) => write!(f, "RPC error: {}", msg),
456 ParseError::ConversionError(msg) => write!(f, "Conversion error: {}", msg),
457 ParseError::MissingField(msg) => write!(f, "Missing field: {}", msg),
458 }
459 }
460}
461
462impl std::error::Error for ParseError {}
463
464pub fn convert_rpc_to_grpc(
469 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
470) -> Result<(TransactionStatusMeta, Transaction), ParseError> {
471 convert_rpc_to_grpc_impl(rpc_tx, true)
472}
473
474#[inline]
477pub(crate) fn convert_rpc_to_grpc_for_parsing(
478 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
479) -> Result<(TransactionStatusMeta, Transaction), ParseError> {
480 convert_rpc_to_grpc_impl(rpc_tx, false)
481}
482
483fn convert_rpc_to_grpc_impl(
484 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
485 include_borrowed_metadata: bool,
486) -> Result<(TransactionStatusMeta, Transaction), ParseError> {
487 let rpc_meta = rpc_tx
488 .transaction
489 .meta
490 .as_ref()
491 .ok_or_else(|| ParseError::MissingField("meta".to_string()))?;
492
493 let (loaded_writable_addresses, loaded_readonly_addresses) = rpc_meta
494 .loaded_addresses
495 .as_ref()
496 .map(|addresses| {
497 let writable = addresses
498 .writable
499 .iter()
500 .map(|address| parse_loaded_address(address))
501 .collect::<Result<Vec<_>, _>>()?;
502 let readonly = addresses
503 .readonly
504 .iter()
505 .map(|address| parse_loaded_address(address))
506 .collect::<Result<Vec<_>, _>>()?;
507 Ok((writable, readonly))
508 })
509 .transpose()?
510 .unwrap_or_default();
511
512 let err = rpc_meta
513 .err
514 .clone()
515 .map(|error| {
516 let error: solana_sdk::transaction::TransactionError = error.into();
517 wincode::serialize(&error).map(|err| TransactionError { err }).map_err(|error| {
518 ParseError::ConversionError(format!(
519 "Failed to serialize transaction error: {error}"
520 ))
521 })
522 })
523 .transpose()?;
524
525 let mut grpc_meta = TransactionStatusMeta {
527 err,
528 fee: rpc_meta.fee,
529 pre_balances: if include_borrowed_metadata {
530 rpc_meta.pre_balances.clone()
531 } else {
532 Vec::new()
533 },
534 post_balances: if include_borrowed_metadata {
535 rpc_meta.post_balances.clone()
536 } else {
537 Vec::new()
538 },
539 inner_instructions: Vec::new(),
540 log_messages: if include_borrowed_metadata {
541 rpc_meta.log_messages.as_ref().map(|messages| messages.clone()).unwrap_or_default()
542 } else {
543 Vec::new()
544 },
545 pre_token_balances: if include_borrowed_metadata {
546 rpc_meta
547 .pre_token_balances
548 .as_ref()
549 .map(|balances| convert_token_balances(balances))
550 .unwrap_or_default()
551 } else {
552 Vec::new()
553 },
554 post_token_balances: if include_borrowed_metadata {
555 rpc_meta
556 .post_token_balances
557 .as_ref()
558 .map(|balances| convert_token_balances(balances))
559 .unwrap_or_default()
560 } else {
561 Vec::new()
562 },
563 rewards: Vec::new(),
564 loaded_writable_addresses,
565 loaded_readonly_addresses,
566 return_data: None,
567 compute_units_consumed: rpc_meta.compute_units_consumed.clone().into(),
568
569 inner_instructions_none: !rpc_meta.inner_instructions.is_some(),
570 log_messages_none: !rpc_meta.log_messages.is_some(),
571 return_data_none: !rpc_meta.return_data.is_some(),
572 cost_units: rpc_meta.cost_units.clone().into(),
573 };
574
575 if let solana_transaction_status::option_serializer::OptionSerializer::Some(
577 inner_instructions,
578 ) = rpc_meta.inner_instructions.as_ref()
579 {
580 for inner in inner_instructions {
581 let mut grpc_inner =
582 InnerInstructions { index: inner.index as u32, instructions: Vec::new() };
583
584 for ix in &inner.instructions {
585 if let UiInstruction::Compiled(compiled) = ix {
586 let data = base58_turbo::BITCOIN.decode(&compiled.data).map_err(|e| {
588 ParseError::ConversionError(format!(
589 "Failed to decode instruction data: {}",
590 e
591 ))
592 })?;
593
594 grpc_inner.instructions.push(InnerInstruction {
595 program_id_index: compiled.program_id_index as u32,
596 accounts: compiled.accounts.clone(),
597 data,
598 stack_height: compiled.stack_height,
599 });
600 }
601 }
602
603 grpc_meta.inner_instructions.push(grpc_inner);
604 }
605 }
606
607 let ui_tx = &rpc_tx.transaction.transaction;
609
610 let (message, signatures) = match ui_tx {
611 EncodedTransaction::Binary(_, _) | EncodedTransaction::LegacyBinary(_) => {
612 let versioned_tx = ui_tx.decode().ok_or_else(|| {
614 ParseError::ConversionError(
615 "Failed to decode or sanitize binary transaction".to_string(),
616 )
617 })?;
618
619 let sigs: Vec<Vec<u8>> =
620 versioned_tx.signatures.iter().map(|s| s.as_ref().to_vec()).collect();
621
622 let message = match versioned_tx.message {
623 solana_sdk::message::VersionedMessage::Legacy(legacy_msg) => {
624 convert_legacy_message(legacy_msg)?
625 }
626 solana_sdk::message::VersionedMessage::V0(v0_msg) => convert_v0_message(v0_msg)?,
627 solana_sdk::message::VersionedMessage::V1(v1_msg) => convert_v1_message(v1_msg)?,
628 };
629
630 (message, sigs)
631 }
632 EncodedTransaction::Json(_) => {
633 return Err(ParseError::ConversionError(
634 "JSON encoded transactions not supported yet".to_string(),
635 ));
636 }
637 _ => {
638 return Err(ParseError::ConversionError(
639 "Unsupported transaction encoding".to_string(),
640 ));
641 }
642 };
643
644 let grpc_tx = Transaction { signatures, message: Some(message) };
645
646 Ok((grpc_meta, grpc_tx))
647}
648
649fn parse_loaded_address(address: &str) -> Result<Vec<u8>, ParseError> {
650 address.parse::<Pubkey>().map(|pubkey| pubkey.to_bytes().to_vec()).map_err(|error| {
651 ParseError::ConversionError(format!("Invalid loaded address {address}: {error}"))
652 })
653}
654
655fn convert_token_balances(balances: &[UiTransactionTokenBalance]) -> Vec<TokenBalance> {
656 balances
657 .iter()
658 .map(|balance| TokenBalance {
659 account_index: balance.account_index as u32,
660 mint: balance.mint.clone(),
661 ui_token_amount: Some(UiTokenAmount {
662 ui_amount: balance.ui_token_amount.ui_amount.unwrap_or_default(),
663 decimals: balance.ui_token_amount.decimals as u32,
664 amount: balance.ui_token_amount.amount.clone(),
665 ui_amount_string: balance.ui_token_amount.ui_amount_string.clone(),
666 }),
667 owner: balance.owner.as_ref().map(|owner| owner.clone()).unwrap_or_default(),
668 program_id: balance
669 .program_id
670 .as_ref()
671 .map(|program_id| program_id.clone())
672 .unwrap_or_default(),
673 })
674 .collect()
675}
676
677fn convert_legacy_message(
678 msg: solana_sdk::message::legacy::Message,
679) -> Result<Message, ParseError> {
680 let account_keys: Vec<Vec<u8>> =
681 msg.account_keys.iter().map(|k| k.to_bytes().to_vec()).collect();
682
683 let instructions: Vec<CompiledInstruction> = msg
684 .instructions
685 .into_iter()
686 .map(|ix| CompiledInstruction {
687 program_id_index: ix.program_id_index as u32,
688 accounts: ix.accounts,
689 data: ix.data,
690 })
691 .collect();
692
693 Ok(Message {
694 header: Some(MessageHeader {
695 num_required_signatures: msg.header.num_required_signatures as u32,
696 num_readonly_signed_accounts: msg.header.num_readonly_signed_accounts as u32,
697 num_readonly_unsigned_accounts: msg.header.num_readonly_unsigned_accounts as u32,
698 }),
699 account_keys,
700 recent_blockhash: msg.recent_blockhash.to_bytes().to_vec(),
701 instructions,
702 versioned: false,
703 address_table_lookups: Vec::new(),
704 config: None,
705 })
706}
707
708fn convert_v0_message(msg: solana_sdk::message::v0::Message) -> Result<Message, ParseError> {
709 let account_keys: Vec<Vec<u8>> =
710 msg.account_keys.iter().map(|k| k.to_bytes().to_vec()).collect();
711
712 let instructions: Vec<CompiledInstruction> = msg
713 .instructions
714 .into_iter()
715 .map(|ix| CompiledInstruction {
716 program_id_index: ix.program_id_index as u32,
717 accounts: ix.accounts,
718 data: ix.data,
719 })
720 .collect();
721
722 Ok(Message {
723 header: Some(MessageHeader {
724 num_required_signatures: msg.header.num_required_signatures as u32,
725 num_readonly_signed_accounts: msg.header.num_readonly_signed_accounts as u32,
726 num_readonly_unsigned_accounts: msg.header.num_readonly_unsigned_accounts as u32,
727 }),
728 account_keys,
729 recent_blockhash: msg.recent_blockhash.to_bytes().to_vec(),
730 instructions,
731 versioned: true,
732 address_table_lookups: msg
733 .address_table_lookups
734 .into_iter()
735 .map(|lookup| MessageAddressTableLookup {
736 account_key: lookup.account_key.to_bytes().to_vec(),
737 writable_indexes: lookup.writable_indexes,
738 readonly_indexes: lookup.readonly_indexes,
739 })
740 .collect(),
741 config: None,
742 })
743}
744
745fn convert_v1_message(msg: solana_sdk::message::v1::Message) -> Result<Message, ParseError> {
746 let account_keys = msg.account_keys.iter().map(|key| key.to_bytes().to_vec()).collect();
747 let instructions = msg
748 .instructions
749 .into_iter()
750 .map(|ix| CompiledInstruction {
751 program_id_index: ix.program_id_index as u32,
752 accounts: ix.accounts,
753 data: ix.data,
754 })
755 .collect();
756
757 Ok(Message {
758 header: Some(MessageHeader {
759 num_required_signatures: msg.header.num_required_signatures as u32,
760 num_readonly_signed_accounts: msg.header.num_readonly_signed_accounts as u32,
761 num_readonly_unsigned_accounts: msg.header.num_readonly_unsigned_accounts as u32,
762 }),
763 account_keys,
764 recent_blockhash: msg.lifetime_specifier.to_bytes().to_vec(),
765 instructions,
766 versioned: true,
767 address_table_lookups: Vec::new(),
768 config: Some(yellowstone_grpc_proto::prelude::TransactionConfig {
769 priority_fee: msg.config.priority_fee,
770 compute_unit_limit: msg.config.compute_unit_limit,
771 loaded_accounts_data_size_limit: msg.config.loaded_accounts_data_size_limit,
772 heap_size: msg.config.heap_size,
773 }),
774 })
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780 use crate::core::events::{
781 DexEvent, EventMetadata, PumpFunTradeEvent, PumpSwapCreatePoolEvent,
782 };
783 use base64::{engine::general_purpose, Engine as _};
784 use solana_client::rpc_response::{
785 UiLoadedAddresses, UiTokenAmount as RpcUiTokenAmount, UiTransactionStatusMeta,
786 UiTransactionTokenBalance,
787 };
788 use solana_sdk::{
789 hash::Hash,
790 message::{legacy, MessageHeader, VersionedMessage},
791 pubkey::Pubkey,
792 signature::Signature,
793 transaction::VersionedTransaction,
794 };
795 use solana_transaction_status::{
796 option_serializer::OptionSerializer, EncodedTransactionWithStatusMeta,
797 TransactionBinaryEncoding,
798 };
799
800 fn rpc_fixture(
801 user: Pubkey,
802 token_account: Pubkey,
803 ) -> EncodedConfirmedTransactionWithStatusMeta {
804 let transaction = VersionedTransaction {
805 signatures: vec![Signature::from([7; 64])],
806 message: VersionedMessage::Legacy(legacy::Message {
807 header: MessageHeader { num_required_signatures: 1, ..Default::default() },
808 account_keys: vec![user, token_account],
809 recent_blockhash: Hash::new_unique(),
810 instructions: Vec::new(),
811 }),
812 };
813 let bytes = wincode::serialize(&transaction).expect("serialize RPC fixture");
814 let token_balance = |amount: &str| UiTransactionTokenBalance {
815 account_index: 1,
816 mint: Pubkey::new_unique().to_string(),
817 ui_token_amount: RpcUiTokenAmount {
818 ui_amount: None,
819 decimals: 6,
820 amount: amount.to_string(),
821 ui_amount_string: amount.to_string(),
822 },
823 owner: OptionSerializer::Some(user.to_string()),
824 program_id: OptionSerializer::None,
825 };
826
827 EncodedConfirmedTransactionWithStatusMeta {
828 slot: 42,
829 transaction: EncodedTransactionWithStatusMeta {
830 transaction: EncodedTransaction::Binary(
831 general_purpose::STANDARD.encode(bytes),
832 TransactionBinaryEncoding::Base64,
833 ),
834 meta: Some(UiTransactionStatusMeta {
835 err: None,
836 status: Ok(()),
837 fee: 5_000,
838 pre_balances: vec![50_000, 2_039_280],
839 post_balances: vec![40_000, 2_039_280],
840 inner_instructions: OptionSerializer::None,
841 log_messages: OptionSerializer::None,
842 pre_token_balances: OptionSerializer::Some(vec![token_balance("10")]),
843 post_token_balances: OptionSerializer::Some(vec![token_balance("35")]),
844 rewards: OptionSerializer::None,
845 loaded_addresses: OptionSerializer::None,
846 return_data: OptionSerializer::None,
847 compute_units_consumed: OptionSerializer::Some(123),
848 cost_units: OptionSerializer::Some(456),
849 }),
850 version: None,
851 },
852 block_time: None,
853 transaction_index: None,
854 }
855 }
856
857 fn dummy_meta() -> EventMetadata {
858 EventMetadata {
859 signature: Signature::default(),
860 slot: 1,
861 tx_index: 0,
862 block_time_us: 0,
863 grpc_recv_us: 0,
864 recent_blockhash: None,
865 }
866 }
867
868 #[test]
869 fn rpc_merge_keeps_instruction_cashback_for_log_only_pumpswap_create_pool() {
870 let pool = Pubkey::new_unique();
871 let base_mint = Pubkey::new_unique();
872 let quote_mint = Pubkey::new_unique();
873
874 let log_create = PumpSwapCreatePoolEvent {
875 metadata: dummy_meta(),
876 pool,
877 base_mint,
878 quote_mint,
879 is_cashback_coin: false,
880 ..Default::default()
881 };
882 let ix_create = PumpSwapCreatePoolEvent {
883 metadata: dummy_meta(),
884 pool,
885 base_mint,
886 quote_mint,
887 is_cashback_coin: true,
888 ..Default::default()
889 };
890
891 let merged = merge_log_and_instruction_events(
892 vec![DexEvent::PumpSwapCreatePool(log_create)],
893 vec![DexEvent::PumpSwapCreatePool(ix_create)],
894 );
895
896 assert_eq!(merged.len(), 1);
897 match &merged[0] {
898 DexEvent::PumpSwapCreatePool(e) => assert!(e.is_cashback_coin),
899 other => panic!("expected PumpSwapCreatePool, got {other:?}"),
900 }
901 }
902
903 #[test]
904 fn optimized_rpc_parsing_skips_borrowed_metadata_clones_but_fills_pumpfun_trade() {
905 let user = Pubkey::new_unique();
906 let token_account = Pubkey::new_unique();
907 let rpc_tx = rpc_fixture(user, token_account);
908 let (public_meta, _) = convert_rpc_to_grpc(&rpc_tx).expect("convert RPC fixture");
909
910 assert_eq!(
911 public_meta.pre_token_balances[0].ui_token_amount.as_ref().unwrap().amount,
912 "10"
913 );
914 assert_eq!(
915 public_meta.post_token_balances[0].ui_token_amount.as_ref().unwrap().amount,
916 "35"
917 );
918 assert_eq!(public_meta.pre_balances, [50_000, 2_039_280]);
919 assert_eq!(public_meta.post_balances, [40_000, 2_039_280]);
920 let (meta, transaction) =
921 convert_rpc_to_grpc_for_parsing(&rpc_tx).expect("convert RPC fixture for parsing");
922 assert!(meta.pre_balances.is_empty());
923 assert!(meta.post_balances.is_empty());
924 assert!(meta.log_messages.is_empty());
925 assert!(meta.pre_token_balances.is_empty());
926 assert!(meta.post_token_balances.is_empty());
927 assert_eq!(meta.compute_units_consumed, Some(123));
928 assert_eq!(meta.cost_units, Some(456));
929
930 let mut events = vec![DexEvent::PumpFunTrade(PumpFunTradeEvent {
931 user,
932 associated_user: token_account,
933 ..Default::default()
934 })];
935 fill_rpc_event_metadata(&mut events, &rpc_tx, &meta, &Some(transaction));
936
937 let DexEvent::PumpFunTrade(trade) = &events[0] else {
938 panic!("expected PumpFun trade");
939 };
940 assert_eq!(trade.token_balance, Some(35));
941 assert_eq!(trade.sol_balance, Some(40_000));
942 }
943
944 #[test]
945 fn optimized_rpc_balance_fill_handles_closed_token_accounts() {
946 let user = Pubkey::new_unique();
947 let token_account = Pubkey::new_unique();
948 let mut rpc_tx = rpc_fixture(user, token_account);
949 rpc_tx.transaction.meta.as_mut().unwrap().post_token_balances = OptionSerializer::None;
950 let (meta, transaction) =
951 convert_rpc_to_grpc_for_parsing(&rpc_tx).expect("convert RPC fixture for parsing");
952 let mut events = vec![DexEvent::PumpFunSell(PumpFunTradeEvent {
953 user,
954 associated_user: token_account,
955 ..Default::default()
956 })];
957
958 fill_rpc_event_metadata(&mut events, &rpc_tx, &meta, &Some(transaction));
959
960 let DexEvent::PumpFunSell(trade) = &events[0] else {
961 panic!("expected PumpFun sell");
962 };
963 assert_eq!(trade.token_balance, Some(0));
964 assert_eq!(trade.sol_balance, Some(40_000));
965 }
966
967 #[test]
968 fn optimized_rpc_balance_fill_keeps_malformed_amount_unknown() {
969 let user = Pubkey::new_unique();
970 let token_account = Pubkey::new_unique();
971 let mut rpc_tx = rpc_fixture(user, token_account);
972 let rpc_meta = rpc_tx.transaction.meta.as_mut().unwrap();
973 let OptionSerializer::Some(balances) = &mut rpc_meta.post_token_balances else {
974 panic!("post token balances");
975 };
976 balances[0].ui_token_amount.amount = "invalid".to_string();
977 let (meta, transaction) =
978 convert_rpc_to_grpc_for_parsing(&rpc_tx).expect("convert RPC fixture for parsing");
979 let mut events = vec![DexEvent::PumpFunBuy(PumpFunTradeEvent {
980 user,
981 associated_user: token_account,
982 ..Default::default()
983 })];
984
985 fill_rpc_event_metadata(&mut events, &rpc_tx, &meta, &Some(transaction));
986
987 let DexEvent::PumpFunBuy(trade) = &events[0] else {
988 panic!("expected PumpFun buy");
989 };
990 assert_eq!(trade.token_balance, None);
991 assert_eq!(trade.sol_balance, Some(40_000));
992 }
993
994 #[test]
995 fn invalid_rpc_loaded_address_returns_error_instead_of_panicking() {
996 let mut rpc_tx = rpc_fixture(Pubkey::new_unique(), Pubkey::new_unique());
997 rpc_tx.transaction.meta.as_mut().unwrap().loaded_addresses =
998 OptionSerializer::Some(UiLoadedAddresses {
999 writable: vec!["not-a-pubkey".to_string()],
1000 readonly: Vec::new(),
1001 });
1002
1003 let error = convert_rpc_to_grpc(&rpc_tx).expect_err("invalid address must fail");
1004 assert!(
1005 matches!(error, ParseError::ConversionError(message) if message.contains("Invalid loaded address"))
1006 );
1007 }
1008
1009 #[test]
1010 fn base58_rpc_transaction_is_supported() {
1011 let mut rpc_tx = rpc_fixture(Pubkey::new_unique(), Pubkey::new_unique());
1012 let EncodedTransaction::Binary(data, TransactionBinaryEncoding::Base64) =
1013 &rpc_tx.transaction.transaction
1014 else {
1015 panic!("expected base64 fixture");
1016 };
1017 let bytes = general_purpose::STANDARD.decode(data).expect("decode fixture");
1018 rpc_tx.transaction.transaction = EncodedTransaction::Binary(
1019 bs58::encode(bytes).into_string(),
1020 TransactionBinaryEncoding::Base58,
1021 );
1022
1023 convert_rpc_to_grpc(&rpc_tx).expect("base58 binary transaction must decode");
1024 }
1025
1026 #[test]
1027 fn unsanitized_rpc_transaction_is_rejected() {
1028 let mut rpc_tx = rpc_fixture(Pubkey::new_unique(), Pubkey::new_unique());
1029 let invalid = VersionedTransaction {
1030 signatures: vec![Signature::from([7; 64])],
1031 message: VersionedMessage::Legacy(legacy::Message {
1032 header: MessageHeader { num_required_signatures: 1, ..Default::default() },
1033 account_keys: vec![Pubkey::new_unique()],
1034 recent_blockhash: Hash::new_unique(),
1035 instructions: vec![
1036 solana_sdk::message::compiled_instruction::CompiledInstruction {
1037 program_id_index: 9,
1038 accounts: Vec::new(),
1039 data: Vec::new(),
1040 },
1041 ],
1042 }),
1043 };
1044 let bytes = wincode::serialize(&invalid).expect("serialize invalid fixture");
1045 rpc_tx.transaction.transaction = EncodedTransaction::Binary(
1046 general_purpose::STANDARD.encode(bytes),
1047 TransactionBinaryEncoding::Base64,
1048 );
1049
1050 let error = convert_rpc_to_grpc(&rpc_tx).expect_err("unsanitized transaction must fail");
1051 assert!(matches!(
1052 error,
1053 ParseError::ConversionError(message) if message.contains("decode or sanitize")
1054 ));
1055 }
1056}