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() >= 18 {
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 && ix.accounts.len() == 17 {
642        use crate::instr::pump::discriminators::*;
643        let buy = disc != Some(&SELL_V3[..]);
644        let exact = disc != Some(&BUY_V3[..]);
645        if ![BUY_V3, BUY_EXACT_QUOTE_IN_V3, SELL_V3].iter().any(|d| disc == Some(&d[..])) {
646            return None;
647        }
648        let quote = a(2);
649        let source = if buy {
650            if quote == ROUTE_WSOL {
651                a(8)
652            } else {
653                a(10)
654            }
655        } else {
656            a(9)
657        };
658        let dest = if buy {
659            a(9)
660        } else if quote == ROUTE_WSOL {
661            a(8)
662        } else {
663            a(10)
664        };
665        (
666            SwapProtocol::PumpFun,
667            a(5),
668            a(8),
669            source,
670            dest,
671            exact,
672            u64_at(ix.data, 8)?,
673            u64_at(ix.data, 16)?,
674        )
675    } else if ix.program == PUMPSWAP_PROGRAM_ID
676        && (ix.accounts.len() >= 21 || ix.accounts.len() == 17)
677    {
678        use crate::instr::pump_amm::discriminators::*;
679        let (buy, exact_in) =
680            if disc == Some(&BUY_EXACT_QUOTE_IN[..]) || disc == Some(&BUY_EXACT_QUOTE_IN_V2[..]) {
681                (true, true)
682            } else if disc == Some(&BUY[..]) || disc == Some(&BUY_V2[..]) {
683                (true, false)
684            } else if disc == Some(&SELL[..]) || disc == Some(&SELL_V2[..]) {
685                (false, true)
686            } else {
687                return None;
688            };
689        if buy && ix.data.len() > 24 {
690            crate::instr::utils::read_option_bool_idl(ix.data, 24)?;
691        }
692        (
693            SwapProtocol::PumpSwap,
694            a(0),
695            a(1),
696            a(if buy { 6 } else { 5 }),
697            a(if buy { 5 } else { 6 }),
698            exact_in,
699            u64_at(ix.data, 8)?,
700            u64_at(ix.data, 16)?,
701        )
702    } else if ix.program == RAYDIUM_AMM_V4_PROGRAM_ID
703        && matches!(ix.data.first(), Some(16 | 17))
704        && ix.accounts.len() >= 8
705    {
706        let exact_in = ix.data[0] == 16;
707        let first = u64_at(ix.data, 1)?;
708        let second = u64_at(ix.data, 9)?;
709        (
710            SwapProtocol::RaydiumAmmV4,
711            a(1),
712            a(7),
713            a(5),
714            a(6),
715            exact_in,
716            if exact_in { first } else { second },
717            if exact_in { second } else { first },
718        )
719    } else if ix.program == RAYDIUM_AMM_V4_PROGRAM_ID
720        && matches!(ix.data.first(), Some(9 | 11))
721        && ix.accounts.len() >= 17
722    {
723        // Optional target_orders account was removed from the modern layout.
724        let shift = usize::from(ix.accounts.len() == 17);
725        let exact_in = ix.data[0] == 9;
726        let first = u64_at(ix.data, 1)?;
727        let second = u64_at(ix.data, 9)?;
728        (
729            SwapProtocol::RaydiumAmmV4,
730            a(1),
731            a(17 - shift),
732            a(15 - shift),
733            a(16 - shift),
734            exact_in,
735            if exact_in { first } else { second },
736            if exact_in { second } else { first },
737        )
738    } else {
739        return None;
740    };
741    let explicit_pair = match protocol {
742        SwapProtocol::PumpFun => {
743            Some(if exact_in && disc == Some(&crate::instr::pump::discriminators::SELL_V3[..]) {
744                (a(1), a(2))
745            } else {
746                (a(2), a(1))
747            })
748        }
749        SwapProtocol::LaunchLab => Some(if input == a(6) { (a(10), a(9)) } else { (a(9), a(10)) }),
750        SwapProtocol::RaydiumCpmm => Some((a(10), a(11))),
751        SwapProtocol::RaydiumClmm if disc == swap_v2 && ix.accounts.len() >= 13 => {
752            Some((a(11), a(12)))
753        }
754        SwapProtocol::OrcaWhirlpool if disc == swap_v2 => {
755            Some(if input == a(7) { (a(5), a(6)) } else { (a(6), a(5)) })
756        }
757        SwapProtocol::PumpSwap => Some(if input == a(6) { (a(4), a(3)) } else { (a(3), a(4)) }),
758        _ => None,
759    };
760    Some(RouteSwapLeg {
761        position: ix.position,
762        program: ix.program,
763        protocol,
764        pool,
765        trader,
766        input_account: input,
767        output_account: output,
768        input_mint: mints
769            .get(&input)
770            .copied()
771            .or_else(|| explicit_pair.map(|pair| pair.0).filter(|mint| *mint != Pubkey::default())),
772        output_mint: mints
773            .get(&output)
774            .copied()
775            .or_else(|| explicit_pair.map(|pair| pair.1).filter(|mint| *mint != Pubkey::default())),
776        amount_specified_is_input: exact_in,
777        specified_amount: amount,
778        other_amount_threshold: threshold,
779        actual_input_amount: None,
780        actual_output_amount: None,
781        stonkfun_mode: mode,
782        stonkfun_graduated: protocol == SwapProtocol::RaydiumCpmm && graduated.contains(&pool),
783    })
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    #[test]
790    fn native_actions_require_wsol_identity_and_preserve_failure_status() {
791        use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TransactionError};
792        let token = ROUTE_SPL_TOKEN;
793        let wsol = ROUTE_WSOL;
794        let keys = [Pubkey::default(), token, Pubkey::new_unique(), Pubkey::new_unique(), wsol];
795        let mut init = vec![18];
796        init.extend_from_slice(keys[2].as_ref());
797        let mut funding = 2u32.to_le_bytes().to_vec();
798        funding.extend_from_slice(&123u64.to_le_bytes());
799        let mut tx = Transaction {
800            signatures: vec![],
801            message: Some(Message {
802                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
803                instructions: vec![
804                    CompiledInstruction { program_id_index: 1, accounts: vec![3, 4], data: init },
805                    CompiledInstruction {
806                        program_id_index: 0,
807                        accounts: vec![2, 3],
808                        data: funding,
809                    },
810                    CompiledInstruction { program_id_index: 1, accounts: vec![3], data: vec![17] },
811                    CompiledInstruction {
812                        program_id_index: 1,
813                        accounts: vec![3, 2, 2],
814                        data: vec![9],
815                    },
816                ],
817                ..Default::default()
818            }),
819            ..Default::default()
820        };
821        let mut meta = TransactionStatusMeta::default();
822        let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
823        assert!(route.succeeded);
824        assert_eq!(route.native_token_actions.len(), 3);
825        assert_eq!(
826            route.native_token_actions[0].action,
827            NativeTokenAction::Fund { source: keys[2], lamports: 123 }
828        );
829        meta.err = Some(TransactionError { err: vec![1] });
830        let failed = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
831        assert!(!failed.succeeded);
832        assert_eq!(failed.native_token_actions.len(), 3);
833        tx.message.as_mut().unwrap().account_keys[4] = Pubkey::new_unique().to_bytes().to_vec();
834        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
835            .native_token_actions
836            .is_empty());
837    }
838    #[test]
839    fn ephemeral_initialized_accounts_propagate_mints_without_balance_snapshots() {
840        use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message};
841        let program = ROUTE_SPL_TOKEN;
842        let keys = [
843            program,
844            Pubkey::new_unique(),
845            Pubkey::new_unique(),
846            Pubkey::new_unique(),
847            Pubkey::new_unique(),
848        ];
849        let mut initialize = vec![18];
850        initialize.extend_from_slice(keys[4].as_ref());
851        let mut plain = vec![3];
852        plain.extend_from_slice(&100u64.to_le_bytes());
853        let transaction = Transaction {
854            signatures: vec![],
855            message: Some(Message {
856                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
857                instructions: vec![
858                    CompiledInstruction {
859                        program_id_index: 0,
860                        accounts: vec![1, 2],
861                        data: initialize,
862                    },
863                    CompiledInstruction {
864                        program_id_index: 0,
865                        accounts: vec![1, 3, 4],
866                        data: plain,
867                    },
868                ],
869                ..Default::default()
870            }),
871        };
872        let meta = TransactionStatusMeta::default();
873        let mints = transaction_token_mints(&transaction, &meta);
874        assert_eq!(mints.get(&keys[1]), Some(&keys[2]));
875        assert_eq!(mints.get(&keys[3]), Some(&keys[2]));
876        let route = analyze_yellowstone_transaction_routes(&transaction, &meta, &[]);
877        assert_eq!(route.transfers[0].mint, Some(keys[2]));
878    }
879    #[test]
880    fn malformed_checked_transfers_do_not_invent_amounts_or_mints() {
881        let keys: Vec<_> = (0..4).map(|_| Pubkey::new_unique()).collect();
882        let mut data = vec![12];
883        data.extend_from_slice(&123u64.to_le_bytes());
884        let mut ix = Invocation {
885            position: InstructionPosition {
886                outer_index: 0,
887                inner_index: None,
888                stack_height: Some(1),
889            },
890            program: ROUTE_SPL_TOKEN,
891            program_resolved: true,
892            accounts: &[0, 1, 2, 3],
893            data: &data,
894        };
895        assert!(!checked_transfer(&ix));
896        assert!(transfer(&ix, &keys, &HashMap::new()).is_none());
897        let mut complete_data = data.clone();
898        complete_data.push(6);
899        ix.data = &complete_data;
900        assert!(checked_transfer(&ix));
901        assert_eq!(transfer(&ix, &keys, &HashMap::new()).unwrap().amount, 123);
902        let mut fee_data = vec![26, 1];
903        fee_data.extend_from_slice(&123u64.to_le_bytes());
904        fee_data.push(6);
905        fee_data.extend_from_slice(&2u64.to_le_bytes());
906        ix.data = &fee_data;
907        assert!(transfer(&ix, &keys, &HashMap::new()).is_none());
908        ix.program = ROUTE_TOKEN_2022;
909        assert_eq!(transfer(&ix, &keys, &HashMap::new()).unwrap().withheld_fee, Some(2));
910    }
911}
912
913/// Analyze instruction and transfer context without altering the normal event API.
914/// Unknown invocations and failed transaction status are preserved explicitly.
915/// `graduated_stonkfun_pools` must be verified pool identities, not all stock pairs.
916pub fn analyze_yellowstone_transaction_routes(
917    transaction: &Transaction,
918    meta: &TransactionStatusMeta,
919    graduated_stonkfun_pools: &[Pubkey],
920) -> TransactionRoute {
921    let keys = transaction_keys(transaction, meta);
922    let invocations = transaction_invocations(transaction, meta, &keys);
923    let mints = token_mints_from_invocations(meta, &keys, &invocations);
924    let mut legs = Vec::new();
925    let analysis =
926        invocation_analysis(&invocations, &keys, &mints, graduated_stonkfun_pools, &mut legs);
927    let succeeded = meta.err.is_none();
928    let mut unknown = Vec::new();
929    for (i, ix) in invocations.iter().enumerate() {
930        let row = &analysis[i];
931        if row.skip_route {
932            continue;
933        }
934        if let Some(index) = row.swap_index {
935            let leg = &mut legs[index];
936            if succeeded {
937                let mut input_sum = Some(0u64);
938                let mut output_sum = Some(0u64);
939                let mut has_input = false;
940                let mut has_output = false;
941                for transfer in analysis[i + 1..row.descendant_end]
942                    .iter()
943                    .filter_map(|row| row.transfer.as_ref())
944                {
945                    if transfer.source == leg.input_account {
946                        has_input = true;
947                        input_sum = input_sum.and_then(|sum| sum.checked_add(transfer.amount));
948                    }
949                    if transfer.destination == leg.output_account {
950                        has_output = true;
951                        output_sum = output_sum.and_then(|sum| {
952                            sum.checked_add(transfer.amount.checked_sub(transfer.withheld_fee?)?)
953                        });
954                    }
955                }
956                leg.actual_input_amount = has_input.then_some(input_sum).flatten();
957                leg.actual_output_amount = has_output.then_some(output_sum).flatten();
958            }
959        } else {
960            // Event CPI self-invocations have no token transfers and remain visible.
961            unknown.push(RouteUnknownInvocation {
962                position: ix.position,
963                program: ix.program,
964                has_token_transfers: row.has_token_transfers,
965                has_known_swap_descendants: row.has_known_swap_descendants,
966            });
967        }
968    }
969    let transfers = analysis.into_iter().filter_map(|row| row.transfer).collect();
970    TransactionRoute {
971        signature: transaction
972            .signatures
973            .first()
974            .and_then(|b| Signature::try_from(b.as_slice()).ok())
975            .unwrap_or_default(),
976        succeeded,
977        legs,
978        transfers,
979        native_token_actions: invocations
980            .iter()
981            .filter_map(|ix| native_action(ix, &keys, &mints))
982            .collect(),
983        unknown_invocations: unknown,
984    }
985}
986
987pub fn analyze_rpc_transaction_routes(
988    transaction: &EncodedConfirmedTransactionWithStatusMeta,
989    graduated_stonkfun_pools: &[Pubkey],
990) -> Result<TransactionRoute, ParseError> {
991    let (meta, transaction) = convert_rpc_to_grpc(transaction)?;
992    Ok(analyze_yellowstone_transaction_routes(&transaction, &meta, graduated_stonkfun_pools))
993}
994
995#[cfg(test)]
996mod review_regressions {
997    use super::*;
998    use yellowstone_grpc_proto::prelude::{
999        CompiledInstruction, InnerInstruction, InnerInstructions, Message,
1000    };
1001
1002    fn cpmm_with_output(program: Pubkey, fee: Option<u64>) -> (Transaction, TransactionStatusMeta) {
1003        let mut keys: Vec<_> = (0..13).map(|_| Pubkey::new_unique()).collect();
1004        keys.push(crate::instr::program_ids::RAYDIUM_CPMM_PROGRAM_ID);
1005        keys.push(program);
1006        let mut swap_data = crate::instr::raydium_cpmm::discriminators::SWAP_BASE_IN.to_vec();
1007        swap_data.extend_from_slice(&100u64.to_le_bytes());
1008        swap_data.extend_from_slice(&50u64.to_le_bytes());
1009        let mut transfer_data = if fee.is_some() { vec![26, 1] } else { vec![3] };
1010        transfer_data.extend_from_slice(&80u64.to_le_bytes());
1011        if let Some(fee) = fee {
1012            transfer_data.push(6);
1013            transfer_data.extend_from_slice(&fee.to_le_bytes());
1014        }
1015        let tx = Transaction {
1016            message: Some(Message {
1017                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1018                instructions: vec![CompiledInstruction {
1019                    program_id_index: 13,
1020                    accounts: (0..13).collect(),
1021                    data: swap_data,
1022                }],
1023                ..Default::default()
1024            }),
1025            ..Default::default()
1026        };
1027        let meta = TransactionStatusMeta {
1028            inner_instructions: vec![InnerInstructions {
1029                index: 0,
1030                instructions: vec![InnerInstruction {
1031                    program_id_index: 14,
1032                    accounts: if fee.is_some() { vec![7, 11, 5, 1] } else { vec![7, 5, 1] },
1033                    data: transfer_data,
1034                    stack_height: Some(2),
1035                }],
1036            }],
1037            ..Default::default()
1038        };
1039        (tx, meta)
1040    }
1041
1042    #[test]
1043    fn output_credit_requires_known_transfer_fee_and_failure_never_reports_credit() {
1044        let spl = ROUTE_SPL_TOKEN;
1045        let token22 = ROUTE_TOKEN_2022;
1046        for (program, fee, expected) in [
1047            (spl, None, Some(80)),
1048            (token22, None, None),
1049            (token22, Some(3), Some(77)),
1050            (token22, Some(81), None),
1051        ] {
1052            let (tx, mut meta) = cpmm_with_output(program, fee);
1053            let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
1054            assert_eq!(route.legs.len(), 1);
1055            assert_eq!(route.legs[0].actual_output_amount, expected);
1056            assert_eq!(route.transfers[0].amount, 80);
1057            meta.err = Some(yellowstone_grpc_proto::prelude::TransactionError { err: vec![1] });
1058            assert_eq!(
1059                analyze_yellowstone_transaction_routes(&tx, &meta, &[]).legs[0]
1060                    .actual_output_amount,
1061                None
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn amm_remaining_accounts_do_not_shift_user_accounts() {
1068        let keys: Vec<_> = (0..21).map(|_| Pubkey::new_unique()).collect();
1069        let mut data = vec![9];
1070        data.extend_from_slice(&100u64.to_le_bytes());
1071        data.extend_from_slice(&80u64.to_le_bytes());
1072        for count in [17, 18, 20] {
1073            let accounts: Vec<u8> = (0..count).collect();
1074            let ix = Invocation {
1075                position: InstructionPosition {
1076                    outer_index: 0,
1077                    inner_index: None,
1078                    stack_height: Some(1),
1079                },
1080                program: crate::instr::program_ids::RAYDIUM_AMM_V4_PROGRAM_ID,
1081                program_resolved: true,
1082                accounts: &accounts,
1083                data: &data,
1084            };
1085            let leg = swap(&ix, &keys, &HashMap::new(), &[]).unwrap();
1086            let shift = usize::from(count == 17);
1087            assert_eq!(leg.input_account, keys[15 - shift]);
1088            assert_eq!(leg.output_account, keys[16 - shift]);
1089            assert_eq!(leg.trader, keys[17 - shift]);
1090        }
1091    }
1092
1093    #[test]
1094    #[ignore = "manual local timing; excludes transport and ALT resolution"]
1095    fn route_parser_local_timing() {
1096        let (tx, meta) = cpmm_with_output(ROUTE_SPL_TOKEN, None);
1097        for _ in 0..100 {
1098            std::hint::black_box(analyze_yellowstone_transaction_routes(&tx, &meta, &[]));
1099        }
1100        let mut samples = Vec::with_capacity(21);
1101        for _ in 0..21 {
1102            let start = std::time::Instant::now();
1103            for _ in 0..2000 {
1104                std::hint::black_box(analyze_yellowstone_transaction_routes(
1105                    std::hint::black_box(&tx),
1106                    std::hint::black_box(&meta),
1107                    &[],
1108                ));
1109            }
1110            samples.push(start.elapsed().as_nanos() / 2000);
1111        }
1112        samples.sort_unstable();
1113        eprintln!(
1114            "CPMM route (one swap, one transfer): median={} ns/tx, max_batch_mean={} ns/tx",
1115            samples[10], samples[20]
1116        );
1117    }
1118}
1119
1120#[cfg(test)]
1121mod review_missing_mint_regressions {
1122    use super::*;
1123    use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TokenBalance};
1124
1125    #[test]
1126    fn missing_accounts_never_seed_or_propagate_a_mint() {
1127        let keys =
1128            [Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::new_unique(), ROUTE_SPL_TOKEN];
1129        let mint = Pubkey::new_unique();
1130        let mut data = vec![3];
1131        data.extend_from_slice(&10u64.to_le_bytes());
1132        let tx = Transaction {
1133            message: Some(Message {
1134                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1135                instructions: vec![
1136                    CompiledInstruction {
1137                        program_id_index: 3,
1138                        accounts: vec![255, 0, 2],
1139                        data: data.clone(),
1140                    },
1141                    CompiledInstruction { program_id_index: 3, accounts: vec![0, 1, 2], data },
1142                ],
1143                ..Default::default()
1144            }),
1145            ..Default::default()
1146        };
1147        let meta = TransactionStatusMeta {
1148            pre_token_balances: vec![TokenBalance {
1149                account_index: u32::MAX,
1150                mint: mint.to_string(),
1151                ..Default::default()
1152            }],
1153            ..Default::default()
1154        };
1155        assert!(transaction_token_mints(&tx, &meta).is_empty());
1156        let route = analyze_yellowstone_transaction_routes(&tx, &meta, &[]);
1157        assert_eq!(route.transfers.len(), 1);
1158        assert_eq!(route.transfers[0].source, keys[0]);
1159        assert_eq!(route.transfers[0].mint, None);
1160    }
1161}
1162
1163#[cfg(test)]
1164mod review_missing_program_regressions {
1165    use super::*;
1166    use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TokenBalance};
1167
1168    #[test]
1169    fn invalid_program_or_source_index_cannot_masquerade_as_system_funding() {
1170        let keys = [Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::default()];
1171        let mut data = 2u32.to_le_bytes().to_vec();
1172        data.extend_from_slice(&100u64.to_le_bytes());
1173        let mut tx = Transaction {
1174            message: Some(Message {
1175                account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
1176                instructions: vec![CompiledInstruction {
1177                    program_id_index: 255,
1178                    accounts: vec![0, 1],
1179                    data,
1180                }],
1181                ..Default::default()
1182            }),
1183            ..Default::default()
1184        };
1185        let meta = TransactionStatusMeta {
1186            pre_token_balances: vec![TokenBalance {
1187                account_index: 1,
1188                mint: ROUTE_WSOL.to_string(),
1189                ..Default::default()
1190            }],
1191            ..Default::default()
1192        };
1193        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
1194            .native_token_actions
1195            .is_empty());
1196        tx.message.as_mut().unwrap().instructions[0].program_id_index = 2;
1197        assert_eq!(
1198            analyze_yellowstone_transaction_routes(&tx, &meta, &[]).native_token_actions.len(),
1199            1
1200        );
1201        tx.message.as_mut().unwrap().instructions[0].accounts[0] = 255;
1202        assert!(analyze_yellowstone_transaction_routes(&tx, &meta, &[])
1203            .native_token_actions
1204            .is_empty());
1205    }
1206}
1207
1208#[cfg(test)]
1209mod review_route_scope_regressions {
1210    use super::*;
1211    use yellowstone_grpc_proto::prelude::{CompiledInstruction, Message, TokenBalance};
1212
1213    #[test]
1214    fn pumpswap_invalid_optional_bool_is_not_a_known_route_swap() {
1215        let mut keys: Vec<_> = (0..21).map(|_| Pubkey::new_unique()).collect();
1216        keys.push(crate::instr::program_ids::PUMPSWAP_PROGRAM_ID);
1217        for discriminator in [
1218            crate::instr::pump_amm::discriminators::BUY,
1219            crate::instr::pump_amm::discriminators::BUY_EXACT_QUOTE_IN,
1220        ] {
1221            let mut data = discriminator.to_vec();
1222            data.extend_from_slice(&100u64.to_le_bytes());
1223            data.extend_from_slice(&50u64.to_le_bytes());
1224            for flag in [None, Some(0), Some(1), Some(2), Some(255)] {
1225                let mut wire = data.clone();
1226                if let Some(flag) = flag {
1227                    wire.push(flag);
1228                }
1229                let tx = Transaction {
1230                    message: Some(Message {
1231                        account_keys: keys.iter().map(|key| key.to_bytes().to_vec()).collect(),
1232                        instructions: vec![CompiledInstruction {
1233                            program_id_index: 21,
1234                            accounts: (0..21).collect(),
1235                            data: wire,
1236                        }],
1237                        ..Default::default()
1238                    }),
1239                    ..Default::default()
1240                };
1241                let meta = TransactionStatusMeta::default();
1242                let invocations = transaction_invocations(&tx, &meta, &keys);
1243                let mut legs = Vec::new();
1244                let rows =
1245                    invocation_analysis(&invocations, &keys, &HashMap::new(), &[], &mut legs);
1246                assert_eq!(legs.len(), usize::from(!matches!(flag, Some(2 | 255))));
1247                assert_eq!(rows[0].swap_index.is_some(), !matches!(flag, Some(2 | 255)));
1248            }
1249        }
1250    }
1251
1252    #[test]
1253    fn dlmm_route_requires_v2_remaining_account_info() {
1254        let mut keys: Vec<_> = (0..16).map(|_| Pubkey::new_unique()).collect();
1255        keys.push(crate::instr::program_ids::METEORA_DLMM_PROGRAM_ID);
1256        let mut wire = crate::instr::meteora_dlmm::discriminators::SWAP2.to_vec();
1257        wire.extend_from_slice(&100u64.to_le_bytes());
1258        wire.extend_from_slice(&50u64.to_le_bytes());
1259        for tail in [vec![], vec![1, 0, 0, 0, 0, 1], vec![0, 0, 0, 0]] {
1260            let mut data = wire.clone();
1261            data.extend_from_slice(&tail);
1262            let tx = Transaction {
1263                message: Some(Message {
1264                    account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1265                    instructions: vec![CompiledInstruction {
1266                        program_id_index: 16,
1267                        accounts: (0..16).collect(),
1268                        data,
1269                    }],
1270                    ..Default::default()
1271                }),
1272                ..Default::default()
1273            };
1274            let route =
1275                analyze_yellowstone_transaction_routes(&tx, &TransactionStatusMeta::default(), &[]);
1276            assert_eq!(route.legs.len(), usize::from(tail == [0, 0, 0, 0]));
1277            assert_eq!(route.unknown_invocations.len(), usize::from(tail != [0, 0, 0, 0]));
1278        }
1279    }
1280
1281    #[test]
1282    fn conflicting_mint_evidence_is_sticky_and_cannot_spread_to_neighbours() {
1283        let keys = [
1284            Pubkey::new_unique(),
1285            Pubkey::new_unique(),
1286            Pubkey::new_unique(),
1287            Pubkey::new_unique(),
1288            Pubkey::new_unique(),
1289            ROUTE_SPL_TOKEN,
1290        ];
1291        let balance = |index, mint: Pubkey| TokenBalance {
1292            account_index: index,
1293            mint: mint.to_string(),
1294            ..Default::default()
1295        };
1296        let mut plain = vec![3];
1297        plain.extend_from_slice(&10u64.to_le_bytes());
1298        let mut checked = vec![12];
1299        checked.extend_from_slice(&10u64.to_le_bytes());
1300        checked.push(6);
1301        let transaction = |with_checked| {
1302            let mut instructions = vec![];
1303            if with_checked {
1304                instructions.push(CompiledInstruction {
1305                    program_id_index: 5,
1306                    accounts: vec![0, 4, 1, 2],
1307                    data: checked.clone(),
1308                });
1309            }
1310            for accounts in [vec![0, 1, 2], vec![1, 2, 3]] {
1311                instructions.push(CompiledInstruction {
1312                    program_id_index: 5,
1313                    accounts,
1314                    data: plain.clone(),
1315                });
1316            }
1317            Transaction {
1318                message: Some(Message {
1319                    account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1320                    instructions,
1321                    ..Default::default()
1322                }),
1323                ..Default::default()
1324            }
1325        };
1326        for with_checked in [false, true] {
1327            for reverse in [false, true] {
1328                let (before, after) = if reverse { (keys[4], keys[3]) } else { (keys[3], keys[4]) };
1329                let meta = TransactionStatusMeta {
1330                    pre_token_balances: vec![balance(0, before), balance(1, keys[4])],
1331                    post_token_balances: vec![balance(0, after)],
1332                    ..Default::default()
1333                };
1334                let tx = transaction(with_checked);
1335                let mints = transaction_token_mints(&tx, &meta);
1336                assert_eq!(mints.get(&keys[0]), None);
1337                assert_eq!(mints.get(&keys[1]), Some(&keys[4]));
1338                assert_eq!(mints.get(&keys[2]), Some(&keys[4]));
1339                assert_eq!(
1340                    analyze_yellowstone_transaction_routes(&tx, &meta, &[]).transfers[0].mint,
1341                    None
1342                );
1343            }
1344        }
1345        let meta = TransactionStatusMeta {
1346            pre_token_balances: vec![balance(0, keys[3])],
1347            ..Default::default()
1348        };
1349        // A checked transfer cannot silently overwrite an authoritative balance mint.
1350        assert_eq!(transaction_token_mints(&transaction(true), &meta).get(&keys[0]), None);
1351    }
1352
1353    #[test]
1354    fn cached_subtree_boundaries_and_flags_match_exhaustive_scans() {
1355        let keys: Vec<_> = (0..13).map(|_| Pubkey::new_unique()).collect();
1356        let opaque = Pubkey::new_unique();
1357        let mut swap_data = crate::instr::raydium_cpmm::discriminators::SWAP_BASE_IN.to_vec();
1358        swap_data.extend_from_slice(&100u64.to_le_bytes());
1359        swap_data.extend_from_slice(&50u64.to_le_bytes());
1360        let swap_accounts: Vec<u8> = (0..13).collect();
1361        let mut transfer_data = vec![3];
1362        transfer_data.extend_from_slice(&70u64.to_le_bytes());
1363        let transfer_accounts = [4, 5, 0];
1364        let mut state = 0x1234_5678u64;
1365        for seed in 0..128 {
1366            let mut invocations = Vec::new();
1367            for outer in 0..3 {
1368                invocations.push(Invocation {
1369                    position: InstructionPosition {
1370                        outer_index: outer,
1371                        inner_index: None,
1372                        stack_height: Some(1),
1373                    },
1374                    program: opaque,
1375                    program_resolved: true,
1376                    accounts: &[],
1377                    data: &[],
1378                });
1379                for j in 0..24 {
1380                    state ^= state << 13;
1381                    state ^= state >> 7;
1382                    state ^= state << 17;
1383                    let (program, accounts, data): (_, &[u8], &[u8]) = match state % 3 {
1384                        0 => (opaque, &[], &[]),
1385                        1 => (
1386                            crate::instr::program_ids::RAYDIUM_CPMM_PROGRAM_ID,
1387                            &swap_accounts,
1388                            &swap_data,
1389                        ),
1390                        _ => (ROUTE_SPL_TOKEN, &transfer_accounts, &transfer_data),
1391                    };
1392                    invocations.push(Invocation {
1393                        position: InstructionPosition {
1394                            outer_index: outer,
1395                            inner_index: Some(if seed % 7 == 0 { j / 2 } else { j }),
1396                            stack_height: if state % 5 == 0 {
1397                                None
1398                            } else {
1399                                Some(2 + (state % 6) as u32)
1400                            },
1401                        },
1402                        program,
1403                        program_resolved: true,
1404                        accounts,
1405                        data,
1406                    });
1407                }
1408            }
1409            let mints = HashMap::new();
1410            let analysis = invocation_analysis(&invocations, &keys, &mints, &[], &mut Vec::new());
1411            for (i, row) in analysis.iter().enumerate() {
1412                let reference = descendants(i, &invocations);
1413                assert_eq!(row.descendant_end, i + 1 + reference.len());
1414                assert_eq!(
1415                    row.has_token_transfers,
1416                    reference.iter().any(|ix| transfer(ix, &keys, &mints).is_some())
1417                );
1418                assert_eq!(
1419                    row.has_known_swap_descendants,
1420                    reference.iter().any(|ix| swap(ix, &keys, &mints, &[]).is_some())
1421                );
1422            }
1423        }
1424    }
1425}
1426
1427#[cfg(test)]
1428mod review_invocation_join_regressions {
1429    use super::*;
1430    use yellowstone_grpc_proto::prelude::{
1431        CompiledInstruction, InnerInstruction, InnerInstructions, Message,
1432    };
1433    fn exhaustive_invocations<'a>(
1434        transaction: &'a Transaction,
1435        meta: &'a TransactionStatusMeta,
1436        keys: &[Pubkey],
1437    ) -> Vec<Invocation<'a>> {
1438        let mut result = Vec::new();
1439        if let Some(message) = &transaction.message {
1440            for (i, ix) in message.instructions.iter().enumerate() {
1441                result.push(Invocation {
1442                    position: InstructionPosition {
1443                        outer_index: i as u32,
1444                        inner_index: None,
1445                        stack_height: Some(1),
1446                    },
1447                    program: key(keys, ix.program_id_index),
1448                    program_resolved: (ix.program_id_index as usize) < keys.len(),
1449                    accounts: &ix.accounts,
1450                    data: &ix.data,
1451                });
1452                for group in meta.inner_instructions.iter().filter(|g| g.index == i as u32) {
1453                    for (j, ix) in group.instructions.iter().enumerate() {
1454                        result.push(Invocation {
1455                            position: InstructionPosition {
1456                                outer_index: i as u32,
1457                                inner_index: Some(j as u32),
1458                                stack_height: ix.stack_height,
1459                            },
1460                            program: key(keys, ix.program_id_index),
1461                            program_resolved: (ix.program_id_index as usize) < keys.len(),
1462                            accounts: &ix.accounts,
1463                            data: &ix.data,
1464                        });
1465                    }
1466                }
1467            }
1468        }
1469        result
1470    }
1471
1472    #[test]
1473    fn ordered_join_preserves_unsorted_duplicate_and_orphan_group_semantics() {
1474        let keys = [Pubkey::new_unique(), Pubkey::new_unique(), Pubkey::new_unique()];
1475        let tx = Transaction {
1476            message: Some(Message {
1477                account_keys: keys.iter().map(|k| k.to_bytes().to_vec()).collect(),
1478                instructions: (0..3)
1479                    .map(|i| CompiledInstruction {
1480                        program_id_index: i,
1481                        data: vec![i as u8],
1482                        accounts: vec![0, 1],
1483                    })
1484                    .collect(),
1485                ..Default::default()
1486            }),
1487            ..Default::default()
1488        };
1489        let indices = [2, 0, u32::MAX, 1, 0, 2];
1490        let mut groups: Vec<_> = indices
1491            .into_iter()
1492            .enumerate()
1493            .map(|(i, index)| InnerInstructions {
1494                index,
1495                instructions: vec![InnerInstruction {
1496                    program_id_index: if i % 2 == 0 { 1 } else { 255 },
1497                    accounts: vec![i as u8],
1498                    data: vec![i as u8],
1499                    stack_height: if i % 3 == 0 { None } else { Some(2) },
1500                }],
1501            })
1502            .collect();
1503        for permutation in 0..12 {
1504            if permutation % 2 == 0 {
1505                groups.rotate_left(1);
1506            } else {
1507                groups.reverse();
1508            }
1509            let meta =
1510                TransactionStatusMeta { inner_instructions: groups.clone(), ..Default::default() };
1511            let expected = exhaustive_invocations(&tx, &meta, &keys);
1512            let actual = transaction_invocations(&tx, &meta, &keys);
1513            assert_eq!(actual.len(), 8);
1514            assert_eq!(actual.len(), expected.len());
1515            for (actual, expected) in actual.iter().zip(expected.iter()) {
1516                assert_eq!(actual.position, expected.position);
1517                assert_eq!(actual.program, expected.program);
1518                assert_eq!(actual.program_resolved, expected.program_resolved);
1519                assert_eq!(actual.accounts, expected.accounts);
1520                assert_eq!(actual.data, expected.data);
1521            }
1522        }
1523        groups.sort_by_key(|g| g.index);
1524        let meta = TransactionStatusMeta { inner_instructions: groups, ..Default::default() };
1525        let expected = exhaustive_invocations(&tx, &meta, &keys);
1526        let actual = transaction_invocations(&tx, &meta, &keys);
1527        assert_eq!(
1528            actual.iter().map(|ix| (ix.position, ix.data)).collect::<Vec<_>>(),
1529            expected.iter().map(|ix| (ix.position, ix.data)).collect::<Vec<_>>()
1530        );
1531        assert!(transaction_invocations(&Transaction::default(), &meta, &keys).is_empty());
1532    }
1533}