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// Force base58 literals into const evaluation, including in builds whose
14// dependency optimization settings do not inline Address::from_str_const.
15const ROUTE_SPL_TOKEN: Pubkey = solana_sdk::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
16const ROUTE_TOKEN_2022: Pubkey = solana_sdk::pubkey!("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
17const ROUTE_WSOL: Pubkey = solana_sdk::pubkey!("So11111111111111111111111111111111111111112");
18const ROUTE_COMPUTE_BUDGET: Pubkey =
19    solana_sdk::pubkey!("ComputeBudget111111111111111111111111111111");
20const ROUTE_ASSOCIATED_TOKEN: Pubkey =
21    solana_sdk::pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
22const ROUTE_MEMO: Pubkey = solana_sdk::pubkey!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct InstructionPosition {
26    pub outer_index: u32,
27    pub inner_index: Option<u32>,
28    pub stack_height: Option<u32>,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub enum SwapProtocol {
33    PumpSwap,
34    LaunchLab,
35    RaydiumCpmm,
36    RaydiumAmmV4,
37    RaydiumClmm,
38    OrcaWhirlpool,
39    MeteoraDlmm,
40    PumpFun,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct RouteSwapLeg {
45    pub position: InstructionPosition,
46    pub program: Pubkey,
47    pub protocol: SwapProtocol,
48    pub pool: Pubkey,
49    pub trader: Pubkey,
50    pub input_account: Pubkey,
51    pub output_account: Pubkey,
52    pub input_mint: Option<Pubkey>,
53    pub output_mint: Option<Pubkey>,
54    /// Instruction arguments, not executed amounts or a fresh quote.
55    pub amount_specified_is_input: bool,
56    pub specified_amount: u64,
57    pub other_amount_threshold: u64,
58    /// Actual debits/credits observed in this invocation's token transfers.
59    /// None when execution failed, transfer context is missing, or CPI depth is unknown.
60    pub actual_input_amount: Option<u64>,
61    pub actual_output_amount: Option<u64>,
62    pub stonkfun_mode: Option<crate::core::events::StonkFunMode>,
63    /// Caller supplied this CPMM pool as a verified graduated StonkFun pool.
64    pub stonkfun_graduated: bool,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct RouteTokenTransfer {
69    pub position: InstructionPosition,
70    pub program: Pubkey,
71    pub source: Pubkey,
72    pub destination: Pubkey,
73    pub mint: Option<Pubkey>,
74    /// Gross transfer argument. Token-2022 transfer fees may reduce the credit.
75    pub amount: u64,
76    pub withheld_fee: Option<u64>,
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct RouteUnknownInvocation {
81    pub position: InstructionPosition,
82    pub program: Pubkey,
83    /// Nested token transfers identify opaque/custom liquidity or router activity.
84    pub has_token_transfers: bool,
85    /// True for opaque routers wrapping decoded swap legs. False plus token
86    /// transfers marks an unresolved economic operation (possibly a custom pool).
87    pub has_known_swap_descendants: bool,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize)]
91pub struct TransactionRoute {
92    pub signature: Signature,
93    pub succeeded: bool,
94    pub legs: Vec<RouteSwapLeg>,
95    pub transfers: Vec<RouteTokenTransfer>,
96    /// Instruction evidence only. Failed transactions do not change balances,
97    /// and WSOL usage alone never establishes the caller's desired SOL/WSOL asset.
98    #[serde(default)]
99    pub native_token_actions: Vec<RouteNativeTokenAction>,
100    /// Preserved even when a program cannot be decoded. Do not treat legs as
101    /// complete coverage when unknown invocations carry token transfers.
102    pub unknown_invocations: Vec<RouteUnknownInvocation>,
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
106pub enum NativeTokenAction {
107    /// System transfer to an account identified as WSOL in this transaction.
108    Fund {
109        source: Pubkey,
110        lamports: u64,
111    },
112    SyncNative,
113    /// Closing a WSOL account returns its lamports (including rent). This is
114    /// not an output amount quote and the destination need not be the trader.
115    Close {
116        destination: Pubkey,
117        authority: Pubkey,
118    },
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize)]
122pub struct RouteNativeTokenAction {
123    pub position: InstructionPosition,
124    pub account: Pubkey,
125    pub action: NativeTokenAction,
126}
127
128fn native_action(
129    ix: &Invocation<'_>,
130    keys: &[Pubkey],
131    mints: &HashMap<Pubkey, Pubkey>,
132) -> Option<RouteNativeTokenAction> {
133    if !ix.program_resolved {
134        return None;
135    }
136    let wsol = ROUTE_WSOL;
137    let (target, action) = if ix.program == Pubkey::default()
138        && ix.data.get(..4) == Some(&2u32.to_le_bytes())
139        && ix.data.len() == 12
140        && ix.accounts.len() >= 2
141    {
142        (
143            account(ix, keys, 1),
144            NativeTokenAction::Fund { source: account(ix, keys, 0), lamports: u64_at(ix.data, 4)? },
145        )
146    } else if token_program(ix.program) && ix.data == [17] && !ix.accounts.is_empty() {
147        (account(ix, keys, 0), NativeTokenAction::SyncNative)
148    } else if token_program(ix.program) && ix.data == [9] && ix.accounts.len() >= 3 {
149        (
150            account(ix, keys, 0),
151            NativeTokenAction::Close {
152                destination: account(ix, keys, 1),
153                authority: account(ix, keys, 2),
154            },
155        )
156    } else {
157        return None;
158    };
159    if ix.accounts.iter().any(|&index| index as usize >= keys.len())
160        || mints.get(&target) != Some(&wsol)
161    {
162        return None;
163    }
164    Some(RouteNativeTokenAction { position: ix.position, account: target, action })
165}
166
167struct Invocation<'a> {
168    position: InstructionPosition,
169    program: Pubkey,
170    program_resolved: bool,
171    accounts: &'a [u8],
172    data: &'a [u8],
173}
174
175fn key(keys: &[Pubkey], index: u32) -> Pubkey {
176    keys.get(index as usize).copied().unwrap_or_default()
177}
178fn account(ix: &Invocation<'_>, keys: &[Pubkey], index: usize) -> Pubkey {
179    ix.accounts.get(index).map(|i| key(keys, u32::from(*i))).unwrap_or_default()
180}
181fn u64_at(data: &[u8], offset: usize) -> Option<u64> {
182    Some(u64::from_le_bytes(data.get(offset..offset + 8)?.try_into().ok()?))
183}
184fn token_program(program: Pubkey) -> bool {
185    program == ROUTE_SPL_TOKEN || program == ROUTE_TOKEN_2022
186}
187fn checked_transfer(ix: &Invocation<'_>) -> bool {
188    token_program(ix.program)
189        && ix.data.first() == Some(&12)
190        && ix.data.len() >= 10
191        && ix.accounts.len() >= 4
192}
193fn checked_transfer_with_fee(ix: &Invocation<'_>) -> bool {
194    ix.program == ROUTE_TOKEN_2022
195        && ix.data.get(..2) == Some(&[26, 1])
196        && ix.data.len() >= 19
197        && ix.accounts.len() >= 4
198}
199fn descendant(parent: InstructionPosition, child: InstructionPosition) -> bool {
200    if parent.outer_index != child.outer_index {
201        return false;
202    }
203    match (parent.inner_index, child.inner_index) {
204        (None, Some(_)) => true,
205        (Some(a), Some(b)) if b > a => match (parent.stack_height, child.stack_height) {
206            (Some(a), Some(b)) => b > a,
207            _ => false,
208        },
209        _ => false,
210    }
211}
212#[cfg(test)]
213fn descendants<'a>(index: usize, invocations: &'a [Invocation<'_>]) -> &'a [Invocation<'a>] {
214    let parent = invocations[index].position;
215    let end = (index + 1..invocations.len())
216        .find(|&i| !descendant(parent, invocations[i].position))
217        .unwrap_or(invocations.len());
218    &invocations[index + 1..end]
219}
220
221// Route decisions and transfer decoding are computed once. Reverse subtree
222// jumps reuse child boundaries and flags instead of rescanning each ancestor.
223struct InvocationAnalysis {
224    transfer: Option<RouteTokenTransfer>,
225    swap_index: Option<usize>,
226    descendant_end: usize,
227    has_token_transfers: bool,
228    has_known_swap_descendants: bool,
229    skip_route: bool,
230}
231
232fn invocation_analysis(
233    invocations: &[Invocation<'_>],
234    keys: &[Pubkey],
235    mints: &HashMap<Pubkey, Pubkey>,
236    graduated: &[Pubkey],
237    legs: &mut Vec<RouteSwapLeg>,
238) -> Vec<InvocationAnalysis> {
239    let mut analysis: Vec<_> = invocations
240        .iter()
241        .enumerate()
242        .map(|(i, ix)| {
243            // Unresolved indexes use the default-key sentinel; they are not SystemProgram.
244            let skip_route = ix.program_resolved
245                && (token_program(ix.program)
246                    || ix.program == Pubkey::default()
247                    || ix.program == ROUTE_COMPUTE_BUDGET
248                    || ix.program == ROUTE_ASSOCIATED_TOKEN
249                    || ix.program == ROUTE_MEMO);
250            let swap_index = if skip_route {
251                None
252            } else {
253                swap(ix, keys, mints, graduated).map(|leg| {
254                    let index = legs.len();
255                    legs.push(leg);
256                    index
257                })
258            };
259            InvocationAnalysis {
260                transfer: transfer(ix, keys, mints),
261                swap_index,
262                descendant_end: i + 1,
263                has_token_transfers: false,
264                has_known_swap_descendants: false,
265                skip_route,
266            }
267        })
268        .collect();
269    for i in (0..invocations.len()).rev() {
270        let mut end = i + 1;
271        let mut has_transfers = false;
272        let mut has_swaps = false;
273        while end < invocations.len()
274            && descendant(invocations[i].position, invocations[end].position)
275        {
276            let child = &analysis[end];
277            has_transfers |= child.transfer.is_some() || child.has_token_transfers;
278            has_swaps |= child.swap_index.is_some() || child.has_known_swap_descendants;
279            end = child.descendant_end;
280        }
281        analysis[i].descendant_end = end;
282        analysis[i].has_token_transfers = has_transfers;
283        analysis[i].has_known_swap_descendants = has_swaps;
284    }
285    analysis
286}
287
288/// Resolve mints from pre/post balances and checked transfers, including accounts
289/// created and closed within the transaction. Plain transfers propagate only
290/// already known mints; an arbitrary account key is never treated as a mint.
291#[cfg(test)]
292fn transaction_token_mints(
293    transaction: &Transaction,
294    meta: &TransactionStatusMeta,
295) -> HashMap<Pubkey, Pubkey> {
296    let keys = transaction_keys(transaction, meta);
297    let invocations = transaction_invocations(transaction, meta, &keys);
298    token_mints_from_invocations(meta, &keys, &invocations)
299}
300
301fn token_mints_from_invocations(
302    meta: &TransactionStatusMeta,
303    keys: &[Pubkey],
304    invocations: &[Invocation<'_>],
305) -> HashMap<Pubkey, Pubkey> {
306    let mut mints = HashMap::new();
307    let record = |mints: &mut HashMap<Pubkey, Pubkey>, account, mint| {
308        // Default is the missing-account sentinel, not evidence of a token mint.
309        if account != Pubkey::default() && mint != Pubkey::default() {
310            use std::collections::hash_map::Entry;
311            match mints.entry(account) {
312                Entry::Vacant(entry) => {
313                    entry.insert(mint);
314                }
315                Entry::Occupied(mut entry) if *entry.get() != mint => {
316                    // Retain an ambiguity marker through propagation. A closed
317                    // and reused account can have different mints in one tx.
318                    entry.insert(Pubkey::default());
319                }
320                _ => {}
321            }
322        }
323    };
324    for balance in meta.pre_token_balances.iter().chain(&meta.post_token_balances) {
325        if let Ok(mint) = balance.mint.parse() {
326            record(&mut mints, key(&keys, balance.account_index), mint);
327        }
328    }
329    for ix in invocations {
330        if checked_transfer(ix) {
331            let mint = account(ix, &keys, 1);
332            record(&mut mints, account(ix, &keys, 0), mint);
333            record(&mut mints, account(ix, &keys, 2), mint);
334        }
335        // Token-2022 TransferFeeExtension::TransferCheckedWithFee.
336        if checked_transfer_with_fee(ix) {
337            let mint = account(ix, &keys, 1);
338            record(&mut mints, account(ix, &keys, 0), mint);
339            record(&mut mints, account(ix, &keys, 2), mint);
340        }
341        // InitializeAccount / InitializeAccount2 / InitializeAccount3 also
342        // identify ephemeral accounts without pre/post token balances.
343        if token_program(ix.program)
344            && ix.accounts.len() >= 2
345            && matches!((ix.data.first(), ix.data.len()), (Some(1), 1) | (Some(16 | 18), 33))
346        {
347            record(&mut mints, account(ix, &keys, 0), account(ix, &keys, 1));
348        }
349    }
350    if mints.is_empty() {
351        return mints;
352    }
353    // Fixed point rather than a fixed number of hops; each pass adds information.
354    loop {
355        let before = mints.len();
356        for ix in invocations {
357            if token_program(ix.program)
358                && ix.data.first() == Some(&3)
359                && ix.data.len() >= 9
360                && ix.accounts.len() >= 3
361            {
362                let source = account(ix, &keys, 0);
363                let destination = account(ix, &keys, 1);
364                if source == Pubkey::default() || destination == Pubkey::default() {
365                    continue;
366                }
367                let source_mint = mints.get(&source).copied();
368                let destination_mint = mints.get(&destination).copied();
369                if source_mint == Some(Pubkey::default())
370                    || destination_mint == Some(Pubkey::default())
371                    || matches!((source_mint, destination_mint), (Some(a), Some(b)) if a != b)
372                {
373                    continue;
374                }
375                if let Some(mint) = source_mint.or(destination_mint) {
376                    mints.entry(source).or_insert(mint);
377                    mints.entry(destination).or_insert(mint);
378                }
379            }
380        }
381        if mints.len() == before {
382            break;
383        }
384    }
385    mints.retain(|_, mint| *mint != Pubkey::default());
386    mints
387}
388fn transaction_keys(transaction: &Transaction, meta: &TransactionStatusMeta) -> Vec<Pubkey> {
389    transaction
390        .message
391        .iter()
392        .flat_map(|m| &m.account_keys)
393        .chain(&meta.loaded_writable_addresses)
394        .chain(&meta.loaded_readonly_addresses)
395        .map(|bytes| crate::instr::read_pubkey_fast(bytes))
396        .collect()
397}
398fn transaction_invocations<'a>(
399    transaction: &'a Transaction,
400    meta: &'a TransactionStatusMeta,
401    keys: &[Pubkey],
402) -> Vec<Invocation<'a>> {
403    let Some(message) = &transaction.message else {
404        return Vec::new();
405    };
406    if message.instructions.is_empty() {
407        return Vec::new();
408    }
409    let count = message.instructions.len()
410        + meta
411            .inner_instructions
412            .iter()
413            .filter(|group| (group.index as usize) < message.instructions.len())
414            .map(|group| group.instructions.len())
415            .sum::<usize>();
416    // Protobuf bytes are not fixed-size Pubkeys. Keep malformed keys unresolved.
417    // Direct indexes preserve static / writable ALT / readonly ALT order without allocation.
418    let resolve_program = |index: u32| {
419        let index = index as usize;
420        let bytes = message.account_keys.get(index).or_else(|| {
421            let index = index.checked_sub(message.account_keys.len())?;
422            meta.loaded_writable_addresses.get(index).or_else(|| {
423                meta.loaded_readonly_addresses
424                    .get(index.checked_sub(meta.loaded_writable_addresses.len())?)
425            })
426        })?;
427        if bytes.len() != 32 {
428            return None;
429        }
430        keys.get(index).copied()
431    };
432    let mut result = Vec::with_capacity(count);
433    // Canonical metadata is already sorted. Only unordered input needs an index;
434    // original ordinal breaks ties so duplicate groups retain their old order.
435    let order = if meta.inner_instructions.windows(2).all(|pair| pair[0].index <= pair[1].index) {
436        None
437    } else {
438        let mut order: Vec<_> = (0..meta.inner_instructions.len()).collect();
439        order.sort_unstable_by_key(|&i| (meta.inner_instructions[i].index, i));
440        Some(order)
441    };
442    let group_at = |i: usize| {
443        let index = if let Some(order) = &order { *order.get(i)? } else { i };
444        meta.inner_instructions.get(index)
445    };
446    let mut cursor = 0;
447    for (i, ix) in message.instructions.iter().enumerate() {
448        let program = resolve_program(ix.program_id_index);
449        result.push(Invocation {
450            position: InstructionPosition {
451                outer_index: i as u32,
452                inner_index: None,
453                stack_height: Some(1),
454            },
455            program: program.unwrap_or_default(),
456            program_resolved: program.is_some(),
457            accounts: &ix.accounts,
458            data: &ix.data,
459        });
460        while let Some(group) = group_at(cursor) {
461            if group.index > i as u32 {
462                break;
463            }
464            cursor += 1;
465            if group.index != i as u32 {
466                continue;
467            }
468            for (j, ix) in group.instructions.iter().enumerate() {
469                let program = resolve_program(ix.program_id_index);
470                result.push(Invocation {
471                    position: InstructionPosition {
472                        outer_index: i as u32,
473                        inner_index: Some(j as u32),
474                        stack_height: ix.stack_height,
475                    },
476                    program: program.unwrap_or_default(),
477                    program_resolved: program.is_some(),
478                    accounts: &ix.accounts,
479                    data: &ix.data,
480                });
481            }
482        }
483    }
484    result
485}
486fn transfer(
487    ix: &Invocation<'_>,
488    keys: &[Pubkey],
489    mints: &HashMap<Pubkey, Pubkey>,
490) -> Option<RouteTokenTransfer> {
491    if !token_program(ix.program) {
492        return None;
493    }
494    let (destination_index, amount_offset, fee) = match ix.data.first()? {
495        3 if ix.accounts.len() >= 3 && ix.data.len() >= 9 => {
496            (1, 1, if ix.program == ROUTE_SPL_TOKEN { Some(0) } else { None })
497        }
498        12 if checked_transfer(ix) => {
499            (2, 1, if ix.program == ROUTE_SPL_TOKEN { Some(0) } else { None })
500        }
501        26 if checked_transfer_with_fee(ix) => (2, 2, Some(u64_at(ix.data, 11)?)),
502        _ => return None,
503    };
504    let source = account(ix, keys, 0);
505    let destination = account(ix, keys, destination_index);
506    if source == Pubkey::default()
507        || destination == Pubkey::default()
508        || (destination_index == 2 && account(ix, keys, 1) == Pubkey::default())
509    {
510        return None;
511    }
512    Some(RouteTokenTransfer {
513        position: ix.position,
514        program: ix.program,
515        source,
516        destination,
517        mint: mints.get(&source).copied(),
518        amount: u64_at(ix.data, amount_offset)?,
519        withheld_fee: fee,
520    })
521}
522
523fn swap(
524    ix: &Invocation<'_>,
525    keys: &[Pubkey],
526    mints: &HashMap<Pubkey, Pubkey>,
527    graduated: &[Pubkey],
528) -> Option<RouteSwapLeg> {
529    use crate::instr::program_ids::*;
530    let a = |i| account(ix, keys, i);
531    let disc = ix.data.get(..8);
532    let swap_disc = Some(&[248, 198, 158, 145, 225, 117, 135, 200][..]);
533    let swap_v2 = Some(&[43, 4, 237, 11, 26, 201, 30, 98][..]);
534    let mut mode = None;
535    let (protocol, pool, trader, input, output, exact_in, amount, threshold) = if ix.program
536        == RAYDIUM_CLMM_PROGRAM_ID
537        && (disc == swap_disc || disc == swap_v2)
538        && ix.accounts.len() >= if disc == swap_v2 { 13 } else { 10 }
539    {
540        (
541            SwapProtocol::RaydiumClmm,
542            a(2),
543            a(0),
544            a(3),
545            a(4),
546            crate::instr::utils::read_option_bool_idl(ix.data, 40)?,
547            u64_at(ix.data, 8)?,
548            u64_at(ix.data, 16)?,
549        )
550    } else if ix.program == ORCA_WHIRLPOOL_PROGRAM_ID && (disc == swap_disc || disc == swap_v2) {
551        let v2 = disc == swap_v2;
552        let direction = crate::instr::utils::read_option_bool_idl(ix.data, 41)?;
553        let (input, output) = if v2 {
554            if ix.accounts.len() < 15 {
555                return None;
556            }
557            (a(if direction { 7 } else { 9 }), a(if direction { 9 } else { 7 }))
558        } else {
559            if ix.accounts.len() < 11 {
560                return None;
561            }
562            (a(if direction { 3 } else { 5 }), a(if direction { 5 } else { 3 }))
563        };
564        (
565            SwapProtocol::OrcaWhirlpool,
566            a(if v2 { 4 } else { 2 }),
567            a(if v2 { 3 } else { 1 }),
568            input,
569            output,
570            crate::instr::utils::read_option_bool_idl(ix.data, 40)?,
571            u64_at(ix.data, 8)?,
572            u64_at(ix.data, 16)?,
573        )
574    } else if ix.program == RAYDIUM_CPMM_PROGRAM_ID && ix.accounts.len() >= 13 {
575        use crate::instr::raydium_cpmm::discriminators::*;
576        let exact_in = if disc == Some(&SWAP_BASE_IN[..]) {
577            true
578        } else if disc == Some(&SWAP_BASE_OUT[..]) {
579            false
580        } else {
581            return None;
582        };
583        let first = u64_at(ix.data, 8)?;
584        let second = u64_at(ix.data, 16)?;
585        (
586            SwapProtocol::RaydiumCpmm,
587            a(3),
588            a(0),
589            a(4),
590            a(5),
591            exact_in,
592            if exact_in { first } else { second },
593            if exact_in { second } else { first },
594        )
595    } else if ix.program == METEORA_DLMM_PROGRAM_ID {
596        crate::instr::meteora_dlmm::validate_swap_layout(ix.data, ix.accounts.len())?;
597        use crate::instr::meteora_dlmm::discriminators::*;
598        let exact_in = if disc == Some(&SWAP[..]) || disc == Some(&SWAP2[..]) {
599            true
600        } else if disc == Some(&SWAP_EXACT_OUT[..]) || disc == Some(&SWAP_EXACT_OUT2[..]) {
601            false
602        } else {
603            return None;
604        };
605        let first = u64_at(ix.data, 8)?;
606        let second = u64_at(ix.data, 16)?;
607        (
608            SwapProtocol::MeteoraDlmm,
609            a(0),
610            a(10),
611            a(4),
612            a(5),
613            exact_in,
614            if exact_in { first } else { second },
615            if exact_in { second } else { first },
616        )
617    } else if ix.program == RAYDIUM_LAUNCHLAB_PROGRAM_ID && ix.accounts.len() >= 15 {
618        use crate::instr::raydium_launchlab::discriminators::*;
619        let (buy, exact_in) = if disc == Some(&BUY_EXACT_IN[..]) {
620            (true, true)
621        } else if disc == Some(&SELL_EXACT_IN[..]) {
622            (false, true)
623        } else if disc == Some(&BUY_EXACT_OUT[..]) {
624            (true, false)
625        } else if disc == Some(&SELL_EXACT_OUT[..]) {
626            (false, false)
627        } else {
628            return None;
629        };
630        mode = crate::core::events::stonkfun_mode_from_platform_config(a(3));
631        (
632            SwapProtocol::LaunchLab,
633            a(4),
634            a(0),
635            a(if buy { 6 } else { 5 }),
636            a(if buy { 5 } else { 6 }),
637            exact_in,
638            u64_at(ix.data, 8)?,
639            u64_at(ix.data, 16)?,
640        )
641    } else if ix.program == PUMPFUN_PROGRAM_ID {
642        use crate::instr::pump::discriminators::*;
643        let v2 = [BUY_V2, BUY_EXACT_QUOTE_IN_V2, SELL_V2].iter().any(|d| disc == Some(&d[..]));
644        let v3 = [BUY_V3, BUY_EXACT_QUOTE_IN_V3, SELL_V3].iter().any(|d| disc == Some(&d[..]));
645        let buy = disc != Some(&SELL_V3[..]) && disc != Some(&SELL_V2[..]);
646        let exact = disc != Some(&BUY_V3[..]) && disc != Some(&BUY_V2[..]);
647        if !(v2 || v3)
648            || ix.accounts.len()
649                < if v2 {
650                    if buy {
651                        27
652                    } else {
653                        26
654                    }
655                } else {
656                    17
657                }
658        {
659            return None;
660        }
661        let (pool_index, user_index, base_account, quote_account) =
662            if v2 { (10, 13, 14, 15) } else { (5, 8, 9, 10) };
663        let quote = a(2);
664        let source = if buy {
665            if quote == ROUTE_WSOL {
666                a(user_index)
667            } else {
668                a(quote_account)
669            }
670        } else {
671            a(base_account)
672        };
673        let dest = if buy {
674            a(base_account)
675        } else if quote == ROUTE_WSOL {
676            a(user_index)
677        } else {
678            a(quote_account)
679        };
680        (
681            SwapProtocol::PumpFun,
682            a(pool_index),
683            a(user_index),
684            source,
685            dest,
686            exact,
687            u64_at(ix.data, 8)?,
688            u64_at(ix.data, 16)?,
689        )
690    } else if ix.program == PUMPSWAP_PROGRAM_ID
691        && (ix.accounts.len() >= 21 || ix.accounts.len() == 17)
692    {
693        use crate::instr::pump_amm::discriminators::*;
694        let (buy, exact_in) =
695            if disc == Some(&BUY_EXACT_QUOTE_IN[..]) || disc == Some(&BUY_EXACT_QUOTE_IN_V2[..]) {
696                (true, true)
697            } else if disc == Some(&BUY[..]) || disc == Some(&BUY_V2[..]) {
698                (true, false)
699            } else if disc == Some(&SELL[..]) || disc == Some(&SELL_V2[..]) {
700                (false, true)
701            } else {
702                return None;
703            };
704        if buy && ix.data.len() > 24 {
705            crate::instr::utils::read_option_bool_idl(ix.data, 24)?;
706        }
707        (
708            SwapProtocol::PumpSwap,
709            a(0),
710            a(1),
711            a(if buy { 6 } else { 5 }),
712            a(if buy { 5 } else { 6 }),
713            exact_in,
714            u64_at(ix.data, 8)?,
715            u64_at(ix.data, 16)?,
716        )
717    } else if ix.program == RAYDIUM_AMM_V4_PROGRAM_ID
718        && matches!(ix.data.first(), Some(16 | 17))
719        && ix.accounts.len() >= 8
720    {
721        let exact_in = ix.data[0] == 16;
722        let first = u64_at(ix.data, 1)?;
723        let second = u64_at(ix.data, 9)?;
724        (
725            SwapProtocol::RaydiumAmmV4,
726            a(1),
727            a(7),
728            a(5),
729            a(6),
730            exact_in,
731            if exact_in { first } else { second },
732            if exact_in { second } else { first },
733        )
734    } else if ix.program == RAYDIUM_AMM_V4_PROGRAM_ID
735        && matches!(ix.data.first(), Some(9 | 11))
736        && ix.accounts.len() >= 17
737    {
738        // Optional target_orders account was removed from the modern layout.
739        let shift = usize::from(ix.accounts.len() == 17);
740        let exact_in = ix.data[0] == 9;
741        let first = u64_at(ix.data, 1)?;
742        let second = u64_at(ix.data, 9)?;
743        (
744            SwapProtocol::RaydiumAmmV4,
745            a(1),
746            a(17 - shift),
747            a(15 - shift),
748            a(16 - shift),
749            exact_in,
750            if exact_in { first } else { second },
751            if exact_in { second } else { first },
752        )
753    } else {
754        return None;
755    };
756    let explicit_pair = match protocol {
757        SwapProtocol::PumpFun => Some(
758            if disc == Some(&crate::instr::pump::discriminators::SELL_V3[..])
759                || disc == Some(&crate::instr::pump::discriminators::SELL_V2[..])
760            {
761                (a(1), a(2))
762            } else {
763                (a(2), a(1))
764            },
765        ),
766        SwapProtocol::LaunchLab => Some(if input == a(6) { (a(10), a(9)) } else { (a(9), a(10)) }),
767        SwapProtocol::RaydiumCpmm => Some((a(10), a(11))),
768        SwapProtocol::RaydiumClmm if disc == swap_v2 && ix.accounts.len() >= 13 => {
769            Some((a(11), a(12)))
770        }
771        SwapProtocol::OrcaWhirlpool if disc == swap_v2 => {
772            Some(if input == a(7) { (a(5), a(6)) } else { (a(6), a(5)) })
773        }
774        SwapProtocol::PumpSwap => Some(if input == a(6) { (a(4), a(3)) } else { (a(3), a(4)) }),
775        _ => None,
776    };
777    Some(RouteSwapLeg {
778        position: ix.position,
779        program: ix.program,
780        protocol,
781        pool,
782        trader,
783        input_account: input,
784        output_account: output,
785        input_mint: mints
786            .get(&input)
787            .copied()
788            .or_else(|| explicit_pair.map(|pair| pair.0).filter(|mint| *mint != Pubkey::default())),
789        output_mint: mints
790            .get(&output)
791            .copied()
792            .or_else(|| explicit_pair.map(|pair| pair.1).filter(|mint| *mint != Pubkey::default())),
793        amount_specified_is_input: exact_in,
794        specified_amount: amount,
795        other_amount_threshold: threshold,
796        actual_input_amount: None,
797        actual_output_amount: None,
798        stonkfun_mode: mode,
799        stonkfun_graduated: protocol == SwapProtocol::RaydiumCpmm && graduated.contains(&pool),
800    })
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    #[test]
807    fn native_actions_require_wsol_identity_and_preserve_failure_status() {
808        use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TransactionError};
809        let token = ROUTE_SPL_TOKEN;
810        let wsol = ROUTE_WSOL;
811        let keys = [Pubkey::default(), token, Pubkey::new_unique(), Pubkey::new_unique(), wsol];
812        let mut init = vec![18];
813        init.extend_from_slice(keys[2].as_ref());
814        let mut funding = 2u32.to_le_bytes().to_vec();
815        funding.extend_from_slice(&123u64.to_le_bytes());
816        let mut tx = Transaction {
817            signatures: vec![],
818            message: Some(Message {
819                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
820                instructions: vec![
821                    CompiledInstruction { program_id_index: 1, accounts: vec![3, 4], data: init },
822                    CompiledInstruction {
823                        program_id_index: 0,
824                        accounts: vec![2, 3],
825                        data: funding,
826                    },
827                    CompiledInstruction { program_id_index: 1, accounts: vec![3], data: vec![17] },
828                    CompiledInstruction {
829                        program_id_index: 1,
830                        accounts: vec![3, 2, 2],
831                        data: vec![9],
832                    },
833                ],
834                ..Default::default()
835            }),
836            ..Default::default()
837        };
838        let mut meta = TransactionStatusMeta::default();
839        let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
840        assert!(route.succeeded);
841        assert_eq!(route.native_token_actions.len(), 3);
842        assert_eq!(
843            route.native_token_actions[0].action,
844            NativeTokenAction::Fund { source: keys[2], lamports: 123 }
845        );
846        meta.err = Some(TransactionError { err: vec![1] });
847        let failed = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
848        assert!(!failed.succeeded);
849        assert_eq!(failed.native_token_actions.len(), 3);
850        tx.message.as_mut().unwrap().account_keys[4] = Pubkey::new_unique().to_bytes().to_vec();
851        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
852            .native_token_actions
853            .is_empty());
854    }
855    #[test]
856    fn ephemeral_initialized_accounts_propagate_mints_without_balance_snapshots() {
857        use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message};
858        let program = ROUTE_SPL_TOKEN;
859        let keys = [
860            program,
861            Pubkey::new_unique(),
862            Pubkey::new_unique(),
863            Pubkey::new_unique(),
864            Pubkey::new_unique(),
865        ];
866        let mut initialize = vec![18];
867        initialize.extend_from_slice(keys[4].as_ref());
868        let mut plain = vec![3];
869        plain.extend_from_slice(&100u64.to_le_bytes());
870        let transaction = Transaction {
871            signatures: vec![],
872            message: Some(Message {
873                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
874                instructions: vec![
875                    CompiledInstruction {
876                        program_id_index: 0,
877                        accounts: vec![1, 2],
878                        data: initialize,
879                    },
880                    CompiledInstruction {
881                        program_id_index: 0,
882                        accounts: vec![1, 3, 4],
883                        data: plain,
884                    },
885                ],
886                ..Default::default()
887            }),
888        };
889        let meta = TransactionStatusMeta::default();
890        let mints = transaction_token_mints(&transaction, &meta);
891        assert_eq!(mints.get(&keys[1]), Some(&keys[2]));
892        assert_eq!(mints.get(&keys[3]), Some(&keys[2]));
893        let route = analyze_yellowstone_transaction_routes(&transaction, &meta, &[]);
894        assert_eq!(route.transfers[0].mint, Some(keys[2]));
895    }
896    #[test]
897    fn malformed_checked_transfers_do_not_invent_amounts_or_mints() {
898        let keys: Vec<_> = (0..4).map(|_| Pubkey::new_unique()).collect();
899        let mut data = vec![12];
900        data.extend_from_slice(&123u64.to_le_bytes());
901        let mut ix = Invocation {
902            position: InstructionPosition {
903                outer_index: 0,
904                inner_index: None,
905                stack_height: Some(1),
906            },
907            program: ROUTE_SPL_TOKEN,
908            program_resolved: true,
909            accounts: &[0, 1, 2, 3],
910            data: &data,
911        };
912        assert!(!checked_transfer(&ix));
913        assert!(transfer(&ix, &keys, &HashMap::new()).is_none());
914        let mut complete_data = data.clone();
915        complete_data.push(6);
916        ix.data = &complete_data;
917        assert!(checked_transfer(&ix));
918        assert_eq!(transfer(&ix, &keys, &HashMap::new()).unwrap().amount, 123);
919        let mut fee_data = vec![26, 1];
920        fee_data.extend_from_slice(&123u64.to_le_bytes());
921        fee_data.push(6);
922        fee_data.extend_from_slice(&2u64.to_le_bytes());
923        ix.data = &fee_data;
924        assert!(transfer(&ix, &keys, &HashMap::new()).is_none());
925        ix.program = ROUTE_TOKEN_2022;
926        assert_eq!(transfer(&ix, &keys, &HashMap::new()).unwrap().withheld_fee, Some(2));
927    }
928}
929
930/// Analyze instruction and transfer context without altering the normal event API.
931/// Unknown invocations and failed transaction status are preserved explicitly.
932/// `graduated_stonkfun_pools` must be verified pool identities, not all stock pairs.
933pub fn analyze_yellowstone_transaction_routes(
934    transaction: &Transaction,
935    meta: &TransactionStatusMeta,
936    graduated_stonkfun_pools: &[Pubkey],
937) -> TransactionRoute {
938    let keys = transaction_keys(transaction, meta);
939    let invocations = transaction_invocations(transaction, meta, &keys);
940    let mints = token_mints_from_invocations(meta, &keys, &invocations);
941    let mut legs = Vec::new();
942    let analysis =
943        invocation_analysis(&invocations, &keys, &mints, graduated_stonkfun_pools, &mut legs);
944    let succeeded = meta.err.is_none();
945    let mut unknown = Vec::new();
946    for (i, ix) in invocations.iter().enumerate() {
947        let row = &analysis[i];
948        if row.skip_route {
949            continue;
950        }
951        if let Some(index) = row.swap_index {
952            let leg = &mut legs[index];
953            if succeeded {
954                let mut input_sum = Some(0u64);
955                let mut output_sum = Some(0u64);
956                let mut has_input = false;
957                let mut has_output = false;
958                for transfer in analysis[i + 1..row.descendant_end]
959                    .iter()
960                    .filter_map(|row| row.transfer.as_ref())
961                {
962                    if transfer.source == leg.input_account {
963                        has_input = true;
964                        input_sum = input_sum.and_then(|sum| sum.checked_add(transfer.amount));
965                    }
966                    if transfer.destination == leg.output_account {
967                        has_output = true;
968                        output_sum = output_sum.and_then(|sum| {
969                            sum.checked_add(transfer.amount.checked_sub(transfer.withheld_fee?)?)
970                        });
971                    }
972                }
973                leg.actual_input_amount = has_input.then_some(input_sum).flatten();
974                leg.actual_output_amount = has_output.then_some(output_sum).flatten();
975            }
976        } else {
977            // Event CPI self-invocations have no token transfers and remain visible.
978            unknown.push(RouteUnknownInvocation {
979                position: ix.position,
980                program: ix.program,
981                has_token_transfers: row.has_token_transfers,
982                has_known_swap_descendants: row.has_known_swap_descendants,
983            });
984        }
985    }
986    let transfers = analysis.into_iter().filter_map(|row| row.transfer).collect();
987    TransactionRoute {
988        signature: transaction
989            .signatures
990            .first()
991            .and_then(|b| Signature::try_from(b.as_slice()).ok())
992            .unwrap_or_default(),
993        succeeded,
994        legs,
995        transfers,
996        native_token_actions: invocations
997            .iter()
998            .filter_map(|ix| native_action(ix, &keys, &mints))
999            .collect(),
1000        unknown_invocations: unknown,
1001    }
1002}
1003
1004pub fn analyze_rpc_transaction_routes(
1005    transaction: &EncodedConfirmedTransactionWithStatusMeta,
1006    graduated_stonkfun_pools: &[Pubkey],
1007) -> Result<TransactionRoute, ParseError> {
1008    let (meta, transaction) = convert_rpc_to_grpc(transaction)?;
1009    Ok(analyze_yellowstone_transaction_routes(&transaction, &meta, graduated_stonkfun_pools))
1010}
1011
1012#[cfg(test)]
1013mod review_regressions {
1014    use super::*;
1015    use yellowstone_grpc_proto::prelude::{
1016        CompiledInstruction, InnerInstruction, InnerInstructions, Message,
1017    };
1018
1019    #[test]
1020    fn idl_minimum_launchlab_and_pump_v2_accounts() {
1021        use crate::instr::{program_ids::*, pump::discriminators as pump};
1022        let keys: Vec<_> = (0..30).map(|_| Pubkey::new_unique()).collect();
1023        for (program, disc, count, input, output) in [
1024            (
1025                RAYDIUM_LAUNCHLAB_PROGRAM_ID,
1026                crate::instr::raydium_launchlab::discriminators::BUY_EXACT_IN,
1027                15,
1028                6,
1029                5,
1030            ),
1031            (PUMPFUN_PROGRAM_ID, pump::BUY_V2, 27, 15, 14),
1032            (PUMPFUN_PROGRAM_ID, pump::BUY_EXACT_QUOTE_IN_V2, 27, 15, 14),
1033            (PUMPFUN_PROGRAM_ID, pump::SELL_V2, 26, 14, 15),
1034        ] {
1035            let mut data = disc.to_vec();
1036            data.extend_from_slice(&100u64.to_le_bytes());
1037            data.extend_from_slice(&20u64.to_le_bytes());
1038            let accounts: Vec<u8> = (0..count).collect();
1039            let mut ix = Invocation {
1040                position: InstructionPosition {
1041                    outer_index: 0,
1042                    inner_index: None,
1043                    stack_height: Some(1),
1044                },
1045                program,
1046                program_resolved: true,
1047                accounts: &accounts,
1048                data: &data,
1049            };
1050            let leg = swap(&ix, &keys, &HashMap::new(), &[]).unwrap();
1051            assert_eq!(leg.input_account, keys[input]);
1052            assert_eq!(leg.output_account, keys[output]);
1053            assert!(leg.input_mint.is_some() && leg.output_mint.is_some());
1054            ix.accounts = &accounts[..accounts.len() - 1];
1055            assert!(swap(&ix, &keys, &HashMap::new(), &[]).is_none());
1056        }
1057    }
1058
1059    fn cpmm_with_output(program: Pubkey, fee: Option<u64>) -> (Transaction, TransactionStatusMeta) {
1060        let mut keys: Vec<_> = (0..13).map(|_| Pubkey::new_unique()).collect();
1061        keys.push(crate::instr::program_ids::RAYDIUM_CPMM_PROGRAM_ID);
1062        keys.push(program);
1063        let mut swap_data = crate::instr::raydium_cpmm::discriminators::SWAP_BASE_IN.to_vec();
1064        swap_data.extend_from_slice(&100u64.to_le_bytes());
1065        swap_data.extend_from_slice(&50u64.to_le_bytes());
1066        let mut transfer_data = if fee.is_some() { vec![26, 1] } else { vec![3] };
1067        transfer_data.extend_from_slice(&80u64.to_le_bytes());
1068        if let Some(fee) = fee {
1069            transfer_data.push(6);
1070            transfer_data.extend_from_slice(&fee.to_le_bytes());
1071        }
1072        let tx = Transaction {
1073            message: Some(Message {
1074                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1075                instructions: vec![CompiledInstruction {
1076                    program_id_index: 13,
1077                    accounts: (0..13).collect(),
1078                    data: swap_data,
1079                }],
1080                ..Default::default()
1081            }),
1082            ..Default::default()
1083        };
1084        let meta = TransactionStatusMeta {
1085            inner_instructions: vec![InnerInstructions {
1086                index: 0,
1087                instructions: vec![InnerInstruction {
1088                    program_id_index: 14,
1089                    accounts: if fee.is_some() { vec![7, 11, 5, 1] } else { vec![7, 5, 1] },
1090                    data: transfer_data,
1091                    stack_height: Some(2),
1092                }],
1093            }],
1094            ..Default::default()
1095        };
1096        (tx, meta)
1097    }
1098
1099    #[test]
1100    fn output_credit_requires_known_transfer_fee_and_failure_never_reports_credit() {
1101        let spl = ROUTE_SPL_TOKEN;
1102        let token22 = ROUTE_TOKEN_2022;
1103        for (program, fee, expected) in [
1104            (spl, None, Some(80)),
1105            (token22, None, None),
1106            (token22, Some(3), Some(77)),
1107            (token22, Some(81), None),
1108        ] {
1109            let (tx, mut meta) = cpmm_with_output(program, fee);
1110            let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
1111            assert_eq!(route.legs.len(), 1);
1112            assert_eq!(route.legs[0].actual_output_amount, expected);
1113            assert_eq!(route.transfers[0].amount, 80);
1114            meta.err = Some(yellowstone_grpc_proto::prelude::TransactionError { err: vec![1] });
1115            assert_eq!(
1116                analyze_yellowstone_transaction_routes(&tx, &meta, &[]).legs[0]
1117                    .actual_output_amount,
1118                None
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn amm_remaining_accounts_do_not_shift_user_accounts() {
1125        let keys: Vec<_> = (0..21).map(|_| Pubkey::new_unique()).collect();
1126        let mut data = vec![9];
1127        data.extend_from_slice(&100u64.to_le_bytes());
1128        data.extend_from_slice(&80u64.to_le_bytes());
1129        for count in [17, 18, 20] {
1130            let accounts: Vec<u8> = (0..count).collect();
1131            let ix = Invocation {
1132                position: InstructionPosition {
1133                    outer_index: 0,
1134                    inner_index: None,
1135                    stack_height: Some(1),
1136                },
1137                program: crate::instr::program_ids::RAYDIUM_AMM_V4_PROGRAM_ID,
1138                program_resolved: true,
1139                accounts: &accounts,
1140                data: &data,
1141            };
1142            let leg = swap(&ix, &keys, &HashMap::new(), &[]).unwrap();
1143            let shift = usize::from(count == 17);
1144            assert_eq!(leg.input_account, keys[15 - shift]);
1145            assert_eq!(leg.output_account, keys[16 - shift]);
1146            assert_eq!(leg.trader, keys[17 - shift]);
1147        }
1148    }
1149
1150    #[test]
1151    #[ignore = "manual local timing; excludes transport and ALT resolution"]
1152    fn route_parser_local_timing() {
1153        let (tx, meta) = cpmm_with_output(ROUTE_SPL_TOKEN, None);
1154        for _ in 0..100 {
1155            std::hint::black_box(analyze_yellowstone_transaction_routes(&tx, &meta, &[]));
1156        }
1157        let mut samples = Vec::with_capacity(21);
1158        for _ in 0..21 {
1159            let start = std::time::Instant::now();
1160            for _ in 0..2000 {
1161                std::hint::black_box(analyze_yellowstone_transaction_routes(
1162                    std::hint::black_box(&tx),
1163                    std::hint::black_box(&meta),
1164                    &[],
1165                ));
1166            }
1167            samples.push(start.elapsed().as_nanos() / 2000);
1168        }
1169        samples.sort_unstable();
1170        eprintln!(
1171            "CPMM route (one swap, one transfer): median={} ns/tx, max_batch_mean={} ns/tx",
1172            samples[10], samples[20]
1173        );
1174    }
1175}
1176
1177#[cfg(test)]
1178mod review_missing_mint_regressions {
1179    use super::*;
1180    use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TokenBalance};
1181
1182    #[test]
1183    fn missing_accounts_never_seed_or_propagate_a_mint() {
1184        let keys =
1185            [Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::new_unique(), ROUTE_SPL_TOKEN];
1186        let mint = Pubkey::new_unique();
1187        let mut data = vec![3];
1188        data.extend_from_slice(&10u64.to_le_bytes());
1189        let tx = Transaction {
1190            message: Some(Message {
1191                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1192                instructions: vec![
1193                    CompiledInstruction {
1194                        program_id_index: 3,
1195                        accounts: vec![255, 0, 2],
1196                        data: data.clone(),
1197                    },
1198                    CompiledInstruction { program_id_index: 3, accounts: vec![0, 1, 2], data },
1199                ],
1200                ..Default::default()
1201            }),
1202            ..Default::default()
1203        };
1204        let meta = TransactionStatusMeta {
1205            pre_token_balances: vec![TokenBalance {
1206                account_index: u32::MAX,
1207                mint: mint.to_string(),
1208                ..Default::default()
1209            }],
1210            ..Default::default()
1211        };
1212        assert!(transaction_token_mints(&tx, &meta).is_empty());
1213        let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
1214        assert_eq!(route.transfers.len(), 1);
1215        assert_eq!(route.transfers[0].source, keys[0]);
1216        assert_eq!(route.transfers[0].mint, None);
1217    }
1218}
1219
1220#[cfg(test)]
1221mod review_missing_program_regressions {
1222    use super::*;
1223    use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TokenBalance};
1224
1225    #[test]
1226    fn invalid_program_or_source_index_cannot_masquerade_as_system_funding() {
1227        let keys = [Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::default()];
1228        let mut data = 2u32.to_le_bytes().to_vec();
1229        data.extend_from_slice(&100u64.to_le_bytes());
1230        let mut tx = Transaction {
1231            message: Some(Message {
1232                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
1233                instructions: vec![CompiledInstruction {
1234                    program_id_index: 255,
1235                    accounts: vec![0, 1],
1236                    data,
1237                }],
1238                ..Default::default()
1239            }),
1240            ..Default::default()
1241        };
1242        let meta = TransactionStatusMeta {
1243            pre_token_balances: vec![TokenBalance {
1244                account_index: 1,
1245                mint: ROUTE_WSOL.to_string(),
1246                ..Default::default()
1247            }],
1248            ..Default::default()
1249        };
1250        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
1251            .native_token_actions
1252            .is_empty());
1253        tx.message.as_mut().unwrap().instructions[0].program_id_index = 2;
1254        assert_eq!(
1255            analyze_yellowstone_transaction_routes(&tx, &meta, &[]).native_token_actions.len(),
1256            1
1257        );
1258        tx.message.as_mut().unwrap().instructions[0].accounts[0] = 255;
1259        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
1260            .native_token_actions
1261            .is_empty());
1262    }
1263}
1264
1265#[cfg(test)]
1266mod review_route_scope_regressions {
1267    use super::*;
1268    use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TokenBalance};
1269
1270    #[test]
1271    fn pumpswap_invalid_optional_bool_is_not_a_known_route_swap() {
1272        let mut keys: Vec<_> = (0..21).map(|_| Pubkey::new_unique()).collect();
1273        keys.push(crate::instr::program_ids::PUMPSWAP_PROGRAM_ID);
1274        for discriminator in [
1275            crate::instr::pump_amm::discriminators::BUY,
1276            crate::instr::pump_amm::discriminators::BUY_EXACT_QUOTE_IN,
1277        ] {
1278            let mut data = discriminator.to_vec();
1279            data.extend_from_slice(&100u64.to_le_bytes());
1280            data.extend_from_slice(&50u64.to_le_bytes());
1281            for flag in [None, Some(0), Some(1), Some(2), Some(255)] {
1282                let mut wire = data.clone();
1283                if let Some(flag) = flag {
1284                    wire.push(flag);
1285                }
1286                let tx = Transaction {
1287                    message: Some(Message {
1288                        account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
1289                        instructions: vec![CompiledInstruction {
1290                            program_id_index: 21,
1291                            accounts: (0..21).collect(),
1292                            data: wire,
1293                        }],
1294                        ..Default::default()
1295                    }),
1296                    ..Default::default()
1297                };
1298                let meta = TransactionStatusMeta::default();
1299                let invocations = transaction_invocations(&tx, &meta, &keys);
1300                let mut legs = Vec::new();
1301                let rows =
1302                    invocation_analysis(&invocations, &keys, &HashMap::new(), &[], &mut legs);
1303                assert_eq!(legs.len(), usize::from(!matches!(flag, Some(2 | 255))));
1304                assert_eq!(rows[0].swap_index.is_some(), !matches!(flag, Some(2 | 255)));
1305            }
1306        }
1307    }
1308
1309    #[test]
1310    fn dlmm_route_requires_v2_remaining_account_info() {
1311        let mut keys: Vec<_> = (0..16).map(|_| Pubkey::new_unique()).collect();
1312        keys.push(crate::instr::program_ids::METEORA_DLMM_PROGRAM_ID);
1313        let mut wire = crate::instr::meteora_dlmm::discriminators::SWAP2.to_vec();
1314        wire.extend_from_slice(&100u64.to_le_bytes());
1315        wire.extend_from_slice(&50u64.to_le_bytes());
1316        for tail in [vec![], vec![1, 0, 0, 0, 0, 1], vec![0, 0, 0, 0]] {
1317            let mut data = wire.clone();
1318            data.extend_from_slice(&tail);
1319            let tx = Transaction {
1320                message: Some(Message {
1321                    account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1322                    instructions: vec![CompiledInstruction {
1323                        program_id_index: 16,
1324                        accounts: (0..16).collect(),
1325                        data,
1326                    }],
1327                    ..Default::default()
1328                }),
1329                ..Default::default()
1330            };
1331            let route =
1332                analyze_yellowstone_transaction_routes(&tx, &TransactionStatusMeta::default(), &[]);
1333            assert_eq!(route.legs.len(), usize::from(tail == [0, 0, 0, 0]));
1334            assert_eq!(route.unknown_invocations.len(), usize::from(tail != [0, 0, 0, 0]));
1335        }
1336    }
1337
1338    #[test]
1339    fn conflicting_mint_evidence_is_sticky_and_cannot_spread_to_neighbours() {
1340        let keys = [
1341            Pubkey::new_unique(),
1342            Pubkey::new_unique(),
1343            Pubkey::new_unique(),
1344            Pubkey::new_unique(),
1345            Pubkey::new_unique(),
1346            ROUTE_SPL_TOKEN,
1347        ];
1348        let balance = |index, mint: Pubkey| TokenBalance {
1349            account_index: index,
1350            mint: mint.to_string(),
1351            ..Default::default()
1352        };
1353        let mut plain = vec![3];
1354        plain.extend_from_slice(&10u64.to_le_bytes());
1355        let mut checked = vec![12];
1356        checked.extend_from_slice(&10u64.to_le_bytes());
1357        checked.push(6);
1358        let transaction = |with_checked| {
1359            let mut instructions = vec![];
1360            if with_checked {
1361                instructions.push(CompiledInstruction {
1362                    program_id_index: 5,
1363                    accounts: vec![0, 4, 1, 2],
1364                    data: checked.clone(),
1365                });
1366            }
1367            for accounts in [vec![0, 1, 2], vec![1, 2, 3]] {
1368                instructions.push(CompiledInstruction {
1369                    program_id_index: 5,
1370                    accounts,
1371                    data: plain.clone(),
1372                });
1373            }
1374            Transaction {
1375                message: Some(Message {
1376                    account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1377                    instructions,
1378                    ..Default::default()
1379                }),
1380                ..Default::default()
1381            }
1382        };
1383        for with_checked in [false, true] {
1384            for reverse in [false, true] {
1385                let (before, after) = if reverse { (keys[4], keys[3]) } else { (keys[3], keys[4]) };
1386                let meta = TransactionStatusMeta {
1387                    pre_token_balances: vec![balance(0, before), balance(1, keys[4])],
1388                    post_token_balances: vec![balance(0, after)],
1389                    ..Default::default()
1390                };
1391                let tx = transaction(with_checked);
1392                let mints = transaction_token_mints(&tx, &meta);
1393                assert_eq!(mints.get(&keys[0]), None);
1394                assert_eq!(mints.get(&keys[1]), Some(&keys[4]));
1395                assert_eq!(mints.get(&keys[2]), Some(&keys[4]));
1396                assert_eq!(
1397                    analyze_yellowstone_transaction_routes(&tx, &meta, &[]).transfers[0].mint,
1398                    None
1399                );
1400            }
1401        }
1402        let meta = TransactionStatusMeta {
1403            pre_token_balances: vec![balance(0, keys[3])],
1404            ..Default::default()
1405        };
1406        // A checked transfer cannot silently overwrite an authoritative balance mint.
1407        assert_eq!(transaction_token_mints(&transaction(true), &meta).get(&keys[0]), None);
1408    }
1409
1410    #[test]
1411    fn cached_subtree_boundaries_and_flags_match_exhaustive_scans() {
1412        let keys: Vec<_> = (0..13).map(|_| Pubkey::new_unique()).collect();
1413        let opaque = Pubkey::new_unique();
1414        let mut swap_data = crate::instr::raydium_cpmm::discriminators::SWAP_BASE_IN.to_vec();
1415        swap_data.extend_from_slice(&100u64.to_le_bytes());
1416        swap_data.extend_from_slice(&50u64.to_le_bytes());
1417        let swap_accounts: Vec<u8> = (0..13).collect();
1418        let mut transfer_data = vec![3];
1419        transfer_data.extend_from_slice(&70u64.to_le_bytes());
1420        let transfer_accounts = [4, 5, 0];
1421        let mut state = 0x1234_5678u64;
1422        for seed in 0..128 {
1423            let mut invocations = Vec::new();
1424            for outer in 0..3 {
1425                invocations.push(Invocation {
1426                    position: InstructionPosition {
1427                        outer_index: outer,
1428                        inner_index: None,
1429                        stack_height: Some(1),
1430                    },
1431                    program: opaque,
1432                    program_resolved: true,
1433                    accounts: &[],
1434                    data: &[],
1435                });
1436                for j in 0..24 {
1437                    state ^= state << 13;
1438                    state ^= state >> 7;
1439                    state ^= state << 17;
1440                    let (program, accounts, data): (_, &[u8], &[u8]) = match state % 3 {
1441                        0 => (opaque, &[], &[]),
1442                        1 => (
1443                            crate::instr::program_ids::RAYDIUM_CPMM_PROGRAM_ID,
1444                            &swap_accounts,
1445                            &swap_data,
1446                        ),
1447                        _ => (ROUTE_SPL_TOKEN, &transfer_accounts, &transfer_data),
1448                    };
1449                    invocations.push(Invocation {
1450                        position: InstructionPosition {
1451                            outer_index: outer,
1452                            inner_index: Some(if seed % 7 == 0 { j / 2 } else { j }),
1453                            stack_height: if state % 5 == 0 {
1454                                None
1455                            } else {
1456                                Some(2 + (state % 6) as u32)
1457                            },
1458                        },
1459                        program,
1460                        program_resolved: true,
1461                        accounts,
1462                        data,
1463                    });
1464                }
1465            }
1466            let mints = HashMap::new();
1467            let analysis = invocation_analysis(&invocations, &keys, &mints, &[], &mut Vec::new());
1468            for (i, row) in analysis.iter().enumerate() {
1469                let reference = descendants(i, &invocations);
1470                assert_eq!(row.descendant_end, i + 1 + reference.len());
1471                assert_eq!(
1472                    row.has_token_transfers,
1473                    reference.iter().any(|ix| transfer(ix, &keys, &mints).is_some())
1474                );
1475                assert_eq!(
1476                    row.has_known_swap_descendants,
1477                    reference.iter().any(|ix| swap(ix, &keys, &mints, &[]).is_some())
1478                );
1479            }
1480        }
1481    }
1482}
1483
1484#[cfg(test)]
1485mod review_invocation_join_regressions {
1486    use super::*;
1487    use yellowstone_grpc_proto::prelude::{
1488        CompiledInstruction, InnerInstruction, InnerInstructions, Message,
1489    };
1490    fn exhaustive_invocations<'a>(
1491        transaction: &'a Transaction,
1492        meta: &'a TransactionStatusMeta,
1493        keys: &[Pubkey],
1494    ) -> Vec<Invocation<'a>> {
1495        let mut result = Vec::new();
1496        if let Some(message) = &transaction.message {
1497            for (i, ix) in message.instructions.iter().enumerate() {
1498                result.push(Invocation {
1499                    position: InstructionPosition {
1500                        outer_index: i as u32,
1501                        inner_index: None,
1502                        stack_height: Some(1),
1503                    },
1504                    program: key(keys, ix.program_id_index),
1505                    program_resolved: (ix.program_id_index as usize) < keys.len(),
1506                    accounts: &ix.accounts,
1507                    data: &ix.data,
1508                });
1509                for group in meta.inner_instructions.iter().filter(|g| g.index == i as u32) {
1510                    for (j, ix) in group.instructions.iter().enumerate() {
1511                        result.push(Invocation {
1512                            position: InstructionPosition {
1513                                outer_index: i as u32,
1514                                inner_index: Some(j as u32),
1515                                stack_height: ix.stack_height,
1516                            },
1517                            program: key(keys, ix.program_id_index),
1518                            program_resolved: (ix.program_id_index as usize) < keys.len(),
1519                            accounts: &ix.accounts,
1520                            data: &ix.data,
1521                        });
1522                    }
1523                }
1524            }
1525        }
1526        result
1527    }
1528
1529    #[test]
1530    fn ordered_join_preserves_unsorted_duplicate_and_orphan_group_semantics() {
1531        let keys = [Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::new_unique()];
1532        let tx = Transaction {
1533            message: Some(Message {
1534                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1535                instructions: (0..3)
1536                    .map(|i| CompiledInstruction {
1537                        program_id_index: i,
1538                        data: vec![i as u8],
1539                        accounts: vec![0, 1],
1540                    })
1541                    .collect(),
1542                ..Default::default()
1543            }),
1544            ..Default::default()
1545        };
1546        let indices = [2, 0, u32::MAX, 1, 0, 2];
1547        let mut groups: Vec<_> = indices
1548            .into_iter()
1549            .enumerate()
1550            .map(|(i, index)| InnerInstructions {
1551                index,
1552                instructions: vec![InnerInstruction {
1553                    program_id_index: if i % 2 == 0 { 1 } else { 255 },
1554                    accounts: vec![i as u8],
1555                    data: vec![i as u8],
1556                    stack_height: if i % 3 == 0 { None } else { Some(2) },
1557                }],
1558            })
1559            .collect();
1560        for permutation in 0..12 {
1561            if permutation % 2 == 0 {
1562                groups.rotate_left(1);
1563            } else {
1564                groups.reverse();
1565            }
1566            let meta =
1567                TransactionStatusMeta { inner_instructions: groups.clone(), ..Default::default() };
1568            let expected = exhaustive_invocations(&tx, &meta, &keys);
1569            let actual = transaction_invocations(&tx, &meta, &keys);
1570            assert_eq!(actual.len(), 8);
1571            assert_eq!(actual.len(), expected.len());
1572            for (actual, expected) in actual.iter().zip(expected.iter()) {
1573                assert_eq!(actual.position, expected.position);
1574                assert_eq!(actual.program, expected.program);
1575                assert_eq!(actual.program_resolved, expected.program_resolved);
1576                assert_eq!(actual.accounts, expected.accounts);
1577                assert_eq!(actual.data, expected.data);
1578            }
1579        }
1580        groups.sort_by_key(|g| g.index);
1581        let meta = TransactionStatusMeta { inner_instructions: groups, ..Default::default() };
1582        let expected = exhaustive_invocations(&tx, &meta, &keys);
1583        let actual = transaction_invocations(&tx, &meta, &keys);
1584        assert_eq!(
1585            actual.iter().map(|ix| (ix.position, ix.data)).collect::<Vec<_>>(),
1586            expected.iter().map(|ix| (ix.position, ix.data)).collect::<Vec<_>>()
1587        );
1588        assert!(transaction_invocations(&Transaction::default(), &meta, &keys).is_empty());
1589    }
1590}