Skip to main content

sol_parser_sdk/
rpc_parser.rs

1//! RPC Transaction Parser
2//!
3//! 提供独立的 RPC 交易解析功能,不依赖 gRPC streaming
4//! 可以用于测试验证和离线分析
5
6use 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
28/// Parse a transaction from RPC by signature
29///
30/// # Arguments
31/// * `rpc_client` - RPC client to fetch the transaction
32/// * `signature` - Transaction signature
33/// * `filter` - Optional event type filter
34///
35/// # Returns
36/// Vector of parsed DEX events
37///
38/// # Example
39/// ```no_run
40/// use solana_client::rpc_client::RpcClient;
41/// use solana_sdk::signature::Signature;
42/// use sol_parser_sdk::parse_transaction_from_rpc;
43/// use std::str::FromStr;
44///
45/// let client = RpcClient::new("https://api.mainnet-beta.solana.com".to_string());
46/// let sig = Signature::from_str("your-signature-here").unwrap();
47/// let events = parse_transaction_from_rpc(&client, &sig, None).unwrap();
48/// ```
49pub fn parse_transaction_from_rpc(
50    rpc_client: &RpcClient,
51    signature: &Signature,
52    filter: Option<&EventTypeFilter>,
53) -> Result<Vec<DexEvent>, ParseError> {
54    // Fetch transaction from RPC with V1 transaction support.
55    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
76/// Parse a RPC transaction structure
77///
78/// # Arguments
79/// * `rpc_tx` - RPC transaction to parse
80/// * `filter` - Optional event type filter
81///
82/// # Returns
83/// Vector of parsed DEX events
84///
85/// # Example
86/// ```no_run
87/// use sol_parser_sdk::parse_rpc_transaction;
88///
89/// // Assuming you have an rpc_tx from RPC
90/// // let events = parse_rpc_transaction(&rpc_tx, None).unwrap();
91/// ```
92pub fn parse_rpc_transaction(
93    rpc_tx: &EncodedConfirmedTransactionWithStatusMeta,
94    filter: Option<&EventTypeFilter>,
95) -> Result<Vec<DexEvent>, ParseError> {
96    // RPC logs from failed transactions describe rolled-back work. Match the
97    // Yellowstone event API and reject before decoding/account allocation.
98    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/// Result of parsing RPC events and transaction costs from one shared decode.
107#[derive(Debug)]
108pub struct ParsedRpcTransaction {
109    pub events: Vec<DexEvent>,
110    pub cost: TransactionCost,
111    pub signature: Signature,
112}
113
114/// Parses events and transaction costs while decoding the RPC payload once.
115pub 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
127/// Parses only transaction cost and signature from one shared RPC decode.
128pub 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    // The combined API still decodes costs/signature for failed transactions,
158    // but must not interpret their logs as successful DEX activity.
159    if grpc_meta.err.is_some() {
160        return Ok(Vec::new());
161    }
162    // Extract metadata
163    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    // Wrap grpc_tx in Option for reuse
171    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    // Final mint-scoped creation context comes from native instructions, not a
212    // transaction-wide log scan whose result would be overwritten after merging.
213    let is_created_buy = false;
214
215    // Parse instructions
216    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, // tx_idx
223            block_time_us,
224            grpc_recv_us,
225            filter,
226            is_created_buy,
227        );
228
229    // Parse logs (for protocols like PumpFun that emit events in logs)
230    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, // tx_index
266            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            // Fill account fields - use same function as gRPC parsing
284            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            // Fill additional data fields (e.g., PumpSwap is_pump_pool)
305            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/// Parse error types
445#[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
464// ============================================================================
465// Internal conversion functions
466// ============================================================================
467
468pub 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/// Converts only metadata that parsing and cost calculation must own; logs and balances stay
475/// borrowed from the original RPC response on these internal paths.
476#[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    // Convert meta
526    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    // Convert inner instructions
576    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                    // Decode base58 data
587                    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    // Convert transaction
608    let ui_tx = &rpc_tx.transaction.transaction;
609
610    let (message, signatures) = match ui_tx {
611        EncodedTransaction::Binary(_, _) | EncodedTransaction::LegacyBinary(_) => {
612            // Solana's decoder handles Base58/Base64, wincode deserialization and sanitization.
613            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}