diff --git a/crates/lib/src/config.rs b/crates/lib/src/config.rs index 9fbc9d226..88aca6c6d 100644 --- a/crates/lib/src/config.rs +++ b/crates/lib/src/config.rs @@ -126,7 +126,7 @@ pub struct ValidationConfig { pub fee_payer_policy: FeePayerPolicy, #[serde(default)] pub price: PriceConfig, - #[serde(default)] + #[serde(default, alias = "token2022")] pub token_2022: Token2022Config, /// Allow durable transactions (nonce-based). Default: false. /// When false, rejects any transaction containing AdvanceNonceAccount instruction. @@ -834,6 +834,42 @@ mod tests { assert!(config.validation.token_2022.get_blocked_account_extensions().is_empty()); } + #[test] + fn test_token2022_config_parsing_alias_token2022_table() { + let wrong_key_alias_toml = r#" +[validation] +max_allowed_lamports = 1 +max_signatures = 1 +allowed_programs = [] +allowed_tokens = [] +allowed_spl_paid_tokens = [] +disallowed_accounts = [] +price_source = "Mock" + +[validation.token2022] +blocked_mint_extensions = ["interest_bearing_config"] +blocked_account_extensions = ["memo_transfer"] + +[kora] +rate_limit = 1 +"#; + + let config = crate::tests::toml_mock::create_invalid_config(wrong_key_alias_toml) + .expect("Config with [validation.token2022] alias should parse"); + + assert!( + config + .validation + .token_2022 + .is_mint_extension_blocked(ExtensionType::InterestBearingConfig), + "InterestBearingConfig should be blocked via [validation.token2022] alias" + ); + assert!( + config.validation.token_2022.is_account_extension_blocked(ExtensionType::MemoTransfer), + "MemoTransfer should be blocked via [validation.token2022] alias" + ); + } + #[test] fn test_token2022_extension_blocking_check() { let config = ConfigBuilder::new() diff --git a/crates/lib/src/constant.rs b/crates/lib/src/constant.rs index 0a33b8551..689a5ecc0 100644 --- a/crates/lib/src/constant.rs +++ b/crates/lib/src/constant.rs @@ -212,6 +212,13 @@ pub mod instruction_indexes { pub const FREEZE_AUTHORITY_INDEX: usize = 2; } + pub mod spl_token_reallocate { + pub const REQUIRED_NUMBER_OF_ACCOUNTS: usize = 4; + pub const ACCOUNT_INDEX: usize = 0; + pub const PAYER_INDEX: usize = 1; + pub const OWNER_INDEX: usize = 3; + } + // ATA instruction indexes pub mod ata_instruction_indexes { pub const ATA_ADDRESS_INDEX: usize = 1; diff --git a/crates/lib/src/signer/bundle_signer.rs b/crates/lib/src/signer/bundle_signer.rs index f60a87014..6ac63e67c 100644 --- a/crates/lib/src/signer/bundle_signer.rs +++ b/crates/lib/src/signer/bundle_signer.rs @@ -65,7 +65,16 @@ impl BundleSigner { }; let fee_payer_position = resolved.find_signer_position(fee_payer)?; - resolved.transaction.signatures[fee_payer_position] = signature; + let signatures_len = resolved.transaction.signatures.len(); + let signature_slot = match resolved.transaction.signatures.get_mut(fee_payer_position) { + Some(slot) => slot, + None => { + return Err(KoraError::InvalidTransaction(format!( + "Signer position {fee_payer_position} is out of bounds for signatures (len={signatures_len})" + ))); + } + }; + *signature_slot = signature; Ok(()) } @@ -91,6 +100,18 @@ mod tests { VersionedTransactionResolved::from_kora_built_transaction(&versioned).unwrap() } + fn create_test_resolved_with_unsigned_fee_payer_occurrence( + signer_like_fee_payer: &Pubkey, + ) -> VersionedTransactionResolved { + let attacker_fee_payer = Keypair::new(); + let instruction = transfer(&attacker_fee_payer.pubkey(), signer_like_fee_payer, 1000); + let message = Message::new(&[instruction], Some(&attacker_fee_payer.pubkey())); + let transaction = Transaction::new_unsigned(message); + let versioned = solana_sdk::transaction::VersionedTransaction::from(transaction); + + VersionedTransactionResolved::from_kora_built_transaction(&versioned).unwrap() + } + #[tokio::test] async fn test_sign_transaction_for_bundle_success() { let fee_payer_keypair = Keypair::new(); @@ -143,6 +164,31 @@ mod tests { assert!(matches!(result.unwrap_err(), KoraError::InvalidTransaction(_))); } + #[tokio::test] + async fn test_sign_transaction_for_bundle_rejects_unsigned_fee_payer_occurrence() { + let fee_payer_keypair = Keypair::new(); + let fee_payer = fee_payer_keypair.pubkey(); + + let external_signer = Signer::from_memory(&fee_payer_keypair.to_base58_string()).unwrap(); + let signer = Arc::new(external_signer); + + let blockhash = Some(Hash::new_unique()); + let config = ConfigMockBuilder::new().build(); + + let mut resolved = create_test_resolved_with_unsigned_fee_payer_occurrence(&fee_payer); + let result = BundleSigner::sign_transaction_for_bundle( + &mut resolved, + &signer, + &fee_payer, + &blockhash, + &config, + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KoraError::InvalidTransaction(_))); + } + #[tokio::test] async fn test_sign_transaction_for_bundle_signature_position() { let fee_payer_keypair = Keypair::new(); diff --git a/crates/lib/src/tests/account_mock.rs b/crates/lib/src/tests/account_mock.rs index c88faa663..d60102eac 100644 --- a/crates/lib/src/tests/account_mock.rs +++ b/crates/lib/src/tests/account_mock.rs @@ -307,6 +307,8 @@ pub struct MintAccountMockBuilder { rent_epoch: u64, // Token2022-specific fields extensions: Vec, + transfer_hook_authority: Option, + transfer_hook_program_id: Option, } impl Default for MintAccountMockBuilder { @@ -326,6 +328,8 @@ impl MintAccountMockBuilder { lamports: 0, rent_epoch: DEFAULT_RENT_EPOCH, extensions: Vec::new(), + transfer_hook_authority: None, + transfer_hook_program_id: None, } } @@ -365,6 +369,16 @@ impl MintAccountMockBuilder { self } + pub fn with_transfer_hook_authority(mut self, authority: Option) -> Self { + self.transfer_hook_authority = authority; + self + } + + pub fn with_transfer_hook_program_id(mut self, program_id: Option) -> Self { + self.transfer_hook_program_id = program_id; + self + } + /// Add an extension type (Token2022 only) pub fn with_extension(mut self, extension: ExtensionType) -> Self { if !self.extensions.contains(&extension) { @@ -472,7 +486,12 @@ impl MintAccountMockBuilder { )?; } ExtensionType::TransferHook => { - state.init_extension::(true)?; + let transfer_hook = + state.init_extension::(true)?; + transfer_hook.authority = + OptionalNonZeroPubkey::try_from(self.transfer_hook_authority)?; + transfer_hook.program_id = + OptionalNonZeroPubkey::try_from(self.transfer_hook_program_id)?; } // Add other extension types as needed _ => {} diff --git a/crates/lib/src/token/token.rs b/crates/lib/src/token/token.rs index 097ef182e..b855b67f8 100644 --- a/crates/lib/src/token/token.rs +++ b/crates/lib/src/token/token.rs @@ -7,6 +7,7 @@ use crate::{ interface::TokenMint, spl_token::TokenProgram, spl_token_2022::{Token2022Account, Token2022Extensions, Token2022Mint, Token2022Program}, + spl_token_2022_util::{MintExtension, ParsedExtension}, TokenInterface, }, transaction::{ @@ -56,6 +57,23 @@ impl TokenType { pub struct TokenUtil; impl TokenUtil { + fn validate_immutable_transfer_hook_for_payment( + mint: &Token2022Mint, + mint_pubkey: &Pubkey, + ) -> Result<(), KoraError> { + if let Some(ParsedExtension::Mint(MintExtension::TransferHook(transfer_hook))) = + mint.get_extension(spl_token_2022_interface::extension::ExtensionType::TransferHook) + { + if transfer_hook.authority != spl_pod::optional_keys::OptionalNonZeroPubkey::default() { + return Err(KoraError::ValidationError(format!( + "Mutable transfer-hook authority found on mint account {mint_pubkey}", + ))); + } + } + + Ok(()) + } + async fn check_price_staleness( rpc_client: &RpcClient, config: &Config, @@ -92,6 +110,56 @@ impl TokenUtil { Ok(()) } + async fn calculate_token2022_net_amount( + amount: u64, + mint: &Pubkey, + rpc_client: &RpcClient, + config: &Config, + cached_epoch: &mut Option, + token2022_mints: &mut HashMap>, + ) -> Result { + let current_epoch = match *cached_epoch { + Some(epoch) => epoch, + None => { + let epoch = rpc_client + .get_epoch_info() + .await + .map_err(|e| KoraError::RpcError(e.to_string()))? + .epoch; + *cached_epoch = Some(epoch); + epoch + } + }; + + if !token2022_mints.contains_key(mint) { + let mint_account = CacheUtil::get_account(config, rpc_client, mint, true).await?; + let token_program = Token2022Program::new(); + let mint_state = token_program.unpack_mint(mint, &mint_account.data)?; + token2022_mints.insert(*mint, mint_state); + } + + let mint_2022 = token2022_mints + .get(mint) + .ok_or_else(|| { + KoraError::InternalServerError(format!( + "Missing cached Token2022 mint state for mint {mint}", + )) + })? + .as_any() + .downcast_ref::() + .ok_or_else(|| { + KoraError::SerializationError( + "Failed to downcast mint state for transfer fee check".to_string(), + ) + })?; + + if let Some(fee) = mint_2022.calculate_transfer_fee(amount, current_epoch)? { + Ok(amount.saturating_sub(fee)) + } else { + Ok(amount) + } + } + pub fn check_valid_tokens(tokens: &[String]) -> Result, KoraError> { tokens .iter() @@ -270,7 +338,7 @@ impl TokenUtil { // Collect all unique mints that need price lookups let mut mint_to_transfers: HashMap< Pubkey, - Vec<(u64, bool)>, // (amount, is_outflow) + Vec<(u64, bool, bool)>, // (amount, is_outflow, is_2022) > = HashMap::new(); for transfer in spl_transfers { @@ -280,6 +348,7 @@ impl TokenUtil { mint, source_address, destination_address, + is_2022, .. } = transfer { @@ -303,7 +372,10 @@ impl TokenUtil { })?; token_account.mint() }; - mint_to_transfers.entry(mint_pubkey).or_default().push((*amount, true)); + mint_to_transfers + .entry(mint_pubkey) + .or_default() + .push((*amount, true, *is_2022)); } else { // Check if fee payer owns the destination (inflow) // We need to check the destination token account owner @@ -327,7 +399,7 @@ impl TokenUtil { mint_to_transfers .entry(*mint_pubkey) .or_default() - .push((*amount, false)); // inflow + .push((*amount, false, *is_2022)); // inflow } } Err(e) => { @@ -353,7 +425,7 @@ impl TokenUtil { mint_to_transfers .entry(*mint_pubkey) .or_default() - .push((*amount, false)); // inflow + .push((*amount, false, *is_2022)); // inflow } // Otherwise, it's not fee payer's account, continue to next transfer } else { @@ -415,6 +487,8 @@ impl TokenUtil { // Calculate total value let mut total_lamports = 0u64; + let mut token2022_mints: HashMap> = HashMap::new(); + let mut cached_epoch: Option = None; for (mint, transfers) in mint_to_transfers.iter() { let price = prices @@ -424,9 +498,25 @@ impl TokenUtil { .get(mint) .ok_or_else(|| KoraError::RpcError(format!("No decimals data for mint {mint}")))?; - for (amount, is_outflow) in transfers { + for (amount, is_outflow, is_2022) in transfers { + let mut effective_amount = *amount; + + // Token2022 transfer fees are withheld from destination credits. + // Net inflows to fee-payer-owned accounts must be credited post-fee. + if *is_2022 && !*is_outflow { + effective_amount = Self::calculate_token2022_net_amount( + *amount, + mint, + rpc_client, + config, + &mut cached_epoch, + &mut token2022_mints, + ) + .await?; + } + // Convert token amount to lamports value using Decimal - let amount_decimal = Decimal::from_u64(*amount).ok_or_else(|| { + let amount_decimal = Decimal::from_u64(effective_amount).ok_or_else(|| { KoraError::ValidationError("Invalid transfer amount".to_string()) })?; let decimals_scale = Decimal::from_u64(10u64.pow(*decimals as u32)) @@ -442,7 +532,7 @@ impl TokenUtil { .and_then(|result| result.checked_div(decimals_scale)) .ok_or_else(|| { log::error!("Token value calculation overflow: amount={}, price={}, decimals={}, lamports_per_sol={}", - amount, + effective_amount, price.price, decimals, lamports_per_sol @@ -504,6 +594,8 @@ impl TokenUtil { } } + Self::validate_immutable_transfer_hook_for_payment(mint_with_extensions, mint)?; + // Check source account extensions (force refresh in case extensions are added) let source_account = CacheUtil::get_account(config, rpc_client, source_address, true).await?; @@ -575,6 +667,8 @@ impl TokenUtil { } } + Self::validate_immutable_transfer_hook_for_payment(mint_with_extensions, mint)?; + // Check source account extensions let source_account = CacheUtil::get_account(config, rpc_client, source_address, true).await?; @@ -610,6 +704,7 @@ impl TokenUtil { ) -> Result, KoraError> { let mut total_lamport_value = 0u64; let mut cached_epoch: Option = None; + let mut token2022_mints: HashMap> = HashMap::new(); let all_instructions = bundle_instructions .map(|bi| bi.to_vec()) @@ -637,8 +732,8 @@ impl TokenUtil { // Get destination owner and mint - the ATA may not exist yet if being created // in this transaction (or another transaction in the bundle) - let (destination_owner, token_mint) = - match CacheUtil::get_account(config, rpc_client, destination_address, false) + let (destination_owner, token_mint, destination_exists) = + match CacheUtil::get_account(config, rpc_client, destination_address, true) .await { Ok(destination_account) => { @@ -650,19 +745,7 @@ impl TokenUtil { )) })?; - // For Token2022, validate that blocked extensions are not used - if *is_2022 { - TokenUtil::validate_token2022_extensions_for_payment( - config, - rpc_client, - source_address, - destination_address, - &mint.unwrap_or(token_state.mint()), - ) - .await?; - } - - (token_state.owner(), token_state.mint()) + (token_state.owner(), token_state.mint(), true) } Err(e) => { // If account not found, check if there's an ATA creation instruction @@ -674,18 +757,7 @@ impl TokenUtil { destination_address, ) { - // For Token2022, validate mint and source extensions - if *is_2022 { - TokenUtil::validate_token2022_partial_for_ata_creation( - config, - rpc_client, - source_address, - &ata_mint, - ) - .await?; - } - - (wallet_owner, ata_mint) + (wallet_owner, ata_mint, false) } else { // No ATA creation found - skip this transfer continue; @@ -701,6 +773,30 @@ impl TokenUtil { continue; } + // For Token2022 payments, validate blocked extensions and immutable transfer-hook authority. + // This must run only after destination-owner matching, otherwise unrelated transfers can fail + // payment validation. + if *is_2022 { + if destination_exists { + TokenUtil::validate_token2022_extensions_for_payment( + config, + rpc_client, + source_address, + destination_address, + &mint.unwrap_or(token_mint), + ) + .await?; + } else { + TokenUtil::validate_token2022_partial_for_ata_creation( + config, + rpc_client, + source_address, + &token_mint, + ) + .await?; + } + } + // Skip unsupported tokens if !config.validation.supports_token(&token_mint.to_string()) { log::warn!("Ignoring payment with unsupported token mint: {}", token_mint,); @@ -708,33 +804,15 @@ impl TokenUtil { } let effective_amount = if *is_2022 { - let mint_account = - CacheUtil::get_account(config, rpc_client, &token_mint, false).await?; - let token_program = Token2022Program::new(); - let mint_state = token_program.unpack_mint(&token_mint, &mint_account.data)?; - let mint_2022 = - mint_state.as_any().downcast_ref::().ok_or_else(|| { - KoraError::SerializationError( - "Failed to downcast mint state for transfer fee check".to_string(), - ) - })?; - let current_epoch = match cached_epoch { - Some(epoch) => epoch, - None => { - let epoch = rpc_client - .get_epoch_info() - .await - .map_err(|e| KoraError::RpcError(e.to_string()))? - .epoch; - cached_epoch = Some(epoch); - epoch - } - }; - if let Some(fee) = mint_2022.calculate_transfer_fee(*amount, current_epoch)? { - amount.saturating_sub(fee) - } else { - *amount - } + Self::calculate_token2022_net_amount( + *amount, + &token_mint, + rpc_client, + config, + &mut cached_epoch, + &mut token2022_mints, + ) + .await? } else { *amount }; @@ -796,13 +874,41 @@ mod tests_token { use crate::{ oracle::utils::{USDC_DEVNET_MINT, WSOL_DEVNET_MINT}, tests::{ - common::{RpcMockBuilder, TokenAccountMockBuilder}, + account_mock::create_transfer_fee_config, + common::{MintAccountMockBuilder, RpcMockBuilder, TokenAccountMockBuilder}, config_mock::ConfigMockBuilder, }, }; + use spl_token_2022_interface::{ + extension::{ + transfer_fee::TransferFeeConfig, BaseStateWithExtensionsMut, ExtensionType, + PodStateWithExtensionsMut, + }, + pod::PodMint, + }; use super::*; + fn create_token2022_mint_account_with_transfer_fee( + decimals: u8, + basis_points: u16, + max_fee: u64, + ) -> solana_sdk::account::Account { + let mut mint_account = MintAccountMockBuilder::new() + .with_decimals(decimals) + .with_extension(ExtensionType::TransferFeeConfig) + .build_token2022(); + + let mut mint_state = PodStateWithExtensionsMut::::unpack(&mut mint_account.data) + .expect("Failed to unpack Token2022 mint state"); + let transfer_fee_config = mint_state + .get_extension_mut::() + .expect("Failed to get mutable TransferFeeConfig extension"); + *transfer_fee_config = create_transfer_fee_config(basis_points, max_fee); + + mint_account + } + #[test] fn test_token_type_get_token_program_from_owner_spl() { let spl_token_owner = spl_token_interface::id(); @@ -1100,6 +1206,94 @@ mod tests_token { } } + #[tokio::test] + async fn test_calculate_spl_transfers_value_in_lamports_token2022_inflow_uses_net_amount() { + let _lock = ConfigMockBuilder::new().with_cache_enabled(false).build_and_setup(); + let config = get_config().unwrap(); + + let fee_payer = Pubkey::new_unique(); + let attacker = Pubkey::new_unique(); + let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap(); + let amount = 100_000_000u64; // 100 USDC + + let fee_payer_token2022_ata = get_associated_token_address_with_program_id( + &fee_payer, + &mint, + &spl_token_2022_interface::id(), + ); + + let spl_transfers = vec![ + ParsedSPLInstructionData::SplTokenTransfer { + amount, + owner: fee_payer, + mint: Some(mint), + source_address: Pubkey::new_unique(), + destination_address: attacker, + is_2022: true, + }, + ParsedSPLInstructionData::SplTokenTransfer { + amount, + owner: attacker, + mint: Some(mint), + source_address: Pubkey::new_unique(), + destination_address: fee_payer_token2022_ata, + is_2022: true, + }, + ]; + + let mint_account = create_token2022_mint_account_with_transfer_fee(6, 100, 1_000_000); + let rpc_client = RpcMockBuilder::new() + .with_account_not_found() + .build_with_sequential_accounts(vec![&mint_account, &mint_account]); + + let spl_outflow_value = TokenUtil::calculate_spl_transfers_value_in_lamports( + &spl_transfers, + &fee_payer, + &rpc_client, + &config, + ) + .await + .unwrap(); + + // Fee payer sends 100 USDC out and receives 99 USDC back (1 USDC transfer fee withheld). + // Expected net outflow = 1 USDC => 7_500_000 lamports at mock pricing. + assert_eq!(spl_outflow_value, 7_500_000); + } + + #[tokio::test] + async fn test_calculate_spl_transfers_value_in_lamports_token2022_outflow_remains_gross() { + let _lock = ConfigMockBuilder::new().with_cache_enabled(false).build_and_setup(); + let config = get_config().unwrap(); + + let fee_payer = Pubkey::new_unique(); + let mint = Pubkey::from_str(USDC_DEVNET_MINT).unwrap(); + let amount = 100_000_000u64; // 100 USDC + + let spl_transfers = vec![ParsedSPLInstructionData::SplTokenTransfer { + amount, + owner: fee_payer, + mint: Some(mint), + source_address: Pubkey::new_unique(), + destination_address: Pubkey::new_unique(), + is_2022: true, + }]; + + let mint_account = create_token2022_mint_account_with_transfer_fee(6, 100, 1_000_000); + let rpc_client = RpcMockBuilder::new().build_with_sequential_accounts(vec![&mint_account]); + + let spl_outflow_value = TokenUtil::calculate_spl_transfers_value_in_lamports( + &spl_transfers, + &fee_payer, + &rpc_client, + &config, + ) + .await + .unwrap(); + + // Outflow should still be charged at transfer gross amount (100 USDC). + assert_eq!(spl_outflow_value, 750_000_000); + } + #[tokio::test] async fn test_price_calculation_with_account_error() { let _lock = ConfigMockBuilder::new().build_and_setup(); @@ -1195,6 +1389,120 @@ mod tests_token { assert!(result.is_err()); } + #[tokio::test] + async fn test_validate_token2022_extensions_for_payment_rejects_mutable_transfer_hook_authority( + ) { + let _lock = ConfigMockBuilder::new().with_cache_enabled(false).build_and_setup(); + + let source_address = Pubkey::new_unique(); + let destination_address = Pubkey::new_unique(); + let mint_address = Pubkey::new_unique(); + + let mint_account = MintAccountMockBuilder::new() + .with_decimals(6) + .with_extension(spl_token_2022_interface::extension::ExtensionType::TransferHook) + .with_transfer_hook_authority(Some(Pubkey::new_unique())) + .with_transfer_hook_program_id(Some(Pubkey::new_unique())) + .build_token2022(); + let source_account = + TokenAccountMockBuilder::new().with_mint(&mint_address).build_token2022(); + let destination_account = + TokenAccountMockBuilder::new().with_mint(&mint_address).build_token2022(); + + let rpc_client = RpcMockBuilder::new().build_with_sequential_accounts(vec![ + &mint_account, + &source_account, + &destination_account, + ]); + + let config = get_config().unwrap(); + let result = TokenUtil::validate_token2022_extensions_for_payment( + &config, + &rpc_client, + &source_address, + &destination_address, + &mint_address, + ) + .await; + + assert!(result.is_err()); + let error_message = result.unwrap_err().to_string(); + assert!(error_message.contains("Mutable transfer-hook authority found on mint account")); + } + + #[tokio::test] + async fn test_validate_token2022_extensions_for_payment_allows_immutable_transfer_hook_authority( + ) { + let _lock = ConfigMockBuilder::new().with_cache_enabled(false).build_and_setup(); + + let source_address = Pubkey::new_unique(); + let destination_address = Pubkey::new_unique(); + let mint_address = Pubkey::new_unique(); + + let mint_account = MintAccountMockBuilder::new() + .with_decimals(6) + .with_extension(spl_token_2022_interface::extension::ExtensionType::TransferHook) + .with_transfer_hook_authority(None) + .with_transfer_hook_program_id(Some(Pubkey::new_unique())) + .build_token2022(); + let source_account = + TokenAccountMockBuilder::new().with_mint(&mint_address).build_token2022(); + let destination_account = + TokenAccountMockBuilder::new().with_mint(&mint_address).build_token2022(); + + let rpc_client = RpcMockBuilder::new().build_with_sequential_accounts(vec![ + &mint_account, + &source_account, + &destination_account, + ]); + + let config = get_config().unwrap(); + let result = TokenUtil::validate_token2022_extensions_for_payment( + &config, + &rpc_client, + &source_address, + &destination_address, + &mint_address, + ) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_validate_token2022_partial_for_ata_creation_rejects_mutable_transfer_hook_authority( + ) { + let _lock = ConfigMockBuilder::new().with_cache_enabled(false).build_and_setup(); + + let source_address = Pubkey::new_unique(); + let mint_address = Pubkey::new_unique(); + + let mint_account = MintAccountMockBuilder::new() + .with_decimals(6) + .with_extension(spl_token_2022_interface::extension::ExtensionType::TransferHook) + .with_transfer_hook_authority(Some(Pubkey::new_unique())) + .with_transfer_hook_program_id(Some(Pubkey::new_unique())) + .build_token2022(); + let source_account = + TokenAccountMockBuilder::new().with_mint(&mint_address).build_token2022(); + + let rpc_client = RpcMockBuilder::new() + .build_with_sequential_accounts(vec![&mint_account, &source_account]); + + let config = get_config().unwrap(); + let result = TokenUtil::validate_token2022_partial_for_ata_creation( + &config, + &rpc_client, + &source_address, + &mint_address, + ) + .await; + + assert!(result.is_err()); + let error_message = result.unwrap_err().to_string(); + assert!(error_message.contains("Mutable transfer-hook authority found on mint account")); + } + #[tokio::test] async fn test_validate_token2022_extensions_for_payment_no_mint_provided() { let _lock = ConfigMockBuilder::new().build_and_setup(); diff --git a/crates/lib/src/transaction/instruction_util.rs b/crates/lib/src/transaction/instruction_util.rs index faefe66d6..5108fefbe 100644 --- a/crates/lib/src/transaction/instruction_util.rs +++ b/crates/lib/src/transaction/instruction_util.rs @@ -33,21 +33,46 @@ pub enum ParsedSystemInstructionType { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ParsedSystemInstructionData { // Includes transfer and transfer with seed - SystemTransfer { lamports: u64, sender: Pubkey, receiver: Pubkey }, + SystemTransfer { + lamports: u64, + sender: Pubkey, + receiver: Pubkey, + }, // Includes create account and create account with seed - SystemCreateAccount { lamports: u64, payer: Pubkey }, + SystemCreateAccount { + lamports: u64, + payer: Pubkey, + }, // Includes withdraw nonce account - SystemWithdrawNonceAccount { lamports: u64, nonce_authority: Pubkey, recipient: Pubkey }, + SystemWithdrawNonceAccount { + lamports: u64, + nonce_authority: Pubkey, + recipient: Pubkey, + }, // Includes assign and assign with seed - SystemAssign { authority: Pubkey }, + SystemAssign { + authority: Pubkey, + }, // Includes allocate and allocate with seed - SystemAllocate { account: Pubkey }, + SystemAllocate { + account: Pubkey, + }, // Initialize nonce account - SystemInitializeNonceAccount { nonce_account: Pubkey, nonce_authority: Pubkey }, + SystemInitializeNonceAccount { + nonce_account: Pubkey, + nonce_authority: Pubkey, + }, // Advance nonce account - SystemAdvanceNonceAccount { nonce_account: Pubkey, nonce_authority: Pubkey }, + SystemAdvanceNonceAccount { + nonce_account: Pubkey, + nonce_authority: Pubkey, + }, // Authorize nonce account - SystemAuthorizeNonceAccount { nonce_account: Pubkey, nonce_authority: Pubkey }, + SystemAuthorizeNonceAccount { + nonce_account: Pubkey, + nonce_authority: Pubkey, + new_authority: Pubkey, + }, // Note: SystemUpgradeNonceAccount not included - no authority parameter } @@ -65,6 +90,7 @@ pub enum ParsedSPLInstructionType { SplTokenInitializeMultisig, SplTokenFreezeAccount, SplTokenThawAccount, + SplTokenReallocate, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -101,6 +127,7 @@ pub enum ParsedSPLInstructionData { // SetAuthority SplTokenSetAuthority { authority: Pubkey, + new_authority: Option, is_2022: bool, }, // MintTo and MintToChecked @@ -111,6 +138,7 @@ pub enum ParsedSPLInstructionData { // InitializeMint and InitializeMint2 SplTokenInitializeMint { mint_authority: Pubkey, + freeze_authority: Option, is_2022: bool, }, // InitializeAccount, InitializeAccount2, InitializeAccount3 @@ -133,6 +161,13 @@ pub enum ParsedSPLInstructionData { freeze_authority: Pubkey, is_2022: bool, }, + // Token2022 Reallocate + SplTokenReallocate { + account: Pubkey, + payer: Pubkey, + owner: Pubkey, + is_2022: bool, + }, } /// Macro to validate that an instruction has the required number of accounts @@ -1530,8 +1565,9 @@ impl IxUtils { nonce_authority: instruction_indexes::system_advance_nonce_account::NONCE_AUTHORITY_INDEX }); } - Ok(SystemInstruction::AuthorizeNonceAccount(_new_authority)) => { + Ok(SystemInstruction::AuthorizeNonceAccount(new_authority)) => { parse_system_instruction!(parsed_instructions, instruction, system_authorize_nonce_account, SystemAuthorizeNonceAccount, SystemAuthorizeNonceAccount { + new_authority: new_authority; nonce_account: instruction_indexes::system_authorize_nonce_account::NONCE_ACCOUNT_INDEX, nonce_authority: instruction_indexes::system_authorize_nonce_account::NONCE_AUTHORITY_INDEX }); @@ -1680,7 +1716,10 @@ impl IxUtils { is_2022: false, }); } - spl_token_interface::instruction::TokenInstruction::SetAuthority { .. } => { + spl_token_interface::instruction::TokenInstruction::SetAuthority { + new_authority, + .. + } => { validate_number_accounts!(instruction, instruction_indexes::spl_token_set_authority::REQUIRED_NUMBER_OF_ACCOUNTS); parsed_instructions @@ -1688,6 +1727,7 @@ impl IxUtils { .or_default() .push(ParsedSPLInstructionData::SplTokenSetAuthority { authority: instruction.accounts[instruction_indexes::spl_token_set_authority::CURRENT_AUTHORITY_INDEX].pubkey, + new_authority: new_authority.into(), is_2022: false, }); } @@ -1718,6 +1758,7 @@ impl IxUtils { } spl_token_interface::instruction::TokenInstruction::InitializeMint { mint_authority, + freeze_authority, .. } => { validate_number_accounts!(instruction, instruction_indexes::spl_token_initialize_mint::REQUIRED_NUMBER_OF_ACCOUNTS); @@ -1727,11 +1768,13 @@ impl IxUtils { .or_default() .push(ParsedSPLInstructionData::SplTokenInitializeMint { mint_authority, + freeze_authority: freeze_authority.into(), is_2022: false, }); } spl_token_interface::instruction::TokenInstruction::InitializeMint2 { mint_authority, + freeze_authority, .. } => { validate_number_accounts!(instruction, instruction_indexes::spl_token_initialize_mint2::REQUIRED_NUMBER_OF_ACCOUNTS); @@ -1741,6 +1784,7 @@ impl IxUtils { .or_default() .push(ParsedSPLInstructionData::SplTokenInitializeMint { mint_authority, + freeze_authority: freeze_authority.into(), is_2022: false, }); } @@ -1956,7 +2000,10 @@ impl IxUtils { is_2022: true, }); } - spl_token_2022_interface::instruction::TokenInstruction::SetAuthority { .. } => { + spl_token_2022_interface::instruction::TokenInstruction::SetAuthority { + new_authority, + .. + } => { validate_number_accounts!(instruction, instruction_indexes::spl_token_set_authority::REQUIRED_NUMBER_OF_ACCOUNTS); parsed_instructions @@ -1964,6 +2011,7 @@ impl IxUtils { .or_default() .push(ParsedSPLInstructionData::SplTokenSetAuthority { authority: instruction.accounts[instruction_indexes::spl_token_set_authority::CURRENT_AUTHORITY_INDEX].pubkey, + new_authority: new_authority.into(), is_2022: true, }); } @@ -1994,6 +2042,7 @@ impl IxUtils { } spl_token_2022_interface::instruction::TokenInstruction::InitializeMint { mint_authority, + freeze_authority, .. } => { validate_number_accounts!(instruction, instruction_indexes::spl_token_initialize_mint::REQUIRED_NUMBER_OF_ACCOUNTS); @@ -2003,11 +2052,13 @@ impl IxUtils { .or_default() .push(ParsedSPLInstructionData::SplTokenInitializeMint { mint_authority, + freeze_authority: freeze_authority.into(), is_2022: true, }); } spl_token_2022_interface::instruction::TokenInstruction::InitializeMint2 { mint_authority, + freeze_authority, .. } => { validate_number_accounts!(instruction, instruction_indexes::spl_token_initialize_mint2::REQUIRED_NUMBER_OF_ACCOUNTS); @@ -2017,6 +2068,7 @@ impl IxUtils { .or_default() .push(ParsedSPLInstructionData::SplTokenInitializeMint { mint_authority, + freeze_authority: freeze_authority.into(), is_2022: true, }); } @@ -2111,6 +2163,29 @@ impl IxUtils { is_2022: true, }); } + spl_token_2022_interface::instruction::TokenInstruction::Reallocate { + .. + } => { + validate_number_accounts!(instruction, instruction_indexes::spl_token_reallocate::REQUIRED_NUMBER_OF_ACCOUNTS); + + parsed_instructions + .entry(ParsedSPLInstructionType::SplTokenReallocate) + .or_default() + .push(ParsedSPLInstructionData::SplTokenReallocate { + account: instruction.accounts[instruction_indexes::spl_token_reallocate::ACCOUNT_INDEX].pubkey, + payer: instruction.accounts[instruction_indexes::spl_token_reallocate::PAYER_INDEX].pubkey, + owner: instruction.accounts[instruction_indexes::spl_token_reallocate::OWNER_INDEX].pubkey, + is_2022: true, + }); + } + spl_token_2022_interface::instruction::TokenInstruction::ConfidentialTransferExtension + | spl_token_2022_interface::instruction::TokenInstruction::ConfidentialTransferFeeExtension + | spl_token_2022_interface::instruction::TokenInstruction::ConfidentialMintBurnExtension => { + return Err(KoraError::InvalidTransaction( + "Confidential Token-2022 instructions are not supported" + .to_string(), + )); + } _ => {} }; } diff --git a/crates/lib/src/transaction/versioned_transaction.rs b/crates/lib/src/transaction/versioned_transaction.rs index 87b76abbb..ab62eb0fd 100644 --- a/crates/lib/src/transaction/versioned_transaction.rs +++ b/crates/lib/src/transaction/versioned_transaction.rs @@ -243,14 +243,17 @@ impl VersionedTransactionOps for VersionedTransactionResolved { } fn find_signer_position(&self, signer_pubkey: &Pubkey) -> Result { + let num_required_signatures = + self.transaction.message.header().num_required_signatures as usize; self.transaction .message .static_account_keys() .iter() + .take(num_required_signatures) .position(|key| key == signer_pubkey) .ok_or_else(|| { KoraError::InvalidTransaction(format!( - "Signer {signer_pubkey} not found in transaction account keys" + "Signer {signer_pubkey} not found in transaction signer keys" )) }) } @@ -375,7 +378,16 @@ impl VersionedTransactionOps for VersionedTransactionResolved { // Find the fee payer position - don't assume it's at position 0 let fee_payer_position = self.find_signer_position(&fee_payer)?; - transaction.signatures[fee_payer_position] = signature; + let signatures_len = transaction.signatures.len(); + let signature_slot = match transaction.signatures.get_mut(fee_payer_position) { + Some(slot) => slot, + None => { + return Err(KoraError::InvalidTransaction(format!( + "Signer position {fee_payer_position} is out of bounds for signatures (len={signatures_len})" + ))); + } + }; + *signature_slot = signature; // Serialize signed transaction let serialized = bincode::serialize(&transaction)?; @@ -585,8 +597,8 @@ mod tests { let position = transaction.find_signer_position(&keypair.pubkey()).unwrap(); assert_eq!(position, 0); - let other_position = transaction.find_signer_position(&other_account).unwrap(); - assert_eq!(other_position, 1); + let other_position = transaction.find_signer_position(&other_account); + assert!(matches!(other_position, Err(KoraError::InvalidTransaction(_)))); } #[test] @@ -639,7 +651,7 @@ mod tests { if let Err(KoraError::InvalidTransaction(msg)) = result { assert!(msg.contains(&missing_keypair.pubkey().to_string())); - assert!(msg.contains("not found in transaction account keys")); + assert!(msg.contains("not found in transaction signer keys")); } } diff --git a/crates/lib/src/validator/config_validator.rs b/crates/lib/src/validator/config_validator.rs index 389a12a4f..809d542d8 100644 --- a/crates/lib/src/validator/config_validator.rs +++ b/crates/lib/src/validator/config_validator.rs @@ -68,7 +68,7 @@ impl ConfigValidator { "⚠️ SECURITY: Token {} has PermanentDelegate extension. \ Risk: The permanent delegate can transfer or burn tokens at any time without owner approval. \ This creates significant risks for payment tokens as funds can be seized after payment. \ - Consider removing this token from allowed_tokens or blocking the extension in [validation.token2022].", + Consider removing this token from allowed_tokens or blocking the extension in [validation.token_2022].", token_str )); } @@ -81,7 +81,7 @@ impl ConfigValidator { "⚠️ SECURITY: Token {} has TransferHook extension. \ Risk: A custom program executes on every transfer which can reject transfers \ or introduce external dependencies and attack surface. \ - Consider removing this token from allowed_tokens or blocking the extension in [validation.token2022].", + Consider removing this token from allowed_tokens or blocking the extension in [validation.token_2022].", token_str )); } @@ -462,7 +462,7 @@ impl ConfigValidator { allow the delegate to transfer/burn tokens at any time without owner approval. \ This creates significant risks:\n\ - Payment tokens: Funds can be seized after payment\n\ - Consider adding \"permanent_delegate\" to blocked_mint_extensions in [validation.token2022] \ + Consider adding \"permanent_delegate\" to blocked_mint_extensions in [validation.token_2022] \ unless explicitly needed for your use case.".to_string() ); } diff --git a/crates/lib/src/validator/transaction_validator.rs b/crates/lib/src/validator/transaction_validator.rs index 1587c8d4f..ccfd122a5 100644 --- a/crates/lib/src/validator/transaction_validator.rs +++ b/crates/lib/src/validator/transaction_validator.rs @@ -248,7 +248,7 @@ impl TransactionValidator { "SPL Token Revoke", "Token2022 Token Revoke"); validate_spl!(self, spl_instructions, SplTokenSetAuthority, - ParsedSPLInstructionData::SplTokenSetAuthority { authority, is_2022 } => { authority, is_2022 }, + ParsedSPLInstructionData::SplTokenSetAuthority { authority, is_2022, .. } => { authority, is_2022 }, self.fee_payer_policy.spl_token.allow_set_authority, self.fee_payer_policy.token_2022.allow_set_authority, "SPL Token SetAuthority", "Token2022 Token SetAuthority"); @@ -260,7 +260,7 @@ impl TransactionValidator { "SPL Token MintTo", "Token2022 Token MintTo"); validate_spl!(self, spl_instructions, SplTokenInitializeMint, - ParsedSPLInstructionData::SplTokenInitializeMint { mint_authority, is_2022 } => { mint_authority, is_2022 }, + ParsedSPLInstructionData::SplTokenInitializeMint { mint_authority, is_2022, .. } => { mint_authority, is_2022 }, self.fee_payer_policy.spl_token.allow_initialize_mint, self.fee_payer_policy.token_2022.allow_initialize_mint, "SPL Token InitializeMint", "Token2022 Token InitializeMint"); @@ -289,6 +289,21 @@ impl TransactionValidator { self.fee_payer_policy.token_2022.allow_thaw_account, "SPL Token ThawAccount", "Token2022 Token ThawAccount"); + for instruction in + spl_instructions.get(&ParsedSPLInstructionType::SplTokenReallocate).unwrap_or(&vec![]) + { + if let ParsedSPLInstructionData::SplTokenReallocate { payer, owner, is_2022, .. } = + instruction + { + if *is_2022 && (*payer == self.fee_payer_pubkey || *owner == self.fee_payer_pubkey) + { + return Err(KoraError::InvalidTransaction( + "Token2022 Reallocate is not allowed when involving fee payer".to_string(), + )); + } + } + } + Ok(()) } @@ -313,7 +328,7 @@ impl TransactionValidator { fn validate_disallowed_accounts( &self, - transaction_resolved: &VersionedTransactionResolved, + transaction_resolved: &mut VersionedTransactionResolved, ) -> Result<(), KoraError> { for instruction in &transaction_resolved.all_instructions { if self.disallowed_accounts.contains(&instruction.program_id) { @@ -332,6 +347,83 @@ impl TransactionValidator { } } } + // Validate instruction-data pubkeys that are not present in account metas. + let system_instructions = transaction_resolved.get_or_parse_system_instructions()?; + for instruction in system_instructions.values().flatten() { + match instruction { + ParsedSystemInstructionData::SystemAuthorizeNonceAccount { + new_authority, .. + } => { + self.validate_disallowed_instruction_data_account( + new_authority, + "System AuthorizeNonceAccount new_authority", + )?; + } + ParsedSystemInstructionData::SystemInitializeNonceAccount { + nonce_authority, + .. + } => { + self.validate_disallowed_instruction_data_account( + nonce_authority, + "System InitializeNonceAccount nonce_authority", + )?; + } + _ => {} + } + } + + let spl_instructions = transaction_resolved.get_or_parse_spl_instructions()?; + for instruction in spl_instructions.values().flatten() { + match instruction { + ParsedSPLInstructionData::SplTokenSetAuthority { + new_authority: Some(new_authority), + .. + } => { + self.validate_disallowed_instruction_data_account( + new_authority, + "SPL/Token2022 SetAuthority new_authority", + )?; + } + ParsedSPLInstructionData::SplTokenInitializeAccount { owner, .. } => { + self.validate_disallowed_instruction_data_account( + owner, + "SPL/Token2022 InitializeAccount owner", + )?; + } + ParsedSPLInstructionData::SplTokenInitializeMint { + mint_authority, + freeze_authority, + .. + } => { + self.validate_disallowed_instruction_data_account( + mint_authority, + "SPL/Token2022 InitializeMint mint_authority", + )?; + if let Some(freeze_authority) = freeze_authority { + self.validate_disallowed_instruction_data_account( + freeze_authority, + "SPL/Token2022 InitializeMint freeze_authority", + )?; + } + } + _ => {} + } + } + + Ok(()) + } + + fn validate_disallowed_instruction_data_account( + &self, + account: &Pubkey, + context: &str, + ) -> Result<(), KoraError> { + if self.is_disallowed_account(account) { + return Err(KoraError::InvalidTransaction(format!( + "Disallowed account {} found in instruction data for {}", + account, context + ))); + } Ok(()) } @@ -477,6 +569,21 @@ mod tests { setup_both_configs(config); } + fn setup_config_with_policy_and_disallowed( + policy: FeePayerPolicy, + allowed_programs: Vec, + disallowed_accounts: Vec, + ) { + let config = ConfigMockBuilder::new() + .with_price_source(PriceSource::Mock) + .with_allowed_programs(allowed_programs) + .with_disallowed_accounts(disallowed_accounts) + .with_max_allowed_lamports(1_000_000) + .with_fee_payer_policy(policy) + .build(); + setup_both_configs(config); + } + #[tokio::test] #[serial] async fn test_validate_transaction() { @@ -694,6 +801,211 @@ mod tests { .is_err()); } + #[tokio::test] + #[serial] + async fn test_disallowed_instruction_data_spl_set_authority_new_authority() { + let fee_payer = Pubkey::new_unique(); + let token_account = Pubkey::new_unique(); + let disallowed_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + let mut policy = FeePayerPolicy::default(); + policy.spl_token.allow_set_authority = true; + setup_config_with_policy_and_disallowed( + policy, + vec![spl_token_interface::id().to_string()], + vec![disallowed_account.to_string()], + ); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let instruction = spl_token_interface::instruction::set_authority( + &spl_token_interface::id(), + &token_account, + Some(&disallowed_account), + spl_token_interface::instruction::AuthorityType::AccountOwner, + &fee_payer, + &[], + ) + .unwrap(); + let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + assert!(result.is_err()); + } + + #[tokio::test] + #[serial] + async fn test_disallowed_instruction_data_token2022_set_authority_new_authority() { + let fee_payer = Pubkey::new_unique(); + let token_account = Pubkey::new_unique(); + let disallowed_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + let mut policy = FeePayerPolicy::default(); + policy.token_2022.allow_set_authority = true; + setup_config_with_policy_and_disallowed( + policy, + vec![spl_token_2022_interface::id().to_string()], + vec![disallowed_account.to_string()], + ); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let instruction = spl_token_2022_interface::instruction::set_authority( + &spl_token_2022_interface::id(), + &token_account, + Some(&disallowed_account), + spl_token_2022_interface::instruction::AuthorityType::AccountOwner, + &fee_payer, + &[], + ) + .unwrap(); + let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + assert!(result.is_err()); + } + + #[tokio::test] + #[serial] + async fn test_disallowed_instruction_data_nonce_authorize_new_authority() { + use solana_system_interface::instruction::authorize_nonce_account; + + let fee_payer = Pubkey::new_unique(); + let nonce_account = Pubkey::new_unique(); + let disallowed_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + let mut policy = FeePayerPolicy::default(); + policy.system.nonce.allow_authorize = true; + setup_config_with_policy_and_disallowed( + policy, + vec![SYSTEM_PROGRAM_ID.to_string()], + vec![disallowed_account.to_string()], + ); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let instruction = authorize_nonce_account(&nonce_account, &fee_payer, &disallowed_account); + let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + assert!(result.is_err()); + } + + #[tokio::test] + #[serial] + async fn test_disallowed_instruction_data_nonce_initialize_nonce_authority() { + use solana_system_interface::instruction::create_nonce_account; + + let fee_payer = Pubkey::new_unique(); + let nonce_account = Pubkey::new_unique(); + let disallowed_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + let mut policy = FeePayerPolicy::default(); + policy.system.nonce.allow_initialize = true; + setup_config_with_policy_and_disallowed( + policy, + vec![SYSTEM_PROGRAM_ID.to_string()], + vec![disallowed_account.to_string()], + ); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let instructions = + create_nonce_account(&fee_payer, &nonce_account, &disallowed_account, 1_000_000); + // InitializeNonceAccount is the second instruction. + let message = + VersionedMessage::Legacy(Message::new(&[instructions[1].clone()], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + assert!(result.is_err()); + } + + #[tokio::test] + #[serial] + async fn test_disallowed_instruction_data_spl_initialize_account2_owner() { + let fee_payer = Pubkey::new_unique(); + let token_account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let disallowed_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + let mut policy = FeePayerPolicy::default(); + policy.spl_token.allow_initialize_account = true; + setup_config_with_policy_and_disallowed( + policy, + vec![spl_token_interface::id().to_string()], + vec![disallowed_account.to_string()], + ); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let instruction = spl_token_interface::instruction::initialize_account2( + &spl_token_interface::id(), + &token_account, + &mint, + &disallowed_account, + ) + .unwrap(); + let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + assert!(result.is_err()); + } + + #[tokio::test] + #[serial] + async fn test_disallowed_instruction_data_spl_initialize_mint2_freeze_authority() { + let fee_payer = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let disallowed_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + let mut policy = FeePayerPolicy::default(); + policy.spl_token.allow_initialize_mint = true; + setup_config_with_policy_and_disallowed( + policy, + vec![spl_token_interface::id().to_string()], + vec![disallowed_account.to_string()], + ); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let instruction = spl_token_interface::instruction::initialize_mint2( + &spl_token_interface::id(), + &mint, + &fee_payer, + Some(&disallowed_account), + 6, + ) + .unwrap(); + let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + assert!(result.is_err()); + } + #[tokio::test] #[serial] async fn test_fee_payer_policy_sol_transfers() { @@ -2993,6 +3305,69 @@ mod tests { } } + #[tokio::test] + #[serial] + async fn test_fee_payer_policy_token2022_reallocate_rejected_for_fee_payer() { + let fee_payer = Pubkey::new_unique(); + let token_account = Pubkey::new_unique(); + + let rpc_client = RpcMockBuilder::new().build(); + setup_token2022_config_with_policy(FeePayerPolicy::default()); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let reallocate_ix = spl_token_2022_interface::instruction::reallocate( + &spl_token_2022_interface::id(), + &token_account, + &fee_payer, + &fee_payer, + &[], + &[spl_token_2022_interface::extension::ExtensionType::MemoTransfer], + ) + .unwrap(); + + let message = VersionedMessage::Legacy(Message::new(&[reallocate_ix], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + if let Err(KoraError::InvalidTransaction(msg)) = result { + assert!(msg.contains("Token2022 Reallocate is not allowed")); + } else { + panic!("Expected InvalidTransaction error for token2022 reallocate"); + } + } + + #[tokio::test] + #[serial] + async fn test_token2022_confidential_extension_instructions_rejected() { + let fee_payer = Pubkey::new_unique(); + let rpc_client = RpcMockBuilder::new().build(); + setup_token2022_config_with_policy(FeePayerPolicy::default()); + + let config = get_config().unwrap(); + let validator = TransactionValidator::new(config, fee_payer).unwrap(); + + let confidential_ix = Instruction { + program_id: spl_token_2022_interface::id(), + accounts: vec![], + data: spl_token_2022_interface::instruction::TokenInstruction::ConfidentialTransferExtension + .pack(), + }; + + let message = VersionedMessage::Legacy(Message::new(&[confidential_ix], Some(&fee_payer))); + let mut transaction = + TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap(); + + let result = validator.validate_transaction(config, &mut transaction, &rpc_client).await; + if let Err(KoraError::InvalidTransaction(msg)) = result { + assert!(msg.contains("Confidential Token-2022 instructions are not supported")); + } else { + panic!("Expected InvalidTransaction error for confidential token2022 instruction"); + } + } + #[tokio::test] #[serial] async fn test_fee_payer_policy_mixed_instructions() { diff --git a/examples/getting-started/demo/server/kora.toml b/examples/getting-started/demo/server/kora.toml index 2e1a8935b..bdf3ef510 100644 --- a/examples/getting-started/demo/server/kora.toml +++ b/examples/getting-started/demo/server/kora.toml @@ -110,7 +110,7 @@ type = "margin" # free / margin / fixed margin = 0.1 # Default margin (10%) for paid transaction validation -[validation.token2022] +[validation.token_2022] blocked_mint_extensions = [ # "confidential_transfer_mint", # Confidential transfer configuration for the mint # "confidential_mint_burn", # Confidential mint and burn configuration diff --git a/examples/jito-bundles/server/kora.toml b/examples/jito-bundles/server/kora.toml index 313020279..b9242462a 100644 --- a/examples/jito-bundles/server/kora.toml +++ b/examples/jito-bundles/server/kora.toml @@ -104,7 +104,7 @@ allow_thaw_account = false [validation.price] type = "free" # free / margin / fixed -[validation.token2022] +[validation.token_2022] blocked_mint_extensions = [ "confidential_transfer_mint", # Confidential transfer configuration for the mint "confidential_mint_burn", # Confidential mint and burn configuration diff --git a/examples/x402/demo/kora/kora.toml b/examples/x402/demo/kora/kora.toml index a4ac489bd..546ec1457 100644 --- a/examples/x402/demo/kora/kora.toml +++ b/examples/x402/demo/kora/kora.toml @@ -105,7 +105,7 @@ allow_thaw_account = false [validation.price] type = "free" # free / margin / fixed -[validation.token2022] +[validation.token_2022] blocked_mint_extensions = [ "confidential_transfer_mint", # Confidential transfer configuration for the mint "confidential_mint_burn", # Confidential mint and burn configuration diff --git a/kora.toml b/kora.toml index 951d5a922..7ab88e9f9 100644 --- a/kora.toml +++ b/kora.toml @@ -120,7 +120,7 @@ allow_thaw_account = true # Allow fee payer to thaw Token2022 accounts type = "margin" # free / margin / fixed margin = 0.1 # 10% margin (0.1 = 10%, 1.0 = 100%) -[validation.token2022] +[validation.token_2022] blocked_mint_extensions = [ # "confidential_transfer_mint", # Confidential transfer configuration for the mint # "confidential_mint_burn", # Confidential mint and burn configuration