Skip to main content

txgate_chain/
solana.rs

1//! Solana transaction parser.
2//!
3//! This module provides the [`SolanaParser`] implementation for parsing
4//! Solana transactions into the common [`ParsedTx`] format.
5//!
6//! # Supported Message Types
7//!
8//! - **Legacy**: Original Solana message format
9//! - **Versioned (V0)**: New message format with address lookup tables
10//!
11//! # Supported Instructions
12//!
13//! - **System Program**: SOL transfers
14//! - **SPL Token**: Token transfers, approvals
15//! - **Token-2022**: Extended token operations
16//!
17//! # Example
18//!
19//! ```ignore
20//! use txgate_chain::{Chain, SolanaParser};
21//!
22//! let parser = SolanaParser::new();
23//!
24//! // Parse a raw Solana transaction
25//! let raw_tx = base64::decode("...").unwrap();
26//! let parsed = parser.parse(&raw_tx)?;
27//!
28//! println!("Fee payer: {:?}", parsed.recipient);
29//! println!("Amount: {:?}", parsed.amount);
30//! ```
31
32use solana_sdk::message::VersionedMessage;
33use solana_sdk::pubkey::Pubkey;
34use solana_sdk::transaction::VersionedTransaction;
35use std::collections::HashMap;
36use std::str::FromStr;
37use txgate_core::error::ParseError;
38use txgate_core::{ParsedTx, TxType, U256};
39use txgate_crypto::CurveType;
40
41use crate::Chain;
42
43/// System Program ID
44const SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111";
45
46/// SPL Token Program ID
47const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
48
49/// Token-2022 Program ID
50const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
51
52/// Solana transaction parser.
53///
54/// Parses raw Solana transactions into the common [`ParsedTx`] format
55/// for policy evaluation.
56///
57/// # Thread Safety
58///
59/// `SolanaParser` is `Send + Sync` and can be safely shared across threads.
60#[derive(Debug, Clone, Copy, Default)]
61pub struct SolanaParser;
62
63impl SolanaParser {
64    /// Create a new Solana parser.
65    #[must_use]
66    pub const fn new() -> Self {
67        Self
68    }
69
70    /// Parse a SOL transfer instruction.
71    fn parse_system_transfer(data: &[u8], accounts: &[Pubkey]) -> Option<(String, u64)> {
72        // System transfer instruction layout:
73        // - 4 bytes: instruction type (2 = transfer)
74        // - 8 bytes: lamports (u64, little-endian)
75        if data.len() < 12 {
76            return None;
77        }
78
79        let instruction_type =
80            u32::from_le_bytes([*data.first()?, *data.get(1)?, *data.get(2)?, *data.get(3)?]);
81
82        // 2 = Transfer instruction
83        if instruction_type != 2 {
84            return None;
85        }
86
87        let lamports = u64::from_le_bytes([
88            *data.get(4)?,
89            *data.get(5)?,
90            *data.get(6)?,
91            *data.get(7)?,
92            *data.get(8)?,
93            *data.get(9)?,
94            *data.get(10)?,
95            *data.get(11)?,
96        ]);
97
98        // accounts[0] = source, accounts[1] = destination
99        let destination = accounts.get(1)?;
100        Some((destination.to_string(), lamports))
101    }
102
103    /// Check if instruction is a token transfer.
104    fn is_token_instruction(program_id: &Pubkey) -> bool {
105        let program_str = program_id.to_string();
106        program_str == TOKEN_PROGRAM_ID || program_str == TOKEN_2022_PROGRAM_ID
107    }
108
109    /// Parse a token transfer instruction.
110    fn parse_token_transfer(data: &[u8], accounts: &[Pubkey]) -> Option<(String, u64, bool)> {
111        if data.is_empty() {
112            return None;
113        }
114
115        let instruction_type = *data.first()?;
116
117        match instruction_type {
118            // Transfer (instruction 3)
119            3 => {
120                if data.len() < 9 {
121                    return None;
122                }
123                let amount = u64::from_le_bytes([
124                    *data.get(1)?,
125                    *data.get(2)?,
126                    *data.get(3)?,
127                    *data.get(4)?,
128                    *data.get(5)?,
129                    *data.get(6)?,
130                    *data.get(7)?,
131                    *data.get(8)?,
132                ]);
133                // accounts: [source, destination, owner]
134                let destination = accounts.get(1)?;
135                Some((destination.to_string(), amount, false))
136            }
137            // TransferChecked (instruction 12)
138            12 => {
139                if data.len() < 10 {
140                    return None;
141                }
142                let amount = u64::from_le_bytes([
143                    *data.get(1)?,
144                    *data.get(2)?,
145                    *data.get(3)?,
146                    *data.get(4)?,
147                    *data.get(5)?,
148                    *data.get(6)?,
149                    *data.get(7)?,
150                    *data.get(8)?,
151                ]);
152                // accounts: [source, mint, destination, owner]
153                let destination = accounts.get(2)?;
154                Some((destination.to_string(), amount, true))
155            }
156            _ => None,
157        }
158    }
159
160    /// Determine transaction type from instructions.
161    /// Reserved for future use with legacy message analysis.
162    #[allow(dead_code)]
163    fn _determine_tx_type(message: &solana_sdk::message::Message) -> TxType {
164        let system_program = Pubkey::from_str(SYSTEM_PROGRAM_ID).ok();
165        let token_program = Pubkey::from_str(TOKEN_PROGRAM_ID).ok();
166        let token_2022_program = Pubkey::from_str(TOKEN_2022_PROGRAM_ID).ok();
167
168        for instruction in &message.instructions {
169            let program_idx = instruction.program_id_index as usize;
170            let program_id = message.account_keys.get(program_idx);
171
172            if let Some(program_id) = program_id {
173                // Check for SOL transfer
174                if system_program.as_ref() == Some(program_id) && !instruction.data.is_empty() {
175                    let instr_type = u32::from_le_bytes([
176                        instruction.data.first().copied().unwrap_or(0),
177                        instruction.data.get(1).copied().unwrap_or(0),
178                        instruction.data.get(2).copied().unwrap_or(0),
179                        instruction.data.get(3).copied().unwrap_or(0),
180                    ]);
181                    if instr_type == 2 {
182                        return TxType::Transfer;
183                    }
184                }
185
186                // Check for token transfer
187                if (token_program.as_ref() == Some(program_id)
188                    || token_2022_program.as_ref() == Some(program_id))
189                    && !instruction.data.is_empty()
190                {
191                    let instr_type = instruction.data.first().copied().unwrap_or(0);
192                    if instr_type == 3 || instr_type == 12 {
193                        return TxType::TokenTransfer;
194                    }
195                    if instr_type == 4 {
196                        return TxType::TokenApproval;
197                    }
198                }
199            }
200        }
201
202        // Default to contract call if we can't determine the type
203        TxType::ContractCall
204    }
205
206    /// Extract account keys based on message type.
207    fn get_account_keys(message: &VersionedMessage) -> &[Pubkey] {
208        match message {
209            VersionedMessage::Legacy(msg) => &msg.account_keys,
210            VersionedMessage::V0(msg) => &msg.account_keys,
211        }
212    }
213
214    /// Get instructions from message.
215    fn get_instructions(message: &VersionedMessage) -> Vec<(Pubkey, Vec<Pubkey>, Vec<u8>)> {
216        let account_keys = Self::get_account_keys(message);
217
218        let instructions = match message {
219            VersionedMessage::Legacy(msg) => &msg.instructions,
220            VersionedMessage::V0(msg) => &msg.instructions,
221        };
222
223        instructions
224            .iter()
225            .filter_map(|instr| {
226                let program_id = account_keys.get(instr.program_id_index as usize)?;
227                let accounts: Vec<Pubkey> = instr
228                    .accounts
229                    .iter()
230                    .filter_map(|&idx| account_keys.get(idx as usize).copied())
231                    .collect();
232                Some((*program_id, accounts, instr.data.clone()))
233            })
234            .collect()
235    }
236}
237
238impl Chain for SolanaParser {
239    fn id(&self) -> &'static str {
240        "solana"
241    }
242
243    fn parse(&self, raw: &[u8]) -> Result<ParsedTx, ParseError> {
244        if raw.is_empty() {
245            return Err(ParseError::MalformedTransaction {
246                context: "empty transaction data".to_string(),
247            });
248        }
249
250        // Try to decode as VersionedTransaction
251        let tx: VersionedTransaction =
252            bincode::deserialize(raw).map_err(|e| ParseError::MalformedTransaction {
253                context: format!("failed to decode Solana transaction: {e}"),
254            })?;
255
256        // Get the message hash for signing
257        let message_bytes = tx.message.serialize();
258        let message_hash = solana_sdk::hash::hash(&message_bytes);
259        let mut hash = [0u8; 32];
260        hash.copy_from_slice(message_hash.as_ref());
261
262        // Get account keys
263        let account_keys = Self::get_account_keys(&tx.message);
264
265        // Fee payer is always the first account
266        let fee_payer = account_keys.first().map(Pubkey::to_string);
267
268        // Get recent blockhash
269        let recent_blockhash = match &tx.message {
270            VersionedMessage::Legacy(msg) => msg.recent_blockhash,
271            VersionedMessage::V0(msg) => msg.recent_blockhash,
272        };
273
274        // Parse instructions to find transfers
275        let instructions = Self::get_instructions(&tx.message);
276
277        let mut recipient = None;
278        let mut amount: Option<u64> = None;
279        let mut token_address = None;
280        let mut tx_type = TxType::ContractCall;
281
282        let system_program = Pubkey::from_str(SYSTEM_PROGRAM_ID).ok();
283
284        for (program_id, accounts, data) in &instructions {
285            // Check for SOL transfer
286            if system_program.as_ref() == Some(program_id) {
287                if let Some((dest, lamports)) = Self::parse_system_transfer(data, accounts) {
288                    recipient = Some(dest);
289                    amount = Some(lamports);
290                    tx_type = TxType::Transfer;
291                    break;
292                }
293            }
294
295            // Check for token transfer
296            if Self::is_token_instruction(program_id) {
297                if let Some((dest, amt, _checked)) = Self::parse_token_transfer(data, accounts) {
298                    recipient = Some(dest);
299                    amount = Some(amt);
300                    token_address = Some(program_id.to_string());
301                    tx_type = TxType::TokenTransfer;
302                    break;
303                }
304            }
305        }
306
307        // Build metadata
308        let mut metadata = HashMap::new();
309
310        // Add fee payer
311        if let Some(ref payer) = fee_payer {
312            metadata.insert(
313                "fee_payer".to_string(),
314                serde_json::Value::String(payer.clone()),
315            );
316        }
317
318        // Add recent blockhash
319        metadata.insert(
320            "recent_blockhash".to_string(),
321            serde_json::Value::String(recent_blockhash.to_string()),
322        );
323
324        // Add signature count
325        metadata.insert(
326            "signature_count".to_string(),
327            serde_json::Value::Number(tx.signatures.len().into()),
328        );
329
330        // Add instruction count
331        metadata.insert(
332            "instruction_count".to_string(),
333            serde_json::Value::Number(instructions.len().into()),
334        );
335
336        // Add message version
337        let version = match &tx.message {
338            VersionedMessage::Legacy(_) => "legacy",
339            VersionedMessage::V0(_) => "v0",
340        };
341        metadata.insert(
342            "message_version".to_string(),
343            serde_json::Value::String(version.to_string()),
344        );
345
346        // Add all involved programs
347        let programs: Vec<serde_json::Value> = instructions
348            .iter()
349            .map(|(program_id, _, _)| serde_json::Value::String(program_id.to_string()))
350            .collect();
351        metadata.insert("programs".to_string(), serde_json::Value::Array(programs));
352
353        Ok(ParsedTx {
354            hash,
355            recipient,
356            amount: amount.map(U256::from),
357            token: if token_address.is_some() {
358                None // Token symbol requires on-chain lookup
359            } else {
360                Some("SOL".to_string())
361            },
362            token_address,
363            tx_type,
364            chain: "solana".to_string(),
365            nonce: None, // Solana uses recent blockhash instead of nonces
366            chain_id: None,
367            metadata,
368        })
369    }
370
371    fn curve(&self) -> CurveType {
372        CurveType::Ed25519
373    }
374
375    fn supports_version(&self, version: u8) -> bool {
376        // Version 0 = legacy, 128+ = versioned (V0, etc.)
377        version == 0 || version >= 128
378    }
379}
380
381// ============================================================================
382// Tests
383// ============================================================================
384
385#[cfg(test)]
386mod tests {
387    #![allow(
388        clippy::expect_used,
389        clippy::unwrap_used,
390        clippy::panic,
391        clippy::indexing_slicing
392    )]
393
394    use super::*;
395
396    #[test]
397    fn test_solana_parser_id() {
398        let parser = SolanaParser::new();
399        assert_eq!(parser.id(), "solana");
400    }
401
402    #[test]
403    fn test_solana_parser_curve() {
404        let parser = SolanaParser::new();
405        assert_eq!(parser.curve(), CurveType::Ed25519);
406    }
407
408    #[test]
409    fn test_solana_parser_default() {
410        let parser = SolanaParser::default();
411        assert_eq!(parser.id(), "solana");
412    }
413
414    #[test]
415    fn test_solana_parser_empty_input() {
416        let parser = SolanaParser::new();
417        let result = parser.parse(&[]);
418
419        assert!(result.is_err());
420        assert!(matches!(
421            result,
422            Err(ParseError::MalformedTransaction { .. })
423        ));
424    }
425
426    #[test]
427    fn test_solana_parser_invalid_input() {
428        let parser = SolanaParser::new();
429        let result = parser.parse(&[0x00, 0x01, 0x02]);
430
431        assert!(result.is_err());
432        assert!(matches!(
433            result,
434            Err(ParseError::MalformedTransaction { .. })
435        ));
436    }
437
438    #[test]
439    fn test_solana_parser_supports_version() {
440        let parser = SolanaParser::new();
441
442        assert!(parser.supports_version(0)); // Legacy
443        assert!(parser.supports_version(128)); // V0
444        assert!(parser.supports_version(129)); // Future versions
445        assert!(!parser.supports_version(1)); // Invalid
446        assert!(!parser.supports_version(127)); // Invalid
447    }
448
449    #[test]
450    fn test_solana_parser_is_send_sync() {
451        fn assert_send_sync<T: Send + Sync>() {}
452        assert_send_sync::<SolanaParser>();
453    }
454
455    #[test]
456    fn test_solana_parser_clone() {
457        let parser = SolanaParser::new();
458        let cloned = parser;
459        assert_eq!(parser.id(), cloned.id());
460    }
461
462    #[test]
463    fn test_solana_parser_debug() {
464        let parser = SolanaParser::new();
465        let debug_str = format!("{parser:?}");
466        assert!(debug_str.contains("SolanaParser"));
467    }
468
469    // -------------------------------------------------------------------------
470    // Real Transaction Parsing Tests
471    // -------------------------------------------------------------------------
472
473    use solana_sdk::hash::Hash;
474    use solana_sdk::instruction::{AccountMeta, Instruction};
475    use solana_sdk::signature::Keypair;
476    use solana_sdk::signer::Signer;
477    use solana_sdk::system_instruction;
478    use solana_sdk::transaction::Transaction;
479
480    /// Create a valid SOL transfer transaction for testing.
481    fn create_sol_transfer_tx(lamports: u64) -> Vec<u8> {
482        let from = Keypair::new();
483        let to = Pubkey::new_unique();
484        let recent_blockhash = Hash::new_unique();
485
486        let instruction = system_instruction::transfer(&from.pubkey(), &to, lamports);
487        let tx = Transaction::new_signed_with_payer(
488            &[instruction],
489            Some(&from.pubkey()),
490            &[&from],
491            recent_blockhash,
492        );
493
494        bincode::serialize(&tx).expect("failed to serialize transaction")
495    }
496
497    #[test]
498    fn test_parse_sol_transfer() {
499        let parser = SolanaParser::new();
500        let lamports = 1_000_000_000u64; // 1 SOL
501        let tx_bytes = create_sol_transfer_tx(lamports);
502
503        let result = parser.parse(&tx_bytes);
504        assert!(result.is_ok(), "Failed to parse SOL transfer: {:?}", result);
505
506        let parsed = result.unwrap();
507        assert_eq!(parsed.chain, "solana");
508        assert!(matches!(parsed.tx_type, TxType::Transfer));
509        assert!(parsed.recipient.is_some());
510        assert!(parsed.amount.is_some());
511        assert_eq!(parsed.amount.unwrap(), U256::from(lamports));
512        assert_eq!(parsed.token, Some("SOL".to_string()));
513    }
514
515    #[test]
516    fn test_parse_sol_transfer_small_amount() {
517        let parser = SolanaParser::new();
518        let lamports = 1u64; // 1 lamport
519        let tx_bytes = create_sol_transfer_tx(lamports);
520
521        let result = parser.parse(&tx_bytes);
522        assert!(result.is_ok());
523
524        let parsed = result.unwrap();
525        assert_eq!(parsed.amount.unwrap(), U256::from(1u64));
526    }
527
528    #[test]
529    fn test_parse_sol_transfer_large_amount() {
530        let parser = SolanaParser::new();
531        let lamports = u64::MAX; // Max lamports
532        let tx_bytes = create_sol_transfer_tx(lamports);
533
534        let result = parser.parse(&tx_bytes);
535        assert!(result.is_ok());
536
537        let parsed = result.unwrap();
538        assert_eq!(parsed.amount.unwrap(), U256::from(u64::MAX));
539    }
540
541    #[test]
542    fn test_parse_transaction_metadata() {
543        let parser = SolanaParser::new();
544        let tx_bytes = create_sol_transfer_tx(1_000_000);
545
546        let result = parser.parse(&tx_bytes);
547        assert!(result.is_ok());
548
549        let parsed = result.unwrap();
550        // Check metadata fields
551        assert!(parsed.metadata.contains_key("fee_payer"));
552        assert!(parsed.metadata.contains_key("recent_blockhash"));
553        assert!(parsed.metadata.contains_key("signature_count"));
554        assert!(parsed.metadata.contains_key("instruction_count"));
555    }
556
557    #[test]
558    fn test_parse_transaction_hash() {
559        let parser = SolanaParser::new();
560        let tx_bytes = create_sol_transfer_tx(1_000_000);
561
562        let result = parser.parse(&tx_bytes);
563        assert!(result.is_ok());
564
565        let parsed = result.unwrap();
566        // Hash should be non-zero
567        assert!(!parsed.hash.iter().all(|&b| b == 0));
568    }
569
570    /// Create a token transfer instruction for testing.
571    /// The owner must be passed in so it can match the signer.
572    fn create_token_transfer_instruction(amount: u64, owner: &Pubkey) -> Instruction {
573        let token_program = Pubkey::from_str(TOKEN_PROGRAM_ID).unwrap();
574        let source = Pubkey::new_unique();
575        let destination = Pubkey::new_unique();
576
577        // Token Transfer instruction: type 3 + amount (little-endian u64)
578        let mut data = vec![3u8]; // instruction type
579        data.extend_from_slice(&amount.to_le_bytes());
580
581        Instruction {
582            program_id: token_program,
583            accounts: vec![
584                AccountMeta::new(source, false),
585                AccountMeta::new(destination, false),
586                AccountMeta::new_readonly(*owner, true),
587            ],
588            data,
589        }
590    }
591
592    #[test]
593    fn test_parse_token_transfer() {
594        let parser = SolanaParser::new();
595
596        let from = Keypair::new();
597        let recent_blockhash = Hash::new_unique();
598        let token_amount = 1_000_000u64;
599
600        // Use from.pubkey() as the owner so we only need one signer
601        let instruction = create_token_transfer_instruction(token_amount, &from.pubkey());
602        let tx = Transaction::new_signed_with_payer(
603            &[instruction],
604            Some(&from.pubkey()),
605            &[&from],
606            recent_blockhash,
607        );
608
609        let tx_bytes = bincode::serialize(&tx).expect("failed to serialize");
610        let result = parser.parse(&tx_bytes);
611        assert!(
612            result.is_ok(),
613            "Failed to parse token transfer: {:?}",
614            result
615        );
616
617        let parsed = result.unwrap();
618        assert_eq!(parsed.chain, "solana");
619        assert!(matches!(parsed.tx_type, TxType::TokenTransfer));
620        assert!(parsed.recipient.is_some());
621        assert!(parsed.amount.is_some());
622        assert_eq!(parsed.amount.unwrap(), U256::from(token_amount));
623    }
624
625    /// Create a TransferChecked instruction for testing.
626    /// The owner must be passed in so it can match the signer.
627    fn create_transfer_checked_instruction(
628        amount: u64,
629        decimals: u8,
630        owner: &Pubkey,
631    ) -> Instruction {
632        let token_program = Pubkey::from_str(TOKEN_PROGRAM_ID).unwrap();
633        let source = Pubkey::new_unique();
634        let mint = Pubkey::new_unique();
635        let destination = Pubkey::new_unique();
636
637        // TransferChecked instruction: type 12 + amount (u64) + decimals (u8)
638        let mut data = vec![12u8]; // instruction type
639        data.extend_from_slice(&amount.to_le_bytes());
640        data.push(decimals);
641
642        Instruction {
643            program_id: token_program,
644            accounts: vec![
645                AccountMeta::new(source, false),
646                AccountMeta::new_readonly(mint, false),
647                AccountMeta::new(destination, false),
648                AccountMeta::new_readonly(*owner, true),
649            ],
650            data,
651        }
652    }
653
654    #[test]
655    fn test_parse_transfer_checked() {
656        let parser = SolanaParser::new();
657
658        let from = Keypair::new();
659        let recent_blockhash = Hash::new_unique();
660        let token_amount = 500_000u64;
661        let decimals = 6u8;
662
663        // Use from.pubkey() as the owner so we only need one signer
664        let instruction =
665            create_transfer_checked_instruction(token_amount, decimals, &from.pubkey());
666        let tx = Transaction::new_signed_with_payer(
667            &[instruction],
668            Some(&from.pubkey()),
669            &[&from],
670            recent_blockhash,
671        );
672
673        let tx_bytes = bincode::serialize(&tx).expect("failed to serialize");
674        let result = parser.parse(&tx_bytes);
675        assert!(
676            result.is_ok(),
677            "Failed to parse TransferChecked: {:?}",
678            result
679        );
680
681        let parsed = result.unwrap();
682        assert!(matches!(parsed.tx_type, TxType::TokenTransfer));
683        assert_eq!(parsed.amount.unwrap(), U256::from(token_amount));
684    }
685
686    #[test]
687    fn test_parse_system_transfer_helper() {
688        // Test the parse_system_transfer helper function directly
689        let lamports = 5_000_000_000u64;
690
691        // Build instruction data: 4 bytes type (2 = transfer) + 8 bytes amount
692        let mut data = vec![2u8, 0, 0, 0]; // instruction type 2 in little-endian
693        data.extend_from_slice(&lamports.to_le_bytes());
694
695        let source = Pubkey::new_unique();
696        let destination = Pubkey::new_unique();
697        let accounts = vec![source, destination];
698
699        let result = SolanaParser::parse_system_transfer(&data, &accounts);
700        assert!(result.is_some());
701
702        let (dest, amount) = result.unwrap();
703        assert_eq!(dest, destination.to_string());
704        assert_eq!(amount, lamports);
705    }
706
707    #[test]
708    fn test_parse_system_transfer_wrong_instruction_type() {
709        // instruction type 1 (CreateAccount) should return None
710        let mut data = vec![1u8, 0, 0, 0];
711        data.extend_from_slice(&1000u64.to_le_bytes());
712
713        let accounts = vec![Pubkey::new_unique(), Pubkey::new_unique()];
714
715        let result = SolanaParser::parse_system_transfer(&data, &accounts);
716        assert!(result.is_none());
717    }
718
719    #[test]
720    fn test_parse_system_transfer_insufficient_data() {
721        // Less than 12 bytes should return None
722        let data = vec![2u8, 0, 0, 0, 0, 0, 0, 0]; // Only 8 bytes
723
724        let accounts = vec![Pubkey::new_unique(), Pubkey::new_unique()];
725
726        let result = SolanaParser::parse_system_transfer(&data, &accounts);
727        assert!(result.is_none());
728    }
729
730    #[test]
731    fn test_parse_token_transfer_helper() {
732        let amount = 1_000_000u64;
733
734        // Transfer instruction: type 3 + amount
735        let mut data = vec![3u8];
736        data.extend_from_slice(&amount.to_le_bytes());
737
738        let accounts = vec![
739            Pubkey::new_unique(), // source
740            Pubkey::new_unique(), // destination
741            Pubkey::new_unique(), // owner
742        ];
743
744        let result = SolanaParser::parse_token_transfer(&data, &accounts);
745        assert!(result.is_some());
746
747        let (dest, parsed_amount, is_checked) = result.unwrap();
748        assert_eq!(dest, accounts[1].to_string());
749        assert_eq!(parsed_amount, amount);
750        assert!(!is_checked);
751    }
752
753    #[test]
754    fn test_parse_token_transfer_checked_helper() {
755        let amount = 2_000_000u64;
756
757        // TransferChecked instruction: type 12 + amount + decimals
758        let mut data = vec![12u8];
759        data.extend_from_slice(&amount.to_le_bytes());
760        data.push(9u8); // decimals
761
762        let accounts = vec![
763            Pubkey::new_unique(), // source
764            Pubkey::new_unique(), // mint
765            Pubkey::new_unique(), // destination
766            Pubkey::new_unique(), // owner
767        ];
768
769        let result = SolanaParser::parse_token_transfer(&data, &accounts);
770        assert!(result.is_some());
771
772        let (dest, parsed_amount, is_checked) = result.unwrap();
773        assert_eq!(dest, accounts[2].to_string()); // destination is at index 2
774        assert_eq!(parsed_amount, amount);
775        assert!(is_checked);
776    }
777
778    #[test]
779    fn test_parse_token_transfer_unknown_instruction() {
780        // Unknown instruction type should return None
781        let data = vec![99u8, 0, 0, 0, 0, 0, 0, 0, 0];
782        let accounts = vec![Pubkey::new_unique(), Pubkey::new_unique()];
783
784        let result = SolanaParser::parse_token_transfer(&data, &accounts);
785        assert!(result.is_none());
786    }
787
788    #[test]
789    fn test_is_token_instruction() {
790        let token_program = Pubkey::from_str(TOKEN_PROGRAM_ID).unwrap();
791        let token_2022_program = Pubkey::from_str(TOKEN_2022_PROGRAM_ID).unwrap();
792        let system_program = Pubkey::from_str(SYSTEM_PROGRAM_ID).unwrap();
793        let random_program = Pubkey::new_unique();
794
795        assert!(SolanaParser::is_token_instruction(&token_program));
796        assert!(SolanaParser::is_token_instruction(&token_2022_program));
797        assert!(!SolanaParser::is_token_instruction(&system_program));
798        assert!(!SolanaParser::is_token_instruction(&random_program));
799    }
800}