Skip to main content

kora_lib/token/
token.rs

1use crate::{
2    constant,
3    error::KoraError,
4    oracle::{get_price_oracle, PriceSource, RetryingPriceOracle, TokenPrice},
5    token::{
6        interface::TokenMint,
7        spl_token::TokenProgram,
8        spl_token_2022::{Token2022Account, Token2022Extensions, Token2022Mint, Token2022Program},
9        TokenInterface,
10    },
11    transaction::{
12        ParsedSPLInstructionData, ParsedSPLInstructionType, VersionedTransactionResolved,
13    },
14    CacheUtil,
15};
16use rust_decimal::{
17    prelude::{FromPrimitive, ToPrimitive},
18    Decimal,
19};
20use solana_client::nonblocking::rpc_client::RpcClient;
21use solana_sdk::{instruction::Instruction, native_token::LAMPORTS_PER_SOL, pubkey::Pubkey};
22use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id;
23use std::{collections::HashMap, str::FromStr, time::Duration};
24
25#[cfg(not(test))]
26use crate::state::get_config;
27
28#[cfg(test)]
29use {crate::tests::config_mock::mock_state::get_config, rust_decimal_macros::dec};
30
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum TokenType {
33    Spl,
34    Token2022,
35}
36
37impl TokenType {
38    pub fn get_token_program_from_owner(
39        owner: &Pubkey,
40    ) -> Result<Box<dyn TokenInterface>, KoraError> {
41        if *owner == spl_token_interface::id() {
42            Ok(Box::new(TokenProgram::new()))
43        } else if *owner == spl_token_2022_interface::id() {
44            Ok(Box::new(Token2022Program::new()))
45        } else {
46            Err(KoraError::TokenOperationError(format!("Invalid token program owner: {owner}")))
47        }
48    }
49
50    pub fn get_token_program(&self) -> Box<dyn TokenInterface> {
51        match self {
52            TokenType::Spl => Box::new(TokenProgram::new()),
53            TokenType::Token2022 => Box::new(Token2022Program::new()),
54        }
55    }
56}
57
58pub struct TokenUtil;
59
60impl TokenUtil {
61    pub fn check_valid_tokens(tokens: &[String]) -> Result<Vec<Pubkey>, KoraError> {
62        tokens
63            .iter()
64            .map(|token| {
65                Pubkey::from_str(token).map_err(|_| {
66                    KoraError::ValidationError(format!("Invalid token address: {token}"))
67                })
68            })
69            .collect()
70    }
71
72    /// Check if the transaction contains an ATA creation instruction for the given destination address.
73    /// Supports both CreateAssociatedTokenAccount and CreateAssociatedTokenAccountIdempotent instructions.
74    /// Returns Some((wallet_owner, mint)) if found, None otherwise.
75    pub fn find_ata_creation_for_destination(
76        instructions: &[Instruction],
77        destination_address: &Pubkey,
78    ) -> Option<(Pubkey, Pubkey)> {
79        let ata_program_id = spl_associated_token_account_interface::program::id();
80
81        for ix in instructions {
82            if ix.program_id == ata_program_id
83                && ix.accounts.len()
84                    >= constant::instruction_indexes::ata_instruction_indexes::MIN_ACCOUNTS
85            {
86                let ata_address = ix.accounts
87                    [constant::instruction_indexes::ata_instruction_indexes::ATA_ADDRESS_INDEX]
88                    .pubkey;
89                if ata_address == *destination_address {
90                    let wallet_owner =
91                        ix.accounts[constant::instruction_indexes::ata_instruction_indexes::WALLET_OWNER_INDEX].pubkey;
92                    let mint = ix.accounts
93                        [constant::instruction_indexes::ata_instruction_indexes::MINT_INDEX]
94                        .pubkey;
95                    return Some((wallet_owner, mint));
96                }
97            }
98        }
99        None
100    }
101
102    pub async fn get_mint(
103        rpc_client: &RpcClient,
104        mint_pubkey: &Pubkey,
105    ) -> Result<Box<dyn TokenMint + Send + Sync>, KoraError> {
106        let mint_account = CacheUtil::get_account(rpc_client, mint_pubkey, false).await?;
107
108        let token_program = TokenType::get_token_program_from_owner(&mint_account.owner)?;
109
110        token_program
111            .unpack_mint(mint_pubkey, &mint_account.data)
112            .map_err(|e| KoraError::TokenOperationError(format!("Failed to unpack mint: {e}")))
113    }
114
115    pub async fn get_mint_decimals(
116        rpc_client: &RpcClient,
117        mint_pubkey: &Pubkey,
118    ) -> Result<u8, KoraError> {
119        let mint = Self::get_mint(rpc_client, mint_pubkey).await?;
120        Ok(mint.decimals())
121    }
122
123    pub async fn get_token_price_and_decimals(
124        mint: &Pubkey,
125        price_source: PriceSource,
126        rpc_client: &RpcClient,
127    ) -> Result<(TokenPrice, u8), KoraError> {
128        let decimals = Self::get_mint_decimals(rpc_client, mint).await?;
129
130        let oracle =
131            RetryingPriceOracle::new(3, Duration::from_secs(1), get_price_oracle(price_source)?);
132
133        // Get token price in SOL directly
134        let token_price = oracle
135            .get_token_price(&mint.to_string())
136            .await
137            .map_err(|e| KoraError::RpcError(format!("Failed to fetch token price: {e}")))?;
138
139        Ok((token_price, decimals))
140    }
141
142    pub async fn calculate_token_value_in_lamports(
143        amount: u64,
144        mint: &Pubkey,
145        price_source: PriceSource,
146        rpc_client: &RpcClient,
147    ) -> Result<u64, KoraError> {
148        let (token_price, decimals) =
149            Self::get_token_price_and_decimals(mint, price_source, rpc_client).await?;
150
151        // Convert amount to Decimal with proper scaling
152        let amount_decimal = Decimal::from_u64(amount)
153            .ok_or_else(|| KoraError::ValidationError("Invalid token amount".to_string()))?;
154        let decimals_scale = Decimal::from_u64(10u64.pow(decimals as u32))
155            .ok_or_else(|| KoraError::ValidationError("Invalid decimals".to_string()))?;
156        let lamports_per_sol = Decimal::from_u64(LAMPORTS_PER_SOL)
157            .ok_or_else(|| KoraError::ValidationError("Invalid LAMPORTS_PER_SOL".to_string()))?;
158
159        // Calculate: (amount * price * LAMPORTS_PER_SOL) / 10^decimals
160        // Multiply before divide to preserve precision
161        let lamports_decimal = amount_decimal.checked_mul(token_price.price).and_then(|result| result.checked_mul(lamports_per_sol)).and_then(|result| result.checked_div(decimals_scale)).ok_or_else(|| {
162            log::error!("Token value calculation overflow: amount={}, price={}, decimals={}, lamports_per_sol={}",
163                amount,
164                token_price.price,
165                decimals,
166                lamports_per_sol
167            );
168            KoraError::ValidationError("Token value calculation overflow".to_string())
169        })?;
170
171        // Floor and convert to u64
172        let lamports = lamports_decimal
173            .floor()
174            .to_u64()
175            .ok_or_else(|| KoraError::ValidationError("Lamports value overflow".to_string()))?;
176
177        Ok(lamports)
178    }
179
180    pub async fn calculate_lamports_value_in_token(
181        lamports: u64,
182        mint: &Pubkey,
183        price_source: &PriceSource,
184        rpc_client: &RpcClient,
185    ) -> Result<u64, KoraError> {
186        let (token_price, decimals) =
187            Self::get_token_price_and_decimals(mint, price_source.clone(), rpc_client).await?;
188
189        // Convert lamports to token base units
190        let lamports_decimal = Decimal::from_u64(lamports)
191            .ok_or_else(|| KoraError::ValidationError("Invalid lamports value".to_string()))?;
192        let lamports_per_sol_decimal = Decimal::from_u64(LAMPORTS_PER_SOL)
193            .ok_or_else(|| KoraError::ValidationError("Invalid LAMPORTS_PER_SOL".to_string()))?;
194        let scale = Decimal::from_u64(10u64.pow(decimals as u32))
195            .ok_or_else(|| KoraError::ValidationError("Invalid decimals".to_string()))?;
196
197        // Calculate: (lamports * 10^decimals) / (LAMPORTS_PER_SOL * price)
198        // Multiply before divide to preserve precision
199        let token_amount = lamports_decimal
200            .checked_mul(scale)
201            .and_then(|result| result.checked_div(lamports_per_sol_decimal.checked_mul(token_price.price)?))
202            .ok_or_else(|| {
203                log::error!("Token value calculation overflow: lamports={}, scale={}, lamports_per_sol_decimal={}, token_price.price={}",
204                    lamports,
205                    scale,
206                    lamports_per_sol_decimal,
207                    token_price.price
208                );
209                KoraError::ValidationError("Token value calculation overflow".to_string())
210            })?;
211
212        // Ceil and convert to u64
213        let result = token_amount
214            .ceil()
215            .to_u64()
216            .ok_or_else(|| KoraError::ValidationError("Token amount overflow".to_string()))?;
217
218        Ok(result)
219    }
220
221    /// Calculate the total lamports value of SPL token transfers where the fee payer is involved
222    /// This includes both outflow (fee payer as owner/source) and inflow (fee payer owns destination)
223    pub async fn calculate_spl_transfers_value_in_lamports(
224        spl_transfers: &[ParsedSPLInstructionData],
225        fee_payer: &Pubkey,
226        price_source: &PriceSource,
227        rpc_client: &RpcClient,
228    ) -> Result<u64, KoraError> {
229        // Collect all unique mints that need price lookups
230        let mut mint_to_transfers: HashMap<
231            Pubkey,
232            Vec<(u64, bool)>, // (amount, is_outflow)
233        > = HashMap::new();
234
235        for transfer in spl_transfers {
236            if let ParsedSPLInstructionData::SplTokenTransfer {
237                amount,
238                owner,
239                mint,
240                source_address,
241                destination_address,
242                ..
243            } = transfer
244            {
245                // Check if fee payer is the source (outflow)
246                if *owner == *fee_payer {
247                    let mint_pubkey = if let Some(m) = mint {
248                        *m
249                    } else {
250                        let source_account =
251                            CacheUtil::get_account(rpc_client, source_address, false).await?;
252                        let token_program =
253                            TokenType::get_token_program_from_owner(&source_account.owner)?;
254                        let token_account = token_program
255                            .unpack_token_account(&source_account.data)
256                            .map_err(|e| {
257                                KoraError::TokenOperationError(format!(
258                                    "Failed to unpack source token account {}: {}",
259                                    source_address, e
260                                ))
261                            })?;
262                        token_account.mint()
263                    };
264                    mint_to_transfers.entry(mint_pubkey).or_default().push((*amount, true));
265                } else {
266                    // Check if fee payer owns the destination (inflow)
267                    // We need to check the destination token account owner
268                    if let Some(mint_pubkey) = mint {
269                        // Get destination account to check owner
270                        match CacheUtil::get_account(rpc_client, destination_address, false).await {
271                            Ok(dest_account) => {
272                                let token_program =
273                                    TokenType::get_token_program_from_owner(&dest_account.owner)?;
274                                let token_account = token_program
275                                    .unpack_token_account(&dest_account.data)
276                                    .map_err(|e| {
277                                        KoraError::TokenOperationError(format!(
278                                            "Failed to unpack destination token account {}: {}",
279                                            destination_address, e
280                                        ))
281                                    })?;
282                                if token_account.owner() == *fee_payer {
283                                    mint_to_transfers
284                                        .entry(*mint_pubkey)
285                                        .or_default()
286                                        .push((*amount, false)); // inflow
287                                }
288                            }
289                            Err(e) => {
290                                // If we get Account not found error, we try to match it to the ATA derivation for the fee payer
291                                // in case that ATA is being created in the current instruction
292                                if matches!(e, KoraError::AccountNotFound(_)) {
293                                    let spl_ata =
294                                        spl_associated_token_account_interface::address::get_associated_token_address(
295                                            fee_payer,
296                                            mint_pubkey,
297                                        );
298                                    let token2022_ata =
299                                        get_associated_token_address_with_program_id(
300                                            fee_payer,
301                                            mint_pubkey,
302                                            &spl_token_2022_interface::id(),
303                                        );
304
305                                    // If destination matches a valid ATA for fee payer, count as inflow
306                                    if *destination_address == spl_ata
307                                        || *destination_address == token2022_ata
308                                    {
309                                        mint_to_transfers
310                                            .entry(*mint_pubkey)
311                                            .or_default()
312                                            .push((*amount, false)); // inflow
313                                    }
314                                    // Otherwise, it's not fee payer's account, continue to next transfer
315                                } else {
316                                    // Skip if destination account doesn't exist or can't be fetched
317                                    // This could be problematic for non ATA token accounts created
318                                    // during the transaction
319                                    continue;
320                                }
321                            }
322                        }
323                    }
324                }
325            }
326        }
327
328        if mint_to_transfers.is_empty() {
329            return Ok(0);
330        }
331
332        // Batch fetch all prices and decimals
333        let mint_addresses: Vec<String> =
334            mint_to_transfers.keys().map(|mint| mint.to_string()).collect();
335
336        let oracle = RetryingPriceOracle::new(
337            3,
338            Duration::from_secs(1),
339            get_price_oracle(price_source.clone())?,
340        );
341
342        let prices = oracle.get_token_prices(&mint_addresses).await?;
343
344        let mut mint_decimals = std::collections::HashMap::new();
345        for mint in mint_to_transfers.keys() {
346            let decimals = Self::get_mint_decimals(rpc_client, mint).await?;
347            mint_decimals.insert(*mint, decimals);
348        }
349
350        // Calculate total value
351        let mut total_lamports = 0u64;
352
353        for (mint, transfers) in mint_to_transfers.iter() {
354            let price = prices
355                .get(&mint.to_string())
356                .ok_or_else(|| KoraError::RpcError(format!("No price data for mint {mint}")))?;
357            let decimals = mint_decimals
358                .get(mint)
359                .ok_or_else(|| KoraError::RpcError(format!("No decimals data for mint {mint}")))?;
360
361            for (amount, is_outflow) in transfers {
362                // Convert token amount to lamports value using Decimal
363                let amount_decimal = Decimal::from_u64(*amount).ok_or_else(|| {
364                    KoraError::ValidationError("Invalid transfer amount".to_string())
365                })?;
366                let decimals_scale = Decimal::from_u64(10u64.pow(*decimals as u32))
367                    .ok_or_else(|| KoraError::ValidationError("Invalid decimals".to_string()))?;
368                let lamports_per_sol = Decimal::from_u64(LAMPORTS_PER_SOL).ok_or_else(|| {
369                    KoraError::ValidationError("Invalid LAMPORTS_PER_SOL".to_string())
370                })?;
371
372                // Calculate: (amount * price * LAMPORTS_PER_SOL) / 10^decimals
373                // Multiply before divide to preserve precision
374                let lamports_decimal = amount_decimal.checked_mul(price.price)
375                    .and_then(|result| result.checked_mul(lamports_per_sol))
376                    .and_then(|result| result.checked_div(decimals_scale))
377                    .ok_or_else(|| {
378                        log::error!("Token value calculation overflow: amount={}, price={}, decimals={}, lamports_per_sol={}",
379                            amount,
380                            price.price,
381                            decimals,
382                            lamports_per_sol
383                        );
384                        KoraError::ValidationError("Token value calculation overflow".to_string())
385                    })?;
386
387                let lamports = lamports_decimal.floor().to_u64().ok_or_else(|| {
388                    KoraError::ValidationError("Lamports value overflow".to_string())
389                })?;
390
391                if *is_outflow {
392                    // Add outflow to total
393                    total_lamports = total_lamports.checked_add(lamports).ok_or_else(|| {
394                        log::error!("SPL outflow calculation overflow");
395                        KoraError::ValidationError("SPL outflow calculation overflow".to_string())
396                    })?;
397                } else {
398                    // Subtract inflow from total (using saturating_sub to prevent underflow)
399                    total_lamports = total_lamports.saturating_sub(lamports);
400                }
401            }
402        }
403
404        Ok(total_lamports)
405    }
406
407    /// Validate Token2022 extensions for payment instructions
408    /// This checks if any blocked extensions are present on the payment accounts
409    pub async fn validate_token2022_extensions_for_payment(
410        rpc_client: &RpcClient,
411        source_address: &Pubkey,
412        destination_address: &Pubkey,
413        mint: &Pubkey,
414    ) -> Result<(), KoraError> {
415        let config = &get_config()?.validation.token_2022;
416
417        let token_program = Token2022Program::new();
418
419        // Get mint account data and validate mint extensions (force refresh in case extensions are added)
420        let mint_account = CacheUtil::get_account(rpc_client, mint, true).await?;
421        let mint_data = mint_account.data;
422
423        // Unpack the mint state with extensions
424        let mint_state = token_program.unpack_mint(mint, &mint_data)?;
425
426        let mint_with_extensions =
427            mint_state.as_any().downcast_ref::<Token2022Mint>().ok_or_else(|| {
428                KoraError::SerializationError("Failed to downcast mint state.".to_string())
429            })?;
430
431        // Check each extension type present on the mint
432        for extension_type in mint_with_extensions.get_extension_types() {
433            if config.is_mint_extension_blocked(*extension_type) {
434                return Err(KoraError::ValidationError(format!(
435                    "Blocked mint extension found on mint account {mint}",
436                )));
437            }
438        }
439
440        // Check source account extensions (force refresh in case extensions are added)
441        let source_account = CacheUtil::get_account(rpc_client, source_address, true).await?;
442        let source_data = source_account.data;
443
444        let source_state = token_program.unpack_token_account(&source_data)?;
445
446        let source_with_extensions =
447            source_state.as_any().downcast_ref::<Token2022Account>().ok_or_else(|| {
448                KoraError::SerializationError("Failed to downcast source state.".to_string())
449            })?;
450
451        for extension_type in source_with_extensions.get_extension_types() {
452            if config.is_account_extension_blocked(*extension_type) {
453                return Err(KoraError::ValidationError(format!(
454                    "Blocked account extension found on source account {source_address}",
455                )));
456            }
457        }
458
459        // Check destination account extensions (force refresh in case extensions are added)
460        let destination_account =
461            CacheUtil::get_account(rpc_client, destination_address, true).await?;
462        let destination_data = destination_account.data;
463
464        let destination_state = token_program.unpack_token_account(&destination_data)?;
465
466        let destination_with_extensions =
467            destination_state.as_any().downcast_ref::<Token2022Account>().ok_or_else(|| {
468                KoraError::SerializationError("Failed to downcast destination state.".to_string())
469            })?;
470
471        for extension_type in destination_with_extensions.get_extension_types() {
472            if config.is_account_extension_blocked(*extension_type) {
473                return Err(KoraError::ValidationError(format!(
474                    "Blocked account extension found on destination account {destination_address}",
475                )));
476            }
477        }
478
479        Ok(())
480    }
481
482    /// Validate Token2022 extensions for payment when destination ATA is being created.
483    /// Only validates mint and source account extensions (destination doesn't exist yet).
484    pub async fn validate_token2022_partial_for_ata_creation(
485        rpc_client: &RpcClient,
486        source_address: &Pubkey,
487        mint: &Pubkey,
488    ) -> Result<(), KoraError> {
489        let token2022_config = &get_config()?.validation.token_2022;
490        let token_program = Token2022Program::new();
491
492        // Get mint account data and validate mint extensions
493        let mint_account = CacheUtil::get_account(rpc_client, mint, true).await?;
494        let mint_state = token_program.unpack_mint(mint, &mint_account.data)?;
495
496        let mint_with_extensions =
497            mint_state.as_any().downcast_ref::<Token2022Mint>().ok_or_else(|| {
498                KoraError::SerializationError("Failed to downcast mint state.".to_string())
499            })?;
500
501        for extension_type in mint_with_extensions.get_extension_types() {
502            if token2022_config.is_mint_extension_blocked(*extension_type) {
503                return Err(KoraError::ValidationError(format!(
504                    "Blocked mint extension found on mint account {mint}",
505                )));
506            }
507        }
508
509        // Check source account extensions
510        let source_account = CacheUtil::get_account(rpc_client, source_address, true).await?;
511        let source_state = token_program.unpack_token_account(&source_account.data)?;
512
513        let source_with_extensions =
514            source_state.as_any().downcast_ref::<Token2022Account>().ok_or_else(|| {
515                KoraError::SerializationError("Failed to downcast source state.".to_string())
516            })?;
517
518        for extension_type in source_with_extensions.get_extension_types() {
519            if token2022_config.is_account_extension_blocked(*extension_type) {
520                return Err(KoraError::ValidationError(format!(
521                    "Blocked account extension found on source account {source_address}",
522                )));
523            }
524        }
525
526        Ok(())
527    }
528
529    pub async fn verify_token_payment(
530        transaction_resolved: &mut VersionedTransactionResolved,
531        rpc_client: &RpcClient,
532        required_lamports: u64,
533        // Wallet address of the owner of the destination token account
534        expected_destination_owner: &Pubkey,
535    ) -> Result<bool, KoraError> {
536        let config = get_config()?;
537        let mut total_lamport_value = 0u64;
538
539        // Clone instructions to avoid borrow conflicts when checking for ATA creation instructions
540        let all_instructions = transaction_resolved.all_instructions.clone();
541
542        for instruction in transaction_resolved
543            .get_or_parse_spl_instructions()?
544            .get(&ParsedSPLInstructionType::SplTokenTransfer)
545            .unwrap_or(&vec![])
546        {
547            if let ParsedSPLInstructionData::SplTokenTransfer {
548                source_address,
549                destination_address,
550                mint,
551                amount,
552                is_2022,
553                ..
554            } = instruction
555            {
556                let token_program: Box<dyn TokenInterface> = if *is_2022 {
557                    Box::new(Token2022Program::new())
558                } else {
559                    Box::new(TokenProgram::new())
560                };
561
562                // Validate the destination account is that of the payment address (or signer if none provided)
563                // The destination ATA may not exist yet if it's being created in the same transaction
564                let (destination_owner, token_mint) =
565                    match CacheUtil::get_account(rpc_client, destination_address, false).await {
566                        Ok(destination_account) => {
567                            let token_state = token_program
568                                .unpack_token_account(&destination_account.data)
569                                .map_err(|e| {
570                                    KoraError::InvalidTransaction(format!(
571                                        "Invalid token account: {e}"
572                                    ))
573                                })?;
574
575                            // For Token2022 payments, validate that blocked extensions are not used
576                            if *is_2022 {
577                                TokenUtil::validate_token2022_extensions_for_payment(
578                                    rpc_client,
579                                    source_address,
580                                    destination_address,
581                                    &mint.unwrap_or(token_state.mint()),
582                                )
583                                .await?;
584                            }
585
586                            (token_state.owner(), token_state.mint())
587                        }
588                        Err(e) => {
589                            // If account not found, check if there's an ATA creation instruction
590                            // in this transaction that creates this destination address
591                            if matches!(e, KoraError::AccountNotFound(_)) {
592                                if let Some((wallet_owner, ata_mint)) =
593                                    Self::find_ata_creation_for_destination(
594                                        &all_instructions,
595                                        destination_address,
596                                    )
597                                {
598                                    // For Token2022, validate mint and source extensions
599                                    if *is_2022 {
600                                        TokenUtil::validate_token2022_partial_for_ata_creation(
601                                            rpc_client,
602                                            source_address,
603                                            &ata_mint,
604                                        )
605                                        .await?;
606                                    }
607
608                                    // ATA creation instruction found - use the wallet owner and mint from it
609                                    (wallet_owner, ata_mint)
610                                } else {
611                                    // No ATA creation instruction found and destination doesn't exist
612                                    return Err(KoraError::AccountNotFound(
613                                        destination_address.to_string(),
614                                    ));
615                                }
616                            } else {
617                                // Other error (not AccountNotFound), propagate it
618                                return Err(KoraError::RpcError(e.to_string()));
619                            }
620                        }
621                    };
622
623                // Skip transfer if destination isn't our expected payment address
624                if destination_owner != *expected_destination_owner {
625                    continue;
626                }
627
628                if !config.validation.supports_token(&token_mint.to_string()) {
629                    log::warn!("Ignoring payment with unsupported token mint: {}", token_mint,);
630                    continue;
631                }
632
633                let lamport_value = TokenUtil::calculate_token_value_in_lamports(
634                    *amount,
635                    &token_mint,
636                    config.validation.price_source.clone(),
637                    rpc_client,
638                )
639                .await?;
640
641                total_lamport_value =
642                    total_lamport_value.checked_add(lamport_value).ok_or_else(|| {
643                        log::error!(
644                            "Payment accumulation overflow: total={}, new_payment={}",
645                            total_lamport_value,
646                            lamport_value
647                        );
648                        KoraError::ValidationError("Payment accumulation overflow".to_string())
649                    })?;
650            }
651        }
652
653        Ok(total_lamport_value >= required_lamports)
654    }
655}
656
657#[cfg(test)]
658mod tests_token {
659    use crate::{
660        oracle::{
661            utils::{USDC_DEVNET_MINT, WSOL_DEVNET_MINT},
662            PriceSource,
663        },
664        tests::{
665            common::{MintAccountMockBuilder, RpcMockBuilder, TokenAccountMockBuilder},
666            config_mock::ConfigMockBuilder,
667        },
668        transaction::ParsedSPLInstructionData,
669    };
670
671    use super::*;
672
673    #[test]
674    fn test_token_type_get_token_program_from_owner_spl() {
675        let spl_token_owner = spl_token_interface::id();
676        let result = TokenType::get_token_program_from_owner(&spl_token_owner).unwrap();
677        assert_eq!(result.program_id(), spl_token_interface::id());
678    }
679
680    #[test]
681    fn test_token_type_get_token_program_from_owner_token2022() {
682        let token2022_owner = spl_token_2022_interface::id();
683        let result = TokenType::get_token_program_from_owner(&token2022_owner).unwrap();
684        assert_eq!(result.program_id(), spl_token_2022_interface::id());
685    }
686
687    #[test]
688    fn test_token_type_get_token_program_from_owner_invalid() {
689        let invalid_owner = Pubkey::new_unique();
690        let result = TokenType::get_token_program_from_owner(&invalid_owner);
691        assert!(result.is_err());
692        if let Err(error) = result {
693            assert!(matches!(error, KoraError::TokenOperationError(_)));
694        }
695    }
696
697    #[test]
698    fn test_token_type_get_token_program_spl() {
699        let token_type = TokenType::Spl;
700        let result = token_type.get_token_program();
701        assert_eq!(result.program_id(), spl_token_interface::id());
702    }
703
704    #[test]
705    fn test_token_type_get_token_program_token2022() {
706        let token_type = TokenType::Token2022;
707        let result = token_type.get_token_program();
708        assert_eq!(result.program_id(), spl_token_2022_interface::id());
709    }
710
711    #[test]
712    fn test_check_valid_tokens_valid() {
713        let valid_tokens = vec![WSOL_DEVNET_MINT.to_string(), USDC_DEVNET_MINT.to_string()];
714        let result = TokenUtil::check_valid_tokens(&valid_tokens).unwrap();
715        assert_eq!(result.len(), 2);
716        assert_eq!(result[0].to_string(), WSOL_DEVNET_MINT);
717        assert_eq!(result[1].to_string(), USDC_DEVNET_MINT);
718    }
719
720    #[test]
721    fn test_check_valid_tokens_invalid() {
722        let invalid_tokens = vec!["invalid_token_address".to_string()];
723        let result = TokenUtil::check_valid_tokens(&invalid_tokens);
724        assert!(result.is_err());
725        assert!(matches!(result.unwrap_err(), KoraError::ValidationError(_)));
726    }
727
728    #[test]
729    fn test_check_valid_tokens_empty() {
730        let empty_tokens = vec![];
731        let result = TokenUtil::check_valid_tokens(&empty_tokens).unwrap();
732        assert_eq!(result.len(), 0);
733    }
734
735    #[test]
736    fn test_check_valid_tokens_mixed_valid_invalid() {
737        let mixed_tokens = vec![WSOL_DEVNET_MINT.to_string(), "invalid_address".to_string()];
738        let result = TokenUtil::check_valid_tokens(&mixed_tokens);
739        assert!(result.is_err());
740        assert!(matches!(result.unwrap_err(), KoraError::ValidationError(_)));
741    }
742
743    #[tokio::test]
744    async fn test_get_mint_valid() {
745        // Any valid mint account (valid owner and valid data) will count as valid here. (not related to allowed mint in Kora's config)
746        let _lock = ConfigMockBuilder::new().build_and_setup();
747        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
748        let rpc_client = RpcMockBuilder::new().with_mint_account(9).build();
749
750        let result = TokenUtil::get_mint(&rpc_client, &mint).await;
751        assert!(result.is_ok());
752        let mint_data = result.unwrap();
753        assert_eq!(mint_data.decimals(), 9);
754    }
755
756    #[tokio::test]
757    async fn test_get_mint_account_not_found() {
758        let _lock = ConfigMockBuilder::new().build_and_setup();
759        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
760        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
761
762        let result = TokenUtil::get_mint(&rpc_client, &mint).await;
763        assert!(result.is_err());
764    }
765
766    #[tokio::test]
767    async fn test_get_mint_decimals_valid() {
768        let _lock = ConfigMockBuilder::new().build_and_setup();
769        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
770        let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
771
772        let result = TokenUtil::get_mint_decimals(&rpc_client, &mint).await;
773        assert!(result.is_ok());
774        assert_eq!(result.unwrap(), 6);
775    }
776
777    #[tokio::test]
778    async fn test_get_token_price_and_decimals_spl() {
779        let _lock = ConfigMockBuilder::new().build_and_setup();
780        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
781        let rpc_client = RpcMockBuilder::new().with_mint_account(9).build();
782
783        let (token_price, decimals) =
784            TokenUtil::get_token_price_and_decimals(&mint, PriceSource::Mock, &rpc_client)
785                .await
786                .unwrap();
787
788        assert_eq!(decimals, 9);
789        assert_eq!(token_price.price, Decimal::from(1));
790    }
791
792    #[tokio::test]
793    async fn test_get_token_price_and_decimals_token2022() {
794        let _lock = ConfigMockBuilder::new().build_and_setup();
795        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
796        let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
797
798        let (token_price, decimals) =
799            TokenUtil::get_token_price_and_decimals(&mint, PriceSource::Mock, &rpc_client)
800                .await
801                .unwrap();
802
803        assert_eq!(decimals, 6);
804        assert_eq!(token_price.price, dec!(0.0001));
805    }
806
807    #[tokio::test]
808    async fn test_get_token_price_and_decimals_account_not_found() {
809        let _lock = ConfigMockBuilder::new().build_and_setup();
810        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
811        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
812
813        let result =
814            TokenUtil::get_token_price_and_decimals(&mint, PriceSource::Mock, &rpc_client).await;
815        assert!(result.is_err());
816    }
817
818    #[tokio::test]
819    async fn test_calculate_token_value_in_lamports_sol() {
820        let _lock = ConfigMockBuilder::new().build_and_setup();
821        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
822        let rpc_client = RpcMockBuilder::new().with_mint_account(9).build();
823
824        let amount = 1_000_000_000; // 1 SOL in lamports
825        let result = TokenUtil::calculate_token_value_in_lamports(
826            amount,
827            &mint,
828            PriceSource::Mock,
829            &rpc_client,
830        )
831        .await
832        .unwrap();
833
834        assert_eq!(result, 1_000_000_000); // Should equal input since SOL price is 1.0
835    }
836
837    #[tokio::test]
838    async fn test_calculate_token_value_in_lamports_usdc() {
839        let _lock = ConfigMockBuilder::new().build_and_setup();
840        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
841        let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
842
843        let amount = 1_000_000; // 1 USDC (6 decimals)
844        let result = TokenUtil::calculate_token_value_in_lamports(
845            amount,
846            &mint,
847            PriceSource::Mock,
848            &rpc_client,
849        )
850        .await
851        .unwrap();
852
853        // 1 USDC * 0.0001 SOL/USDC = 0.0001 SOL = 100,000 lamports
854        assert_eq!(result, 100_000);
855    }
856
857    #[tokio::test]
858    async fn test_calculate_token_value_in_lamports_zero_amount() {
859        let _lock = ConfigMockBuilder::new().build_and_setup();
860        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
861        let rpc_client = RpcMockBuilder::new().with_mint_account(9).build();
862
863        let amount = 0;
864        let result = TokenUtil::calculate_token_value_in_lamports(
865            amount,
866            &mint,
867            PriceSource::Mock,
868            &rpc_client,
869        )
870        .await
871        .unwrap();
872
873        assert_eq!(result, 0);
874    }
875
876    #[tokio::test]
877    async fn test_calculate_token_value_in_lamports_small_amount() {
878        let _lock = ConfigMockBuilder::new().build_and_setup();
879        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
880        let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
881
882        let amount = 1; // 0.000001 USDC (smallest unit)
883        let result = TokenUtil::calculate_token_value_in_lamports(
884            amount,
885            &mint,
886            PriceSource::Mock,
887            &rpc_client,
888        )
889        .await
890        .unwrap();
891
892        // 0.000001 USDC * 0.0001 SOL/USDC = very small amount, should floor to 0
893        assert_eq!(result, 0);
894    }
895
896    #[tokio::test]
897    async fn test_calculate_lamports_value_in_token_sol() {
898        let _lock = ConfigMockBuilder::new().build_and_setup();
899        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
900        let rpc_client = RpcMockBuilder::new().with_mint_account(9).build();
901
902        let lamports = 1_000_000_000; // 1 SOL
903        let result = TokenUtil::calculate_lamports_value_in_token(
904            lamports,
905            &mint,
906            &PriceSource::Mock,
907            &rpc_client,
908        )
909        .await
910        .unwrap();
911
912        assert_eq!(result, 1_000_000_000); // Should equal input since SOL price is 1.0
913    }
914
915    #[tokio::test]
916    async fn test_calculate_lamports_value_in_token_usdc() {
917        let _lock = ConfigMockBuilder::new().build_and_setup();
918        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
919        let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
920
921        let lamports = 100_000; // 0.0001 SOL
922        let result = TokenUtil::calculate_lamports_value_in_token(
923            lamports,
924            &mint,
925            &PriceSource::Mock,
926            &rpc_client,
927        )
928        .await
929        .unwrap();
930
931        // 0.0001 SOL / 0.0001 SOL/USDC = 1 USDC = 1,000,000 base units
932        assert_eq!(result, 1_000_000);
933    }
934
935    #[tokio::test]
936    async fn test_calculate_lamports_value_in_token_zero_lamports() {
937        let _lock = ConfigMockBuilder::new().build_and_setup();
938        let mint = Pubkey::from_str(WSOL_DEVNET_MINT).unwrap();
939        let rpc_client = RpcMockBuilder::new().with_mint_account(9).build();
940
941        let lamports = 0;
942        let result = TokenUtil::calculate_lamports_value_in_token(
943            lamports,
944            &mint,
945            &PriceSource::Mock,
946            &rpc_client,
947        )
948        .await
949        .unwrap();
950
951        assert_eq!(result, 0);
952    }
953
954    #[tokio::test]
955    async fn test_calculate_price_functions_consistency() {
956        let _lock = ConfigMockBuilder::new().build_and_setup();
957        // Test that convert to lamports and back to token amount gives approximately the same result
958        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
959        let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
960
961        let original_amount = 1_000_000u64; // 1 USDC
962
963        // Convert token amount to lamports
964        let lamports_result = TokenUtil::calculate_token_value_in_lamports(
965            original_amount,
966            &mint,
967            PriceSource::Mock,
968            &rpc_client,
969        )
970        .await;
971
972        if lamports_result.is_err() {
973            // If we can't get the account data, skip this test as it requires account lookup
974            return;
975        }
976
977        let lamports = lamports_result.unwrap();
978
979        // Convert lamports back to token amount
980        let recovered_amount_result = TokenUtil::calculate_lamports_value_in_token(
981            lamports,
982            &mint,
983            &PriceSource::Mock,
984            &rpc_client,
985        )
986        .await;
987
988        if let Ok(recovered_amount) = recovered_amount_result {
989            assert_eq!(recovered_amount, original_amount);
990        }
991    }
992
993    #[tokio::test]
994    async fn test_price_calculation_with_account_error() {
995        let _lock = ConfigMockBuilder::new().build_and_setup();
996        let mint = Pubkey::new_unique();
997        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
998
999        let result = TokenUtil::calculate_token_value_in_lamports(
1000            1_000_000,
1001            &mint,
1002            PriceSource::Mock,
1003            &rpc_client,
1004        )
1005        .await;
1006
1007        assert!(result.is_err());
1008    }
1009
1010    #[tokio::test]
1011    async fn test_lamports_calculation_with_account_error() {
1012        let _lock = ConfigMockBuilder::new().build_and_setup();
1013        let mint = Pubkey::new_unique();
1014        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
1015
1016        let result = TokenUtil::calculate_lamports_value_in_token(
1017            1_000_000,
1018            &mint,
1019            &PriceSource::Mock,
1020            &rpc_client,
1021        )
1022        .await;
1023
1024        assert!(result.is_err());
1025    }
1026
1027    #[tokio::test]
1028    async fn test_calculate_lamports_value_in_token_decimal_precision() {
1029        let _lock = ConfigMockBuilder::new().build_and_setup();
1030        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
1031
1032        // Explanation (i.e. for case 1)
1033        // 1. Lamports → SOL: 5,000 / 1,000,000,000 = 0.000005 SOL
1034        // 2. SOL → USDC: 0.000005 SOL / 0.0001 SOL/USDC = 0.05 USDC
1035        // 3. USDC → Base units: 0.05 USDC × 10^6 = 50,000 base units
1036
1037        let test_cases = vec![
1038            // Low priority fees
1039            (5_000u64, 50_000u64, "low priority base case"),
1040            (10_001u64, 100_010u64, "odd number precision"),
1041            // High priority fees
1042            (1_010_050u64, 10_100_500u64, "high priority problematic case"),
1043            // High compute unit scenarios
1044            (5_000_000u64, 50_000_000u64, "very high CU limit"),
1045            (2_500_050u64, 25_000_500u64, "odd high amount"), // exact result with Decimal
1046            (10_000_000u64, 100_000_000u64, "maximum CU cost"),
1047            // Edge cases
1048            (1_010_049u64, 10_100_490u64, "precision edge case -1"),
1049            (1_010_051u64, 10_100_510u64, "precision edge case +1"),
1050            (999_999u64, 9_999_990u64, "near million boundary"),
1051            (1_000_001u64, 10_000_010u64, "over million boundary"),
1052            (1_333_337u64, 13_333_370u64, "repeating digits edge case"),
1053        ];
1054
1055        for (lamports, expected, description) in test_cases {
1056            let rpc_client = RpcMockBuilder::new().with_mint_account(6).build();
1057            let result = TokenUtil::calculate_lamports_value_in_token(
1058                lamports,
1059                &mint,
1060                &PriceSource::Mock,
1061                &rpc_client,
1062            )
1063            .await
1064            .unwrap();
1065
1066            assert_eq!(
1067                result, expected,
1068                "Failed for {description}: lamports={lamports}, expected={expected}, got={result}",
1069            );
1070        }
1071    }
1072
1073    #[tokio::test]
1074    async fn test_validate_token2022_extensions_for_payment_rpc_error() {
1075        let _lock = ConfigMockBuilder::new().build_and_setup();
1076
1077        let source_address = Pubkey::new_unique();
1078        let destination_address = Pubkey::new_unique();
1079        let mint_address = Pubkey::new_unique();
1080
1081        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
1082
1083        let result = TokenUtil::validate_token2022_extensions_for_payment(
1084            &rpc_client,
1085            &source_address,
1086            &destination_address,
1087            &mint_address,
1088        )
1089        .await;
1090
1091        assert!(result.is_err());
1092    }
1093
1094    #[tokio::test]
1095    async fn test_validate_token2022_extensions_for_payment_no_mint_provided() {
1096        let _lock = ConfigMockBuilder::new().build_and_setup();
1097
1098        let source_address = Pubkey::new_unique();
1099        let destination_address = Pubkey::new_unique();
1100        let mint_address = Pubkey::new_unique();
1101
1102        // Create accounts without any blocked extensions - test source account first
1103        let source_account = TokenAccountMockBuilder::new().build_token2022();
1104
1105        let rpc_client = RpcMockBuilder::new().with_account_info(&source_account).build();
1106
1107        // Test with None mint (should only check account extensions but will fail on dest account lookup)
1108        let result = TokenUtil::validate_token2022_extensions_for_payment(
1109            &rpc_client,
1110            &source_address,
1111            &destination_address,
1112            &mint_address,
1113        )
1114        .await;
1115
1116        // This will fail on destination lookup, but validates source account extension logic
1117        assert!(result.is_err());
1118        let error_msg = result.unwrap_err().to_string();
1119        assert!(!error_msg.contains("Blocked account extension found on source account"));
1120    }
1121
1122    #[test]
1123    fn test_config_token2022_extension_blocking() {
1124        use spl_token_2022_interface::extension::ExtensionType;
1125
1126        let mut config_builder = ConfigMockBuilder::new();
1127        config_builder = config_builder
1128            .with_blocked_token2022_mint_extensions(vec![
1129                "transfer_fee_config".to_string(),
1130                "pausable".to_string(),
1131                "non_transferable".to_string(),
1132            ])
1133            .with_blocked_token2022_account_extensions(vec![
1134                "non_transferable_account".to_string(),
1135                "cpi_guard".to_string(),
1136                "memo_transfer".to_string(),
1137            ]);
1138        let _lock = config_builder.build_and_setup();
1139
1140        let config = get_config().unwrap();
1141
1142        // Test mint extension blocking
1143        assert!(config
1144            .validation
1145            .token_2022
1146            .is_mint_extension_blocked(ExtensionType::TransferFeeConfig));
1147        assert!(config.validation.token_2022.is_mint_extension_blocked(ExtensionType::Pausable));
1148        assert!(config
1149            .validation
1150            .token_2022
1151            .is_mint_extension_blocked(ExtensionType::NonTransferable));
1152        assert!(!config
1153            .validation
1154            .token_2022
1155            .is_mint_extension_blocked(ExtensionType::InterestBearingConfig));
1156
1157        // Test account extension blocking
1158        assert!(config
1159            .validation
1160            .token_2022
1161            .is_account_extension_blocked(ExtensionType::NonTransferableAccount));
1162        assert!(config.validation.token_2022.is_account_extension_blocked(ExtensionType::CpiGuard));
1163        assert!(config
1164            .validation
1165            .token_2022
1166            .is_account_extension_blocked(ExtensionType::MemoTransfer));
1167        assert!(!config
1168            .validation
1169            .token_2022
1170            .is_account_extension_blocked(ExtensionType::ImmutableOwner));
1171    }
1172
1173    #[test]
1174    fn test_config_token2022_empty_extension_blocking() {
1175        use spl_token_2022_interface::extension::ExtensionType;
1176
1177        let _lock = ConfigMockBuilder::new().build_and_setup();
1178        let config = crate::tests::config_mock::mock_state::get_config().unwrap();
1179
1180        // Test that no extensions are blocked by default
1181        assert!(!config
1182            .validation
1183            .token_2022
1184            .is_mint_extension_blocked(ExtensionType::TransferFeeConfig));
1185        assert!(!config.validation.token_2022.is_mint_extension_blocked(ExtensionType::Pausable));
1186        assert!(!config
1187            .validation
1188            .token_2022
1189            .is_account_extension_blocked(ExtensionType::NonTransferableAccount));
1190        assert!(!config
1191            .validation
1192            .token_2022
1193            .is_account_extension_blocked(ExtensionType::CpiGuard));
1194    }
1195
1196    #[test]
1197    fn test_find_ata_creation_for_destination_found() {
1198        use solana_sdk::instruction::AccountMeta;
1199
1200        let funding_account = Pubkey::new_unique();
1201        let wallet_owner = Pubkey::new_unique();
1202        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
1203        let ata_program_id = spl_associated_token_account_interface::program::id();
1204
1205        // Derive the ATA address
1206        let ata_address =
1207            spl_associated_token_account_interface::address::get_associated_token_address(
1208                &wallet_owner,
1209                &mint,
1210            );
1211
1212        // Create a mock ATA creation instruction
1213        let ata_instruction = Instruction {
1214            program_id: ata_program_id,
1215            accounts: vec![
1216                AccountMeta::new(funding_account, true), // 0: funding account
1217                AccountMeta::new(ata_address, false),    // 1: ATA to be created
1218                AccountMeta::new_readonly(wallet_owner, false), // 2: wallet owner
1219                AccountMeta::new_readonly(mint, false),  // 3: mint
1220                AccountMeta::new_readonly(solana_system_interface::program::ID, false), // 4: system program
1221                AccountMeta::new_readonly(spl_token_interface::id(), false), // 5: token program
1222            ],
1223            data: vec![0], // CreateAssociatedTokenAccount instruction discriminator
1224        };
1225
1226        let instructions = vec![ata_instruction];
1227
1228        // Should find the ATA creation instruction
1229        let result = TokenUtil::find_ata_creation_for_destination(&instructions, &ata_address);
1230        assert!(result.is_some());
1231        let (found_wallet, found_mint) = result.unwrap();
1232        assert_eq!(found_wallet, wallet_owner);
1233        assert_eq!(found_mint, mint);
1234    }
1235
1236    #[test]
1237    fn test_find_ata_creation_for_destination_not_found() {
1238        use solana_sdk::instruction::AccountMeta;
1239
1240        let funding_account = Pubkey::new_unique();
1241        let wallet_owner = Pubkey::new_unique();
1242        let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
1243        let ata_program_id = spl_associated_token_account_interface::program::id();
1244
1245        // Derive the ATA address
1246        let ata_address =
1247            spl_associated_token_account_interface::address::get_associated_token_address(
1248                &wallet_owner,
1249                &mint,
1250            );
1251
1252        // Create a mock ATA creation instruction for a different address
1253        let different_ata = Pubkey::new_unique();
1254        let ata_instruction = Instruction {
1255            program_id: ata_program_id,
1256            accounts: vec![
1257                AccountMeta::new(funding_account, true),
1258                AccountMeta::new(different_ata, false), // Different ATA
1259                AccountMeta::new_readonly(wallet_owner, false),
1260                AccountMeta::new_readonly(mint, false),
1261                AccountMeta::new_readonly(solana_system_interface::program::ID, false),
1262                AccountMeta::new_readonly(spl_token_interface::id(), false),
1263            ],
1264            data: vec![0],
1265        };
1266
1267        let instructions = vec![ata_instruction];
1268
1269        // Should NOT find an ATA creation for our target address
1270        let result = TokenUtil::find_ata_creation_for_destination(&instructions, &ata_address);
1271        assert!(result.is_none());
1272    }
1273
1274    #[test]
1275    fn test_find_ata_creation_for_destination_empty_instructions() {
1276        let target_address = Pubkey::new_unique();
1277        let instructions: Vec<Instruction> = vec![];
1278
1279        let result = TokenUtil::find_ata_creation_for_destination(&instructions, &target_address);
1280        assert!(result.is_none());
1281    }
1282
1283    #[test]
1284    fn test_find_ata_creation_for_destination_wrong_program() {
1285        use solana_sdk::instruction::AccountMeta;
1286
1287        let target_address = Pubkey::new_unique();
1288        let wallet_owner = Pubkey::new_unique();
1289        let mint = Pubkey::new_unique();
1290
1291        // Create an instruction with the wrong program ID
1292        let wrong_program_instruction = Instruction {
1293            program_id: Pubkey::new_unique(), // Not the ATA program
1294            accounts: vec![
1295                AccountMeta::new(Pubkey::new_unique(), true),
1296                AccountMeta::new(target_address, false),
1297                AccountMeta::new_readonly(wallet_owner, false),
1298                AccountMeta::new_readonly(mint, false),
1299                AccountMeta::new_readonly(solana_system_interface::program::ID, false),
1300                AccountMeta::new_readonly(spl_token_interface::id(), false),
1301            ],
1302            data: vec![0],
1303        };
1304
1305        let instructions = vec![wrong_program_instruction];
1306
1307        let result = TokenUtil::find_ata_creation_for_destination(&instructions, &target_address);
1308        assert!(result.is_none());
1309    }
1310
1311    #[tokio::test]
1312    async fn test_calculate_spl_transfers_value_plain_transfer_resolves_mint() {
1313        let fee_payer = Pubkey::new_unique();
1314        let source_address = Pubkey::new_unique();
1315        let destination_address = Pubkey::new_unique();
1316        let usdc_mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
1317
1318        // Plain Transfer: mint is None — the function must resolve it from the source account
1319        let transfers = vec![ParsedSPLInstructionData::SplTokenTransfer {
1320            amount: 1_000_000, // 1 USDC
1321            owner: fee_payer,
1322            mint: None,
1323            source_address,
1324            destination_address,
1325            is_2022: false,
1326        }];
1327
1328        // Sequential RPC responses:
1329        // 1. Source token account (for mint resolution via CacheUtil::get_account)
1330        // 2. Mint account (for decimals lookup via get_mint_decimals)
1331        let source_token_account = TokenAccountMockBuilder::new()
1332            .with_mint(&usdc_mint)
1333            .with_owner(&fee_payer)
1334            .with_amount(1_000_000)
1335            .build();
1336        let mint_account = MintAccountMockBuilder::new().with_decimals(6).build();
1337
1338        let rpc_client = RpcMockBuilder::new()
1339            .build_with_sequential_accounts(vec![&source_token_account, &mint_account]);
1340
1341        let result = TokenUtil::calculate_spl_transfers_value_in_lamports(
1342            &transfers,
1343            &fee_payer,
1344            &PriceSource::Mock,
1345            &rpc_client,
1346        )
1347        .await;
1348
1349        assert!(
1350            result.is_ok(),
1351            "Plain Transfer with mint=None should resolve mint from source account"
1352        );
1353        // 1 USDC * 0.0001 SOL/USDC = 0.0001 SOL = 100,000 lamports
1354        assert_eq!(result.unwrap(), 100_000);
1355    }
1356
1357    #[tokio::test]
1358    async fn test_calculate_spl_transfers_value_transfer_checked_has_mint() {
1359        let fee_payer = Pubkey::new_unique();
1360        let source_address = Pubkey::new_unique();
1361        let destination_address = Pubkey::new_unique();
1362        let usdc_mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap();
1363
1364        // TransferChecked: mint is Some — no extra RPC call needed for mint resolution
1365        let transfers = vec![ParsedSPLInstructionData::SplTokenTransfer {
1366            amount: 1_000_000,
1367            owner: fee_payer,
1368            mint: Some(usdc_mint),
1369            source_address,
1370            destination_address,
1371            is_2022: false,
1372        }];
1373
1374        // Only need 1 RPC response: mint account (for decimals lookup)
1375        let mint_account = MintAccountMockBuilder::new().with_decimals(6).build();
1376        let rpc_client = RpcMockBuilder::new().build_with_sequential_accounts(vec![&mint_account]);
1377
1378        let result = TokenUtil::calculate_spl_transfers_value_in_lamports(
1379            &transfers,
1380            &fee_payer,
1381            &PriceSource::Mock,
1382            &rpc_client,
1383        )
1384        .await;
1385
1386        assert!(result.is_ok());
1387        assert_eq!(result.unwrap(), 100_000);
1388    }
1389}