Skip to main content

sol_parser_sdk/
transaction_route.rs

1//! Opt-in instruction-level route inspection for RPC and Yellowstone transactions.
2//!
3//! Keeps execution order, CPI positions, transfers, and unknown programs. A list
4//! of swaps is not assumed to be a linear route. Graduated StonkFun attribution
5//! requires pool identities established by the caller (e.g. from migration).
6use crate::{convert_rpc_to_grpc, ParseError};
7use serde::{Deserialize, Serialize};
8use solana_sdk::{pubkey::Pubkey, signature::Signature};
9use solana_transaction_status::EncodedConfirmedTransactionWithStatusMeta;
10use std::collections::HashMap;
11use yellowstone_grpc_proto::prelude::{Transaction, TransactionStatusMeta};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct InstructionPosition {
15    pub outer_index: u32,
16    pub inner_index: Option<u32>,
17    pub stack_height: Option<u32>,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
21pub enum SwapProtocol {
22    PumpSwap,
23    LaunchLab,
24    RaydiumCpmm,
25    RaydiumAmmV4,
26    RaydiumClmm,
27    OrcaWhirlpool,
28    MeteoraDlmm,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct RouteSwapLeg {
33    pub position: InstructionPosition,
34    pub program: Pubkey,
35    pub protocol: SwapProtocol,
36    pub pool: Pubkey,
37    pub trader: Pubkey,
38    pub input_account: Pubkey,
39    pub output_account: Pubkey,
40    pub input_mint: Option<Pubkey>,
41    pub output_mint: Option<Pubkey>,
42    /// Instruction arguments, not executed amounts or a fresh quote.
43    pub amount_specified_is_input: bool,
44    pub specified_amount: u64,
45    pub other_amount_threshold: u64,
46    /// Actual debits/credits observed in this invocation's token transfers.
47    /// None when execution failed, transfer context is missing, or CPI depth is unknown.
48    pub actual_input_amount: Option<u64>,
49    pub actual_output_amount: Option<u64>,
50    pub stonkfun_mode: Option<crate::core::events::StonkFunMode>,
51    /// Caller supplied this CPMM pool as a verified graduated StonkFun pool.
52    pub stonkfun_graduated: bool,
53}
54
55#[derive(Clone, Debug, Serialize, Deserialize)]
56pub struct RouteTokenTransfer {
57    pub position: InstructionPosition,
58    pub program: Pubkey,
59    pub source: Pubkey,
60    pub destination: Pubkey,
61    pub mint: Option<Pubkey>,
62    /// Gross transfer argument. Token-2022 transfer fees may reduce the credit.
63    pub amount: u64,
64    pub withheld_fee: Option<u64>,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct RouteUnknownInvocation {
69    pub position: InstructionPosition,
70    pub program: Pubkey,
71    /// Nested token transfers identify opaque/custom liquidity or router activity.
72    pub has_token_transfers: bool,
73    /// True for opaque routers wrapping decoded swap legs. False plus token
74    /// transfers marks an unresolved economic operation (possibly a custom pool).
75    pub has_known_swap_descendants: bool,
76}
77
78#[derive(Clone, Debug, Serialize, Deserialize)]
79pub struct TransactionRoute {
80    pub signature: Signature,
81    pub succeeded: bool,
82    pub legs: Vec<RouteSwapLeg>,
83    pub transfers: Vec<RouteTokenTransfer>,
84    /// Instruction evidence only. Failed transactions do not change balances,
85    /// and WSOL usage alone never establishes the caller's desired SOL/WSOL asset.
86    #[serde(default)]
87    pub native_token_actions: Vec<RouteNativeTokenAction>,
88    /// Preserved even when a program cannot be decoded. Do not treat legs as
89    /// complete coverage when unknown invocations carry token transfers.
90    pub unknown_invocations: Vec<RouteUnknownInvocation>,
91}
92
93#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
94pub enum NativeTokenAction {
95    /// System transfer to an account identified as WSOL in this transaction.
96    Fund {
97        source: Pubkey,
98        lamports: u64,
99    },
100    SyncNative,
101    /// Closing a WSOL account returns its lamports (including rent). This is
102    /// not an output amount quote and the destination need not be the trader.
103    Close {
104        destination: Pubkey,
105        authority: Pubkey,
106    },
107}
108
109#[derive(Clone, Debug, Serialize, Deserialize)]
110pub struct RouteNativeTokenAction {
111    pub position: InstructionPosition,
112    pub account: Pubkey,
113    pub action: NativeTokenAction,
114}
115
116fn native_action(
117    ix: &Invocation<'_>,
118    keys: &[Pubkey],
119    mints: &HashMap<Pubkey, Pubkey>,
120) -> Option<RouteNativeTokenAction> {
121    let wsol = solana_sdk::pubkey!("So11111111111111111111111111111111111111112");
122    let (target, action) = if ix.program == Pubkey::default()
123        && ix.data.get(..4) == Some(&2u32.to_le_bytes())
124        && ix.data.len() == 12
125        && ix.accounts.len() >= 2
126    {
127        (
128            account(ix, keys, 1),
129            NativeTokenAction::Fund { source: account(ix, keys, 0), lamports: u64_at(ix.data, 4)? },
130        )
131    } else if token_program(ix.program) && ix.data == [17] && !ix.accounts.is_empty() {
132        (account(ix, keys, 0), NativeTokenAction::SyncNative)
133    } else if token_program(ix.program) && ix.data == [9] && ix.accounts.len() >= 3 {
134        (
135            account(ix, keys, 0),
136            NativeTokenAction::Close {
137                destination: account(ix, keys, 1),
138                authority: account(ix, keys, 2),
139            },
140        )
141    } else {
142        return None;
143    };
144    if mints.get(&target) != Some(&wsol) {
145        return None;
146    }
147    Some(RouteNativeTokenAction { position: ix.position, account: target, action })
148}
149
150struct Invocation<'a> {
151    position: InstructionPosition,
152    program: Pubkey,
153    accounts: &'a [u8],
154    data: &'a [u8],
155}
156
157fn key(keys: &[Pubkey], index: u32) -> Pubkey {
158    keys.get(index as usize).copied().unwrap_or_default()
159}
160fn account(ix: &Invocation<'_>, keys: &[Pubkey], index: usize) -> Pubkey {
161    ix.accounts.get(index).map(|i| key(keys, u32::from(*i))).unwrap_or_default()
162}
163fn u64_at(data: &[u8], offset: usize) -> Option<u64> {
164    Some(u64::from_le_bytes(data.get(offset..offset + 8)?.try_into().ok()?))
165}
166fn token_program(program: Pubkey) -> bool {
167    program == solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
168        || program == solana_sdk::pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
169}
170fn checked_transfer(ix: &Invocation<'_>) -> bool {
171    token_program(ix.program)
172        && ix.data.first() == Some(&12)
173        && ix.data.len() >= 10
174        && ix.accounts.len() >= 4
175}
176fn checked_transfer_with_fee(ix: &Invocation<'_>) -> bool {
177    ix.program == solana_sdk::pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
178        && ix.data.get(..2) == Some(&[26, 1])
179        && ix.data.len() >= 19
180        && ix.accounts.len() >= 4
181}
182fn descendant(parent: InstructionPosition, child: InstructionPosition) -> bool {
183    if parent.outer_index != child.outer_index {
184        return false;
185    }
186    match (parent.inner_index, child.inner_index) {
187        (None, Some(_)) => true,
188        (Some(a), Some(b)) if b > a => match (parent.stack_height, child.stack_height) {
189            (Some(a), Some(b)) => b > a,
190            _ => false,
191        },
192        _ => false,
193    }
194}
195fn descendants<'a>(index: usize, invocations: &'a [Invocation<'_>]) -> &'a [Invocation<'a>] {
196    let parent = invocations[index].position;
197    let end = (index + 1..invocations.len())
198        .find(|&i| !descendant(parent, invocations[i].position))
199        .unwrap_or(invocations.len());
200    &invocations[index + 1..end]
201}
202
203/// Resolve mints from pre/post balances and checked transfers, including accounts
204/// created and closed within the transaction. Plain transfers propagate only
205/// already known mints; an arbitrary account key is never treated as a mint.
206pub(crate) fn transaction_token_mints(
207    transaction: &Transaction,
208    meta: &TransactionStatusMeta,
209) -> HashMap<Pubkey, Pubkey> {
210    let keys = transaction_keys(transaction, meta);
211    let invocations = transaction_invocations(transaction, meta, &keys);
212    let mut mints = HashMap::new();
213    for balance in meta.pre_token_balances.iter().chain(&meta.post_token_balances) {
214        if let Ok(mint) = balance.mint.parse() {
215            mints.insert(key(&keys, balance.account_index), mint);
216        }
217    }
218    for ix in &invocations {
219        if checked_transfer(ix) {
220            let mint = account(ix, &keys, 1);
221            mints.insert(account(ix, &keys, 0), mint);
222            mints.insert(account(ix, &keys, 2), mint);
223        }
224        // Token-2022 TransferFeeExtension::TransferCheckedWithFee.
225        if checked_transfer_with_fee(ix) {
226            let mint = account(ix, &keys, 1);
227            mints.insert(account(ix, &keys, 0), mint);
228            mints.insert(account(ix, &keys, 2), mint);
229        }
230        // InitializeAccount / InitializeAccount2 / InitializeAccount3 also
231        // identify ephemeral accounts without pre/post token balances.
232        if token_program(ix.program)
233            && ix.accounts.len() >= 2
234            && matches!((ix.data.first(), ix.data.len()), (Some(1), 1) | (Some(16 | 18), 33))
235        {
236            mints.insert(account(ix, &keys, 0), account(ix, &keys, 1));
237        }
238    }
239    // Fixed point rather than a fixed number of hops; each pass adds information.
240    loop {
241        let before = mints.len();
242        for ix in &invocations {
243            if token_program(ix.program)
244                && ix.data.first() == Some(&3)
245                && ix.data.len() >= 9
246                && ix.accounts.len() >= 3
247            {
248                let source = account(ix, &keys, 0);
249                let destination = account(ix, &keys, 1);
250                if let Some(mint) = mints.get(&source).or_else(|| mints.get(&destination)).copied()
251                {
252                    mints.entry(source).or_insert(mint);
253                    mints.entry(destination).or_insert(mint);
254                }
255            }
256        }
257        if mints.len() == before {
258            break;
259        }
260    }
261    mints.remove(&Pubkey::default());
262    mints
263}
264fn transaction_keys(transaction: &Transaction, meta: &TransactionStatusMeta) -> Vec<Pubkey> {
265    transaction
266        .message
267        .iter()
268        .flat_map(|m| &m.account_keys)
269        .chain(&meta.loaded_writable_addresses)
270        .chain(&meta.loaded_readonly_addresses)
271        .map(|bytes| crate::instr::read_pubkey_fast(bytes))
272        .collect()
273}
274fn transaction_invocations<'a>(
275    transaction: &'a Transaction,
276    meta: &'a TransactionStatusMeta,
277    keys: &[Pubkey],
278) -> Vec<Invocation<'a>> {
279    let mut result = Vec::new();
280    if let Some(message) = &transaction.message {
281        for (i, ix) in message.instructions.iter().enumerate() {
282            result.push(Invocation {
283                position: InstructionPosition {
284                    outer_index: i as u32,
285                    inner_index: None,
286                    stack_height: Some(1),
287                },
288                program: key(keys, ix.program_id_index),
289                accounts: &ix.accounts,
290                data: &ix.data,
291            });
292            for group in meta.inner_instructions.iter().filter(|g| g.index == i as u32) {
293                for (j, ix) in group.instructions.iter().enumerate() {
294                    result.push(Invocation {
295                        position: InstructionPosition {
296                            outer_index: i as u32,
297                            inner_index: Some(j as u32),
298                            stack_height: ix.stack_height,
299                        },
300                        program: key(keys, ix.program_id_index),
301                        accounts: &ix.accounts,
302                        data: &ix.data,
303                    });
304                }
305            }
306        }
307    }
308    result
309}
310fn transfer(
311    ix: &Invocation<'_>,
312    keys: &[Pubkey],
313    mints: &HashMap<Pubkey, Pubkey>,
314) -> Option<RouteTokenTransfer> {
315    if !token_program(ix.program) {
316        return None;
317    }
318    let (destination_index, amount_offset, fee) = match ix.data.first()? {
319        3 if ix.accounts.len() >= 3 && ix.data.len() >= 9 => (1, 1, Some(0)),
320        12 if checked_transfer(ix) => (
321            2,
322            1,
323            if ix.program == solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") {
324                Some(0)
325            } else {
326                None
327            },
328        ),
329        26 if checked_transfer_with_fee(ix) => (2, 2, Some(u64_at(ix.data, 11)?)),
330        _ => return None,
331    };
332    let source = account(ix, keys, 0);
333    Some(RouteTokenTransfer {
334        position: ix.position,
335        program: ix.program,
336        source,
337        destination: account(ix, keys, destination_index),
338        mint: mints.get(&source).copied(),
339        amount: u64_at(ix.data, amount_offset)?,
340        withheld_fee: fee,
341    })
342}
343
344fn swap(
345    ix: &Invocation<'_>,
346    keys: &[Pubkey],
347    mints: &HashMap<Pubkey, Pubkey>,
348    graduated: &[Pubkey],
349) -> Option<RouteSwapLeg> {
350    use crate::instr::program_ids::*;
351    let a = |i| account(ix, keys, i);
352    let disc = ix.data.get(..8);
353    let swap_disc = Some(&[248, 198, 158, 145, 225, 117, 135, 200][..]);
354    let swap_v2 = Some(&[43, 4, 237, 11, 26, 201, 30, 98][..]);
355    let mut mode = None;
356    let (protocol, pool, trader, input, output, exact_in, amount, threshold) = if ix.program
357        == RAYDIUM_CLMM_PROGRAM_ID
358        && (disc == swap_disc || disc == swap_v2)
359        && ix.accounts.len() >= 10
360    {
361        (
362            SwapProtocol::RaydiumClmm,
363            a(2),
364            a(0),
365            a(3),
366            a(4),
367            *ix.data.get(40)? != 0,
368            u64_at(ix.data, 8)?,
369            u64_at(ix.data, 16)?,
370        )
371    } else if ix.program == ORCA_WHIRLPOOL_PROGRAM_ID && (disc == swap_disc || disc == swap_v2) {
372        let v2 = disc == swap_v2;
373        let direction = *ix.data.get(41)? != 0;
374        let (input, output) = if v2 {
375            if ix.accounts.len() < 15 {
376                return None;
377            }
378            (a(if direction { 7 } else { 9 }), a(if direction { 9 } else { 7 }))
379        } else {
380            if ix.accounts.len() < 11 {
381                return None;
382            }
383            (a(if direction { 3 } else { 5 }), a(if direction { 5 } else { 3 }))
384        };
385        (
386            SwapProtocol::OrcaWhirlpool,
387            a(if v2 { 4 } else { 2 }),
388            a(if v2 { 3 } else { 1 }),
389            input,
390            output,
391            *ix.data.get(40)? != 0,
392            u64_at(ix.data, 8)?,
393            u64_at(ix.data, 16)?,
394        )
395    } else if ix.program == RAYDIUM_CPMM_PROGRAM_ID && ix.accounts.len() >= 13 {
396        use crate::instr::raydium_cpmm::discriminators::*;
397        let exact_in = if disc == Some(&SWAP_BASE_IN[..]) {
398            true
399        } else if disc == Some(&SWAP_BASE_OUT[..]) {
400            false
401        } else {
402            return None;
403        };
404        let first = u64_at(ix.data, 8)?;
405        let second = u64_at(ix.data, 16)?;
406        (
407            SwapProtocol::RaydiumCpmm,
408            a(3),
409            a(0),
410            a(4),
411            a(5),
412            exact_in,
413            if exact_in { first } else { second },
414            if exact_in { second } else { first },
415        )
416    } else if ix.program == METEORA_DLMM_PROGRAM_ID && ix.accounts.len() >= 11 {
417        use crate::instr::meteora_dlmm::discriminators::*;
418        let exact_in = if disc == Some(&SWAP[..]) || disc == Some(&SWAP2[..]) {
419            true
420        } else if disc == Some(&SWAP_EXACT_OUT[..]) || disc == Some(&SWAP_EXACT_OUT2[..]) {
421            false
422        } else {
423            return None;
424        };
425        let first = u64_at(ix.data, 8)?;
426        let second = u64_at(ix.data, 16)?;
427        (
428            SwapProtocol::MeteoraDlmm,
429            a(0),
430            a(10),
431            a(4),
432            a(5),
433            exact_in,
434            if exact_in { first } else { second },
435            if exact_in { second } else { first },
436        )
437    } else if ix.program == RAYDIUM_LAUNCHLAB_PROGRAM_ID && ix.accounts.len() >= 18 {
438        use crate::instr::raydium_launchlab::discriminators::*;
439        let (buy, exact_in) = if disc == Some(&BUY_EXACT_IN[..]) {
440            (true, true)
441        } else if disc == Some(&SELL_EXACT_IN[..]) {
442            (false, true)
443        } else if disc == Some(&BUY_EXACT_OUT[..]) {
444            (true, false)
445        } else if disc == Some(&SELL_EXACT_OUT[..]) {
446            (false, false)
447        } else {
448            return None;
449        };
450        mode = crate::core::events::stonkfun_mode_from_platform_config(a(3));
451        (
452            SwapProtocol::LaunchLab,
453            a(4),
454            a(0),
455            a(if buy { 6 } else { 5 }),
456            a(if buy { 5 } else { 6 }),
457            exact_in,
458            u64_at(ix.data, 8)?,
459            u64_at(ix.data, 16)?,
460        )
461    } else if ix.program == PUMPSWAP_PROGRAM_ID && ix.accounts.len() >= 21 {
462        use crate::instr::pump_amm::discriminators::*;
463        let (buy, exact_in) = if disc == Some(&BUY_EXACT_QUOTE_IN[..]) {
464            (true, true)
465        } else if disc == Some(&BUY[..]) {
466            (true, false)
467        } else if disc == Some(&SELL[..]) {
468            (false, true)
469        } else {
470            return None;
471        };
472        (
473            SwapProtocol::PumpSwap,
474            a(0),
475            a(1),
476            a(if buy { 6 } else { 5 }),
477            a(if buy { 5 } else { 6 }),
478            exact_in,
479            u64_at(ix.data, 8)?,
480            u64_at(ix.data, 16)?,
481        )
482    } else if ix.program == RAYDIUM_AMM_V4_PROGRAM_ID
483        && matches!(ix.data.first(), Some(16 | 17))
484        && ix.accounts.len() >= 8
485    {
486        let exact_in = ix.data[0] == 16;
487        let first = u64_at(ix.data, 1)?;
488        let second = u64_at(ix.data, 9)?;
489        (
490            SwapProtocol::RaydiumAmmV4,
491            a(1),
492            a(7),
493            a(5),
494            a(6),
495            exact_in,
496            if exact_in { first } else { second },
497            if exact_in { second } else { first },
498        )
499    } else if ix.program == RAYDIUM_AMM_V4_PROGRAM_ID
500        && matches!(ix.data.first(), Some(9 | 11))
501        && ix.accounts.len() >= 17
502    {
503        // Optional target_orders account was removed from the modern layout.
504        let n = ix.accounts.len();
505        let exact_in = ix.data[0] == 9;
506        let first = u64_at(ix.data, 1)?;
507        let second = u64_at(ix.data, 9)?;
508        (
509            SwapProtocol::RaydiumAmmV4,
510            a(1),
511            a(n - 1),
512            a(n - 3),
513            a(n - 2),
514            exact_in,
515            if exact_in { first } else { second },
516            if exact_in { second } else { first },
517        )
518    } else {
519        return None;
520    };
521    let explicit_pair = match protocol {
522        SwapProtocol::LaunchLab => Some(if input == a(6) { (a(10), a(9)) } else { (a(9), a(10)) }),
523        SwapProtocol::RaydiumCpmm => Some((a(10), a(11))),
524        SwapProtocol::RaydiumClmm if disc == swap_v2 && ix.accounts.len() >= 13 => {
525            Some((a(11), a(12)))
526        }
527        SwapProtocol::OrcaWhirlpool if disc == swap_v2 => {
528            Some(if input == a(7) { (a(5), a(6)) } else { (a(6), a(5)) })
529        }
530        SwapProtocol::PumpSwap => Some(if input == a(6) { (a(4), a(3)) } else { (a(3), a(4)) }),
531        _ => None,
532    };
533    Some(RouteSwapLeg {
534        position: ix.position,
535        program: ix.program,
536        protocol,
537        pool,
538        trader,
539        input_account: input,
540        output_account: output,
541        input_mint: mints
542            .get(&input)
543            .copied()
544            .or_else(|| explicit_pair.map(|pair| pair.0).filter(|mint| *mint != Pubkey::default())),
545        output_mint: mints
546            .get(&output)
547            .copied()
548            .or_else(|| explicit_pair.map(|pair| pair.1).filter(|mint| *mint != Pubkey::default())),
549        amount_specified_is_input: exact_in,
550        specified_amount: amount,
551        other_amount_threshold: threshold,
552        actual_input_amount: None,
553        actual_output_amount: None,
554        stonkfun_mode: mode,
555        stonkfun_graduated: protocol == SwapProtocol::RaydiumCpmm && graduated.contains(&pool),
556    })
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    #[test]
563    fn native_actions_require_wsol_identity_and_preserve_failure_status() {
564        use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TransactionError};
565        let token = solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
566        let wsol = solana_sdk::pubkey!("So11111111111111111111111111111111111111112");
567        let keys = [Pubkey::default(), token, Pubkey::new_unique(), Pubkey::new_unique(), wsol];
568        let mut init = vec![18];
569        init.extend_from_slice(keys[2].as_ref());
570        let mut funding = 2u32.to_le_bytes().to_vec();
571        funding.extend_from_slice(&123u64.to_le_bytes());
572        let mut tx = Transaction {
573            signatures: vec![],
574            message: Some(Message {
575                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
576                instructions: vec![
577                    CompiledInstruction { program_id_index: 1, accounts: vec![3, 4], data: init },
578                    CompiledInstruction {
579                        program_id_index: 0,
580                        accounts: vec![2, 3],
581                        data: funding,
582                    },
583                    CompiledInstruction { program_id_index: 1, accounts: vec![3], data: vec![17] },
584                    CompiledInstruction {
585                        program_id_index: 1,
586                        accounts: vec![3, 2, 2],
587                        data: vec![9],
588                    },
589                ],
590                ..Default::default()
591            }),
592            ..Default::default()
593        };
594        let mut meta = TransactionStatusMeta::default();
595        let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
596        assert!(route.succeeded);
597        assert_eq!(route.native_token_actions.len(), 3);
598        assert_eq!(
599            route.native_token_actions[0].action,
600            NativeTokenAction::Fund { source: keys[2], lamports: 123 }
601        );
602        meta.err = Some(TransactionError { err: vec![1] });
603        let failed = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
604        assert!(!failed.succeeded);
605        assert_eq!(failed.native_token_actions.len(), 3);
606        tx.message.as_mut().unwrap().account_keys[4] = Pubkey::new_unique().to_bytes().to_vec();
607        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
608            .native_token_actions
609            .is_empty());
610    }
611    #[test]
612    fn ephemeral_initialized_accounts_propagate_mints_without_balance_snapshots() {
613        use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message};
614        let program = solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
615        let keys = [
616            program,
617            Pubkey::new_unique(),
618            Pubkey::new_unique(),
619            Pubkey::new_unique(),
620            Pubkey::new_unique(),
621        ];
622        let mut initialize = vec![18];
623        initialize.extend_from_slice(keys[4].as_ref());
624        let mut plain = vec![3];
625        plain.extend_from_slice(&100u64.to_le_bytes());
626        let transaction = Transaction {
627            signatures: vec![],
628            message: Some(Message {
629                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
630                instructions: vec![
631                    CompiledInstruction {
632                        program_id_index: 0,
633                        accounts: vec![1, 2],
634                        data: initialize,
635                    },
636                    CompiledInstruction {
637                        program_id_index: 0,
638                        accounts: vec![1, 3, 4],
639                        data: plain,
640                    },
641                ],
642                ..Default::default()
643            }),
644        };
645        let meta = TransactionStatusMeta::default();
646        let mints = transaction_token_mints(&transaction, &meta);
647        assert_eq!(mints.get(&keys[1]), Some(&keys[2]));
648        assert_eq!(mints.get(&keys[3]), Some(&keys[2]));
649        let route = analyze_yellowstone_transaction_routes(&transaction, &meta, &[]);
650        assert_eq!(route.transfers[0].mint, Some(keys[2]));
651    }
652    #[test]
653    fn malformed_checked_transfers_do_not_invent_amounts_or_mints() {
654        let keys: Vec<_> = (0..4).map(|_| Pubkey::new_unique()).collect();
655        let mut data = vec![12];
656        data.extend_from_slice(&123u64.to_le_bytes());
657        let mut ix = Invocation {
658            position: InstructionPosition {
659                outer_index: 0,
660                inner_index: None,
661                stack_height: Some(1),
662            },
663            program: solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"),
664            accounts: &[0, 1, 2, 3],
665            data: &data,
666        };
667        assert!(!checked_transfer(&ix));
668        assert!(transfer(&ix, &keys, &HashMap::new()).is_none());
669        let mut complete_data = data.clone();
670        complete_data.push(6);
671        ix.data = &complete_data;
672        assert!(checked_transfer(&ix));
673        assert_eq!(transfer(&ix, &keys, &HashMap::new()).unwrap().amount, 123);
674        let mut fee_data = vec![26, 1];
675        fee_data.extend_from_slice(&123u64.to_le_bytes());
676        fee_data.push(6);
677        fee_data.extend_from_slice(&2u64.to_le_bytes());
678        ix.data = &fee_data;
679        assert!(transfer(&ix, &keys, &HashMap::new()).is_none());
680        ix.program = solana_sdk::pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
681        assert_eq!(transfer(&ix, &keys, &HashMap::new()).unwrap().withheld_fee, Some(2));
682    }
683}
684
685/// Analyze instruction and transfer context without altering the normal event API.
686/// Unknown invocations and failed transaction status are preserved explicitly.
687/// `graduated_stonkfun_pools` must be verified pool identities, not all stock pairs.
688pub fn analyze_yellowstone_transaction_routes(
689    transaction: &Transaction,
690    meta: &TransactionStatusMeta,
691    graduated_stonkfun_pools: &[Pubkey],
692) -> TransactionRoute {
693    let keys = transaction_keys(transaction, meta);
694    let invocations = transaction_invocations(transaction, meta, &keys);
695    let mints = transaction_token_mints(transaction, meta);
696    let transfers: Vec<_> =
697        invocations.iter().filter_map(|ix| transfer(ix, &keys, &mints)).collect();
698    let succeeded = meta.err.is_none();
699    let mut legs = Vec::new();
700    let mut unknown = Vec::new();
701    for (i, ix) in invocations.iter().enumerate() {
702        let children = descendants(i, &invocations);
703        let nested: Vec<_> =
704            children.iter().filter_map(|child| transfer(child, &keys, &mints)).collect();
705        if let Some(mut leg) = swap(ix, &keys, &mints, graduated_stonkfun_pools) {
706            if succeeded && !nested.is_empty() {
707                let inputs: Vec<_> =
708                    nested.iter().filter(|t| t.source == leg.input_account).collect();
709                let outputs: Vec<_> =
710                    nested.iter().filter(|t| t.destination == leg.output_account).collect();
711                if !inputs.is_empty() {
712                    leg.actual_input_amount =
713                        inputs.iter().try_fold(0u64, |sum, t| sum.checked_add(t.amount));
714                }
715                if !outputs.is_empty() {
716                    leg.actual_output_amount = outputs.iter().try_fold(0u64, |sum, t| {
717                        sum.checked_add(t.amount.checked_sub(t.withheld_fee?)?)
718                    });
719                }
720            }
721            legs.push(leg);
722        } else if !token_program(ix.program)
723            && ix.program != Pubkey::default()
724            && ix.program != solana_sdk::pubkey!("ComputeBudget111111111111111111111111111111")
725            && ix.program != solana_sdk::pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL")
726            && ix.program != solana_sdk::pubkey!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr")
727        {
728            // Event CPI self-invocations have no token transfers and remain visible.
729            unknown.push(RouteUnknownInvocation {
730                position: ix.position,
731                program: ix.program,
732                has_token_transfers: !nested.is_empty(),
733                has_known_swap_descendants: children
734                    .iter()
735                    .any(|child| swap(child, &keys, &mints, graduated_stonkfun_pools).is_some()),
736            });
737        }
738    }
739    TransactionRoute {
740        signature: transaction
741            .signatures
742            .first()
743            .and_then(|b| Signature::try_from(b.as_slice()).ok())
744            .unwrap_or_default(),
745        succeeded,
746        legs,
747        transfers,
748        native_token_actions: invocations
749            .iter()
750            .filter_map(|ix| native_action(ix, &keys, &mints))
751            .collect(),
752        unknown_invocations: unknown,
753    }
754}
755
756pub fn analyze_rpc_transaction_routes(
757    transaction: &EncodedConfirmedTransactionWithStatusMeta,
758    graduated_stonkfun_pools: &[Pubkey],
759) -> Result<TransactionRoute, ParseError> {
760    let (meta, transaction) = convert_rpc_to_grpc(transaction)?;
761    Ok(analyze_yellowstone_transaction_routes(&transaction, &meta, graduated_stonkfun_pools))
762}