Skip to main content

kora_lib/fee/
fee.rs

1use std::str::FromStr;
2
3use crate::{
4    constant::{ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION, LAMPORTS_PER_SIGNATURE},
5    error::KoraError,
6    fee::price::PriceModel,
7    oracle::PriceSource,
8    token::{
9        spl_token_2022::Token2022Mint,
10        token::{TokenType, TokenUtil},
11        TokenState,
12    },
13    transaction::{
14        ParsedSPLInstructionData, ParsedSPLInstructionType, ParsedSystemInstructionData,
15        ParsedSystemInstructionType, VersionedTransactionResolved,
16    },
17};
18
19#[cfg(not(test))]
20use {crate::cache::CacheUtil, crate::state::get_config};
21
22#[cfg(test)]
23use crate::tests::{cache_mock::MockCacheUtil as CacheUtil, config_mock::mock_state::get_config};
24use solana_client::nonblocking::rpc_client::RpcClient;
25use solana_message::VersionedMessage;
26use solana_sdk::pubkey::Pubkey;
27
28#[derive(Debug, Clone)]
29pub struct TotalFeeCalculation {
30    pub total_fee_lamports: u64,
31    pub base_fee: u64,
32    pub kora_signature_fee: u64,
33    pub fee_payer_outflow: u64,
34    pub payment_instruction_fee: u64,
35    pub transfer_fee_amount: u64,
36}
37
38impl TotalFeeCalculation {
39    pub fn new(
40        total_fee_lamports: u64,
41        base_fee: u64,
42        kora_signature_fee: u64,
43        fee_payer_outflow: u64,
44        payment_instruction_fee: u64,
45        transfer_fee_amount: u64,
46    ) -> Self {
47        Self {
48            total_fee_lamports,
49            base_fee,
50            kora_signature_fee,
51            fee_payer_outflow,
52            payment_instruction_fee,
53            transfer_fee_amount,
54        }
55    }
56
57    pub fn new_fixed(total_fee_lamports: u64) -> Self {
58        Self {
59            total_fee_lamports,
60            base_fee: 0,
61            kora_signature_fee: 0,
62            fee_payer_outflow: 0,
63            payment_instruction_fee: 0,
64            transfer_fee_amount: 0,
65        }
66    }
67
68    pub fn get_total_fee_lamports(&self) -> Result<u64, KoraError> {
69        self.base_fee
70            .checked_add(self.kora_signature_fee)
71            .and_then(|sum| sum.checked_add(self.fee_payer_outflow))
72            .and_then(|sum| sum.checked_add(self.payment_instruction_fee))
73            .and_then(|sum| sum.checked_add(self.transfer_fee_amount))
74            .ok_or_else(|| {
75                log::error!("Fee calculation overflow: base_fee={}, kora_signature_fee={}, fee_payer_outflow={}, payment_instruction_fee={}, transfer_fee_amount={}",
76                    self.base_fee, self.kora_signature_fee, self.fee_payer_outflow, self.payment_instruction_fee, self.transfer_fee_amount);
77                KoraError::ValidationError("Fee calculation overflow".to_string())
78            })
79    }
80}
81
82pub struct FeeConfigUtil {}
83
84impl FeeConfigUtil {
85    fn is_fee_payer_in_signers(
86        transaction: &VersionedTransactionResolved,
87        fee_payer: &Pubkey,
88    ) -> Result<bool, KoraError> {
89        let all_account_keys = &transaction.all_account_keys;
90        let transaction_inner = &transaction.transaction;
91
92        // In messages, the first num_required_signatures accounts are signers
93        Ok(match &transaction_inner.message {
94            VersionedMessage::Legacy(legacy_message) => {
95                let num_signers = legacy_message.header.num_required_signatures as usize;
96                all_account_keys.iter().take(num_signers).any(|key| *key == *fee_payer)
97            }
98            VersionedMessage::V0(v0_message) => {
99                let num_signers = v0_message.header.num_required_signatures as usize;
100                all_account_keys.iter().take(num_signers).any(|key| *key == *fee_payer)
101            }
102        })
103    }
104
105    /// Helper function to check if a token transfer instruction is a payment to Kora
106    /// Returns Some(token_account_data) if it's a payment, None otherwise
107    async fn get_payment_instruction_info(
108        rpc_client: &RpcClient,
109        destination_address: &Pubkey,
110        payment_destination: &Pubkey,
111        skip_missing_accounts: bool,
112    ) -> Result<Option<Box<dyn TokenState + Send + Sync>>, KoraError> {
113        // Get destination account - handle missing accounts based on skip_missing_accounts
114        let destination_account =
115            match CacheUtil::get_account(rpc_client, destination_address, false).await {
116                Ok(account) => account,
117                Err(_) if skip_missing_accounts => {
118                    return Ok(None);
119                }
120                Err(e) => {
121                    return Err(e);
122                }
123            };
124
125        let token_program = TokenType::get_token_program_from_owner(&destination_account.owner)?;
126        let token_account = token_program.unpack_token_account(&destination_account.data)?;
127
128        // Check if this is a payment to Kora
129        if token_account.owner() == *payment_destination {
130            Ok(Some(token_account))
131        } else {
132            Ok(None)
133        }
134    }
135
136    /// Analyze payment instructions in transaction
137    /// Returns (has_payment, total_transfer_fees)
138    async fn analyze_payment_instructions(
139        resolved_transaction: &mut VersionedTransactionResolved,
140        rpc_client: &RpcClient,
141        fee_payer: &Pubkey,
142    ) -> Result<(bool, u64), KoraError> {
143        let config = get_config()?;
144        let payment_destination = config.kora.get_payment_address(fee_payer)?;
145        let mut has_payment = false;
146        let mut total_transfer_fees = 0u64;
147
148        let parsed_spl_instructions = resolved_transaction.get_or_parse_spl_instructions()?;
149
150        for instruction in parsed_spl_instructions
151            .get(&ParsedSPLInstructionType::SplTokenTransfer)
152            .unwrap_or(&vec![])
153        {
154            if let ParsedSPLInstructionData::SplTokenTransfer {
155                mint,
156                amount,
157                is_2022,
158                destination_address,
159                ..
160            } = instruction
161            {
162                // Check if this is a payment to Kora
163                let payment_info = Self::get_payment_instruction_info(
164                    rpc_client,
165                    destination_address,
166                    &payment_destination,
167                    true, // Skip missing accounts
168                )
169                .await?;
170
171                if payment_info.is_some() {
172                    has_payment = true;
173
174                    // Calculate Token2022 transfer fees if applicable
175                    if *is_2022 {
176                        if let Some(mint_pubkey) = mint {
177                            let mint_account =
178                                CacheUtil::get_account(rpc_client, mint_pubkey, true).await?;
179
180                            let token_program =
181                                TokenType::get_token_program_from_owner(&mint_account.owner)?;
182                            let mint_state =
183                                token_program.unpack_mint(mint_pubkey, &mint_account.data)?;
184
185                            if let Some(token2022_mint) =
186                                mint_state.as_any().downcast_ref::<Token2022Mint>()
187                            {
188                                let current_epoch = rpc_client.get_epoch_info().await?.epoch;
189
190                                if let Some(fee_amount) =
191                                    token2022_mint.calculate_transfer_fee(*amount, current_epoch)?
192                                {
193                                    total_transfer_fees = total_transfer_fees
194                                        .checked_add(fee_amount)
195                                        .ok_or_else(|| {
196                                            log::error!(
197                                                "Transfer fee accumulation overflow: total={}, new_fee={}",
198                                                total_transfer_fees,
199                                                fee_amount
200                                            );
201                                            KoraError::ValidationError(
202                                                "Transfer fee accumulation overflow".to_string(),
203                                            )
204                                        })?;
205                                }
206                            }
207                        }
208                    }
209                }
210            }
211        }
212
213        Ok((has_payment, total_transfer_fees))
214    }
215
216    async fn estimate_transaction_fee(
217        rpc_client: &RpcClient,
218        transaction: &mut VersionedTransactionResolved,
219        fee_payer: &Pubkey,
220        is_payment_required: bool,
221    ) -> Result<TotalFeeCalculation, KoraError> {
222        // Get base transaction fee using resolved transaction to handle lookup tables
223        let base_fee =
224            TransactionFeeUtil::get_estimate_fee_resolved(rpc_client, transaction).await?;
225
226        // Priority fees are now included in the calculate done by the RPC getFeeForMessage
227        // ATA and Token account creation fees are captured in the calculate fee payer outflow (System Transfer)
228
229        // If the Kora signer is not inclded in the signers, we add another base fee, since each transaction will be 5000 lamports
230        let mut kora_signature_fee = 0u64;
231        if !FeeConfigUtil::is_fee_payer_in_signers(transaction, fee_payer)? {
232            kora_signature_fee = LAMPORTS_PER_SIGNATURE;
233        }
234
235        // Calculate fee payer outflow if fee payer is provided, to better estimate the potential fee
236        let config = get_config()?;
237        let fee_payer_outflow = FeeConfigUtil::calculate_fee_payer_outflow(
238            fee_payer,
239            transaction,
240            rpc_client,
241            &config.validation.price_source,
242        )
243        .await?;
244
245        // Analyze payment instructions (checks if payment exists + calculates Token2022 fees)
246        let (has_payment, transfer_fee_config_amount) =
247            FeeConfigUtil::analyze_payment_instructions(transaction, rpc_client, fee_payer).await?;
248
249        // If payment is required but not found, add estimated payment instruction fee
250        let fee_for_payment_instruction = if is_payment_required && !has_payment {
251            ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION
252        } else {
253            0
254        };
255
256        let total_fee_lamports = base_fee
257            .checked_add(kora_signature_fee)
258            .and_then(|sum| sum.checked_add(fee_payer_outflow))
259            .and_then(|sum| sum.checked_add(fee_for_payment_instruction))
260            .and_then(|sum| sum.checked_add(transfer_fee_config_amount))
261            .ok_or_else(|| {
262                log::error!("Fee calculation overflow: base_fee={}, kora_signature_fee={}, fee_payer_outflow={}, payment_instruction_fee={}, transfer_fee_amount={}",
263                    base_fee, kora_signature_fee, fee_payer_outflow, fee_for_payment_instruction, transfer_fee_config_amount);
264                KoraError::ValidationError("Fee calculation overflow".to_string())
265            })?;
266
267        Ok(TotalFeeCalculation {
268            total_fee_lamports,
269            base_fee,
270            kora_signature_fee,
271            fee_payer_outflow,
272            payment_instruction_fee: fee_for_payment_instruction,
273            transfer_fee_amount: transfer_fee_config_amount,
274        })
275    }
276
277    /// Main entry point for fee calculation with Kora's price model applied
278    pub async fn estimate_kora_fee(
279        rpc_client: &RpcClient,
280        transaction: &mut VersionedTransactionResolved,
281        fee_payer: &Pubkey,
282        is_payment_required: bool,
283        price_source: PriceSource,
284    ) -> Result<TotalFeeCalculation, KoraError> {
285        let config = get_config()?;
286
287        match &config.validation.price.model {
288            PriceModel::Free => Ok(TotalFeeCalculation::new_fixed(0)),
289            PriceModel::Fixed { strict, .. } => {
290                let fixed_fee_lamports = config
291                    .validation
292                    .price
293                    .get_required_lamports_with_fixed(rpc_client, price_source)
294                    .await?;
295
296                if *strict {
297                    let fee_calculation = Self::estimate_transaction_fee(
298                        rpc_client,
299                        transaction,
300                        fee_payer,
301                        is_payment_required,
302                    )
303                    .await?;
304
305                    Ok(TotalFeeCalculation::new(
306                        fixed_fee_lamports,
307                        fee_calculation.base_fee,
308                        fee_calculation.kora_signature_fee,
309                        fee_calculation.fee_payer_outflow,
310                        fee_calculation.payment_instruction_fee,
311                        fee_calculation.transfer_fee_amount,
312                    ))
313                } else {
314                    Ok(TotalFeeCalculation::new_fixed(fixed_fee_lamports))
315                }
316            }
317            PriceModel::Margin { .. } => {
318                // Get the raw transaction
319                let fee_calculation = Self::estimate_transaction_fee(
320                    rpc_client,
321                    transaction,
322                    fee_payer,
323                    is_payment_required,
324                )
325                .await?;
326
327                let total_fee_lamports = config
328                    .validation
329                    .price
330                    .get_required_lamports_with_margin(fee_calculation.total_fee_lamports)
331                    .await?;
332
333                Ok(TotalFeeCalculation::new(
334                    total_fee_lamports,
335                    fee_calculation.base_fee,
336                    fee_calculation.kora_signature_fee,
337                    fee_calculation.fee_payer_outflow,
338                    fee_calculation.payment_instruction_fee,
339                    fee_calculation.transfer_fee_amount,
340                ))
341            }
342        }
343    }
344
345    /// Calculate the fee in a specific token if provided
346    pub async fn calculate_fee_in_token(
347        rpc_client: &RpcClient,
348        fee_in_lamports: u64,
349        fee_token: Option<&str>,
350    ) -> Result<Option<u64>, KoraError> {
351        if let Some(fee_token) = fee_token {
352            let token_mint = Pubkey::from_str(fee_token).map_err(|_| {
353                KoraError::InvalidTransaction("Invalid fee token mint address".to_string())
354            })?;
355
356            let config = get_config()?;
357            let validation_config = &config.validation;
358
359            if !validation_config.supports_token(fee_token) {
360                return Err(KoraError::InvalidRequest(format!(
361                    "Token {fee_token} is not supported"
362                )));
363            }
364
365            let fee_value_in_token = TokenUtil::calculate_lamports_value_in_token(
366                fee_in_lamports,
367                &token_mint,
368                &validation_config.price_source,
369                rpc_client,
370            )
371            .await?;
372
373            Ok(Some(fee_value_in_token))
374        } else {
375            Ok(None)
376        }
377    }
378
379    /// Calculate the total outflow (SOL + SPL token value) that could occur for a fee payer account in a transaction.
380    /// This includes SOL transfers, account creation, SPL token transfers, and other operations that could drain the fee payer's balance.
381    pub async fn calculate_fee_payer_outflow(
382        fee_payer_pubkey: &Pubkey,
383        transaction: &mut VersionedTransactionResolved,
384        rpc_client: &RpcClient,
385        price_source: &PriceSource,
386    ) -> Result<u64, KoraError> {
387        let mut total = 0u64;
388
389        // Calculate SOL outflow from System Program instructions
390        let parsed_system_instructions = transaction.get_or_parse_system_instructions()?;
391
392        for instruction in parsed_system_instructions
393            .get(&ParsedSystemInstructionType::SystemTransfer)
394            .unwrap_or(&vec![])
395        {
396            if let ParsedSystemInstructionData::SystemTransfer { lamports, sender, receiver } =
397                instruction
398            {
399                if *sender == *fee_payer_pubkey {
400                    total = total.checked_add(*lamports).ok_or_else(|| {
401                        log::error!("Outflow calculation overflow in SystemTransfer");
402                        KoraError::ValidationError("Outflow calculation overflow".to_string())
403                    })?;
404                }
405                if *receiver == *fee_payer_pubkey {
406                    total = total.saturating_sub(*lamports);
407                }
408            }
409        }
410
411        for instruction in parsed_system_instructions
412            .get(&ParsedSystemInstructionType::SystemCreateAccount)
413            .unwrap_or(&vec![])
414        {
415            if let ParsedSystemInstructionData::SystemCreateAccount { lamports, payer } =
416                instruction
417            {
418                if *payer == *fee_payer_pubkey {
419                    total = total.checked_add(*lamports).ok_or_else(|| {
420                        log::error!("Outflow calculation overflow in SystemCreateAccount");
421                        KoraError::ValidationError("Outflow calculation overflow".to_string())
422                    })?;
423                }
424            }
425        }
426
427        for instruction in parsed_system_instructions
428            .get(&ParsedSystemInstructionType::SystemWithdrawNonceAccount)
429            .unwrap_or(&vec![])
430        {
431            if let ParsedSystemInstructionData::SystemWithdrawNonceAccount {
432                lamports,
433                nonce_authority,
434                recipient,
435            } = instruction
436            {
437                if *nonce_authority == *fee_payer_pubkey {
438                    total = total.checked_add(*lamports).ok_or_else(|| {
439                        log::error!("Outflow calculation overflow in SystemWithdrawNonceAccount");
440                        KoraError::ValidationError("Outflow calculation overflow".to_string())
441                    })?;
442                }
443                if *recipient == *fee_payer_pubkey {
444                    total = total.saturating_sub(*lamports);
445                }
446            }
447        }
448
449        // Calculate SPL token transfer outflow (converted to lamports value)
450        let spl_instructions = transaction.get_or_parse_spl_instructions()?;
451        let empty_vec = vec![];
452        let spl_transfers =
453            spl_instructions.get(&ParsedSPLInstructionType::SplTokenTransfer).unwrap_or(&empty_vec);
454
455        if !spl_transfers.is_empty() {
456            let spl_outflow = TokenUtil::calculate_spl_transfers_value_in_lamports(
457                spl_transfers,
458                fee_payer_pubkey,
459                price_source,
460                rpc_client,
461            )
462            .await?;
463
464            total = total.checked_add(spl_outflow).ok_or_else(|| {
465                log::error!("Fee payer outflow overflow: sol={}, spl={}", total, spl_outflow);
466                KoraError::ValidationError("Fee payer outflow calculation overflow".to_string())
467            })?;
468        }
469
470        Ok(total)
471    }
472}
473
474pub struct TransactionFeeUtil {}
475
476impl TransactionFeeUtil {
477    pub async fn get_estimate_fee(
478        rpc_client: &RpcClient,
479        message: &VersionedMessage,
480    ) -> Result<u64, KoraError> {
481        match message {
482            VersionedMessage::Legacy(message) => rpc_client.get_fee_for_message(message).await,
483            VersionedMessage::V0(message) => rpc_client.get_fee_for_message(message).await,
484        }
485        .map_err(|e| KoraError::RpcError(e.to_string()))
486    }
487
488    /// Get fee estimate for a resolved transaction, handling V0 transactions with lookup tables
489    pub async fn get_estimate_fee_resolved(
490        rpc_client: &RpcClient,
491        resolved_transaction: &VersionedTransactionResolved,
492    ) -> Result<u64, KoraError> {
493        let message = &resolved_transaction.transaction.message;
494
495        match message {
496            VersionedMessage::Legacy(message) => {
497                // Legacy transactions don't have lookup tables, use as-is
498                rpc_client.get_fee_for_message(message).await
499            }
500            VersionedMessage::V0(v0_message) => rpc_client.get_fee_for_message(v0_message).await,
501        }
502        .map_err(|e| KoraError::RpcError(e.to_string()))
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::{
510        constant::{ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION, LAMPORTS_PER_SIGNATURE},
511        fee::fee::{FeeConfigUtil, TransactionFeeUtil},
512        tests::{
513            common::{
514                create_mock_rpc_client_with_account, create_mock_token_account,
515                setup_or_get_test_config, setup_or_get_test_signer,
516            },
517            config_mock::ConfigMockBuilder,
518            rpc_mock::RpcMockBuilder,
519        },
520        token::{interface::TokenInterface, spl_token::TokenProgram},
521        transaction::TransactionUtil,
522    };
523    use solana_message::{v0, Message, VersionedMessage};
524    use solana_sdk::{
525        account::Account,
526        hash::Hash,
527        instruction::Instruction,
528        pubkey::Pubkey,
529        signature::{Keypair, Signer},
530    };
531    use solana_system_interface::{
532        instruction::{
533            create_account, create_account_with_seed, transfer, transfer_with_seed,
534            withdraw_nonce_account,
535        },
536        program::ID as SYSTEM_PROGRAM_ID,
537    };
538    use spl_associated_token_account_interface::address::get_associated_token_address;
539
540    #[test]
541    fn test_is_fee_payer_in_signers_legacy_fee_payer_is_signer() {
542        let fee_payer = setup_or_get_test_signer();
543        let other_signer = Keypair::new();
544        let recipient = Keypair::new();
545
546        let instruction = transfer(&other_signer.pubkey(), &recipient.pubkey(), 1000);
547
548        let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer)));
549
550        let resolved_transaction =
551            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
552
553        assert!(FeeConfigUtil::is_fee_payer_in_signers(&resolved_transaction, &fee_payer).unwrap());
554    }
555
556    #[test]
557    fn test_is_fee_payer_in_signers_legacy_fee_payer_not_signer() {
558        let fee_payer_pubkey = setup_or_get_test_signer();
559        let sender = Keypair::new();
560        let recipient = Keypair::new();
561
562        let instruction = transfer(&sender.pubkey(), &recipient.pubkey(), 1000);
563
564        let message =
565            VersionedMessage::Legacy(Message::new(&[instruction], Some(&sender.pubkey())));
566
567        let resolved_transaction =
568            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
569
570        assert!(!FeeConfigUtil::is_fee_payer_in_signers(&resolved_transaction, &fee_payer_pubkey)
571            .unwrap());
572    }
573
574    #[test]
575    fn test_is_fee_payer_in_signers_v0_fee_payer_is_signer() {
576        let fee_payer = setup_or_get_test_signer();
577        let other_signer = Keypair::new();
578        let recipient = Keypair::new();
579
580        let v0_message = v0::Message::try_compile(
581            &fee_payer,
582            &[transfer(&other_signer.pubkey(), &recipient.pubkey(), 1000)],
583            &[],
584            Hash::default(),
585        )
586        .expect("Failed to compile V0 message");
587
588        let message = VersionedMessage::V0(v0_message);
589        let resolved_transaction =
590            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
591
592        assert!(FeeConfigUtil::is_fee_payer_in_signers(&resolved_transaction, &fee_payer).unwrap());
593    }
594
595    #[test]
596    fn test_is_fee_payer_in_signers_v0_fee_payer_not_signer() {
597        let fee_payer_pubkey = setup_or_get_test_signer();
598        let sender = Keypair::new();
599        let recipient = Keypair::new();
600
601        let v0_message = v0::Message::try_compile(
602            &sender.pubkey(),
603            &[transfer(&sender.pubkey(), &recipient.pubkey(), 1000)],
604            &[],
605            Hash::default(),
606        )
607        .expect("Failed to compile V0 message");
608
609        let message = VersionedMessage::V0(v0_message);
610        let resolved_transaction =
611            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
612
613        assert!(!FeeConfigUtil::is_fee_payer_in_signers(&resolved_transaction, &fee_payer_pubkey)
614            .unwrap());
615    }
616
617    #[tokio::test]
618    async fn test_calculate_fee_payer_outflow_transfer() {
619        setup_or_get_test_config();
620        let mocked_rpc_client = RpcMockBuilder::new().build();
621        let fee_payer = Pubkey::new_unique();
622        let recipient = Pubkey::new_unique();
623
624        // Test 1: Fee payer as sender - should add to outflow
625        let transfer_instruction = transfer(&fee_payer, &recipient, 100_000);
626        let message =
627            VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer)));
628        let mut resolved_transaction =
629            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
630
631        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
632            &fee_payer,
633            &mut resolved_transaction,
634            &mocked_rpc_client,
635            &crate::oracle::PriceSource::Mock,
636        )
637        .await
638        .unwrap();
639        assert_eq!(outflow, 100_000, "Transfer from fee payer should add to outflow");
640
641        // Test 2: Fee payer as recipient - should subtract from outflow
642        let sender = Pubkey::new_unique();
643        let transfer_instruction = transfer(&sender, &fee_payer, 50_000);
644        let message =
645            VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer)));
646        let mut resolved_transaction =
647            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
648        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
649            &fee_payer,
650            &mut resolved_transaction,
651            &mocked_rpc_client,
652            &crate::oracle::PriceSource::Mock,
653        )
654        .await
655        .unwrap();
656        assert_eq!(outflow, 0, "Transfer to fee payer should subtract from outflow (saturating)");
657
658        // Test 3: Other account as sender - should not affect outflow
659        let other_sender = Pubkey::new_unique();
660        let transfer_instruction = transfer(&other_sender, &recipient, 500_000);
661        let message =
662            VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer)));
663        let mut resolved_transaction =
664            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
665        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
666            &fee_payer,
667            &mut resolved_transaction,
668            &mocked_rpc_client,
669            &crate::oracle::PriceSource::Mock,
670        )
671        .await
672        .unwrap();
673        assert_eq!(outflow, 0, "Transfer from other account should not affect outflow");
674    }
675
676    #[tokio::test]
677    async fn test_calculate_fee_payer_outflow_transfer_with_seed() {
678        setup_or_get_test_config();
679        let mocked_rpc_client = RpcMockBuilder::new().build();
680        let fee_payer = Pubkey::new_unique();
681        let recipient = Pubkey::new_unique();
682
683        // Test 1: Fee payer as sender (index 1 for TransferWithSeed)
684        let transfer_instruction = transfer_with_seed(
685            &fee_payer,
686            &fee_payer,
687            "test_seed".to_string(),
688            &SYSTEM_PROGRAM_ID,
689            &recipient,
690            150_000,
691        );
692        let message =
693            VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer)));
694        let mut resolved_transaction =
695            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
696        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
697            &fee_payer,
698            &mut resolved_transaction,
699            &mocked_rpc_client,
700            &crate::oracle::PriceSource::Mock,
701        )
702        .await
703        .unwrap();
704        assert_eq!(outflow, 150_000, "TransferWithSeed from fee payer should add to outflow");
705
706        // Test 2: Fee payer as recipient (index 2 for TransferWithSeed)
707        let other_sender = Pubkey::new_unique();
708        let transfer_instruction = transfer_with_seed(
709            &other_sender,
710            &other_sender,
711            "test_seed".to_string(),
712            &SYSTEM_PROGRAM_ID,
713            &fee_payer,
714            75_000,
715        );
716        let message =
717            VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&fee_payer)));
718        let mut resolved_transaction =
719            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
720        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
721            &fee_payer,
722            &mut resolved_transaction,
723            &mocked_rpc_client,
724            &crate::oracle::PriceSource::Mock,
725        )
726        .await
727        .unwrap();
728        assert_eq!(
729            outflow, 0,
730            "TransferWithSeed to fee payer should subtract from outflow (saturating)"
731        );
732    }
733
734    #[tokio::test]
735    async fn test_calculate_fee_payer_outflow_create_account() {
736        setup_or_get_test_config();
737        let mocked_rpc_client = RpcMockBuilder::new().build();
738        let fee_payer = Pubkey::new_unique();
739        let new_account = Pubkey::new_unique();
740
741        // Test 1: Fee payer funding CreateAccount
742        let create_instruction =
743            create_account(&fee_payer, &new_account, 200_000, 100, &SYSTEM_PROGRAM_ID);
744        let message =
745            VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer)));
746        let mut resolved_transaction =
747            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
748        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
749            &fee_payer,
750            &mut resolved_transaction,
751            &mocked_rpc_client,
752            &crate::oracle::PriceSource::Mock,
753        )
754        .await
755        .unwrap();
756        assert_eq!(outflow, 200_000, "CreateAccount funded by fee payer should add to outflow");
757
758        // Test 2: Other account funding CreateAccount
759        let other_funder = Pubkey::new_unique();
760        let create_instruction =
761            create_account(&other_funder, &new_account, 1_000_000, 100, &SYSTEM_PROGRAM_ID);
762        let message =
763            VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer)));
764        let mut resolved_transaction =
765            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
766        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
767            &fee_payer,
768            &mut resolved_transaction,
769            &mocked_rpc_client,
770            &crate::oracle::PriceSource::Mock,
771        )
772        .await
773        .unwrap();
774        assert_eq!(outflow, 0, "CreateAccount funded by other account should not affect outflow");
775    }
776
777    #[tokio::test]
778    async fn test_calculate_fee_payer_outflow_create_account_with_seed() {
779        setup_or_get_test_config();
780        let mocked_rpc_client = RpcMockBuilder::new().build();
781        let fee_payer = Pubkey::new_unique();
782        let new_account = Pubkey::new_unique();
783
784        // Test: Fee payer funding CreateAccountWithSeed
785        let create_instruction = create_account_with_seed(
786            &fee_payer,
787            &new_account,
788            &fee_payer,
789            "test_seed",
790            300_000,
791            100,
792            &SYSTEM_PROGRAM_ID,
793        );
794        let message =
795            VersionedMessage::Legacy(Message::new(&[create_instruction], Some(&fee_payer)));
796        let mut resolved_transaction =
797            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
798        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
799            &fee_payer,
800            &mut resolved_transaction,
801            &mocked_rpc_client,
802            &crate::oracle::PriceSource::Mock,
803        )
804        .await
805        .unwrap();
806        assert_eq!(
807            outflow, 300_000,
808            "CreateAccountWithSeed funded by fee payer should add to outflow"
809        );
810    }
811
812    #[tokio::test]
813    async fn test_calculate_fee_payer_outflow_nonce_withdraw() {
814        setup_or_get_test_config();
815        let mocked_rpc_client = RpcMockBuilder::new().build();
816        let nonce_account = Pubkey::new_unique();
817        let fee_payer = Pubkey::new_unique();
818        let recipient = Pubkey::new_unique();
819
820        // Test 1: Fee payer as nonce account (outflow)
821        let withdraw_instruction =
822            withdraw_nonce_account(&nonce_account, &fee_payer, &recipient, 50_000);
823        let message =
824            VersionedMessage::Legacy(Message::new(&[withdraw_instruction], Some(&fee_payer)));
825        let mut resolved_transaction =
826            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
827        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
828            &fee_payer,
829            &mut resolved_transaction,
830            &mocked_rpc_client,
831            &crate::oracle::PriceSource::Mock,
832        )
833        .await
834        .unwrap();
835        assert_eq!(
836            outflow, 50_000,
837            "WithdrawNonceAccount from fee payer nonce should add to outflow"
838        );
839
840        // Test 2: Fee payer as recipient (inflow)
841        let nonce_account = Pubkey::new_unique();
842        let withdraw_instruction =
843            withdraw_nonce_account(&nonce_account, &fee_payer, &fee_payer, 25_000);
844        let message =
845            VersionedMessage::Legacy(Message::new(&[withdraw_instruction], Some(&fee_payer)));
846        let mut resolved_transaction =
847            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
848        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
849            &fee_payer,
850            &mut resolved_transaction,
851            &mocked_rpc_client,
852            &crate::oracle::PriceSource::Mock,
853        )
854        .await
855        .unwrap();
856        assert_eq!(
857            outflow, 0,
858            "WithdrawNonceAccount to fee payer should subtract from outflow (saturating)"
859        );
860    }
861
862    #[tokio::test]
863    async fn test_calculate_fee_payer_outflow_multiple_instructions() {
864        setup_or_get_test_config();
865        let mocked_rpc_client = RpcMockBuilder::new().build();
866        let fee_payer = Pubkey::new_unique();
867        let recipient = Pubkey::new_unique();
868        let sender = Pubkey::new_unique();
869        let new_account = Pubkey::new_unique();
870
871        // Multiple instructions involving fee payer
872        let instructions = vec![
873            transfer(&fee_payer, &recipient, 100_000), // +100,000
874            transfer(&sender, &fee_payer, 30_000),     // -30,000
875            create_account(&fee_payer, &new_account, 50_000, 100, &SYSTEM_PROGRAM_ID), // +50,000
876        ];
877        let message = VersionedMessage::Legacy(Message::new(&instructions, Some(&fee_payer)));
878        let mut resolved_transaction =
879            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
880        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
881            &fee_payer,
882            &mut resolved_transaction,
883            &mocked_rpc_client,
884            &crate::oracle::PriceSource::Mock,
885        )
886        .await
887        .unwrap();
888        assert_eq!(
889            outflow, 120_000,
890            "Multiple instructions should sum correctly: 100000 - 30000 + 50000 = 120000"
891        );
892    }
893
894    #[tokio::test]
895    async fn test_calculate_fee_payer_outflow_non_system_program() {
896        setup_or_get_test_config();
897        let mocked_rpc_client = RpcMockBuilder::new().build();
898        let fee_payer = Pubkey::new_unique();
899        let fake_program = Pubkey::new_unique();
900
901        // Test with non-system program - should not affect outflow
902        let instruction = Instruction::new_with_bincode(
903            fake_program,
904            &[0u8],
905            vec![], // no accounts needed for this test
906        );
907        let message = VersionedMessage::Legacy(Message::new(&[instruction], Some(&fee_payer)));
908        let mut resolved_transaction =
909            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
910        let outflow = FeeConfigUtil::calculate_fee_payer_outflow(
911            &fee_payer,
912            &mut resolved_transaction,
913            &mocked_rpc_client,
914            &crate::oracle::PriceSource::Mock,
915        )
916        .await
917        .unwrap();
918        assert_eq!(outflow, 0, "Non-system program should not affect outflow");
919    }
920
921    #[tokio::test]
922    async fn test_analyze_payment_instructions_with_payment() {
923        let _m = ConfigMockBuilder::new().build_and_setup();
924        let cache_ctx = CacheUtil::get_account_context();
925        cache_ctx.checkpoint();
926        let signer = setup_or_get_test_signer();
927        let mint = Pubkey::new_unique();
928
929        let mocked_account = create_mock_token_account(&signer, &mint);
930        let mocked_rpc_client = create_mock_rpc_client_with_account(&mocked_account);
931
932        // Set up cache expectation for token account lookup
933        cache_ctx.expect().times(1).returning(move |_, _, _| Ok(mocked_account.clone()));
934
935        let sender = Keypair::new();
936
937        let sender_token_account = get_associated_token_address(&sender.pubkey(), &mint);
938        let payment_token_account = get_associated_token_address(&signer, &mint);
939
940        let transfer_instruction = TokenProgram::new()
941            .create_transfer_instruction(
942                &sender_token_account,
943                &payment_token_account,
944                &sender.pubkey(),
945                1000,
946            )
947            .unwrap();
948
949        // Create message with the payment instruction
950        let message = VersionedMessage::Legacy(Message::new(&[transfer_instruction], None));
951        let mut resolved_transaction =
952            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
953
954        let (has_payment, transfer_fees) = FeeConfigUtil::analyze_payment_instructions(
955            &mut resolved_transaction,
956            &mocked_rpc_client,
957            &signer,
958        )
959        .await
960        .unwrap();
961
962        assert!(has_payment, "Should detect payment instruction");
963        assert_eq!(transfer_fees, 0, "Should have no transfer fees for SPL token");
964    }
965
966    #[tokio::test]
967    async fn test_analyze_payment_instructions_without_payment() {
968        let signer = setup_or_get_test_signer();
969        setup_or_get_test_config();
970        let mocked_rpc_client = create_mock_rpc_client_with_account(&Account::default());
971
972        let sender = Keypair::new();
973        let recipient = Pubkey::new_unique();
974
975        // Create SOL transfer instruction (no SPL transfer to payment destination)
976        let sol_transfer = transfer(&sender.pubkey(), &recipient, 100_000);
977
978        // Create message without payment instruction
979        let message = VersionedMessage::Legacy(Message::new(&[sol_transfer], None));
980        let mut resolved_transaction =
981            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
982
983        let (has_payment, transfer_fees) = FeeConfigUtil::analyze_payment_instructions(
984            &mut resolved_transaction,
985            &mocked_rpc_client,
986            &signer,
987        )
988        .await
989        .unwrap();
990
991        assert!(!has_payment, "Should not detect payment instruction");
992        assert_eq!(transfer_fees, 0, "Should have no transfer fees");
993    }
994
995    #[tokio::test]
996    async fn test_analyze_payment_instructions_with_wrong_destination() {
997        let _m = ConfigMockBuilder::new().build_and_setup();
998        let cache_ctx = CacheUtil::get_account_context();
999        cache_ctx.checkpoint();
1000        let signer = setup_or_get_test_signer();
1001        let sender = Keypair::new();
1002        let mint = Pubkey::new_unique();
1003
1004        let mocked_account = create_mock_token_account(&sender.pubkey(), &mint);
1005        let mocked_rpc_client = create_mock_rpc_client_with_account(&mocked_account);
1006
1007        // Set up cache expectation for token account lookup
1008        cache_ctx.expect().times(1).returning(move |_, _, _| Ok(mocked_account.clone()));
1009
1010        // Create token accounts
1011        let sender_token_account = get_associated_token_address(&sender.pubkey(), &mint);
1012        let recipient_token_account = get_associated_token_address(&sender.pubkey(), &mint);
1013
1014        // Create SPL transfer instruction to DIFFERENT destination (not payment)
1015        let transfer_instruction = TokenProgram::new()
1016            .create_transfer_instruction(
1017                &sender_token_account,
1018                &recipient_token_account,
1019                &sender.pubkey(),
1020                1000,
1021            )
1022            .unwrap();
1023
1024        // Create message with non-payment transfer
1025        let message = VersionedMessage::Legacy(Message::new(&[transfer_instruction], None));
1026        let mut resolved_transaction =
1027            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
1028
1029        let (has_payment, transfer_fees) = FeeConfigUtil::analyze_payment_instructions(
1030            &mut resolved_transaction,
1031            &mocked_rpc_client,
1032            &signer,
1033        )
1034        .await
1035        .unwrap();
1036
1037        assert!(!has_payment, "Should not detect payment to wrong destination");
1038        assert_eq!(transfer_fees, 0, "Should have no transfer fees");
1039    }
1040
1041    #[tokio::test]
1042    async fn test_estimate_transaction_fee_basic() {
1043        let _m = ConfigMockBuilder::new().build_and_setup();
1044
1045        let fee_payer = Keypair::new();
1046        let recipient = Pubkey::new_unique();
1047
1048        // Mock RPC client that returns base fee
1049        let mocked_rpc_client = RpcMockBuilder::new().with_fee_estimate(5000).build();
1050
1051        // Create simple SOL transfer
1052        let transfer_instruction = transfer(&fee_payer.pubkey(), &recipient, 100_000);
1053        let message = VersionedMessage::Legacy(Message::new(
1054            &[transfer_instruction],
1055            Some(&fee_payer.pubkey()),
1056        ));
1057        let mut resolved_transaction =
1058            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
1059
1060        let result = FeeConfigUtil::estimate_transaction_fee(
1061            &mocked_rpc_client,
1062            &mut resolved_transaction,
1063            &fee_payer.pubkey(),
1064            false,
1065        )
1066        .await
1067        .unwrap();
1068
1069        // Should include base fee (5000) + fee payer outflow (100_000)
1070        assert_eq!(result.total_fee_lamports, 105_000, "Should return base fee + outflow");
1071    }
1072
1073    #[tokio::test]
1074    async fn test_estimate_transaction_fee_kora_signer_not_in_signers() {
1075        let _m = ConfigMockBuilder::new().build_and_setup();
1076
1077        let sender = Keypair::new();
1078        let kora_fee_payer = Keypair::new();
1079        let recipient = Pubkey::new_unique();
1080
1081        let mocked_rpc_client = RpcMockBuilder::new().with_fee_estimate(5000).build();
1082
1083        // Create transaction where sender pays, but kora_fee_payer is different
1084        let transfer_instruction = transfer(&sender.pubkey(), &recipient, 100_000);
1085        let message =
1086            VersionedMessage::Legacy(Message::new(&[transfer_instruction], Some(&sender.pubkey())));
1087        let mut resolved_transaction =
1088            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
1089
1090        let result = FeeConfigUtil::estimate_transaction_fee(
1091            &mocked_rpc_client,
1092            &mut resolved_transaction,
1093            &kora_fee_payer.pubkey(),
1094            false,
1095        )
1096        .await
1097        .unwrap();
1098
1099        // Should include base fee + kora signature fee since kora signer not in transaction signers
1100        assert_eq!(
1101            result.total_fee_lamports,
1102            5000 + LAMPORTS_PER_SIGNATURE,
1103            "Should add Kora signature fee"
1104        );
1105    }
1106
1107    #[tokio::test]
1108    async fn test_estimate_transaction_fee_with_payment_required() {
1109        let _m = ConfigMockBuilder::new().build_and_setup();
1110        let cache_ctx = CacheUtil::get_account_context();
1111        cache_ctx.checkpoint();
1112
1113        let fee_payer = Keypair::new();
1114        let recipient = Pubkey::new_unique();
1115
1116        let mocked_rpc_client = RpcMockBuilder::new().with_fee_estimate(5000).build();
1117
1118        // Create transaction with no payment instruction
1119        let transfer_instruction = transfer(&fee_payer.pubkey(), &recipient, 100_000);
1120        let message = VersionedMessage::Legacy(Message::new(
1121            &[transfer_instruction],
1122            Some(&fee_payer.pubkey()),
1123        ));
1124        let mut resolved_transaction =
1125            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
1126
1127        let result = FeeConfigUtil::estimate_transaction_fee(
1128            &mocked_rpc_client,
1129            &mut resolved_transaction,
1130            &fee_payer.pubkey(),
1131            true, // payment required
1132        )
1133        .await
1134        .unwrap();
1135
1136        // Should include base fee + fee payer outflow + payment instruction fee
1137        let expected = 5000 + 100_000 + ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION;
1138        assert_eq!(
1139            result.total_fee_lamports, expected,
1140            "Should include payment instruction fee when required"
1141        );
1142    }
1143
1144    #[tokio::test]
1145    async fn test_analyze_payment_instructions_with_multiple_payments() {
1146        let _m = ConfigMockBuilder::new().build_and_setup();
1147        let cache_ctx = CacheUtil::get_account_context();
1148        cache_ctx.checkpoint();
1149        let signer = setup_or_get_test_signer();
1150        let mint = Pubkey::new_unique();
1151
1152        let mocked_account = create_mock_token_account(&signer, &mint);
1153        let mocked_rpc_client = create_mock_rpc_client_with_account(&mocked_account);
1154
1155        cache_ctx.expect().times(2).returning(move |_, _, _| Ok(mocked_account.clone()));
1156
1157        let sender = Keypair::new();
1158        let sender_token_account = get_associated_token_address(&sender.pubkey(), &mint);
1159        let payment_token_account = get_associated_token_address(&signer, &mint);
1160
1161        let transfer_1 = TokenProgram::new()
1162            .create_transfer_instruction(
1163                &sender_token_account,
1164                &payment_token_account,
1165                &sender.pubkey(),
1166                500,
1167            )
1168            .unwrap();
1169
1170        let transfer_2 = TokenProgram::new()
1171            .create_transfer_instruction(
1172                &sender_token_account,
1173                &payment_token_account,
1174                &sender.pubkey(),
1175                500,
1176            )
1177            .unwrap();
1178
1179        let message = VersionedMessage::Legacy(Message::new(&[transfer_1, transfer_2], None));
1180        let mut resolved_transaction =
1181            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
1182
1183        let (has_payment, transfer_fees) = FeeConfigUtil::analyze_payment_instructions(
1184            &mut resolved_transaction,
1185            &mocked_rpc_client,
1186            &signer,
1187        )
1188        .await
1189        .unwrap();
1190
1191        assert!(has_payment, "Should detect payment instructions");
1192        assert_eq!(transfer_fees, 0, "Should have no transfer fees for SPL tokens");
1193    }
1194
1195    #[tokio::test]
1196    async fn test_transaction_fee_util_get_estimate_fee_legacy() {
1197        let mocked_rpc_client = RpcMockBuilder::new().with_fee_estimate(7500).build();
1198
1199        let fee_payer = Keypair::new();
1200        let recipient = Pubkey::new_unique();
1201        let transfer_instruction = transfer(&fee_payer.pubkey(), &recipient, 50_000);
1202
1203        let legacy_message = Message::new(&[transfer_instruction], Some(&fee_payer.pubkey()));
1204        let versioned_message = VersionedMessage::Legacy(legacy_message);
1205
1206        let result = TransactionFeeUtil::get_estimate_fee(&mocked_rpc_client, &versioned_message)
1207            .await
1208            .unwrap();
1209
1210        assert_eq!(result, 7500, "Should return mocked base fee for legacy message");
1211    }
1212
1213    #[tokio::test]
1214    async fn test_transaction_fee_util_get_estimate_fee_v0() {
1215        let mocked_rpc_client = RpcMockBuilder::new().with_fee_estimate(12500).build();
1216
1217        let fee_payer = Keypair::new();
1218        let recipient = Pubkey::new_unique();
1219        let transfer_instruction = transfer(&fee_payer.pubkey(), &recipient, 50_000);
1220
1221        let v0_message = v0::Message::try_compile(
1222            &fee_payer.pubkey(),
1223            &[transfer_instruction],
1224            &[],
1225            Hash::default(),
1226        )
1227        .expect("Failed to compile V0 message");
1228
1229        let versioned_message = VersionedMessage::V0(v0_message);
1230
1231        let result = TransactionFeeUtil::get_estimate_fee(&mocked_rpc_client, &versioned_message)
1232            .await
1233            .unwrap();
1234
1235        assert_eq!(result, 12500, "Should return mocked base fee for V0 message");
1236    }
1237}