diff --git a/crates/lib/src/fee/fee.rs b/crates/lib/src/fee/fee.rs index 710a8f60..7c156a62 100644 --- a/crates/lib/src/fee/fee.rs +++ b/crates/lib/src/fee/fee.rs @@ -241,8 +241,14 @@ impl FeeConfigUtil { } // Calculate fee payer outflow if fee payer is provided, to better estimate the potential fee - let fee_payer_outflow = - FeeConfigUtil::calculate_fee_payer_outflow(fee_payer, transaction).await?; + let config = get_config()?; + let fee_payer_outflow = FeeConfigUtil::calculate_fee_payer_outflow( + fee_payer, + transaction, + rpc_client, + &config.validation.price_source, + ) + .await?; // If the transaction for paying the gasless relayer is not included, but we expect a payment, we need to add the fee for the payment instruction // for a better approximation of the fee @@ -359,14 +365,17 @@ impl FeeConfigUtil { } } - /// Calculate the total outflow (SOL spending) that could occur for a fee payer account in a transaction. - /// This includes transfers, account creation, and other operations that could drain the fee payer's balance. + /// Calculate the total outflow (SOL + SPL token value) that could occur for a fee payer account in a transaction. + /// This includes SOL transfers, account creation, SPL token transfers, and other operations that could drain the fee payer's balance. pub async fn calculate_fee_payer_outflow( fee_payer_pubkey: &Pubkey, transaction: &mut VersionedTransactionResolved, + rpc_client: &RpcClient, + price_source: &PriceSource, ) -> Result { let mut total = 0u64; + // Calculate SOL outflow from System Program instructions let parsed_system_instructions = transaction.get_or_parse_system_instructions()?; for instruction in parsed_system_instructions @@ -417,6 +426,27 @@ impl FeeConfigUtil { } } + // Calculate SPL token transfer outflow (converted to lamports value) + let spl_instructions = transaction.get_or_parse_spl_instructions()?; + let empty_vec = vec![]; + let spl_transfers = + spl_instructions.get(&ParsedSPLInstructionType::SplTokenTransfer).unwrap_or(&empty_vec); + + if !spl_transfers.is_empty() { + let spl_outflow = TokenUtil::calculate_spl_transfers_value_in_lamports( + spl_transfers, + fee_payer_pubkey, + price_source, + rpc_client, + ) + .await?; + + total = total.checked_add(spl_outflow).ok_or_else(|| { + log::error!("Fee payer outflow overflow: sol={}, spl={}", total, spl_outflow); + KoraError::ValidationError("Fee payer outflow calculation overflow".to_string()) + })?; + } + Ok(total) } } @@ -580,6 +610,8 @@ mod tests { #[tokio::test] async fn test_calculate_fee_payer_outflow_transfer() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let fee_payer = Pubkey::new_unique(); let recipient = Pubkey::new_unique(); @@ -590,10 +622,14 @@ mod tests { let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 100_000, "Transfer from fee payer should add to outflow"); // Test 2: Fee payer as recipient - should subtract from outflow @@ -603,10 +639,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 0, "Transfer to fee payer should subtract from outflow (saturating)"); // Test 3: Other account as sender - should not affect outflow @@ -616,15 +656,21 @@ mod tests { VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 0, "Transfer from other account should not affect outflow"); } #[tokio::test] async fn test_calculate_fee_payer_outflow_transfer_with_seed() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let fee_payer = Pubkey::new_unique(); let recipient = Pubkey::new_unique(); @@ -641,10 +687,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 150_000, "TransferWithSeed from fee payer should add to outflow"); // Test 2: Fee payer as recipient (index 2 for TransferWithSeed) @@ -661,10 +711,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!( outflow, 0, "TransferWithSeed to fee payer should subtract from outflow (saturating)" @@ -673,6 +727,8 @@ mod tests { #[tokio::test] async fn test_calculate_fee_payer_outflow_create_account() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let fee_payer = Pubkey::new_unique(); let new_account = Pubkey::new_unique(); @@ -683,10 +739,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 200_000, "CreateAccount funded by fee payer should add to outflow"); // Test 2: Other account funding CreateAccount @@ -697,15 +757,21 @@ mod tests { VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 0, "CreateAccount funded by other account should not affect outflow"); } #[tokio::test] async fn test_calculate_fee_payer_outflow_create_account_with_seed() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let fee_payer = Pubkey::new_unique(); let new_account = Pubkey::new_unique(); @@ -723,10 +789,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!( outflow, 300_000, "CreateAccountWithSeed funded by fee payer should add to outflow" @@ -735,6 +805,8 @@ mod tests { #[tokio::test] async fn test_calculate_fee_payer_outflow_nonce_withdraw() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let nonce_account = Pubkey::new_unique(); let fee_payer = Pubkey::new_unique(); let recipient = Pubkey::new_unique(); @@ -746,10 +818,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[withdraw_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!( outflow, 50_000, "WithdrawNonceAccount from fee payer nonce should add to outflow" @@ -763,10 +839,14 @@ mod tests { VersionedMessage::Legacy(Message::new(&[withdraw_instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!( outflow, 0, "WithdrawNonceAccount to fee payer should subtract from outflow (saturating)" @@ -775,6 +855,8 @@ mod tests { #[tokio::test] async fn test_calculate_fee_payer_outflow_multiple_instructions() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let fee_payer = Pubkey::new_unique(); let recipient = Pubkey::new_unique(); let sender = Pubkey::new_unique(); @@ -789,10 +871,14 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&instructions, Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!( outflow, 120_000, "Multiple instructions should sum correctly: 100000 - 30000 + 50000 = 120000" @@ -801,6 +887,8 @@ mod tests { #[tokio::test] async fn test_calculate_fee_payer_outflow_non_system_program() { + setup_or_get_test_config(); + let mocked_rpc_client = RpcMockBuilder::new().build(); let fee_payer = Pubkey::new_unique(); let fake_program = Pubkey::new_unique(); @@ -813,10 +901,14 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut resolved_transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = - FeeConfigUtil::calculate_fee_payer_outflow(&fee_payer, &mut resolved_transaction) - .await - .unwrap(); + let outflow = FeeConfigUtil::calculate_fee_payer_outflow( + &fee_payer, + &mut resolved_transaction, + &mocked_rpc_client, + &crate::oracle::PriceSource::Mock, + ) + .await + .unwrap(); assert_eq!(outflow, 0, "Non-system program should not affect outflow"); } diff --git a/crates/lib/src/metrics/handler.rs b/crates/lib/src/metrics/handler.rs index 13925893..0c6cecbc 100644 --- a/crates/lib/src/metrics/handler.rs +++ b/crates/lib/src/metrics/handler.rs @@ -61,14 +61,14 @@ where .status(StatusCode::OK) .header("content-type", "text/plain; version=0.0.4") .body(Body::from(metrics)) - .unwrap(); + .expect("Failed to build response"); Ok(response) } Err(e) => { let response = Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Body::from(format!("Error gathering metrics: {e}"))) - .unwrap(); + .expect("Failed to build response"); Ok(response) } } diff --git a/crates/lib/src/oracle/jupiter.rs b/crates/lib/src/oracle/jupiter.rs index 8aa09f28..ea0e79f1 100644 --- a/crates/lib/src/oracle/jupiter.rs +++ b/crates/lib/src/oracle/jupiter.rs @@ -3,6 +3,7 @@ use crate::{ constant::{JUPITER_API_LITE_URL, JUPITER_API_PRO_URL, SOL_MINT}, error::KoraError, sanitize_error, + validator::math_validator, }; use once_cell::sync::Lazy; use parking_lot::RwLock; @@ -81,13 +82,29 @@ impl PriceOracle for JupiterPriceOracle { client: &Client, mint_address: &str, ) -> Result { + let prices = self.get_prices(client, &[mint_address.to_string()]).await?; + + prices.get(mint_address).cloned().ok_or_else(|| { + KoraError::RpcError(format!("No price data from Jupiter for mint {mint_address}")) + }) + } + + async fn get_prices( + &self, + client: &Client, + mint_addresses: &[String], + ) -> Result, KoraError> { + if mint_addresses.is_empty() { + return Ok(HashMap::new()); + } + // Try pro API first if API key is available, then fallback to free API if let Some(api_key) = &self.api_key { match self - .fetch_price_from_url(client, &self.pro_api_url, mint_address, Some(api_key)) + .fetch_prices_from_url(client, &self.pro_api_url, mint_addresses, Some(api_key)) .await { - Ok(price) => return Ok(price), + Ok(prices) => return Ok(prices), Err(e) => { if e == KoraError::RateLimitExceeded { log::warn!("Pro Jupiter API rate limit exceeded, falling back to free API"); @@ -99,20 +116,27 @@ impl PriceOracle for JupiterPriceOracle { } // Use free API (either as fallback or primary if no API key) - self.fetch_price_from_url(client, &self.lite_api_url, mint_address, None).await + self.fetch_prices_from_url(client, &self.lite_api_url, mint_addresses, None).await } } impl JupiterPriceOracle { - async fn fetch_price_from_url( + async fn fetch_prices_from_url( &self, client: &Client, api_url: &str, - mint_address: &str, + mint_addresses: &[String], api_key: Option<&String>, - ) -> Result { - // Always fetch SOL price as well so we can convert to SOL - let url = format!("{api_url}?ids={SOL_MINT},{mint_address}"); + ) -> Result, KoraError> { + if mint_addresses.is_empty() { + return Ok(HashMap::new()); + } + + let mut all_mints = vec![SOL_MINT.to_string()]; + all_mints.extend_from_slice(mint_addresses); + let ids = all_mints.join(","); + + let url = format!("{api_url}?ids={ids}"); let mut request = client.get(&url); @@ -143,16 +167,35 @@ impl JupiterPriceOracle { KoraError::RpcError(format!("Failed to parse Jupiter response: {}", sanitize_error!(e))) })?; + // Get SOL price for conversion let sol_price = jupiter_response .get(SOL_MINT) .ok_or_else(|| KoraError::RpcError("No SOL price data from Jupiter".to_string()))?; - let price_data = jupiter_response - .get(mint_address) - .ok_or_else(|| KoraError::RpcError("No price data from Jupiter".to_string()))?; - let price = price_data.usd_price / sol_price.usd_price; + math_validator::validate_division(sol_price.usd_price)?; + + // Convert all prices to SOL-denominated + let mut result = HashMap::new(); + for mint_address in mint_addresses { + if let Some(price_data) = jupiter_response.get(mint_address.as_str()) { + let price = price_data.usd_price / sol_price.usd_price; + result.insert( + mint_address.clone(), + TokenPrice { price, confidence: 0.95, source: PriceSource::Jupiter }, + ); + } else { + log::error!("No price data for mint {mint_address} from Jupiter"); + return Err(KoraError::RpcError(format!( + "No price data from Jupiter for mint {mint_address}" + ))); + } + } + + if result.is_empty() { + return Err(KoraError::RpcError("No price data from Jupiter".to_string())); + } - Ok(TokenPrice { price, confidence: 0.95, source: PriceSource::Jupiter }) + Ok(result) } } @@ -260,7 +303,10 @@ mod tests { assert!(result.is_err()); assert_eq!( result.err(), - Some(KoraError::RpcError("No price data from Jupiter".to_string())) + Some(KoraError::RpcError( + "No price data from Jupiter for mint JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN" + .to_string() + )) ); } } diff --git a/crates/lib/src/oracle/oracle.rs b/crates/lib/src/oracle/oracle.rs index cb9189a1..e2e29b91 100644 --- a/crates/lib/src/oracle/oracle.rs +++ b/crates/lib/src/oracle/oracle.rs @@ -5,7 +5,7 @@ use crate::{ use mockall::automock; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::{sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use tokio::time::sleep; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,6 +28,12 @@ pub enum PriceSource { pub trait PriceOracle { async fn get_price(&self, client: &Client, mint_address: &str) -> Result; + + async fn get_prices( + &self, + client: &Client, + mint_addresses: &[String], + ) -> Result, KoraError>; } pub struct RetryingPriceOracle { @@ -54,14 +60,29 @@ impl RetryingPriceOracle { } pub async fn get_token_price(&self, mint_address: &str) -> Result { + let prices = self.get_token_prices(&[mint_address.to_string()]).await?; + + prices.get(mint_address).cloned().ok_or_else(|| { + KoraError::InternalServerError("Failed to fetch token price".to_string()) + }) + } + + pub async fn get_token_prices( + &self, + mint_addresses: &[String], + ) -> Result, KoraError> { + if mint_addresses.is_empty() { + return Ok(HashMap::new()); + } + let mut last_error = None; let mut delay = self.base_delay; for attempt in 0..self.max_retries { - let price_result = self.oracle.get_price(&self.client, mint_address).await; + let price_result = self.oracle.get_prices(&self.client, mint_addresses).await; match price_result { - Ok(price) => return Ok(price), + Ok(prices) => return Ok(prices), Err(e) => { last_error = Some(e); if attempt < self.max_retries - 1 { @@ -73,7 +94,7 @@ impl RetryingPriceOracle { } Err(last_error.unwrap_or_else(|| { - KoraError::InternalServerError("Failed to fetch token price".to_string()) + KoraError::InternalServerError("Failed to fetch token prices".to_string()) })) } } @@ -87,8 +108,15 @@ mod tests { #[tokio::test] async fn test_price_oracle_retries() { let mut mock_oracle = MockPriceOracle::new(); - mock_oracle.expect_get_price().times(1).returning(|_, _| { - Ok(TokenPrice { price: 1.0, confidence: 0.95, source: PriceSource::Jupiter }) + mock_oracle.expect_get_prices().times(1).returning(|_, mint_addresses| { + let mut result = HashMap::new(); + for mint in mint_addresses { + result.insert( + mint.clone(), + TokenPrice { price: 1.0, confidence: 0.95, source: PriceSource::Jupiter }, + ); + } + Ok(result) }); let oracle = RetryingPriceOracle::new(3, Duration::from_millis(100), Arc::new(mock_oracle)); diff --git a/crates/lib/src/oracle/utils.rs b/crates/lib/src/oracle/utils.rs index 338d1058..5166cc42 100644 --- a/crates/lib/src/oracle/utils.rs +++ b/crates/lib/src/oracle/utils.rs @@ -1,5 +1,5 @@ use crate::oracle::{MockPriceOracle, PriceOracle, PriceSource, TokenPrice}; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; pub const DEFAULT_MOCKED_PRICE: f64 = 0.001; pub const DEFAULT_MOCKED_USDC_PRICE: f64 = 0.0001; @@ -24,6 +24,24 @@ impl OracleUtil { }; Ok(TokenPrice { price, confidence: 1.0, source: PriceSource::Mock }) }); + + mock.expect_get_prices() + .times(..) // Allow unlimited calls + .returning(|_, mint_addresses| { + let mut result = HashMap::new(); + for mint_address in mint_addresses { + let price = match mint_address.as_str() { + USDC_DEVNET_MINT => DEFAULT_MOCKED_USDC_PRICE, // USDC + WSOL_DEVNET_MINT => DEFAULT_MOCKED_WSOL_PRICE, // SOL + _ => DEFAULT_MOCKED_PRICE, // Default price for unknown tokens + }; + result.insert( + mint_address.clone(), + TokenPrice { price, confidence: 1.0, source: PriceSource::Mock }, + ); + } + Ok(result) + }); Arc::new(mock) } } diff --git a/crates/lib/src/rpc_server/auth.rs b/crates/lib/src/rpc_server/auth.rs index 4fa36105..b666023e 100644 --- a/crates/lib/src/rpc_server/auth.rs +++ b/crates/lib/src/rpc_server/auth.rs @@ -54,8 +54,10 @@ where let mut inner = self.inner.clone(); Box::pin(async move { - let unauthorized_response = - Response::builder().status(StatusCode::UNAUTHORIZED).body(Body::empty()).unwrap(); + let unauthorized_response = Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::empty()) + .expect("Failed to build unauthorized response"); let (parts, body_bytes) = extract_parts_and_body_bytes(request).await; if get_jsonrpc_method(&body_bytes) == Some("liveness".to_string()) { @@ -131,8 +133,10 @@ where let mut inner = self.inner.clone(); Box::pin(async move { - let unauthorized_response = - Response::builder().status(StatusCode::UNAUTHORIZED).body(Body::empty()).unwrap(); + let unauthorized_response = Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::empty()) + .expect("Failed to build unauthorized response"); let signature_header = request.headers().get(X_HMAC_SIGNATURE).cloned(); let timestamp_header = request.headers().get(X_TIMESTAMP).cloned(); diff --git a/crates/lib/src/rpc_server/method/sign_transaction.rs b/crates/lib/src/rpc_server/method/sign_transaction.rs index 74d15cbc..c2b24d9e 100644 --- a/crates/lib/src/rpc_server/method/sign_transaction.rs +++ b/crates/lib/src/rpc_server/method/sign_transaction.rs @@ -50,7 +50,7 @@ pub async fn sign_transaction( let (signed_transaction, _) = resolved_transaction.sign_transaction(&signer, rpc_client).await?; - let encoded = TransactionUtil::encode_versioned_transaction(&signed_transaction); + let encoded = TransactionUtil::encode_versioned_transaction(&signed_transaction)?; Ok(SignTransactionResponse { signed_transaction: encoded, diff --git a/crates/lib/src/rpc_server/method/sign_transaction_if_paid.rs b/crates/lib/src/rpc_server/method/sign_transaction_if_paid.rs index f71ef017..a15b4ae9 100644 --- a/crates/lib/src/rpc_server/method/sign_transaction_if_paid.rs +++ b/crates/lib/src/rpc_server/method/sign_transaction_if_paid.rs @@ -54,7 +54,7 @@ pub async fn sign_transaction_if_paid( .map_err(|e| KoraError::TokenOperationError(e.to_string()))?; Ok(SignTransactionIfPaidResponse { - transaction: TransactionUtil::encode_versioned_transaction(&transaction), + transaction: TransactionUtil::encode_versioned_transaction(&transaction)?, signed_transaction, signer_pubkey: signer.pubkey().to_string(), }) diff --git a/crates/lib/src/rpc_server/method/transfer_transaction.rs b/crates/lib/src/rpc_server/method/transfer_transaction.rs index 033a45ef..2e82c650 100644 --- a/crates/lib/src/rpc_server/method/transfer_transaction.rs +++ b/crates/lib/src/rpc_server/method/transfer_transaction.rs @@ -126,7 +126,7 @@ pub async fn transfer_transaction( VersionedTransactionResolved::from_kora_built_transaction(&transaction); // validate transaction before signing - validator.validate_transaction(&mut resolved_transaction).await?; + validator.validate_transaction(&mut resolved_transaction, rpc_client).await?; // Find the fee payer position in the account keys let fee_payer_position = resolved_transaction.find_signer_position(&fee_payer)?; @@ -167,7 +167,7 @@ mod tests { let _ = update_config(config); let _ = setup_or_get_test_signer(); - let rpc_client = Arc::new(RpcMockBuilder::new().build()); + let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build()); let request = TransferTransactionRequest { amount: 1000, @@ -196,7 +196,7 @@ mod tests { let _ = update_config(config); let _ = setup_or_get_test_signer(); - let rpc_client = Arc::new(RpcMockBuilder::new().build()); + let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build()); let request = TransferTransactionRequest { amount: 1000, @@ -224,7 +224,7 @@ mod tests { let _ = update_config(config); let _ = setup_or_get_test_signer(); - let rpc_client = Arc::new(RpcMockBuilder::new().build()); + let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build()); let request = TransferTransactionRequest { amount: 1000, diff --git a/crates/lib/src/sanitize.rs b/crates/lib/src/sanitize.rs index 6b1b3a16..554f49a2 100644 --- a/crates/lib/src/sanitize.rs +++ b/crates/lib/src/sanitize.rs @@ -12,12 +12,13 @@ use std::sync::LazyLock; static URL_WITH_CREDENTIALS_PATTERN: LazyLock = LazyLock::new(|| { // Generic URL pattern with embedded credentials: protocol://user:password@host // Matches any protocol (redis, http, https, postgres, mysql, mongodb, etc.) - Regex::new(r"[a-z][a-z0-9+.-]*://[^:@\s]+:[^@\s]+@[^\s]+").unwrap() + Regex::new(r"[a-z][a-z0-9+.-]*://[^:@\s]+:[^@\s]+@[^\s]+") + .expect("Failed to create url regex pattern") }); static HEX_PATTERN: LazyLock = LazyLock::new(|| { // Long hex strings (likely keys/hashes) - 32+ chars, with optional 0x prefix - Regex::new(r"(?:0x)?[0-9a-fA-F]{32,}").unwrap() + Regex::new(r"(?:0x)?[0-9a-fA-F]{32,}").expect("Failed to create hex regex pattern") }); /// Sanitizes a message by redacting sensitive information diff --git a/crates/lib/src/tests/transaction_mock.rs b/crates/lib/src/tests/transaction_mock.rs index c6e36a03..30e5861f 100644 --- a/crates/lib/src/tests/transaction_mock.rs +++ b/crates/lib/src/tests/transaction_mock.rs @@ -14,7 +14,7 @@ pub fn create_mock_encoded_transaction() -> String { let message = VersionedMessage::Legacy(Message::new(&[ix], Some(&Pubkey::new_unique()))); let transaction = TransactionUtil::new_unsigned_versioned_transaction(message); - TransactionUtil::encode_versioned_transaction(&transaction) + TransactionUtil::encode_versioned_transaction(&transaction).unwrap() } pub fn create_mock_transaction() -> VersionedTransaction { diff --git a/crates/lib/src/token/token.rs b/crates/lib/src/token/token.rs index c4b43df1..23bc2f6e 100644 --- a/crates/lib/src/token/token.rs +++ b/crates/lib/src/token/token.rs @@ -13,7 +13,8 @@ use crate::{ }; use solana_client::nonblocking::rpc_client::RpcClient; use solana_sdk::{native_token::LAMPORTS_PER_SOL, pubkey::Pubkey}; -use std::{str::FromStr, time::Duration}; +use spl_associated_token_account::get_associated_token_address_with_program_id; +use std::{collections::HashMap, str::FromStr, time::Duration}; #[cfg(not(test))] use crate::state::get_config; @@ -140,6 +141,148 @@ impl TokenUtil { Ok(fee_in_token.ceil()) } + /// Calculate the total lamports value of SPL token transfers where the fee payer is involved + /// This includes both outflow (fee payer as owner/source) and inflow (fee payer owns destination) + pub async fn calculate_spl_transfers_value_in_lamports( + spl_transfers: &[ParsedSPLInstructionData], + fee_payer: &Pubkey, + price_source: &PriceSource, + rpc_client: &RpcClient, + ) -> Result { + // Collect all unique mints that need price lookups + let mut mint_to_transfers: HashMap< + Pubkey, + Vec<(u64, bool)>, // (amount, is_outflow) + > = HashMap::new(); + + for transfer in spl_transfers { + if let ParsedSPLInstructionData::SplTokenTransfer { + amount, + owner, + mint, + destination_address, + .. + } = transfer + { + // Check if fee payer is the source (outflow) + if *owner == *fee_payer { + if let Some(mint_pubkey) = mint { + mint_to_transfers.entry(*mint_pubkey).or_default().push((*amount, true)); + } + } else { + // Check if fee payer owns the destination (inflow) + // We need to check the destination token account owner + if let Some(mint_pubkey) = mint { + // Get destination account to check owner + match CacheUtil::get_account(rpc_client, destination_address, false).await { + Ok(dest_account) => { + let token_program = + TokenType::get_token_program_from_owner(&dest_account.owner)?; + if let Ok(token_account) = + token_program.unpack_token_account(&dest_account.data) + { + if token_account.owner() == *fee_payer { + mint_to_transfers + .entry(*mint_pubkey) + .or_default() + .push((*amount, false)); // inflow + } + } + } + Err(e) => { + // If we get Account not found error, we try to match it to the ATA derivation for the fee payer + // in case that ATA is being created in the current instruction + if matches!(e, KoraError::AccountNotFound(_)) { + let spl_ata = + spl_associated_token_account::get_associated_token_address( + fee_payer, + mint_pubkey, + ); + let token2022_ata = + get_associated_token_address_with_program_id( + fee_payer, + mint_pubkey, + &spl_token_2022::id(), + ); + + // If destination matches a valid ATA for fee payer, count as inflow + if *destination_address == spl_ata + || *destination_address == token2022_ata + { + mint_to_transfers + .entry(*mint_pubkey) + .or_default() + .push((*amount, false)); // inflow + } + // Otherwise, it's not fee payer's account, continue to next transfer + } else { + // Skip if destination account doesn't exist or can't be fetched + // This could be problematic for non ATA token accounts created + // during the transaction + continue; + } + } + } + } + } + } + } + + if mint_to_transfers.is_empty() { + return Ok(0); + } + + // Batch fetch all prices and decimals + let mint_addresses: Vec = + mint_to_transfers.keys().map(|mint| mint.to_string()).collect(); + + let oracle = RetryingPriceOracle::new( + 3, + Duration::from_secs(1), + get_price_oracle(price_source.clone()), + ); + + let prices = oracle.get_token_prices(&mint_addresses).await?; + + let mut mint_decimals = std::collections::HashMap::new(); + for mint in mint_to_transfers.keys() { + let decimals = Self::get_mint_decimals(rpc_client, mint).await?; + mint_decimals.insert(*mint, decimals); + } + + // Calculate total value + let mut total_lamports = 0u64; + + for (mint, transfers) in mint_to_transfers.iter() { + let price = prices + .get(&mint.to_string()) + .ok_or_else(|| KoraError::RpcError(format!("No price data for mint {mint}")))?; + let decimals = mint_decimals + .get(mint) + .ok_or_else(|| KoraError::RpcError(format!("No decimals data for mint {mint}")))?; + + for (amount, is_outflow) in transfers { + // Convert token amount to lamports value + let token_amount = *amount as f64 / 10f64.powi(*decimals as i32); + let sol_amount = token_amount * price.price; + let lamports = (sol_amount * LAMPORTS_PER_SOL as f64).floor() as u64; + + if *is_outflow { + // Add outflow to total + total_lamports = total_lamports.checked_add(lamports).ok_or_else(|| { + log::error!("SPL outflow calculation overflow"); + KoraError::ValidationError("SPL outflow calculation overflow".to_string()) + })?; + } else { + // Subtract inflow from total (using saturating_sub to prevent underflow) + total_lamports = total_lamports.saturating_sub(lamports); + } + } + } + + Ok(total_lamports) + } + /// Validate Token2022 extensions for payment instructions /// This checks if any blocked extensions are present on the payment accounts pub async fn validate_token2022_extensions_for_payment( @@ -159,7 +302,10 @@ impl TokenUtil { // Unpack the mint state with extensions let mint_state = token_program.unpack_mint(mint, &mint_data)?; - let mint_with_extensions = mint_state.as_any().downcast_ref::().unwrap(); + let mint_with_extensions = + mint_state.as_any().downcast_ref::().ok_or_else(|| { + KoraError::SerializationError("Failed to downcast mint state.".to_string()) + })?; // Check each extension type present on the mint for extension_type in mint_with_extensions.get_extension_types() { @@ -177,7 +323,9 @@ impl TokenUtil { let source_state = token_program.unpack_token_account(&source_data)?; let source_with_extensions = - source_state.as_any().downcast_ref::().unwrap(); + source_state.as_any().downcast_ref::().ok_or_else(|| { + KoraError::SerializationError("Failed to downcast source state.".to_string()) + })?; for extension_type in source_with_extensions.get_extension_types() { if config.is_account_extension_blocked(*extension_type) { @@ -195,7 +343,9 @@ impl TokenUtil { let destination_state = token_program.unpack_token_account(&destination_data)?; let destination_with_extensions = - destination_state.as_any().downcast_ref::().unwrap(); + destination_state.as_any().downcast_ref::().ok_or_else(|| { + KoraError::SerializationError("Failed to downcast destination state.".to_string()) + })?; for extension_type in destination_with_extensions.get_extension_types() { if config.is_account_extension_blocked(*extension_type) { diff --git a/crates/lib/src/transaction/transaction.rs b/crates/lib/src/transaction/transaction.rs index b74a2531..945bf360 100644 --- a/crates/lib/src/transaction/transaction.rs +++ b/crates/lib/src/transaction/transaction.rs @@ -47,9 +47,13 @@ impl TransactionUtil { VersionedTransactionResolved::from_kora_built_transaction(&transaction) } - pub fn encode_versioned_transaction(transaction: &VersionedTransaction) -> String { - let serialized = bincode::serialize(transaction).unwrap(); - STANDARD.encode(serialized) + pub fn encode_versioned_transaction( + transaction: &VersionedTransaction, + ) -> Result { + let serialized = bincode::serialize(transaction).map_err(|_| { + KoraError::SerializationError("Failed to serialize transaction.".to_string()) + })?; + Ok(STANDARD.encode(serialized)) } } diff --git a/crates/lib/src/transaction/versioned_transaction.rs b/crates/lib/src/transaction/versioned_transaction.rs index 438d9848..2935565b 100644 --- a/crates/lib/src/transaction/versioned_transaction.rs +++ b/crates/lib/src/transaction/versioned_transaction.rs @@ -203,7 +203,10 @@ impl VersionedTransactionResolved { if self.parsed_system_instructions.is_none() { self.parsed_system_instructions = Some(IxUtils::parse_system_instructions(self)?); } - Ok(self.parsed_system_instructions.as_ref().unwrap()) + + self.parsed_system_instructions.as_ref().ok_or_else(|| { + KoraError::SerializationError("Parsed system instructions not found".to_string()) + }) } pub fn get_or_parse_spl_instructions( @@ -212,7 +215,10 @@ impl VersionedTransactionResolved { if self.parsed_spl_instructions.is_none() { self.parsed_spl_instructions = Some(IxUtils::parse_token_instructions(self)?); } - Ok(self.parsed_spl_instructions.as_ref().unwrap()) + + self.parsed_spl_instructions.as_ref().ok_or_else(|| { + KoraError::SerializationError("Parsed SPL instructions not found".to_string()) + }) } } @@ -248,7 +254,7 @@ impl VersionedTransactionOps for VersionedTransactionResolved { let validator = TransactionValidator::new(fee_payer)?; // Validate transaction and accounts (already resolved) - validator.validate_transaction(self).await?; + validator.validate_transaction(self, rpc_client).await?; // Get latest blockhash and update transaction let mut transaction = self.transaction.clone(); diff --git a/crates/lib/src/validator/config_validator.rs b/crates/lib/src/validator/config_validator.rs index 530f0108..c8449e67 100644 --- a/crates/lib/src/validator/config_validator.rs +++ b/crates/lib/src/validator/config_validator.rs @@ -211,10 +211,13 @@ impl ConfigValidator { // Validate usage limit configuration let usage_config = &config.kora.usage_limit; if usage_config.enabled { - let (usage_errors, usage_warnings) = - CacheValidator::validate(usage_config).await.unwrap(); - errors.extend(usage_errors); - warnings.extend(usage_warnings); + if let Ok((usage_errors, usage_warnings)) = CacheValidator::validate(usage_config).await + { + errors.extend(usage_errors); + warnings.extend(usage_warnings); + } else { + errors.push("Failed to validate usage limit cache configuration".to_string()); + } } // RPC validation - only if not skipped @@ -255,14 +258,19 @@ impl ConfigValidator { // Validate missing ATAs for payment address if let Some(payment_address) = &config.kora.payment_address { - let payment_address = Pubkey::from_str(payment_address).unwrap(); - - let atas_to_create = find_missing_atas(rpc_client, &payment_address).await; - - if let Err(e) = atas_to_create { - errors.push(format!("Failed to find missing ATAs: {e}")); - } else if !atas_to_create.unwrap().is_empty() { - errors.push(format!("Missing ATAs for payment address: {payment_address}")); + if let Ok(payment_address) = Pubkey::from_str(payment_address) { + match find_missing_atas(rpc_client, &payment_address).await { + Ok(atas_to_create) => { + if !atas_to_create.is_empty() { + errors.push(format!( + "Missing ATAs for payment address: {payment_address}" + )); + } + } + Err(e) => errors.push(format!("Failed to find missing ATAs: {e}")), + } + } else { + errors.push(format!("Invalid payment address: {payment_address}")); } } } diff --git a/crates/lib/src/validator/math_validator.rs b/crates/lib/src/validator/math_validator.rs new file mode 100644 index 00000000..b5054b53 --- /dev/null +++ b/crates/lib/src/validator/math_validator.rs @@ -0,0 +1,9 @@ +use crate::KoraError; + +pub fn validate_division(divisor: f64) -> Result<(), KoraError> { + if !divisor.is_finite() || divisor <= 0.0 { + return Err(KoraError::RpcError(format!("Invalid division: {}", divisor))); + } + + Ok(()) +} diff --git a/crates/lib/src/validator/mod.rs b/crates/lib/src/validator/mod.rs index ac5c1922..88fb3414 100644 --- a/crates/lib/src/validator/mod.rs +++ b/crates/lib/src/validator/mod.rs @@ -1,5 +1,6 @@ pub mod account_validator; pub mod cache_validator; pub mod config_validator; +pub mod math_validator; pub mod signer_validator; pub mod transaction_validator; diff --git a/crates/lib/src/validator/transaction_validator.rs b/crates/lib/src/validator/transaction_validator.rs index 30ef3949..d2ee3277 100644 --- a/crates/lib/src/validator/transaction_validator.rs +++ b/crates/lib/src/validator/transaction_validator.rs @@ -105,6 +105,7 @@ impl TransactionValidator { pub async fn validate_transaction( &self, transaction_resolved: &mut VersionedTransactionResolved, + rpc_client: &RpcClient, ) -> Result<(), KoraError> { if transaction_resolved.all_instructions.is_empty() { return Err(KoraError::InvalidTransaction( @@ -121,7 +122,7 @@ impl TransactionValidator { self.validate_signatures(&transaction_resolved.transaction)?; self.validate_programs(transaction_resolved)?; - self.validate_transfer_amounts(transaction_resolved).await?; + self.validate_transfer_amounts(transaction_resolved, rpc_client).await?; self.validate_disallowed_accounts(transaction_resolved)?; self.validate_fee_payer_usage(transaction_resolved)?; @@ -262,8 +263,9 @@ impl TransactionValidator { async fn validate_transfer_amounts( &self, transaction_resolved: &mut VersionedTransactionResolved, + rpc_client: &RpcClient, ) -> Result<(), KoraError> { - let total_outflow = self.calculate_total_outflow(transaction_resolved).await?; + let total_outflow = self.calculate_total_outflow(transaction_resolved, rpc_client).await?; if total_outflow > self.max_allowed_lamports { return Err(KoraError::InvalidTransaction(format!( @@ -306,9 +308,16 @@ impl TransactionValidator { async fn calculate_total_outflow( &self, transaction_resolved: &mut VersionedTransactionResolved, + rpc_client: &RpcClient, ) -> Result { - FeeConfigUtil::calculate_fee_payer_outflow(&self.fee_payer_pubkey, transaction_resolved) - .await + let config = get_config()?; + FeeConfigUtil::calculate_fee_payer_outflow( + &self.fee_payer_pubkey, + transaction_resolved, + rpc_client, + &config.validation.price_source, + ) + .await } pub async fn validate_token_payment( @@ -337,7 +346,9 @@ impl TransactionValidator { #[cfg(test)] mod tests { use crate::{ - config::FeePayerPolicy, state::update_config, tests::config_mock::ConfigMockBuilder, + config::FeePayerPolicy, + state::update_config, + tests::{config_mock::ConfigMockBuilder, rpc_mock::RpcMockBuilder}, transaction::TransactionUtil, }; use serial_test::serial; @@ -398,6 +409,7 @@ mod tests { async fn test_validate_transaction() { let fee_payer = Pubkey::new_unique(); setup_default_config(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -407,7 +419,7 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); } #[tokio::test] @@ -415,6 +427,7 @@ mod tests { async fn test_transfer_amount_limits() { let fee_payer = Pubkey::new_unique(); setup_default_config(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); let sender = Pubkey::new_unique(); @@ -425,14 +438,14 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test multiple transfers let instructions = vec![transfer(&sender, &recipient, 500_000), transfer(&sender, &recipient, 500_000)]; let message = VersionedMessage::Legacy(Message::new(&instructions, Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); } #[tokio::test] @@ -440,6 +453,7 @@ mod tests { async fn test_validate_programs() { let fee_payer = Pubkey::new_unique(); setup_default_config(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); let sender = Pubkey::new_unique(); @@ -449,7 +463,7 @@ mod tests { let instruction = transfer(&sender, &recipient, 1000); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test disallowed program let fake_program = Pubkey::new_unique(); @@ -461,7 +475,7 @@ mod tests { ); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -477,6 +491,7 @@ mod tests { .build(); update_config(config).unwrap(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); let sender = Pubkey::new_unique(); let recipient = Pubkey::new_unique(); @@ -490,7 +505,7 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&instructions, Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); transaction.transaction.signatures = vec![Default::default(); 3]; // Add 3 dummy signatures - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -498,6 +513,7 @@ mod tests { async fn test_sign_and_send_transaction_mode() { let fee_payer = Pubkey::new_unique(); setup_default_config(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); let sender = Pubkey::new_unique(); @@ -507,13 +523,13 @@ mod tests { let instruction = transfer(&sender, &recipient, 1000); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test SignAndSend mode without fee payer (should succeed) let instruction = transfer(&sender, &recipient, 1000); let message = VersionedMessage::Legacy(Message::new(&[instruction], None)); // No fee payer specified let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); } #[tokio::test] @@ -521,13 +537,14 @@ mod tests { async fn test_empty_transaction() { let fee_payer = Pubkey::new_unique(); setup_default_config(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); // Create an empty message using Message::new with empty instructions let message = VersionedMessage::Legacy(Message::new(&[], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -545,6 +562,7 @@ mod tests { .build(); update_config(config).unwrap(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); let instruction = transfer( &Pubkey::from_str("hndXZGK45hCxfBYvxejAXzCfCujoqkNf7rk4sTB8pek").unwrap(), @@ -553,7 +571,7 @@ mod tests { ); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -563,6 +581,7 @@ mod tests { let recipient = Pubkey::new_unique(); // Test with allow_sol_transfers = true + let rpc_client = RpcMockBuilder::new().build(); setup_config_with_policy(FeePayerPolicy { allow_sol_transfers: true, ..Default::default() @@ -574,9 +593,10 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_sol_transfers = false + let rpc_client = RpcMockBuilder::new().build(); setup_config_with_policy(FeePayerPolicy { allow_sol_transfers: false, ..Default::default() @@ -587,7 +607,7 @@ mod tests { let instruction = transfer(&fee_payer, &recipient, 1000); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -597,6 +617,7 @@ mod tests { let new_owner = Pubkey::new_unique(); // Test with allow_assign = true + let rpc_client = RpcMockBuilder::new().build(); setup_config_with_policy(FeePayerPolicy { allow_assign: true, ..Default::default() }); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -604,9 +625,10 @@ mod tests { let instruction = assign(&fee_payer, &new_owner); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_assign = false + let rpc_client = RpcMockBuilder::new().build(); setup_config_with_policy(FeePayerPolicy { allow_assign: false, ..Default::default() }); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -614,7 +636,7 @@ mod tests { let instruction = assign(&fee_payer, &new_owner); let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -626,6 +648,7 @@ mod tests { let recipient_token_account = Pubkey::new_unique(); // Test with allow_spl_transfers = true + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_spl_transfers: true, ..Default::default() @@ -645,9 +668,10 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[transfer_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_spl_transfers = false + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_spl_transfers: false, ..Default::default() @@ -667,7 +691,7 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[transfer_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); // Test with other account as source - should always pass let other_signer = Pubkey::new_unique(); @@ -683,7 +707,7 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[transfer_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); } #[tokio::test] @@ -696,6 +720,9 @@ mod tests { let mint = Pubkey::new_unique(); // Test with allow_token2022_transfers = true + let rpc_client = RpcMockBuilder::new() + .with_mint_account(2) // Mock mint with 2 decimals for SPL outflow calculation + .build(); setup_token2022_config_with_policy(FeePayerPolicy { allow_token2022_transfers: true, ..Default::default() @@ -710,16 +737,19 @@ mod tests { &recipient_token_account, &fee_payer, // fee payer is the signer &[], - 1000, + 1, 2, ) .unwrap(); let message = VersionedMessage::Legacy(Message::new(&[transfer_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_token2022_transfers = false + let rpc_client = RpcMockBuilder::new() + .with_mint_account(2) // Mock mint with 2 decimals for SPL outflow calculation + .build(); setup_token2022_config_with_policy(FeePayerPolicy { allow_token2022_transfers: false, ..Default::default() @@ -743,7 +773,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail because fee payer is not allowed to be source - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); // Test with other account as source - should always pass let other_signer = Pubkey::new_unique(); @@ -763,7 +793,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should pass because fee payer is not the source - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); } #[tokio::test] @@ -778,6 +808,7 @@ mod tests { .build(); update_config(config).unwrap(); + let rpc_client = RpcMockBuilder::new().build(); let validator = TransactionValidator::new(fee_payer).unwrap(); // Test 1: Fee payer as sender in Transfer - should add to outflow @@ -786,7 +817,8 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!(outflow, 100_000, "Transfer from fee payer should add to outflow"); // Test 2: Fee payer as recipient in Transfer - should subtract from outflow (account closure) @@ -796,7 +828,8 @@ mod tests { VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!(outflow, 0, "Transfer to fee payer should subtract from outflow"); // 0 - 50_000 = 0 (saturating_sub) // Test 3: Fee payer as funding account in CreateAccount - should add to outflow @@ -811,7 +844,8 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!(outflow, 200_000, "CreateAccount funded by fee payer should add to outflow"); // Test 4: Fee payer as funding account in CreateAccountWithSeed - should add to outflow @@ -829,7 +863,8 @@ mod tests { Some(&fee_payer), )); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!( outflow, 300_000, "CreateAccountWithSeed funded by fee payer should add to outflow" @@ -849,7 +884,8 @@ mod tests { Some(&fee_payer), )); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!(outflow, 150_000, "TransferWithSeed from fee payer should add to outflow"); // Test 6: Multiple instructions - should sum correctly @@ -860,7 +896,8 @@ mod tests { ]; let message = VersionedMessage::Legacy(Message::new(&instructions, Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!( outflow, 120_000, "Multiple instructions should sum correctly: 100000 - 30000 + 50000 = 120000" @@ -872,7 +909,8 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!(outflow, 0, "Transfer from other account should not affect outflow"); // Test 8: Other account funding CreateAccount - should not affect outflow @@ -882,7 +920,8 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); - let outflow = validator.calculate_total_outflow(&mut transaction).await.unwrap(); + let outflow = + validator.calculate_total_outflow(&mut transaction, &rpc_client).await.unwrap(); assert_eq!(outflow, 0, "CreateAccount funded by other account should not affect outflow"); } @@ -894,6 +933,7 @@ mod tests { let mint = Pubkey::new_unique(); // Test with allow_burn = true + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_burn: true, ..Default::default() }); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -911,9 +951,10 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[burn_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should pass because allow_burn is true by default - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_burn = false + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_burn: false, ..Default::default() }); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -932,7 +973,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail because fee payer cannot burn tokens when allow_burn is false - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); // Test burn_checked instruction let burn_checked_ix = spl_token::instruction::burn_checked( @@ -950,7 +991,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should also fail for burn_checked - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -961,6 +1002,7 @@ mod tests { let destination = Pubkey::new_unique(); // Test with allow_close_account = true + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_close_account: true, ..Default::default() @@ -980,9 +1022,10 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[close_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should pass because allow_close_account is true by default - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_close_account = false + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_close_account: false, ..Default::default() @@ -1003,7 +1046,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail because fee payer cannot close accounts when allow_close_account is false - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -1014,6 +1057,7 @@ mod tests { let delegate = Pubkey::new_unique(); // Test with allow_approve = true + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_approve: true, ..Default::default() }); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -1031,9 +1075,10 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[approve_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should pass because allow_approve is true by default - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_approve = false + let rpc_client = RpcMockBuilder::new().build(); setup_spl_config_with_policy(FeePayerPolicy { allow_approve: false, ..Default::default() }); let validator = TransactionValidator::new(fee_payer).unwrap(); @@ -1052,7 +1097,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail because fee payer cannot approve when allow_approve is false - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); // Test approve_checked instruction let mint = Pubkey::new_unique(); @@ -1073,7 +1118,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should also fail for approve_checked - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -1084,6 +1129,7 @@ mod tests { let mint = Pubkey::new_unique(); // Test with allow_burn = false for Token2022 + let rpc_client = RpcMockBuilder::new().build(); setup_token2022_config_with_policy(FeePayerPolicy { allow_burn: false, ..Default::default() @@ -1104,7 +1150,7 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[burn_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail for Token2022 burn - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -1115,6 +1161,7 @@ mod tests { let destination = Pubkey::new_unique(); // Test with allow_close_account = false for Token2022 + let rpc_client = RpcMockBuilder::new().build(); setup_token2022_config_with_policy(FeePayerPolicy { allow_close_account: false, ..FeePayerPolicy::default() @@ -1134,7 +1181,7 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[close_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail for Token2022 close account - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } #[tokio::test] @@ -1145,6 +1192,7 @@ mod tests { let delegate = Pubkey::new_unique(); // Test with allow_approve = true + let rpc_client = RpcMockBuilder::new().build(); setup_token2022_config_with_policy(FeePayerPolicy { allow_approve: true, ..Default::default() @@ -1165,9 +1213,10 @@ mod tests { let message = VersionedMessage::Legacy(Message::new(&[approve_ix], Some(&fee_payer))); let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should pass because allow_approve is true by default - assert!(validator.validate_transaction(&mut transaction).await.is_ok()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_ok()); // Test with allow_approve = false + let rpc_client = RpcMockBuilder::new().build(); setup_token2022_config_with_policy(FeePayerPolicy { allow_approve: false, ..Default::default() @@ -1189,7 +1238,7 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should fail because fee payer cannot approve when allow_approve is false - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); // Test approve_checked instruction let mint = Pubkey::new_unique(); @@ -1210,6 +1259,6 @@ mod tests { let mut transaction = TransactionUtil::new_unsigned_versioned_transaction_resolved(message); // Should also fail for approve_checked - assert!(validator.validate_transaction(&mut transaction).await.is_err()); + assert!(validator.validate_transaction(&mut transaction, &rpc_client).await.is_err()); } } diff --git a/tests/external/jupiter_integration.rs b/tests/external/jupiter_integration.rs index 5cd2f26e..681f6c11 100644 --- a/tests/external/jupiter_integration.rs +++ b/tests/external/jupiter_integration.rs @@ -1,3 +1,4 @@ +use jsonrpsee::core::Error; use kora_lib::oracle::{get_price_oracle, PriceSource, RetryingPriceOracle}; use std::time::Duration; @@ -76,3 +77,27 @@ async fn test_jupiter_integration_sol() { } } } + +#[tokio::test] +async fn test_jupiter_integration_unknown_token() { + const SOL_MINT: &str = "So11111111111111111111111111111111111111112"; + // Invalid token mint + const UNKNOWN_TOKEN_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1w"; + + let oracle = get_price_oracle(PriceSource::Jupiter); + let retrying_oracle = RetryingPriceOracle::new(3, Duration::from_millis(500), oracle); + + let result = retrying_oracle + .get_token_prices(&[SOL_MINT.to_string(), UNKNOWN_TOKEN_MINT.to_string()]) + .await; + + assert!(result.is_err(), "Expected error for unknown token"); + let error = result.unwrap_err(); + assert!( + error.to_string().contains( + "No price data from Jupiter for mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1w" + ), + "Expected error message about unknown mint, got: {}", + error + ); +} diff --git a/tests/rpc/fee_estimation.rs b/tests/rpc/fee_estimation.rs index 2cf96306..2d5b280e 100644 --- a/tests/rpc/fee_estimation.rs +++ b/tests/rpc/fee_estimation.rs @@ -1,6 +1,10 @@ use crate::common::*; use jsonrpsee::rpc_params; -use solana_sdk::{program_pack::Pack, pubkey::Pubkey, signature::Keypair, signer::Signer}; +use solana_sdk::{ + program_pack::Pack, pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::Transaction, +}; +use spl_associated_token_account::get_associated_token_address; #[tokio::test] async fn test_estimate_transaction_fee_legacy() { @@ -345,3 +349,87 @@ async fn test_estimate_fee_comprehensive_with_token_accounts_creation() { expected_minimum_fee + 50_000 ); } + +#[tokio::test] +async fn test_estimate_fee_with_spl_token_transfer_from_fee_payer() { + let ctx = TestContext::new().await.expect("Failed to create test context"); + + let fee_payer = FeePayerTestHelper::get_fee_payer_pubkey(); + let usdc_mint = USDCMintTestHelper::get_test_usdc_mint_pubkey(); + let recipient = Pubkey::new_unique(); + + let fee_payer_ata = get_associated_token_address(&fee_payer, &usdc_mint); + let mint_amount = 10_000_000; + let sender = SenderTestHelper::get_test_sender_keypair(); + + let create_recipient_ata_ix = + spl_associated_token_account::instruction::create_associated_token_account_idempotent( + &sender.pubkey(), // payer + &recipient, // owner + &usdc_mint, // mint + &spl_token::id(), + ); + + let mint_instruction = spl_token::instruction::mint_to( + &spl_token::id(), + &usdc_mint, + &fee_payer_ata, + &sender.pubkey(), // mint authority is sender + &[], + mint_amount, + ) + .expect("Failed to create mint instruction"); + + let recent_blockhash = + ctx.rpc_client().get_latest_blockhash().await.expect("Failed to get blockhash"); + + let fund_transaction = Transaction::new_signed_with_payer( + &[create_recipient_ata_ix, mint_instruction], + Some(&sender.pubkey()), + &[&sender], + recent_blockhash, + ); + + ctx.rpc_client() + .send_and_confirm_transaction(&fund_transaction) + .await + .expect("Failed to fund fee payer ATA"); + + let test_tx = ctx + .transaction_builder() + .with_fee_payer(fee_payer) + .with_spl_transfer_checked( + &usdc_mint, &fee_payer, // Fee payer owns the source token account + &recipient, 1_000_000, 6, + ) + .with_spl_transfer_checked( + &usdc_mint, &fee_payer, // Fee payer owns the source token account + &recipient, 3_000_000, 6, + ) + .build() + .await + .expect("Failed to build transaction"); + + let response: serde_json::Value = ctx + .rpc_call("estimateTransactionFee", rpc_params![test_tx]) + .await + .expect("Failed to estimate transaction fee"); + + response.assert_success(); + response.assert_has_field("fee_in_lamports"); + + let fee_lamports = response["fee_in_lamports"].as_u64().unwrap(); + + // Expected fee breakdown: + // - Base signature fee: ~5,000 lamports + // - SPL token outflow #1: 1 USDC × 0.001 SOL/USDC = 1,000,000 lamports + // - SPL token outflow #2: 3 USDC × 0.001 SOL/USDC = 3,000,000 lamports + // - Payment instruction: ~50 lamports + // Total: ~4,005,050 lamports + + println!("fee_lamports: {fee_lamports}"); + assert_eq!( + fee_lamports, 4_005_050, + "Fee should include SPL token outflow value. Got {fee_lamports} expected 4_005_050", + ); +}