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 needs_pumpfun = filter.map(EventTypeFilter::includes_pumpfun).unwrap_or(true);
211 let log_messages = rpc_log_messages(rpc_tx);
212 let is_created_buy =
213 needs_pumpfun && crate::logs::optimized_matcher::detect_pumpfun_create(log_messages);
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 matches!(&event, DexEvent::RaydiumAmmV4Swap(_)) {
275 let mut scoped = crate::core::invoke_context::InvokeContext::default();
276 if let Some(active) = active_program_stack.last() {
277 scoped.push(active.pubkey, active.position);
278 }
279 crate::core::account_dispatcher::fill_accounts_with_invoke_context(
280 &mut event,
281 &grpc_meta,
282 &grpc_tx_opt,
283 &scoped,
284 );
285 } else {
286 crate::core::account_dispatcher::fill_accounts_with_owned_keys(
287 &mut event,
288 &grpc_meta,
289 &grpc_tx_opt,
290 &program_invokes,
291 );
292 }
293
294 crate::core::common_filler::fill_data(
296 &mut event,
297 &grpc_meta,
298 &grpc_tx_opt,
299 &program_invokes,
300 );
301
302 log_events.push(event);
303 }
304
305 if let Some(pid) = crate::logs::optimized_matcher::parse_program_complete_info(log) {
306 if let Some(pos) = active_program_stack.iter().rposition(|active| active.encoded == pid)
307 {
308 active_program_stack.truncate(pos);
309 }
310 }
311 }
312
313 let mut events = merge_log_and_instruction_events(log_events, instr_events);
314 crate::core::common_filler::fill_pump_creation_context(&mut events, &grpc_meta, &grpc_tx_opt);
315 fill_rpc_event_metadata(&mut events, rpc_tx, &grpc_meta, &grpc_tx_opt);
316 Ok(events)
317}
318
319fn fill_rpc_event_metadata(
320 events: &mut [DexEvent],
321 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
322 meta: &TransactionStatusMeta,
323 transaction: &Option<Transaction>,
324) {
325 if let Some(rpc_meta) = rpc_tx.transaction.meta.as_ref() {
326 for event in events.iter_mut() {
327 fill_rpc_token_balances(event, rpc_meta, meta, transaction);
328 }
329 }
330 crate::grpc::transaction_meta::fill_recent_blockhash(events, transaction);
331}
332
333#[inline]
334fn fill_rpc_token_balances(
335 event: &mut DexEvent,
336 rpc_meta: &UiTransactionStatusMeta,
337 meta: &TransactionStatusMeta,
338 transaction: &Option<Transaction>,
339) {
340 let trade = match event {
341 DexEvent::PumpFunTrade(event)
342 | DexEvent::PumpFunBuy(event)
343 | DexEvent::PumpFunSell(event)
344 | DexEvent::PumpFunBuyExactSolIn(event) => event,
345 _ => return,
346 };
347
348 if let Some(user_index) = rpc_account_index(transaction, meta, &trade.user) {
349 trade.sol_balance = rpc_meta.post_balances.get(user_index).copied();
350 }
351
352 if trade.associated_user == Pubkey::default() {
353 return;
354 }
355
356 let matches_account = |balance: &UiTransactionTokenBalance| {
357 rpc_account_key(transaction, meta, balance.account_index as usize)
358 .is_some_and(|key| key.as_slice() == trade.associated_user.as_ref())
359 };
360
361 if let OptionSerializer::Some(balances) = &rpc_meta.post_token_balances {
362 if let Some(balance) = balances.iter().find(|balance| matches_account(balance)) {
363 trade.token_balance = balance.ui_token_amount.amount.parse().ok();
364 return;
365 }
366 }
367
368 if let OptionSerializer::Some(balances) = &rpc_meta.pre_token_balances {
369 if balances.iter().any(matches_account) {
370 trade.token_balance = Some(0);
371 }
372 }
373}
374
375#[inline]
376fn rpc_account_index(
377 transaction: &Option<Transaction>,
378 meta: &TransactionStatusMeta,
379 account: &Pubkey,
380) -> Option<usize> {
381 if *account == Pubkey::default() {
382 return None;
383 }
384
385 let message = transaction.as_ref()?.message.as_ref()?;
386 message
387 .account_keys
388 .iter()
389 .chain(&meta.loaded_writable_addresses)
390 .chain(&meta.loaded_readonly_addresses)
391 .position(|key| key.as_slice() == account.as_ref())
392}
393
394#[inline]
395fn rpc_account_key<'a>(
396 transaction: &'a Option<Transaction>,
397 meta: &'a TransactionStatusMeta,
398 index: usize,
399) -> Option<&'a Vec<u8>> {
400 let message = transaction.as_ref()?.message.as_ref()?;
401 let static_len = message.account_keys.len();
402 let writable_len = meta.loaded_writable_addresses.len();
403
404 if index < static_len {
405 message.account_keys.get(index)
406 } else if index < static_len + writable_len {
407 meta.loaded_writable_addresses.get(index - static_len)
408 } else {
409 meta.loaded_readonly_addresses.get(index - static_len - writable_len)
410 }
411}
412
413#[inline]
414fn rpc_log_messages(rpc_tx: &EncodedConfirmedTransactionWithStatusMeta) -> &[String] {
415 let Some(meta) = rpc_tx.transaction.meta.as_ref() else { return &[] };
416 match &meta.log_messages {
417 OptionSerializer::Some(messages) => messages,
418 _ => &[],
419 }
420}
421
422fn merge_log_and_instruction_events(
423 log_events: Vec<DexEvent>,
424 instr_events: Vec<DexEvent>,
425) -> Vec<DexEvent> {
426 crate::grpc::log_instr_dedup::dedupe_log_instruction_events(log_events, instr_events)
427}
428
429#[derive(Debug)]
431pub enum ParseError {
432 RpcError(String),
433 ConversionError(String),
434 MissingField(String),
435}
436
437impl std::fmt::Display for ParseError {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 match self {
440 ParseError::RpcError(msg) => write!(f, "RPC error: {}", msg),
441 ParseError::ConversionError(msg) => write!(f, "Conversion error: {}", msg),
442 ParseError::MissingField(msg) => write!(f, "Missing field: {}", msg),
443 }
444 }
445}
446
447impl std::error::Error for ParseError {}
448
449pub fn convert_rpc_to_grpc(
454 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
455) -> Result<(TransactionStatusMeta, Transaction), ParseError> {
456 convert_rpc_to_grpc_impl(rpc_tx, true)
457}
458
459#[inline]
462pub(crate) fn convert_rpc_to_grpc_for_parsing(
463 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
464) -> Result<(TransactionStatusMeta, Transaction), ParseError> {
465 convert_rpc_to_grpc_impl(rpc_tx, false)
466}
467
468fn convert_rpc_to_grpc_impl(
469 rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
470 include_borrowed_metadata: bool,
471) -> Result<(TransactionStatusMeta, Transaction), ParseError> {
472 let rpc_meta = rpc_tx
473 .transaction
474 .meta
475 .as_ref()
476 .ok_or_else(|| ParseError::MissingField("meta".to_string()))?;
477
478 let (loaded_writable_addresses, loaded_readonly_addresses) = rpc_meta
479 .loaded_addresses
480 .as_ref()
481 .map(|addresses| {
482 let writable = addresses
483 .writable
484 .iter()
485 .map(|address| parse_loaded_address(address))
486 .collect::<Result<Vec<_>, _>>()?;
487 let readonly = addresses
488 .readonly
489 .iter()
490 .map(|address| parse_loaded_address(address))
491 .collect::<Result<Vec<_>, _>>()?;
492 Ok((writable, readonly))
493 })
494 .transpose()?
495 .unwrap_or_default();
496
497 let err = rpc_meta
498 .err
499 .clone()
500 .map(|error| {
501 let error: solana_sdk::transaction::TransactionError = error.into();
502 wincode::serialize(&error).map(|err| TransactionError { err }).map_err(|error| {
503 ParseError::ConversionError(format!(
504 "Failed to serialize transaction error: {error}"
505 ))
506 })
507 })
508 .transpose()?;
509
510 let mut grpc_meta = TransactionStatusMeta {
512 err,
513 fee: rpc_meta.fee,
514 pre_balances: if include_borrowed_metadata {
515 rpc_meta.pre_balances.clone()
516 } else {
517 Vec::new()
518 },
519 post_balances: if include_borrowed_metadata {
520 rpc_meta.post_balances.clone()
521 } else {
522 Vec::new()
523 },
524 inner_instructions: Vec::new(),
525 log_messages: if include_borrowed_metadata {
526 rpc_meta.log_messages.as_ref().map(|messages| messages.clone()).unwrap_or_default()
527 } else {
528 Vec::new()
529 },
530 pre_token_balances: if include_borrowed_metadata {
531 rpc_meta
532 .pre_token_balances
533 .as_ref()
534 .map(|balances| convert_token_balances(balances))
535 .unwrap_or_default()
536 } else {
537 Vec::new()
538 },
539 post_token_balances: if include_borrowed_metadata {
540 rpc_meta
541 .post_token_balances
542 .as_ref()
543 .map(|balances| convert_token_balances(balances))
544 .unwrap_or_default()
545 } else {
546 Vec::new()
547 },
548 rewards: Vec::new(),
549 loaded_writable_addresses,
550 loaded_readonly_addresses,
551 return_data: None,
552 compute_units_consumed: rpc_meta.compute_units_consumed.clone().into(),
553
554 inner_instructions_none: !rpc_meta.inner_instructions.is_some(),
555 log_messages_none: !rpc_meta.log_messages.is_some(),
556 return_data_none: !rpc_meta.return_data.is_some(),
557 cost_units: rpc_meta.cost_units.clone().into(),
558 };
559
560 if let solana_transaction_status::option_serializer::OptionSerializer::Some(
562 inner_instructions,
563 ) = rpc_meta.inner_instructions.as_ref()
564 {
565 for inner in inner_instructions {
566 let mut grpc_inner =
567 InnerInstructions { index: inner.index as u32, instructions: Vec::new() };
568
569 for ix in &inner.instructions {
570 if let UiInstruction::Compiled(compiled) = ix {
571 let data = base58_turbo::BITCOIN.decode(&compiled.data).map_err(|e| {
573 ParseError::ConversionError(format!(
574 "Failed to decode instruction data: {}",
575 e
576 ))
577 })?;
578
579 grpc_inner.instructions.push(InnerInstruction {
580 program_id_index: compiled.program_id_index as u32,
581 accounts: compiled.accounts.clone(),
582 data,
583 stack_height: compiled.stack_height,
584 });
585 }
586 }
587
588 grpc_meta.inner_instructions.push(grpc_inner);
589 }
590 }
591
592 let ui_tx = &rpc_tx.transaction.transaction;
594
595 let (message, signatures) = match ui_tx {
596 EncodedTransaction::Binary(_, _) | EncodedTransaction::LegacyBinary(_) => {
597 let versioned_tx = ui_tx.decode().ok_or_else(|| {
599 ParseError::ConversionError(
600 "Failed to decode or sanitize binary transaction".to_string(),
601 )
602 })?;
603
604 let sigs: Vec<Vec<u8>> =
605 versioned_tx.signatures.iter().map(|s| s.as_ref().to_vec()).collect();
606
607 let message = match versioned_tx.message {
608 solana_sdk::message::VersionedMessage::Legacy(legacy_msg) => {
609 convert_legacy_message(legacy_msg)?
610 }
611 solana_sdk::message::VersionedMessage::V0(v0_msg) => convert_v0_message(v0_msg)?,
612 solana_sdk::message::VersionedMessage::V1(v1_msg) => convert_v1_message(v1_msg)?,
613 };
614
615 (message, sigs)
616 }
617 EncodedTransaction::Json(_) => {
618 return Err(ParseError::ConversionError(
619 "JSON encoded transactions not supported yet".to_string(),
620 ));
621 }
622 _ => {
623 return Err(ParseError::ConversionError(
624 "Unsupported transaction encoding".to_string(),
625 ));
626 }
627 };
628
629 let grpc_tx = Transaction { signatures, message: Some(message) };
630
631 Ok((grpc_meta, grpc_tx))
632}
633
634fn parse_loaded_address(address: &str) -> Result<Vec<u8>, ParseError> {
635 address.parse::<Pubkey>().map(|pubkey| pubkey.to_bytes().to_vec()).map_err(|error| {
636 ParseError::ConversionError(format!("Invalid loaded address {address}: {error}"))
637 })
638}
639
640fn convert_token_balances(balances: &[UiTransactionTokenBalance]) -> Vec<TokenBalance> {
641 balances
642 .iter()
643 .map(|balance| TokenBalance {
644 account_index: balance.account_index as u32,
645 mint: balance.mint.clone(),
646 ui_token_amount: Some(UiTokenAmount {
647 ui_amount: balance.ui_token_amount.ui_amount.unwrap_or_default(),
648 decimals: balance.ui_token_amount.decimals as u32,
649 amount: balance.ui_token_amount.amount.clone(),
650 ui_amount_string: balance.ui_token_amount.ui_amount_string.clone(),
651 }),
652 owner: balance.owner.as_ref().map(|owner| owner.clone()).unwrap_or_default(),
653 program_id: balance
654 .program_id
655 .as_ref()
656 .map(|program_id| program_id.clone())
657 .unwrap_or_default(),
658 })
659 .collect()
660}
661
662fn convert_legacy_message(
663 msg: solana_sdk::message::legacy::Message,
664) -> Result<Message, ParseError> {
665 let account_keys: Vec<Vec<u8>> =
666 msg.account_keys.iter().map(|k| k.to_bytes().to_vec()).collect();
667
668 let instructions: Vec<CompiledInstruction> = msg
669 .instructions
670 .into_iter()
671 .map(|ix| CompiledInstruction {
672 program_id_index: ix.program_id_index as u32,
673 accounts: ix.accounts,
674 data: ix.data,
675 })
676 .collect();
677
678 Ok(Message {
679 header: Some(MessageHeader {
680 num_required_signatures: msg.header.num_required_signatures as u32,
681 num_readonly_signed_accounts: msg.header.num_readonly_signed_accounts as u32,
682 num_readonly_unsigned_accounts: msg.header.num_readonly_unsigned_accounts as u32,
683 }),
684 account_keys,
685 recent_blockhash: msg.recent_blockhash.to_bytes().to_vec(),
686 instructions,
687 versioned: false,
688 address_table_lookups: Vec::new(),
689 config: None,
690 })
691}
692
693fn convert_v0_message(msg: solana_sdk::message::v0::Message) -> Result<Message, ParseError> {
694 let account_keys: Vec<Vec<u8>> =
695 msg.account_keys.iter().map(|k| k.to_bytes().to_vec()).collect();
696
697 let instructions: Vec<CompiledInstruction> = msg
698 .instructions
699 .into_iter()
700 .map(|ix| CompiledInstruction {
701 program_id_index: ix.program_id_index as u32,
702 accounts: ix.accounts,
703 data: ix.data,
704 })
705 .collect();
706
707 Ok(Message {
708 header: Some(MessageHeader {
709 num_required_signatures: msg.header.num_required_signatures as u32,
710 num_readonly_signed_accounts: msg.header.num_readonly_signed_accounts as u32,
711 num_readonly_unsigned_accounts: msg.header.num_readonly_unsigned_accounts as u32,
712 }),
713 account_keys,
714 recent_blockhash: msg.recent_blockhash.to_bytes().to_vec(),
715 instructions,
716 versioned: true,
717 address_table_lookups: msg
718 .address_table_lookups
719 .into_iter()
720 .map(|lookup| MessageAddressTableLookup {
721 account_key: lookup.account_key.to_bytes().to_vec(),
722 writable_indexes: lookup.writable_indexes,
723 readonly_indexes: lookup.readonly_indexes,
724 })
725 .collect(),
726 config: None,
727 })
728}
729
730fn convert_v1_message(msg: solana_sdk::message::v1::Message) -> Result<Message, ParseError> {
731 let account_keys = msg.account_keys.iter().map(|key| key.to_bytes().to_vec()).collect();
732 let instructions = msg
733 .instructions
734 .into_iter()
735 .map(|ix| CompiledInstruction {
736 program_id_index: ix.program_id_index as u32,
737 accounts: ix.accounts,
738 data: ix.data,
739 })
740 .collect();
741
742 Ok(Message {
743 header: Some(MessageHeader {
744 num_required_signatures: msg.header.num_required_signatures as u32,
745 num_readonly_signed_accounts: msg.header.num_readonly_signed_accounts as u32,
746 num_readonly_unsigned_accounts: msg.header.num_readonly_unsigned_accounts as u32,
747 }),
748 account_keys,
749 recent_blockhash: msg.lifetime_specifier.to_bytes().to_vec(),
750 instructions,
751 versioned: true,
752 address_table_lookups: Vec::new(),
753 config: Some(yellowstone_grpc_proto::prelude::TransactionConfig {
754 priority_fee: msg.config.priority_fee,
755 compute_unit_limit: msg.config.compute_unit_limit,
756 loaded_accounts_data_size_limit: msg.config.loaded_accounts_data_size_limit,
757 heap_size: msg.config.heap_size,
758 }),
759 })
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use crate::core::events::{
766 DexEvent, EventMetadata, PumpFunTradeEvent, PumpSwapCreatePoolEvent,
767 };
768 use base64::{engine::general_purpose, Engine as _};
769 use solana_client::rpc_response::{
770 UiLoadedAddresses, UiTokenAmount as RpcUiTokenAmount, UiTransactionStatusMeta,
771 UiTransactionTokenBalance,
772 };
773 use solana_sdk::{
774 hash::Hash,
775 message::{legacy, MessageHeader, VersionedMessage},
776 pubkey::Pubkey,
777 signature::Signature,
778 transaction::VersionedTransaction,
779 };
780 use solana_transaction_status::{
781 option_serializer::OptionSerializer, EncodedTransactionWithStatusMeta,
782 TransactionBinaryEncoding,
783 };
784
785 fn rpc_fixture(
786 user: Pubkey,
787 token_account: Pubkey,
788 ) -> EncodedConfirmedTransactionWithStatusMeta {
789 let transaction = VersionedTransaction {
790 signatures: vec![Signature::from([7; 64])],
791 message: VersionedMessage::Legacy(legacy::Message {
792 header: MessageHeader { num_required_signatures: 1, ..Default::default() },
793 account_keys: vec![user, token_account],
794 recent_blockhash: Hash::new_unique(),
795 instructions: Vec::new(),
796 }),
797 };
798 let bytes = wincode::serialize(&transaction).expect("serialize RPC fixture");
799 let token_balance = |amount: &str| UiTransactionTokenBalance {
800 account_index: 1,
801 mint: Pubkey::new_unique().to_string(),
802 ui_token_amount: RpcUiTokenAmount {
803 ui_amount: None,
804 decimals: 6,
805 amount: amount.to_string(),
806 ui_amount_string: amount.to_string(),
807 },
808 owner: OptionSerializer::Some(user.to_string()),
809 program_id: OptionSerializer::None,
810 };
811
812 EncodedConfirmedTransactionWithStatusMeta {
813 slot: 42,
814 transaction: EncodedTransactionWithStatusMeta {
815 transaction: EncodedTransaction::Binary(
816 general_purpose::STANDARD.encode(bytes),
817 TransactionBinaryEncoding::Base64,
818 ),
819 meta: Some(UiTransactionStatusMeta {
820 err: None,
821 status: Ok(()),
822 fee: 5_000,
823 pre_balances: vec![50_000, 2_039_280],
824 post_balances: vec![40_000, 2_039_280],
825 inner_instructions: OptionSerializer::None,
826 log_messages: OptionSerializer::None,
827 pre_token_balances: OptionSerializer::Some(vec![token_balance("10")]),
828 post_token_balances: OptionSerializer::Some(vec![token_balance("35")]),
829 rewards: OptionSerializer::None,
830 loaded_addresses: OptionSerializer::None,
831 return_data: OptionSerializer::None,
832 compute_units_consumed: OptionSerializer::Some(123),
833 cost_units: OptionSerializer::Some(456),
834 }),
835 version: None,
836 },
837 block_time: None,
838 transaction_index: None,
839 }
840 }
841
842 fn dummy_meta() -> EventMetadata {
843 EventMetadata {
844 signature: Signature::default(),
845 slot: 1,
846 tx_index: 0,
847 block_time_us: 0,
848 grpc_recv_us: 0,
849 recent_blockhash: None,
850 }
851 }
852
853 #[test]
854 fn rpc_merge_keeps_instruction_cashback_for_log_only_pumpswap_create_pool() {
855 let pool = Pubkey::new_unique();
856 let base_mint = Pubkey::new_unique();
857 let quote_mint = Pubkey::new_unique();
858
859 let log_create = PumpSwapCreatePoolEvent {
860 metadata: dummy_meta(),
861 pool,
862 base_mint,
863 quote_mint,
864 is_cashback_coin: false,
865 ..Default::default()
866 };
867 let ix_create = PumpSwapCreatePoolEvent {
868 metadata: dummy_meta(),
869 pool,
870 base_mint,
871 quote_mint,
872 is_cashback_coin: true,
873 ..Default::default()
874 };
875
876 let merged = merge_log_and_instruction_events(
877 vec![DexEvent::PumpSwapCreatePool(log_create)],
878 vec![DexEvent::PumpSwapCreatePool(ix_create)],
879 );
880
881 assert_eq!(merged.len(), 1);
882 match &merged[0] {
883 DexEvent::PumpSwapCreatePool(e) => assert!(e.is_cashback_coin),
884 other => panic!("expected PumpSwapCreatePool, got {other:?}"),
885 }
886 }
887
888 #[test]
889 fn optimized_rpc_parsing_skips_borrowed_metadata_clones_but_fills_pumpfun_trade() {
890 let user = Pubkey::new_unique();
891 let token_account = Pubkey::new_unique();
892 let rpc_tx = rpc_fixture(user, token_account);
893 let (public_meta, _) = convert_rpc_to_grpc(&rpc_tx).expect("convert RPC fixture");
894
895 assert_eq!(
896 public_meta.pre_token_balances[0].ui_token_amount.as_ref().unwrap().amount,
897 "10"
898 );
899 assert_eq!(
900 public_meta.post_token_balances[0].ui_token_amount.as_ref().unwrap().amount,
901 "35"
902 );
903 assert_eq!(public_meta.pre_balances, [50_000, 2_039_280]);
904 assert_eq!(public_meta.post_balances, [40_000, 2_039_280]);
905 let (meta, transaction) =
906 convert_rpc_to_grpc_for_parsing(&rpc_tx).expect("convert RPC fixture for parsing");
907 assert!(meta.pre_balances.is_empty());
908 assert!(meta.post_balances.is_empty());
909 assert!(meta.log_messages.is_empty());
910 assert!(meta.pre_token_balances.is_empty());
911 assert!(meta.post_token_balances.is_empty());
912 assert_eq!(meta.compute_units_consumed, Some(123));
913 assert_eq!(meta.cost_units, Some(456));
914
915 let mut events = vec![DexEvent::PumpFunTrade(PumpFunTradeEvent {
916 user,
917 associated_user: token_account,
918 ..Default::default()
919 })];
920 fill_rpc_event_metadata(&mut events, &rpc_tx, &meta, &Some(transaction));
921
922 let DexEvent::PumpFunTrade(trade) = &events[0] else {
923 panic!("expected PumpFun trade");
924 };
925 assert_eq!(trade.token_balance, Some(35));
926 assert_eq!(trade.sol_balance, Some(40_000));
927 }
928
929 #[test]
930 fn optimized_rpc_balance_fill_handles_closed_token_accounts() {
931 let user = Pubkey::new_unique();
932 let token_account = Pubkey::new_unique();
933 let mut rpc_tx = rpc_fixture(user, token_account);
934 rpc_tx.transaction.meta.as_mut().unwrap().post_token_balances = OptionSerializer::None;
935 let (meta, transaction) =
936 convert_rpc_to_grpc_for_parsing(&rpc_tx).expect("convert RPC fixture for parsing");
937 let mut events = vec![DexEvent::PumpFunSell(PumpFunTradeEvent {
938 user,
939 associated_user: token_account,
940 ..Default::default()
941 })];
942
943 fill_rpc_event_metadata(&mut events, &rpc_tx, &meta, &Some(transaction));
944
945 let DexEvent::PumpFunSell(trade) = &events[0] else {
946 panic!("expected PumpFun sell");
947 };
948 assert_eq!(trade.token_balance, Some(0));
949 assert_eq!(trade.sol_balance, Some(40_000));
950 }
951
952 #[test]
953 fn optimized_rpc_balance_fill_keeps_malformed_amount_unknown() {
954 let user = Pubkey::new_unique();
955 let token_account = Pubkey::new_unique();
956 let mut rpc_tx = rpc_fixture(user, token_account);
957 let rpc_meta = rpc_tx.transaction.meta.as_mut().unwrap();
958 let OptionSerializer::Some(balances) = &mut rpc_meta.post_token_balances else {
959 panic!("post token balances");
960 };
961 balances[0].ui_token_amount.amount = "invalid".to_string();
962 let (meta, transaction) =
963 convert_rpc_to_grpc_for_parsing(&rpc_tx).expect("convert RPC fixture for parsing");
964 let mut events = vec![DexEvent::PumpFunBuy(PumpFunTradeEvent {
965 user,
966 associated_user: token_account,
967 ..Default::default()
968 })];
969
970 fill_rpc_event_metadata(&mut events, &rpc_tx, &meta, &Some(transaction));
971
972 let DexEvent::PumpFunBuy(trade) = &events[0] else {
973 panic!("expected PumpFun buy");
974 };
975 assert_eq!(trade.token_balance, None);
976 assert_eq!(trade.sol_balance, Some(40_000));
977 }
978
979 #[test]
980 fn invalid_rpc_loaded_address_returns_error_instead_of_panicking() {
981 let mut rpc_tx = rpc_fixture(Pubkey::new_unique(), Pubkey::new_unique());
982 rpc_tx.transaction.meta.as_mut().unwrap().loaded_addresses =
983 OptionSerializer::Some(UiLoadedAddresses {
984 writable: vec!["not-a-pubkey".to_string()],
985 readonly: Vec::new(),
986 });
987
988 let error = convert_rpc_to_grpc(&rpc_tx).expect_err("invalid address must fail");
989 assert!(
990 matches!(error, ParseError::ConversionError(message) if message.contains("Invalid loaded address"))
991 );
992 }
993
994 #[test]
995 fn base58_rpc_transaction_is_supported() {
996 let mut rpc_tx = rpc_fixture(Pubkey::new_unique(), Pubkey::new_unique());
997 let EncodedTransaction::Binary(data, TransactionBinaryEncoding::Base64) =
998 &rpc_tx.transaction.transaction
999 else {
1000 panic!("expected base64 fixture");
1001 };
1002 let bytes = general_purpose::STANDARD.decode(data).expect("decode fixture");
1003 rpc_tx.transaction.transaction = EncodedTransaction::Binary(
1004 bs58::encode(bytes).into_string(),
1005 TransactionBinaryEncoding::Base58,
1006 );
1007
1008 convert_rpc_to_grpc(&rpc_tx).expect("base58 binary transaction must decode");
1009 }
1010
1011 #[test]
1012 fn unsanitized_rpc_transaction_is_rejected() {
1013 let mut rpc_tx = rpc_fixture(Pubkey::new_unique(), Pubkey::new_unique());
1014 let invalid = VersionedTransaction {
1015 signatures: vec![Signature::from([7; 64])],
1016 message: VersionedMessage::Legacy(legacy::Message {
1017 header: MessageHeader { num_required_signatures: 1, ..Default::default() },
1018 account_keys: vec![Pubkey::new_unique()],
1019 recent_blockhash: Hash::new_unique(),
1020 instructions: vec![
1021 solana_sdk::message::compiled_instruction::CompiledInstruction {
1022 program_id_index: 9,
1023 accounts: Vec::new(),
1024 data: Vec::new(),
1025 },
1026 ],
1027 }),
1028 };
1029 let bytes = wincode::serialize(&invalid).expect("serialize invalid fixture");
1030 rpc_tx.transaction.transaction = EncodedTransaction::Binary(
1031 general_purpose::STANDARD.encode(bytes),
1032 TransactionBinaryEncoding::Base64,
1033 );
1034
1035 let error = convert_rpc_to_grpc(&rpc_tx).expect_err("unsanitized transaction must fail");
1036 assert!(matches!(
1037 error,
1038 ParseError::ConversionError(message) if message.contains("decode or sanitize")
1039 ));
1040 }
1041}