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 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 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 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 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 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 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 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 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 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 let mut mint_to_transfers: HashMap<
231 Pubkey,
232 Vec<(u64, bool)>, > = 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 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 if let Some(mint_pubkey) = mint {
269 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)); }
288 }
289 Err(e) => {
290 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_address == spl_ata
307 || *destination_address == token2022_ata
308 {
309 mint_to_transfers
310 .entry(*mint_pubkey)
311 .or_default()
312 .push((*amount, false)); }
314 } else {
316 continue;
320 }
321 }
322 }
323 }
324 }
325 }
326 }
327
328 if mint_to_transfers.is_empty() {
329 return Ok(0);
330 }
331
332 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 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 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 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 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 total_lamports = total_lamports.saturating_sub(lamports);
400 }
401 }
402 }
403
404 Ok(total_lamports)
405 }
406
407 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 let mint_account = CacheUtil::get_account(rpc_client, mint, true).await?;
421 let mint_data = mint_account.data;
422
423 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 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 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 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 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 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 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 expected_destination_owner: &Pubkey,
535 ) -> Result<bool, KoraError> {
536 let config = get_config()?;
537 let mut total_lamport_value = 0u64;
538
539 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 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 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 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 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 (wallet_owner, ata_mint)
610 } else {
611 return Err(KoraError::AccountNotFound(
613 destination_address.to_string(),
614 ));
615 }
616 } else {
617 return Err(KoraError::RpcError(e.to_string()));
619 }
620 }
621 };
622
623 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 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; 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); }
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; 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 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; 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 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; 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); }
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; 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 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 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; 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 return;
975 }
976
977 let lamports = lamports_result.unwrap();
978
979 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 let test_cases = vec![
1038 (5_000u64, 50_000u64, "low priority base case"),
1040 (10_001u64, 100_010u64, "odd number precision"),
1041 (1_010_050u64, 10_100_500u64, "high priority problematic case"),
1043 (5_000_000u64, 50_000_000u64, "very high CU limit"),
1045 (2_500_050u64, 25_000_500u64, "odd high amount"), (10_000_000u64, 100_000_000u64, "maximum CU cost"),
1047 (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 let source_account = TokenAccountMockBuilder::new().build_token2022();
1104
1105 let rpc_client = RpcMockBuilder::new().with_account_info(&source_account).build();
1106
1107 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 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 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 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 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 let ata_address =
1207 spl_associated_token_account_interface::address::get_associated_token_address(
1208 &wallet_owner,
1209 &mint,
1210 );
1211
1212 let ata_instruction = Instruction {
1214 program_id: ata_program_id,
1215 accounts: vec![
1216 AccountMeta::new(funding_account, true), AccountMeta::new(ata_address, false), AccountMeta::new_readonly(wallet_owner, false), AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(solana_system_interface::program::ID, false), AccountMeta::new_readonly(spl_token_interface::id(), false), ],
1223 data: vec![0], };
1225
1226 let instructions = vec![ata_instruction];
1227
1228 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 let ata_address =
1247 spl_associated_token_account_interface::address::get_associated_token_address(
1248 &wallet_owner,
1249 &mint,
1250 );
1251
1252 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), 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 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 let wrong_program_instruction = Instruction {
1293 program_id: Pubkey::new_unique(), 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 let transfers = vec![ParsedSPLInstructionData::SplTokenTransfer {
1320 amount: 1_000_000, owner: fee_payer,
1322 mint: None,
1323 source_address,
1324 destination_address,
1325 is_2022: false,
1326 }];
1327
1328 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 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 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 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}