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