openzeppelin_relayer/domain/transaction/solana/
validation.rs

1use std::collections::HashMap;
2
3/// Validator for Solana transactions that enforces relayer policies and transaction
4/// constraints.
5///
6/// This validator ensures that transactions meet the following criteria:
7/// * Use allowed programs and accounts
8/// * Have valid blockhash
9/// * Meet size and signature requirements
10/// * Have correct fee payer configuration
11/// * Comply with relayer policies
12use crate::{
13    constants::{DEFAULT_SOLANA_MAX_TX_DATA_SIZE, DEFAULT_SOLANA_MIN_BALANCE},
14    domain::{SolanaTokenProgram, TokenInstruction as SolanaTokenInstruction},
15    models::RelayerSolanaPolicy,
16    services::provider::{SolanaProviderError, SolanaProviderTrait},
17};
18use serde::Serialize;
19use solana_client::rpc_response::RpcSimulateTransactionResult;
20use solana_commitment_config::CommitmentConfig;
21use solana_sdk::{pubkey::Pubkey, transaction::Transaction};
22use solana_system_interface::{instruction::SystemInstruction, program};
23use thiserror::Error;
24use tracing::info;
25
26#[derive(Debug, Error, Serialize)]
27#[allow(dead_code)]
28pub enum SolanaTransactionValidationError {
29    #[error("Failed to decode transaction: {0}")]
30    DecodeError(String),
31    #[error("Failed to deserialize transaction: {0}")]
32    DeserializeError(String),
33    #[error("Validation error: {0}")]
34    SigningError(String),
35    #[error("Policy violation: {0}")]
36    PolicyViolation(String),
37    #[error("Blockhash {0} is expired")]
38    ExpiredBlockhash(String),
39    #[error("Validation error: {0}")]
40    ValidationError(String),
41    #[error("Fee payer error: {0}")]
42    FeePayer(String),
43    #[error("Insufficient funds: {0}")]
44    InsufficientFunds(String),
45    #[error("Insufficient balance: {0}")]
46    InsufficientBalance(String),
47    #[error("Underlying Solana provider error: {0}")]
48    UnderlyingSolanaProvider(#[from] SolanaProviderError),
49}
50
51impl SolanaTransactionValidationError {
52    /// Determines if this validation error is transient (retriable) or permanent.
53    ///
54    /// Transient errors are typically RPC/network issues that may succeed on retry:
55    /// - RPC connection errors
56    /// - Network timeouts
57    /// - Node behind errors
58    ///
59    /// Permanent errors are validation failures that won't change on retry:
60    /// - Policy violations
61    /// - Invalid transaction structure
62    /// - Insufficient funds (actual balance issue, not RPC error)
63    pub fn is_transient(&self) -> bool {
64        match self {
65            // Policy violations are always permanent
66            Self::PolicyViolation(_) => false,
67
68            // Fee payer mismatch is permanent
69            Self::FeePayer(_) => false,
70
71            // Decode/deserialize errors are permanent (invalid transaction)
72            Self::DecodeError(_) | Self::DeserializeError(_) => false,
73
74            // Expired blockhash is permanent (cannot be fixed by retry)
75            Self::ExpiredBlockhash(_) => false,
76
77            // Signing errors are permanent
78            Self::SigningError(_) => false,
79
80            Self::UnderlyingSolanaProvider(err) => err.is_transient(),
81
82            // Generic validation errors - check message for transient patterns
83            Self::ValidationError(msg) => {
84                // Check for known transient error patterns in the message
85                msg.contains("RPC")
86                    || msg.contains("timeout")
87                    || msg.contains("timed out")
88                    || msg.contains("connection")
89                    || msg.contains("network")
90                    || msg.contains("Failed to check")
91                    || msg.contains("Failed to get")
92                    || msg.contains("node behind")
93                    || msg.contains("rate limit")
94            }
95
96            // Balance errors - check if it's an RPC error or actual insufficient balance
97            Self::InsufficientBalance(msg) | Self::InsufficientFunds(msg) => {
98                // If the message indicates an RPC failure, it's transient
99                msg.contains("Failed to get balance")
100                    || msg.contains("RPC")
101                    || msg.contains("timeout")
102                    || msg.contains("network")
103            }
104        }
105    }
106}
107
108#[allow(dead_code)]
109pub struct SolanaTransactionValidator {}
110
111#[allow(dead_code)]
112impl SolanaTransactionValidator {
113    pub fn validate_allowed_token(
114        token_mint: &str,
115        policy: &RelayerSolanaPolicy,
116    ) -> Result<(), SolanaTransactionValidationError> {
117        // Check if allowed tokens are configured
118        let no_tokens_configured = match &policy.allowed_tokens {
119            None => true,                      // No tokens configured
120            Some(tokens) => tokens.is_empty(), // Tokens configured but empty
121        };
122
123        // If no allowed tokens are configured or empty, allow all tokens
124        if no_tokens_configured {
125            return Ok(());
126        }
127
128        // If allowed tokens are configured, check if the token is in the list
129        let allowed_token = policy.get_allowed_token_entry(token_mint);
130        if allowed_token.is_none() {
131            return Err(SolanaTransactionValidationError::PolicyViolation(format!(
132                "Token {token_mint} not allowed for transfers"
133            )));
134        }
135
136        Ok(())
137    }
138
139    /// Validates that the transaction's fee payer matches the relayer's address.
140    pub fn validate_fee_payer(
141        tx: &Transaction,
142        relayer_pubkey: &Pubkey,
143    ) -> Result<(), SolanaTransactionValidationError> {
144        // Get fee payer (first account in account_keys)
145        let fee_payer = tx.message.account_keys.first().ok_or_else(|| {
146            SolanaTransactionValidationError::FeePayer("No fee payer account found".to_string())
147        })?;
148
149        // Verify fee payer matches relayer address
150        if fee_payer != relayer_pubkey {
151            return Err(SolanaTransactionValidationError::PolicyViolation(format!(
152                "Fee payer {fee_payer} does not match relayer address {relayer_pubkey}"
153            )));
154        }
155
156        // Verify fee payer is a signer
157        if tx.message.header.num_required_signatures < 1 {
158            return Err(SolanaTransactionValidationError::FeePayer(
159                "Fee payer must be a signer".to_string(),
160            ));
161        }
162
163        Ok(())
164    }
165
166    /// Validates that the transaction's blockhash is still valid.
167    ///
168    /// Checks if the provided blockhash is still valid on-chain. If the blockhash has expired,
169    /// the transaction will fail when submitted.
170    ///
171    /// **Note**: For single-signer transactions, expired blockhashes can be refreshed during
172    /// resubmission. However, validation still occurs to provide early feedback.
173    /// For multi-signer transactions, expired blockhashes cannot be refreshed without
174    /// invalidating existing signatures, so validation is critical.
175    pub async fn validate_blockhash<T: SolanaProviderTrait>(
176        tx: &Transaction,
177        provider: &T,
178    ) -> Result<(), SolanaTransactionValidationError> {
179        let blockhash = tx.message.recent_blockhash;
180
181        // Check if blockhash is still valid
182        let is_valid = provider
183            .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed())
184            .await?;
185
186        if !is_valid {
187            return Err(SolanaTransactionValidationError::ExpiredBlockhash(format!(
188                "Blockhash {blockhash} is no longer valid"
189            )));
190        }
191
192        Ok(())
193    }
194
195    /// Validates the number of required signatures against policy limits.
196    pub fn validate_max_signatures(
197        tx: &Transaction,
198        policy: &RelayerSolanaPolicy,
199    ) -> Result<(), SolanaTransactionValidationError> {
200        let num_signatures = tx.message.header.num_required_signatures;
201
202        let Some(max_signatures) = policy.max_signatures else {
203            return Ok(());
204        };
205
206        if num_signatures > max_signatures {
207            return Err(SolanaTransactionValidationError::PolicyViolation(format!(
208                "Transaction requires {num_signatures} signatures, which exceeds maximum allowed {max_signatures}"
209            )));
210        }
211
212        Ok(())
213    }
214
215    /// Validates that the transaction's programs are allowed by the relayer's policy.
216    pub fn validate_allowed_programs(
217        tx: &Transaction,
218        policy: &RelayerSolanaPolicy,
219    ) -> Result<(), SolanaTransactionValidationError> {
220        if let Some(allowed_programs) = &policy.allowed_programs {
221            for program_id in tx
222                .message
223                .instructions
224                .iter()
225                .map(|ix| tx.message.account_keys[ix.program_id_index as usize])
226            {
227                if !allowed_programs.contains(&program_id.to_string()) {
228                    return Err(SolanaTransactionValidationError::PolicyViolation(format!(
229                        "Program {program_id} not allowed"
230                    )));
231                }
232            }
233        }
234
235        Ok(())
236    }
237
238    pub fn validate_allowed_account(
239        account: &str,
240        policy: &RelayerSolanaPolicy,
241    ) -> Result<(), SolanaTransactionValidationError> {
242        if let Some(allowed_accounts) = &policy.allowed_accounts {
243            if !allowed_accounts.contains(&account.to_string()) {
244                return Err(SolanaTransactionValidationError::PolicyViolation(format!(
245                    "Account {account} not allowed"
246                )));
247            }
248        }
249
250        Ok(())
251    }
252
253    /// Validates that the transaction's accounts are allowed by the relayer's policy.
254    pub fn validate_tx_allowed_accounts(
255        tx: &Transaction,
256        policy: &RelayerSolanaPolicy,
257    ) -> Result<(), SolanaTransactionValidationError> {
258        if let Some(allowed_accounts) = &policy.allowed_accounts {
259            for account_key in &tx.message.account_keys {
260                info!(account_key = %account_key, "checking account");
261                if !allowed_accounts.contains(&account_key.to_string()) {
262                    return Err(SolanaTransactionValidationError::PolicyViolation(format!(
263                        "Account {account_key} not allowed"
264                    )));
265                }
266            }
267        }
268
269        Ok(())
270    }
271
272    pub fn validate_disallowed_account(
273        account: &str,
274        policy: &RelayerSolanaPolicy,
275    ) -> Result<(), SolanaTransactionValidationError> {
276        if let Some(disallowed_accounts) = &policy.disallowed_accounts {
277            if disallowed_accounts.contains(&account.to_string()) {
278                return Err(SolanaTransactionValidationError::PolicyViolation(format!(
279                    "Account {account} not allowed"
280                )));
281            }
282        }
283
284        Ok(())
285    }
286
287    /// Validates that the transaction's accounts are not disallowed by the relayer's policy.
288    pub fn validate_tx_disallowed_accounts(
289        tx: &Transaction,
290        policy: &RelayerSolanaPolicy,
291    ) -> Result<(), SolanaTransactionValidationError> {
292        let Some(disallowed_accounts) = &policy.disallowed_accounts else {
293            return Ok(());
294        };
295
296        for account_key in &tx.message.account_keys {
297            if disallowed_accounts.contains(&account_key.to_string()) {
298                return Err(SolanaTransactionValidationError::PolicyViolation(format!(
299                    "Account {account_key} is explicitly disallowed"
300                )));
301            }
302        }
303
304        Ok(())
305    }
306
307    /// Validates that the transaction's data size is within policy limits.
308    pub fn validate_data_size(
309        tx: &Transaction,
310        config: &RelayerSolanaPolicy,
311    ) -> Result<(), SolanaTransactionValidationError> {
312        let max_size: usize = config
313            .max_tx_data_size
314            .unwrap_or(DEFAULT_SOLANA_MAX_TX_DATA_SIZE)
315            .into();
316        let tx_bytes = bincode::serialize(tx)
317            .map_err(|e| SolanaTransactionValidationError::DeserializeError(e.to_string()))?;
318
319        if tx_bytes.len() > max_size {
320            return Err(SolanaTransactionValidationError::PolicyViolation(format!(
321                "Transaction size {} exceeds maximum allowed {}",
322                tx_bytes.len(),
323                max_size
324            )));
325        }
326        Ok(())
327    }
328
329    /// Validates that the relayer is not used as source in lamports transfers.
330    pub async fn validate_lamports_transfers(
331        tx: &Transaction,
332        relayer_account: &Pubkey,
333    ) -> Result<(), SolanaTransactionValidationError> {
334        // Iterate over each instruction in the transaction
335        for (ix_index, ix) in tx.message.instructions.iter().enumerate() {
336            let program_id = tx.message.account_keys[ix.program_id_index as usize];
337
338            // Check if the instruction comes from the System Program (native SOL transfers)
339            #[allow(clippy::collapsible_match)]
340            if program_id == program::id() {
341                if let Ok(system_ix) = bincode::deserialize::<SystemInstruction>(&ix.data) {
342                    if let SystemInstruction::Transfer { .. } = system_ix {
343                        // In a system transfer instruction, the first account is the source and the
344                        // second is the destination.
345                        let source_index = ix.accounts.first().ok_or_else(|| {
346                            SolanaTransactionValidationError::ValidationError(format!(
347                                "Missing source account in instruction {ix_index}"
348                            ))
349                        })?;
350                        let source_pubkey = &tx.message.account_keys[*source_index as usize];
351
352                        // Only validate transfers where the source is the relayer fee account.
353                        if source_pubkey == relayer_account {
354                            return Err(SolanaTransactionValidationError::PolicyViolation(
355                                "Lamports transfers are not allowed from the relayer account"
356                                    .to_string(),
357                            ));
358                        }
359                    }
360                }
361            }
362        }
363        Ok(())
364    }
365
366    /// Validates transfer amount against policy limits.
367    pub fn validate_max_fee(
368        amount: u64,
369        policy: &RelayerSolanaPolicy,
370    ) -> Result<(), SolanaTransactionValidationError> {
371        if let Some(max_amount) = policy.max_allowed_fee_lamports {
372            if amount > max_amount {
373                return Err(SolanaTransactionValidationError::PolicyViolation(format!(
374                    "Fee amount {amount} exceeds max allowed fee amount {max_amount}"
375                )));
376            }
377        }
378
379        Ok(())
380    }
381
382    /// Validates transfer amount against policy limits.
383    pub async fn validate_sufficient_relayer_balance(
384        fee: u64,
385        relayer_address: &str,
386        policy: &RelayerSolanaPolicy,
387        provider: &impl SolanaProviderTrait,
388    ) -> Result<(), SolanaTransactionValidationError> {
389        let balance = provider.get_balance(relayer_address).await?;
390        // Ensure minimum balance policy is maintained
391        let min_balance = policy.min_balance.unwrap_or(DEFAULT_SOLANA_MIN_BALANCE);
392        let required_balance = fee + min_balance;
393
394        if balance < required_balance {
395            return Err(SolanaTransactionValidationError::InsufficientBalance(format!(
396                "Insufficient relayer balance. Required: {required_balance}, Available: {balance}, Fee: {fee}, Min balance: {min_balance}"
397            )));
398        }
399
400        Ok(())
401    }
402
403    /// Validates token transfers against policy restrictions.
404    pub async fn validate_token_transfers(
405        tx: &Transaction,
406        policy: &RelayerSolanaPolicy,
407        provider: &impl SolanaProviderTrait,
408        relayer_account: &Pubkey,
409    ) -> Result<(), SolanaTransactionValidationError> {
410        let allowed_tokens = match &policy.allowed_tokens {
411            Some(tokens) if !tokens.is_empty() => tokens,
412            _ => return Ok(()), // No token restrictions
413        };
414
415        // Track cumulative transfers from each source account
416        let mut account_transfers: HashMap<Pubkey, u64> = HashMap::new();
417        let mut account_balances: HashMap<Pubkey, u64> = HashMap::new();
418
419        for ix in &tx.message.instructions {
420            let program_id = tx.message.account_keys[ix.program_id_index as usize];
421
422            if !SolanaTokenProgram::is_token_program(&program_id) {
423                continue;
424            }
425
426            let token_ix = match SolanaTokenProgram::unpack_instruction(&program_id, &ix.data) {
427                Ok(ix) => ix,
428                Err(_) => continue, // Skip instructions we can't decode
429            };
430
431            // Decode token instruction
432            match token_ix {
433                SolanaTokenInstruction::Transfer { amount }
434                | SolanaTokenInstruction::TransferChecked { amount, .. } => {
435                    // Get source account info
436                    let source_index = ix.accounts[0] as usize;
437                    let source_pubkey = &tx.message.account_keys[source_index];
438
439                    // Validate source account is writable but not signer
440                    if !tx.message.is_maybe_writable(source_index, None) {
441                        return Err(SolanaTransactionValidationError::ValidationError(
442                            "Source account must be writable".to_string(),
443                        ));
444                    }
445                    if tx.message.is_signer(source_index) {
446                        return Err(SolanaTransactionValidationError::ValidationError(
447                            "Source account must not be signer".to_string(),
448                        ));
449                    }
450
451                    if source_pubkey == relayer_account {
452                        return Err(SolanaTransactionValidationError::PolicyViolation(
453                            "Relayer account cannot be source".to_string(),
454                        ));
455                    }
456
457                    let dest_index = match token_ix {
458                        SolanaTokenInstruction::TransferChecked { .. } => ix.accounts[2] as usize,
459                        _ => ix.accounts[1] as usize,
460                    };
461                    let destination_pubkey = &tx.message.account_keys[dest_index];
462
463                    // Validate destination account is writable but not signer
464                    if !tx.message.is_maybe_writable(dest_index, None) {
465                        return Err(SolanaTransactionValidationError::ValidationError(
466                            "Destination account must be writable".to_string(),
467                        ));
468                    }
469                    if tx.message.is_signer(dest_index) {
470                        return Err(SolanaTransactionValidationError::ValidationError(
471                            "Destination account must not be signer".to_string(),
472                        ));
473                    }
474
475                    let owner_index = match token_ix {
476                        SolanaTokenInstruction::TransferChecked { .. } => ix.accounts[3] as usize,
477                        _ => ix.accounts[2] as usize,
478                    };
479                    // Validate owner is signer but not writable
480                    if !tx.message.is_signer(owner_index) {
481                        return Err(SolanaTransactionValidationError::ValidationError(format!(
482                            "Owner must be signer {}",
483                            &tx.message.account_keys[owner_index]
484                        )));
485                    }
486
487                    // Get mint address from token account - only once per source account
488                    if !account_balances.contains_key(source_pubkey) {
489                        let source_account = provider
490                            .get_account_from_pubkey(source_pubkey)
491                            .await
492                            .map_err(|e| {
493                                SolanaTransactionValidationError::ValidationError(e.to_string())
494                            })?;
495
496                        let token_account =
497                            SolanaTokenProgram::unpack_account(&program_id, &source_account)
498                                .map_err(|e| {
499                                    SolanaTransactionValidationError::ValidationError(format!(
500                                        "Invalid token account: {e}"
501                                    ))
502                                })?;
503
504                        if token_account.is_frozen {
505                            return Err(SolanaTransactionValidationError::PolicyViolation(
506                                "Token account is frozen".to_string(),
507                            ));
508                        }
509
510                        let token_config = allowed_tokens
511                            .iter()
512                            .find(|t| t.mint == token_account.mint.to_string());
513
514                        // check if token is allowed by policy
515                        if token_config.is_none() {
516                            return Err(SolanaTransactionValidationError::PolicyViolation(
517                                format!("Token {} not allowed for transfers", token_account.mint),
518                            ));
519                        }
520                        // Store the balance for later use
521                        account_balances.insert(*source_pubkey, token_account.amount);
522
523                        // Validate decimals for TransferChecked
524                        if let (
525                            Some(config),
526                            SolanaTokenInstruction::TransferChecked { decimals, .. },
527                        ) = (token_config, &token_ix)
528                        {
529                            if Some(*decimals) != config.decimals {
530                                return Err(SolanaTransactionValidationError::ValidationError(
531                                    format!(
532                                        "Invalid decimals: expected {:?}, got {}",
533                                        config.decimals, decimals
534                                    ),
535                                ));
536                            }
537                        }
538
539                        // if relayer is destination, check max fee
540                        if destination_pubkey == relayer_account {
541                            // Check max fee if configured
542                            if let Some(config) = token_config {
543                                if let Some(max_fee) = config.max_allowed_fee {
544                                    if amount > max_fee {
545                                        return Err(
546                                            SolanaTransactionValidationError::PolicyViolation(
547                                                format!(
548                                                    "Transfer amount {} exceeds max fee \
549                                                    allowed {} for token {}",
550                                                    amount, max_fee, token_account.mint
551                                                ),
552                                            ),
553                                        );
554                                    }
555                                }
556                            }
557                        }
558                    }
559
560                    *account_transfers.entry(*source_pubkey).or_insert(0) += amount;
561                }
562                _ => {
563                    // For any other token instruction, verify relayer account is not used
564                    // as a source by checking if it's marked as writable
565                    for account in ix.accounts.iter() {
566                        let account_index = *account as usize;
567                        if account_index < tx.message.account_keys.len() {
568                            let pubkey = &tx.message.account_keys[account_index];
569                            if pubkey == relayer_account
570                                && tx.message.is_maybe_writable(account_index, None)
571                                && !tx.message.is_signer(account_index)
572                            {
573                                // It's ok if relayer is just signing
574                                return Err(SolanaTransactionValidationError::PolicyViolation(
575                                            "Relayer account cannot be used as writable account in token instructions".to_string(),
576                                        ));
577                            }
578                        }
579                    }
580                }
581            }
582        }
583
584        // validate that cumulative transfers don't exceed balances
585        for (account, total_transfer) in account_transfers {
586            let balance = *account_balances.get(&account).unwrap();
587
588            if balance < total_transfer {
589                return Err(SolanaTransactionValidationError::ValidationError(
590                    format!(
591                        "Insufficient balance for cumulative transfers: account {account} has balance {balance} but requires {total_transfer} across all instructions"
592                    ),
593                ));
594            }
595        }
596        Ok(())
597    }
598
599    /// Simulates transaction
600    pub async fn simulate_transaction<T: SolanaProviderTrait>(
601        tx: &Transaction,
602        provider: &T,
603    ) -> Result<RpcSimulateTransactionResult, SolanaTransactionValidationError> {
604        let new_tx = Transaction::new_unsigned(tx.message.clone());
605
606        let result = provider.simulate_transaction(&new_tx).await?;
607
608        Ok(result)
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use crate::{
615        models::{relayer::SolanaAllowedTokensSwapConfig, SolanaAllowedTokensPolicy},
616        services::provider::{MockSolanaProviderTrait, SolanaProviderError},
617    };
618
619    use super::*;
620    use mockall::predicate::*;
621    use solana_sdk::{
622        instruction::{AccountMeta, Instruction},
623        message::Message,
624        program_pack::Pack,
625        signature::{Keypair, Signer},
626    };
627    use solana_system_interface::{instruction, program};
628    use spl_token_interface::{instruction as token_instruction, state::Account};
629
630    fn setup_token_transfer_test(
631        transfer_amount: Option<u64>,
632    ) -> (
633        Transaction,
634        RelayerSolanaPolicy,
635        MockSolanaProviderTrait,
636        Keypair, // source owner
637        Pubkey,  // token mint
638        Pubkey,  // source token account
639        Pubkey,  // destination token account
640    ) {
641        let owner = Keypair::new();
642        let mint = Pubkey::new_unique();
643        let source = Pubkey::new_unique();
644        let destination = Pubkey::new_unique();
645
646        // Create token transfer instruction
647        let transfer_ix = token_instruction::transfer(
648            &spl_token_interface::id(),
649            &source,
650            &destination,
651            &owner.pubkey(),
652            &[],
653            transfer_amount.unwrap_or(100),
654        )
655        .unwrap();
656
657        let message = Message::new(&[transfer_ix], Some(&owner.pubkey()));
658        let mut transaction = Transaction::new_unsigned(message);
659
660        // Ensure owner is marked as signer but not writable
661        if let Some(owner_index) = transaction
662            .message
663            .account_keys
664            .iter()
665            .position(|&pubkey| pubkey == owner.pubkey())
666        {
667            transaction.message.header.num_required_signatures = (owner_index + 1) as u8;
668            transaction.message.header.num_readonly_signed_accounts = 1;
669        }
670
671        let policy = RelayerSolanaPolicy {
672            allowed_tokens: Some(vec![SolanaAllowedTokensPolicy {
673                mint: mint.to_string(),
674                decimals: Some(9),
675                symbol: Some("USDC".to_string()),
676                max_allowed_fee: Some(100),
677                swap_config: Some(SolanaAllowedTokensSwapConfig {
678                    ..Default::default()
679                }),
680            }]),
681            ..Default::default()
682        };
683
684        let mut mock_provider = MockSolanaProviderTrait::new();
685
686        // Setup default mock responses
687        let token_account = Account {
688            mint,
689            owner: owner.pubkey(),
690            amount: 999,
691            state: spl_token_interface::state::AccountState::Initialized,
692            ..Default::default()
693        };
694        let mut account_data = vec![0; Account::LEN];
695        Account::pack(token_account, &mut account_data).unwrap();
696
697        mock_provider
698            .expect_get_account_from_pubkey()
699            .returning(move |_| {
700                let local_account_data = account_data.clone();
701                Box::pin(async move {
702                    Ok(solana_sdk::account::Account {
703                        lamports: 1000000,
704                        data: local_account_data,
705                        owner: spl_token_interface::id(),
706                        executable: false,
707                        rent_epoch: 0,
708                    })
709                })
710            });
711
712        (
713            transaction,
714            policy,
715            mock_provider,
716            owner,
717            mint,
718            source,
719            destination,
720        )
721    }
722
723    fn create_test_transaction(fee_payer: &Pubkey) -> Transaction {
724        let recipient = Pubkey::new_unique();
725        let instruction = instruction::transfer(fee_payer, &recipient, 1000);
726        let message = Message::new(&[instruction], Some(fee_payer));
727        Transaction::new_unsigned(message)
728    }
729
730    fn create_multi_signer_test_transaction(
731        fee_payer: &Pubkey,
732        additional_signer: &Pubkey,
733    ) -> Transaction {
734        let recipient = Pubkey::new_unique();
735        let instruction = instruction::transfer(fee_payer, &recipient, 1000);
736        // Create message with 2 required signatures
737        let mut message = Message::new(&[instruction], Some(fee_payer));
738        // Add second signer to account keys
739        if !message.account_keys.contains(additional_signer) {
740            message.account_keys.push(*additional_signer);
741        }
742        // Set num_required_signatures to 2
743        message.header.num_required_signatures = 2;
744        Transaction::new_unsigned(message)
745    }
746
747    #[test]
748    fn test_validate_fee_payer_success() {
749        let relayer_keypair = Keypair::new();
750        let relayer_address = relayer_keypair.pubkey();
751        let tx = create_test_transaction(&relayer_address);
752
753        let result = SolanaTransactionValidator::validate_fee_payer(&tx, &relayer_address);
754
755        assert!(result.is_ok());
756    }
757
758    #[test]
759    fn test_validate_fee_payer_mismatch() {
760        let wrong_keypair = Keypair::new();
761        let relayer_address = Keypair::new().pubkey();
762
763        let tx = create_test_transaction(&wrong_keypair.pubkey());
764
765        let result = SolanaTransactionValidator::validate_fee_payer(&tx, &relayer_address);
766        assert!(matches!(
767            result.unwrap_err(),
768            SolanaTransactionValidationError::PolicyViolation(_)
769        ));
770    }
771
772    #[tokio::test]
773    async fn test_validate_blockhash_valid() {
774        // Use multi-signer transaction so blockhash validation actually runs
775        let fee_payer = Keypair::new().pubkey();
776        let additional_signer = Keypair::new().pubkey();
777        let transaction = create_multi_signer_test_transaction(&fee_payer, &additional_signer);
778        let mut mock_provider = MockSolanaProviderTrait::new();
779
780        mock_provider
781            .expect_is_blockhash_valid()
782            .with(
783                eq(transaction.message.recent_blockhash),
784                eq(CommitmentConfig::confirmed()),
785            )
786            .returning(|_, _| Box::pin(async { Ok(true) }));
787
788        let result =
789            SolanaTransactionValidator::validate_blockhash(&transaction, &mock_provider).await;
790
791        assert!(result.is_ok());
792    }
793
794    #[tokio::test]
795    async fn test_validate_blockhash_expired() {
796        // Use multi-signer transaction so blockhash validation actually runs
797        let fee_payer = Keypair::new().pubkey();
798        let additional_signer = Keypair::new().pubkey();
799        let transaction = create_multi_signer_test_transaction(&fee_payer, &additional_signer);
800        let mut mock_provider = MockSolanaProviderTrait::new();
801
802        mock_provider
803            .expect_is_blockhash_valid()
804            .returning(|_, _| Box::pin(async { Ok(false) }));
805
806        let result =
807            SolanaTransactionValidator::validate_blockhash(&transaction, &mock_provider).await;
808
809        assert!(matches!(
810            result.unwrap_err(),
811            SolanaTransactionValidationError::ExpiredBlockhash(_)
812        ));
813    }
814
815    #[tokio::test]
816    async fn test_validate_blockhash_provider_error() {
817        // Use multi-signer transaction so blockhash validation actually runs
818        let fee_payer = Keypair::new().pubkey();
819        let additional_signer = Keypair::new().pubkey();
820        let transaction = create_multi_signer_test_transaction(&fee_payer, &additional_signer);
821        let mut mock_provider = MockSolanaProviderTrait::new();
822
823        mock_provider.expect_is_blockhash_valid().returning(|_, _| {
824            Box::pin(async { Err(SolanaProviderError::RpcError("RPC error".to_string())) })
825        });
826
827        let result =
828            SolanaTransactionValidator::validate_blockhash(&transaction, &mock_provider).await;
829
830        assert!(matches!(
831            result.unwrap_err(),
832            SolanaTransactionValidationError::UnderlyingSolanaProvider(_)
833        ));
834    }
835
836    #[tokio::test]
837    async fn test_validate_blockhash_validates_single_signer() {
838        // Single-signer transactions are now validated (no longer skipped)
839        // This provides early feedback even though blockhash can be refreshed during resubmit
840        let transaction = create_test_transaction(&Keypair::new().pubkey());
841        let mut mock_provider = MockSolanaProviderTrait::new();
842
843        // Expect provider call for blockhash validation
844        mock_provider
845            .expect_is_blockhash_valid()
846            .returning(|_, _| Box::pin(async { Ok(true) }));
847
848        let result =
849            SolanaTransactionValidator::validate_blockhash(&transaction, &mock_provider).await;
850
851        // Should succeed after validation
852        assert!(result.is_ok());
853    }
854
855    #[test]
856    fn test_validate_max_signatures_within_limit() {
857        let transaction = create_test_transaction(&Keypair::new().pubkey());
858        let policy = RelayerSolanaPolicy {
859            max_signatures: Some(2),
860            ..Default::default()
861        };
862
863        let result = SolanaTransactionValidator::validate_max_signatures(&transaction, &policy);
864        assert!(result.is_ok());
865    }
866
867    #[test]
868    fn test_validate_max_signatures_exceeds_limit() {
869        let transaction = create_test_transaction(&Keypair::new().pubkey());
870        let policy = RelayerSolanaPolicy {
871            max_signatures: Some(0),
872            ..Default::default()
873        };
874
875        let result = SolanaTransactionValidator::validate_max_signatures(&transaction, &policy);
876        assert!(matches!(
877            result.unwrap_err(),
878            SolanaTransactionValidationError::PolicyViolation(_)
879        ));
880    }
881
882    #[test]
883    fn test_validate_max_signatures_no_limit() {
884        let transaction = create_test_transaction(&Keypair::new().pubkey());
885        let policy = RelayerSolanaPolicy {
886            max_signatures: None,
887            ..Default::default()
888        };
889
890        let result = SolanaTransactionValidator::validate_max_signatures(&transaction, &policy);
891        assert!(result.is_ok());
892    }
893
894    #[test]
895    fn test_validate_max_signatures_exact_limit() {
896        let transaction = create_test_transaction(&Keypair::new().pubkey());
897        let policy = RelayerSolanaPolicy {
898            max_signatures: Some(1),
899            ..Default::default()
900        };
901
902        let result = SolanaTransactionValidator::validate_max_signatures(&transaction, &policy);
903        assert!(result.is_ok());
904    }
905
906    #[test]
907    fn test_validate_allowed_programs_success() {
908        let payer = Keypair::new();
909        let tx = create_test_transaction(&payer.pubkey());
910        let policy = RelayerSolanaPolicy {
911            allowed_programs: Some(vec![program::id().to_string()]),
912            ..Default::default()
913        };
914
915        let result = SolanaTransactionValidator::validate_allowed_programs(&tx, &policy);
916        assert!(result.is_ok());
917    }
918
919    #[test]
920    fn test_validate_allowed_programs_disallowed() {
921        let payer = Keypair::new();
922        let tx = create_test_transaction(&payer.pubkey());
923
924        let policy = RelayerSolanaPolicy {
925            allowed_programs: Some(vec![Pubkey::new_unique().to_string()]),
926            ..Default::default()
927        };
928
929        let result = SolanaTransactionValidator::validate_allowed_programs(&tx, &policy);
930        assert!(matches!(
931            result.unwrap_err(),
932            SolanaTransactionValidationError::PolicyViolation(_)
933        ));
934    }
935
936    #[test]
937    fn test_validate_allowed_programs_no_restrictions() {
938        let payer = Keypair::new();
939        let tx = create_test_transaction(&payer.pubkey());
940
941        let policy = RelayerSolanaPolicy {
942            allowed_programs: None,
943            ..Default::default()
944        };
945
946        let result = SolanaTransactionValidator::validate_allowed_programs(&tx, &policy);
947        assert!(result.is_ok());
948    }
949
950    #[test]
951    fn test_validate_allowed_programs_multiple_instructions() {
952        let payer = Keypair::new();
953        let recipient = Pubkey::new_unique();
954
955        let ix1 = instruction::transfer(&payer.pubkey(), &recipient, 1000);
956        let ix2 = instruction::transfer(&payer.pubkey(), &recipient, 2000);
957        let message = Message::new(&[ix1, ix2], Some(&payer.pubkey()));
958        let tx = Transaction::new_unsigned(message);
959
960        let policy = RelayerSolanaPolicy {
961            allowed_programs: Some(vec![program::id().to_string()]),
962            ..Default::default()
963        };
964
965        let result = SolanaTransactionValidator::validate_allowed_programs(&tx, &policy);
966        assert!(result.is_ok());
967    }
968
969    #[test]
970    fn test_validate_tx_allowed_accounts_success() {
971        let payer = Keypair::new();
972        let recipient = Pubkey::new_unique();
973
974        let ix = instruction::transfer(&payer.pubkey(), &recipient, 1000);
975        let message = Message::new(&[ix], Some(&payer.pubkey()));
976        let tx = Transaction::new_unsigned(message);
977
978        let policy = RelayerSolanaPolicy {
979            allowed_accounts: Some(vec![
980                payer.pubkey().to_string(),
981                recipient.to_string(),
982                program::id().to_string(),
983            ]),
984            ..Default::default()
985        };
986
987        let result = SolanaTransactionValidator::validate_tx_allowed_accounts(&tx, &policy);
988        assert!(result.is_ok());
989    }
990
991    #[test]
992    fn test_validate_tx_allowed_accounts_disallowed() {
993        let payer = Keypair::new();
994
995        let tx = create_test_transaction(&payer.pubkey());
996
997        let policy = RelayerSolanaPolicy {
998            allowed_accounts: Some(vec![payer.pubkey().to_string()]),
999            ..Default::default()
1000        };
1001
1002        let result = SolanaTransactionValidator::validate_tx_allowed_accounts(&tx, &policy);
1003        assert!(matches!(
1004            result.unwrap_err(),
1005            SolanaTransactionValidationError::PolicyViolation(_)
1006        ));
1007    }
1008
1009    #[test]
1010    fn test_validate_tx_allowed_accounts_no_restrictions() {
1011        let tx = create_test_transaction(&Keypair::new().pubkey());
1012
1013        let policy = RelayerSolanaPolicy {
1014            allowed_accounts: None,
1015            ..Default::default()
1016        };
1017
1018        let result = SolanaTransactionValidator::validate_tx_allowed_accounts(&tx, &policy);
1019        assert!(result.is_ok());
1020    }
1021
1022    #[test]
1023    fn test_validate_tx_allowed_accounts_system_program() {
1024        let payer = Keypair::new();
1025        let tx = create_test_transaction(&payer.pubkey());
1026
1027        let policy = RelayerSolanaPolicy {
1028            allowed_accounts: Some(vec![payer.pubkey().to_string(), program::id().to_string()]),
1029            ..Default::default()
1030        };
1031
1032        let result = SolanaTransactionValidator::validate_tx_allowed_accounts(&tx, &policy);
1033        assert!(matches!(
1034            result.unwrap_err(),
1035            SolanaTransactionValidationError::PolicyViolation(_)
1036        ));
1037    }
1038
1039    #[test]
1040    fn test_validate_tx_disallowed_accounts_success() {
1041        let payer = Keypair::new();
1042
1043        let tx = create_test_transaction(&payer.pubkey());
1044
1045        let policy = RelayerSolanaPolicy {
1046            disallowed_accounts: Some(vec![Pubkey::new_unique().to_string()]),
1047            ..Default::default()
1048        };
1049
1050        let result = SolanaTransactionValidator::validate_tx_disallowed_accounts(&tx, &policy);
1051        assert!(result.is_ok());
1052    }
1053
1054    #[test]
1055    fn test_validate_tx_disallowed_accounts_blocked() {
1056        let payer = Keypair::new();
1057        let recipient = Pubkey::new_unique();
1058
1059        let ix = instruction::transfer(&payer.pubkey(), &recipient, 1000);
1060        let message = Message::new(&[ix], Some(&payer.pubkey()));
1061        let tx = Transaction::new_unsigned(message);
1062
1063        let policy = RelayerSolanaPolicy {
1064            disallowed_accounts: Some(vec![recipient.to_string()]),
1065            ..Default::default()
1066        };
1067
1068        let result = SolanaTransactionValidator::validate_tx_disallowed_accounts(&tx, &policy);
1069        assert!(matches!(
1070            result.unwrap_err(),
1071            SolanaTransactionValidationError::PolicyViolation(_)
1072        ));
1073    }
1074
1075    #[test]
1076    fn test_validate_tx_disallowed_accounts_no_restrictions() {
1077        let tx = create_test_transaction(&Keypair::new().pubkey());
1078
1079        let policy = RelayerSolanaPolicy {
1080            disallowed_accounts: None,
1081            ..Default::default()
1082        };
1083
1084        let result = SolanaTransactionValidator::validate_tx_disallowed_accounts(&tx, &policy);
1085        assert!(result.is_ok());
1086    }
1087
1088    #[test]
1089    fn test_validate_tx_disallowed_accounts_system_program() {
1090        let payer = Keypair::new();
1091        let tx = create_test_transaction(&payer.pubkey());
1092
1093        let policy = RelayerSolanaPolicy {
1094            disallowed_accounts: Some(vec![program::id().to_string()]),
1095            ..Default::default()
1096        };
1097
1098        let result = SolanaTransactionValidator::validate_tx_disallowed_accounts(&tx, &policy);
1099        assert!(matches!(
1100            result.unwrap_err(),
1101            SolanaTransactionValidationError::PolicyViolation(_)
1102        ));
1103    }
1104
1105    #[test]
1106    fn test_validate_data_size_within_limit() {
1107        let payer = Keypair::new();
1108        let tx = create_test_transaction(&payer.pubkey());
1109
1110        let policy = RelayerSolanaPolicy {
1111            max_tx_data_size: Some(1500),
1112            ..Default::default()
1113        };
1114
1115        let result = SolanaTransactionValidator::validate_data_size(&tx, &policy);
1116        assert!(result.is_ok());
1117    }
1118
1119    #[test]
1120    fn test_validate_data_size_exceeds_limit() {
1121        let payer = Keypair::new();
1122        let tx = create_test_transaction(&payer.pubkey());
1123
1124        let policy = RelayerSolanaPolicy {
1125            max_tx_data_size: Some(10),
1126            ..Default::default()
1127        };
1128
1129        let result = SolanaTransactionValidator::validate_data_size(&tx, &policy);
1130        assert!(matches!(
1131            result.unwrap_err(),
1132            SolanaTransactionValidationError::PolicyViolation(_)
1133        ));
1134    }
1135
1136    #[test]
1137    fn test_validate_data_size_large_instruction() {
1138        let payer = Keypair::new();
1139        let recipient = Pubkey::new_unique();
1140
1141        let large_data = vec![0u8; 1000];
1142        let ix = Instruction::new_with_bytes(
1143            program::id(),
1144            &large_data,
1145            vec![
1146                AccountMeta::new(payer.pubkey(), true),
1147                AccountMeta::new(recipient, false),
1148            ],
1149        );
1150
1151        let message = Message::new(&[ix], Some(&payer.pubkey()));
1152        let tx = Transaction::new_unsigned(message);
1153
1154        let policy = RelayerSolanaPolicy {
1155            max_tx_data_size: Some(500),
1156            ..Default::default()
1157        };
1158
1159        let result = SolanaTransactionValidator::validate_data_size(&tx, &policy);
1160        assert!(matches!(
1161            result.unwrap_err(),
1162            SolanaTransactionValidationError::PolicyViolation(_)
1163        ));
1164    }
1165
1166    #[test]
1167    fn test_validate_data_size_multiple_instructions() {
1168        let payer = Keypair::new();
1169        let recipient = Pubkey::new_unique();
1170
1171        let ix1 = instruction::transfer(&payer.pubkey(), &recipient, 1000);
1172        let ix2 = instruction::transfer(&payer.pubkey(), &recipient, 2000);
1173        let message = Message::new(&[ix1, ix2], Some(&payer.pubkey()));
1174        let tx = Transaction::new_unsigned(message);
1175
1176        let policy = RelayerSolanaPolicy {
1177            max_tx_data_size: Some(1500),
1178            ..Default::default()
1179        };
1180
1181        let result = SolanaTransactionValidator::validate_data_size(&tx, &policy);
1182        assert!(result.is_ok());
1183    }
1184
1185    #[tokio::test]
1186    async fn test_simulate_transaction_success() {
1187        let transaction = create_test_transaction(&Keypair::new().pubkey());
1188        let mut mock_provider = MockSolanaProviderTrait::new();
1189
1190        mock_provider
1191            .expect_simulate_transaction()
1192            .with(eq(transaction.clone()))
1193            .returning(move |_| {
1194                let simulation_result = RpcSimulateTransactionResult {
1195                    err: None,
1196                    logs: Some(vec!["Program log: success".to_string()]),
1197                    accounts: None,
1198                    units_consumed: Some(100000),
1199                    return_data: None,
1200                    inner_instructions: None,
1201                    replacement_blockhash: None,
1202                    loaded_accounts_data_size: None,
1203                    fee: None,
1204                    pre_balances: None,
1205                    post_balances: None,
1206                    pre_token_balances: None,
1207                    post_token_balances: None,
1208                    loaded_addresses: None,
1209                };
1210                Box::pin(async { Ok(simulation_result) })
1211            });
1212
1213        let result =
1214            SolanaTransactionValidator::simulate_transaction(&transaction, &mock_provider).await;
1215
1216        assert!(result.is_ok());
1217        let simulation = result.unwrap();
1218        assert!(simulation.err.is_none());
1219        assert_eq!(simulation.units_consumed, Some(100000));
1220    }
1221
1222    #[tokio::test]
1223    async fn test_simulate_transaction_failure() {
1224        let transaction = create_test_transaction(&Keypair::new().pubkey());
1225        let mut mock_provider = MockSolanaProviderTrait::new();
1226
1227        mock_provider.expect_simulate_transaction().returning(|_| {
1228            Box::pin(async {
1229                Err(SolanaProviderError::RpcError(
1230                    "Simulation failed".to_string(),
1231                ))
1232            })
1233        });
1234
1235        let result =
1236            SolanaTransactionValidator::simulate_transaction(&transaction, &mock_provider).await;
1237
1238        assert!(matches!(
1239            result.unwrap_err(),
1240            SolanaTransactionValidationError::UnderlyingSolanaProvider(_)
1241        ));
1242    }
1243
1244    #[tokio::test]
1245    async fn test_validate_token_transfers_success() {
1246        let (tx, policy, provider, ..) = setup_token_transfer_test(Some(100));
1247
1248        let result = SolanaTransactionValidator::validate_token_transfers(
1249            &tx,
1250            &policy,
1251            &provider,
1252            &Pubkey::new_unique(),
1253        )
1254        .await;
1255
1256        assert!(result.is_ok());
1257    }
1258
1259    #[tokio::test]
1260    async fn test_validate_token_transfers_insufficient_balance() {
1261        let (tx, policy, provider, ..) = setup_token_transfer_test(Some(2000));
1262
1263        let result = SolanaTransactionValidator::validate_token_transfers(
1264            &tx,
1265            &policy,
1266            &provider,
1267            &Pubkey::new_unique(),
1268        )
1269        .await;
1270
1271        match result {
1272            Err(SolanaTransactionValidationError::ValidationError(msg)) => {
1273                assert!(
1274                    msg.contains("Insufficient balance for cumulative transfers: account "),
1275                    "Unexpected error message: {}",
1276                    msg
1277                );
1278                assert!(
1279                    msg.contains("has balance 999 but requires 2000 across all instructions"),
1280                    "Unexpected error message: {}",
1281                    msg
1282                );
1283            }
1284            other => panic!(
1285                "Expected ValidationError for insufficient balance, got {:?}",
1286                other
1287            ),
1288        }
1289    }
1290
1291    #[tokio::test]
1292    async fn test_validate_token_transfers_relayer_max_fee() {
1293        let (tx, policy, provider, _owner, _mint, _source, destination) =
1294            setup_token_transfer_test(Some(500));
1295
1296        let result = SolanaTransactionValidator::validate_token_transfers(
1297            &tx,
1298            &policy,
1299            &provider,
1300            &destination,
1301        )
1302        .await;
1303
1304        match result {
1305            Err(SolanaTransactionValidationError::PolicyViolation(msg)) => {
1306                assert!(
1307                    msg.contains("Transfer amount 500 exceeds max fee allowed 100"),
1308                    "Unexpected error message: {}",
1309                    msg
1310                );
1311            }
1312            other => panic!(
1313                "Expected ValidationError for insufficient balance, got {:?}",
1314                other
1315            ),
1316        }
1317    }
1318
1319    #[tokio::test]
1320    async fn test_validate_token_transfers_relayer_max_fee_not_applied_for_secondary_accounts() {
1321        let (tx, policy, provider, ..) = setup_token_transfer_test(Some(500));
1322
1323        let result = SolanaTransactionValidator::validate_token_transfers(
1324            &tx,
1325            &policy,
1326            &provider,
1327            &Pubkey::new_unique(),
1328        )
1329        .await;
1330
1331        assert!(result.is_ok());
1332    }
1333
1334    #[tokio::test]
1335    async fn test_validate_token_transfers_disallowed_token() {
1336        let (tx, mut policy, provider, ..) = setup_token_transfer_test(Some(100));
1337
1338        policy.allowed_tokens = Some(vec![SolanaAllowedTokensPolicy {
1339            mint: Pubkey::new_unique().to_string(), // Different mint
1340            decimals: Some(9),
1341            symbol: Some("USDT".to_string()),
1342            max_allowed_fee: None,
1343            swap_config: Some(SolanaAllowedTokensSwapConfig {
1344                ..Default::default()
1345            }),
1346        }]);
1347
1348        let result = SolanaTransactionValidator::validate_token_transfers(
1349            &tx,
1350            &policy,
1351            &provider,
1352            &Pubkey::new_unique(),
1353        )
1354        .await;
1355
1356        match result {
1357            Err(SolanaTransactionValidationError::PolicyViolation(msg)) => {
1358                assert!(
1359                    msg.contains("not allowed for transfers"),
1360                    "Error message '{}' should contain 'not allowed for transfers'",
1361                    msg
1362                );
1363            }
1364            other => panic!("Expected PolicyViolation error, got {:?}", other),
1365        }
1366    }
1367
1368    #[test]
1369    fn test_validate_allowed_token_no_tokens_configured() {
1370        let token_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC mint
1371
1372        let policy = RelayerSolanaPolicy {
1373            allowed_tokens: None, // No tokens configured
1374            ..Default::default()
1375        };
1376
1377        let result = SolanaTransactionValidator::validate_allowed_token(token_mint, &policy);
1378
1379        assert!(result.is_ok());
1380    }
1381
1382    #[test]
1383    fn test_validate_allowed_token_empty_tokens_list() {
1384        let token_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC mint
1385
1386        let policy = RelayerSolanaPolicy {
1387            allowed_tokens: Some(vec![]), // Empty tokens list
1388            ..Default::default()
1389        };
1390
1391        let result = SolanaTransactionValidator::validate_allowed_token(token_mint, &policy);
1392
1393        assert!(result.is_ok());
1394    }
1395
1396    #[test]
1397    fn test_validate_allowed_token_success() {
1398        let token_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC mint
1399
1400        let policy = RelayerSolanaPolicy {
1401            allowed_tokens: Some(vec![
1402                SolanaAllowedTokensPolicy {
1403                    mint: token_mint.to_string(),
1404                    decimals: Some(6),
1405                    symbol: Some("USDC".to_string()),
1406                    max_allowed_fee: Some(1000),
1407                    swap_config: None,
1408                },
1409                SolanaAllowedTokensPolicy {
1410                    mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), // USDT mint
1411                    decimals: Some(6),
1412                    symbol: Some("USDT".to_string()),
1413                    max_allowed_fee: Some(2000),
1414                    swap_config: None,
1415                },
1416            ]),
1417            ..Default::default()
1418        };
1419
1420        let result = SolanaTransactionValidator::validate_allowed_token(token_mint, &policy);
1421
1422        assert!(result.is_ok());
1423    }
1424
1425    #[test]
1426    fn test_validate_allowed_token_not_allowed() {
1427        let token_mint = "11111111111111111111111111111112"; // System Program (not a valid token mint)
1428
1429        let policy = RelayerSolanaPolicy {
1430            allowed_tokens: Some(vec![
1431                SolanaAllowedTokensPolicy {
1432                    mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), // USDC mint
1433                    decimals: Some(6),
1434                    symbol: Some("USDC".to_string()),
1435                    max_allowed_fee: Some(1000),
1436                    swap_config: None,
1437                },
1438                SolanaAllowedTokensPolicy {
1439                    mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(), // USDT mint
1440                    decimals: Some(6),
1441                    symbol: Some("USDT".to_string()),
1442                    max_allowed_fee: Some(2000),
1443                    swap_config: None,
1444                },
1445            ]),
1446            ..Default::default()
1447        };
1448
1449        let result = SolanaTransactionValidator::validate_allowed_token(token_mint, &policy);
1450
1451        match result {
1452            Err(SolanaTransactionValidationError::PolicyViolation(msg)) => {
1453                assert_eq!(
1454                    msg,
1455                    format!("Token {} not allowed for transfers", token_mint),
1456                    "Error message should match expected format"
1457                );
1458            }
1459            other => panic!("Expected PolicyViolation error, got {:?}", other),
1460        }
1461    }
1462
1463    #[test]
1464    fn test_validate_allowed_token_case_sensitive() {
1465        let token_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC mint
1466        let uppercase_mint = token_mint.to_uppercase();
1467
1468        let policy = RelayerSolanaPolicy {
1469            allowed_tokens: Some(vec![SolanaAllowedTokensPolicy {
1470                mint: token_mint.to_string(), // lowercase version
1471                decimals: Some(6),
1472                symbol: Some("USDC".to_string()),
1473                max_allowed_fee: Some(1000),
1474                swap_config: None,
1475            }]),
1476            ..Default::default()
1477        };
1478
1479        // Test with exact case - should succeed
1480        let result = SolanaTransactionValidator::validate_allowed_token(token_mint, &policy);
1481        assert!(result.is_ok());
1482
1483        // Test with different case - should fail (case sensitive)
1484        let result = SolanaTransactionValidator::validate_allowed_token(&uppercase_mint, &policy);
1485        assert!(matches!(
1486            result.unwrap_err(),
1487            SolanaTransactionValidationError::PolicyViolation(_)
1488        ));
1489    }
1490
1491    #[test]
1492    fn test_validate_allowed_token_with_minimal_config() {
1493        let token_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; // USDC mint
1494
1495        let policy = RelayerSolanaPolicy {
1496            allowed_tokens: Some(vec![SolanaAllowedTokensPolicy {
1497                mint: token_mint.to_string(),
1498                decimals: None,
1499                symbol: None,
1500                max_allowed_fee: None,
1501                swap_config: None,
1502            }]),
1503            ..Default::default()
1504        };
1505
1506        let result = SolanaTransactionValidator::validate_allowed_token(token_mint, &policy);
1507
1508        assert!(result.is_ok());
1509    }
1510}