1+ use std:: str:: FromStr ;
2+
13use crate :: {
24 constant:: { ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION , LAMPORTS_PER_SIGNATURE } ,
35 error:: KoraError ,
4- token:: token:: TokenType ,
6+ fee:: price:: PriceModel ,
7+ oracle:: PriceSource ,
8+ token:: {
9+ spl_token_2022:: Token2022Mint ,
10+ token:: { TokenType , TokenUtil } ,
11+ TokenState ,
12+ } ,
513 transaction:: {
614 ParsedSPLInstructionData , ParsedSPLInstructionType , ParsedSystemInstructionData ,
715 ParsedSystemInstructionType , VersionedTransactionResolved ,
@@ -23,6 +31,17 @@ use solana_sdk::{pubkey::Pubkey, rent::Rent};
2331use spl_associated_token_account:: get_associated_token_address;
2432use spl_token:: state:: Account as SplTokenAccountState ;
2533
34+ #[ derive( Debug , Clone ) ]
35+ pub struct TotalFeeCalculation {
36+ pub total_fee_lamports : u64 ,
37+ pub base_fee : u64 ,
38+ pub account_creation_fee : u64 ,
39+ pub kora_signature_fee : u64 ,
40+ pub fee_payer_outflow : u64 ,
41+ pub payment_instruction_fee : u64 ,
42+ pub transfer_fee_amount : u64 ,
43+ }
44+
2645pub struct FeeConfigUtil { }
2746
2847impl FeeConfigUtil {
@@ -115,12 +134,43 @@ impl FeeConfigUtil {
115134 Ok ( total_lamports)
116135 }
117136
137+ /// Helper function to check if a token transfer instruction is a payment to Kora
138+ /// Returns Some(token_account_data) if it's a payment, None otherwise
139+ async fn get_payment_instruction_info (
140+ rpc_client : & RpcClient ,
141+ destination_address : & Pubkey ,
142+ payment_destination : & Pubkey ,
143+ skip_missing_accounts : bool ,
144+ ) -> Result < Option < Box < dyn TokenState + Send + Sync > > , KoraError > {
145+ // Get destination account - handle missing accounts based on skip_missing_accounts
146+ let destination_account =
147+ match CacheUtil :: get_account ( rpc_client, destination_address, false ) . await {
148+ Ok ( account) => account,
149+ Err ( _) if skip_missing_accounts => {
150+ return Ok ( None ) ;
151+ }
152+ Err ( e) => {
153+ return Err ( e) ;
154+ }
155+ } ;
156+
157+ let token_program = TokenType :: get_token_program_from_owner ( & destination_account. owner ) ?;
158+ let token_account = token_program. unpack_token_account ( & destination_account. data ) ?;
159+
160+ // Check if this is a payment to Kora
161+ if token_account. owner ( ) == * payment_destination {
162+ Ok ( Some ( token_account) )
163+ } else {
164+ Ok ( None )
165+ }
166+ }
167+
118168 async fn has_payment_instruction (
119169 resolved_transaction : & mut VersionedTransactionResolved ,
120170 rpc_client : & RpcClient ,
121171 fee_payer : & Pubkey ,
122172 ) -> Result < u64 , KoraError > {
123- let payment_destination = & get_config ( ) ?. kora . get_payment_address ( fee_payer) ?;
173+ let payment_destination = get_config ( ) ?. kora . get_payment_address ( fee_payer) ?;
124174
125175 for instruction in resolved_transaction
126176 . get_or_parse_spl_instructions ( ) ?
@@ -130,16 +180,15 @@ impl FeeConfigUtil {
130180 if let ParsedSPLInstructionData :: SplTokenTransfer { destination_address, .. } =
131181 instruction
132182 {
133- let destination_account =
134- CacheUtil :: get_account ( rpc_client, destination_address, false ) . await ?;
135-
136- let token_program =
137- TokenType :: get_token_program_from_owner ( & destination_account. owner ) ?;
138-
139- let token_account =
140- token_program. unpack_token_account ( & destination_account. data ) ?;
141-
142- if token_account. owner ( ) == * payment_destination {
183+ if Self :: get_payment_instruction_info (
184+ rpc_client,
185+ destination_address,
186+ & payment_destination,
187+ false , // Don't skip missing accounts for has_payment_instruction
188+ )
189+ . await ?
190+ . is_some ( )
191+ {
143192 return Ok ( 0 ) ;
144193 }
145194 }
@@ -149,12 +198,79 @@ impl FeeConfigUtil {
149198 Ok ( ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION )
150199 }
151200
152- pub async fn estimate_transaction_fee (
201+ /// Calculate transfer fees for token transfers in the transaction
202+ async fn calculate_transfer_fees (
153203 rpc_client : & RpcClient ,
154204 transaction : & mut VersionedTransactionResolved ,
155205 fee_payer : & Pubkey ,
156- is_payment_required : bool ,
157206 ) -> Result < u64 , KoraError > {
207+ let config = get_config ( ) ?;
208+ let payment_destination = config. kora . get_payment_address ( fee_payer) ?;
209+
210+ let parsed_spl_instructions = transaction. get_or_parse_spl_instructions ( ) ?;
211+
212+ for instruction in parsed_spl_instructions
213+ . get ( & ParsedSPLInstructionType :: SplTokenTransfer )
214+ . unwrap_or ( & vec ! [ ] )
215+ {
216+ if let ParsedSPLInstructionData :: SplTokenTransfer {
217+ mint,
218+ amount,
219+ is_2022,
220+ destination_address,
221+ ..
222+ } = instruction
223+ {
224+ // Check if this is a payment to Kora
225+ // Skip if destination account doesn't exist (not a payment to existing Kora account)
226+ if Self :: get_payment_instruction_info (
227+ rpc_client,
228+ destination_address,
229+ & payment_destination,
230+ true , // Skip missing accounts for transfer fee calculation
231+ )
232+ . await ?
233+ . is_none ( )
234+ {
235+ continue ;
236+ }
237+
238+ if let Some ( mint_pubkey) = mint {
239+ // Get mint account to calculate transfer fees
240+ let mint_account =
241+ CacheUtil :: get_account ( rpc_client, mint_pubkey, true ) . await ?;
242+
243+ let token_program =
244+ TokenType :: get_token_program_from_owner ( & mint_account. owner ) ?;
245+ let mint_state = token_program. unpack_mint ( mint_pubkey, & mint_account. data ) ?;
246+
247+ if * is_2022 {
248+ // For Token2022, check for transfer fees
249+ if let Some ( token2022_mint) =
250+ mint_state. as_any ( ) . downcast_ref :: < Token2022Mint > ( )
251+ {
252+ let current_epoch = rpc_client. get_epoch_info ( ) . await ?. epoch ;
253+
254+ if let Some ( fee_amount) =
255+ token2022_mint. calculate_transfer_fee ( * amount, current_epoch)
256+ {
257+ return Ok ( fee_amount) ;
258+ }
259+ }
260+ }
261+ }
262+ }
263+ }
264+
265+ Ok ( 0 )
266+ }
267+
268+ async fn estimate_transaction_fee (
269+ rpc_client : & RpcClient ,
270+ transaction : & mut VersionedTransactionResolved ,
271+ fee_payer : & Pubkey ,
272+ is_payment_required : bool ,
273+ ) -> Result < TotalFeeCalculation , KoraError > {
158274 // Get base transaction fee using resolved transaction to handle lookup tables
159275 let base_fee =
160276 TransactionFeeUtil :: get_estimate_fee_resolved ( rpc_client, transaction) . await ?;
@@ -185,11 +301,106 @@ impl FeeConfigUtil {
185301 0
186302 } ;
187303
188- Ok ( base_fee
304+ let transfer_fee_config_amount =
305+ FeeConfigUtil :: calculate_transfer_fees ( rpc_client, transaction, fee_payer) . await ?;
306+
307+ let total_fee_lamports = base_fee
189308 + account_creation_fee
190309 + kora_signature_fee
191310 + fee_payer_outflow
192- + fee_for_payment_instruction)
311+ + fee_for_payment_instruction
312+ + transfer_fee_config_amount;
313+
314+ Ok ( TotalFeeCalculation {
315+ total_fee_lamports,
316+ base_fee,
317+ account_creation_fee,
318+ kora_signature_fee,
319+ fee_payer_outflow,
320+ payment_instruction_fee : fee_for_payment_instruction,
321+ transfer_fee_amount : transfer_fee_config_amount,
322+ } )
323+ }
324+
325+ /// Main entry point for fee calculation with Kora's price model applied
326+ pub async fn estimate_kora_fee (
327+ rpc_client : & RpcClient ,
328+ transaction : & mut VersionedTransactionResolved ,
329+ fee_payer : & Pubkey ,
330+ is_payment_required : bool ,
331+ price_source : Option < PriceSource > ,
332+ ) -> Result < TotalFeeCalculation , KoraError > {
333+ let config = get_config ( ) ?;
334+
335+ // Check if the price is free, so that we can return early (and skip expensive RPC calls / estimation)
336+ if matches ! ( & config. validation. price. model, PriceModel :: Free ) {
337+ return Ok ( TotalFeeCalculation {
338+ total_fee_lamports : 0 ,
339+ base_fee : 0 ,
340+ account_creation_fee : 0 ,
341+ kora_signature_fee : 0 ,
342+ fee_payer_outflow : 0 ,
343+ payment_instruction_fee : 0 ,
344+ transfer_fee_amount : 0 ,
345+ } ) ;
346+ }
347+
348+ // Get the raw transaction fees
349+ let mut fee_calculation =
350+ Self :: estimate_transaction_fee ( rpc_client, transaction, fee_payer, is_payment_required)
351+ . await ?;
352+
353+ // Apply Kora's price model
354+ if let Some ( price_source) = price_source {
355+ let adjusted_fee = config
356+ . validation
357+ . price
358+ . get_required_lamports (
359+ Some ( rpc_client) ,
360+ Some ( price_source) ,
361+ fee_calculation. total_fee_lamports ,
362+ )
363+ . await ?;
364+
365+ // Update the total with the price model applied
366+ fee_calculation. total_fee_lamports = adjusted_fee;
367+ }
368+
369+ Ok ( fee_calculation)
370+ }
371+
372+ /// Calculate the fee in a specific token if provided
373+ pub async fn calculate_fee_in_token (
374+ rpc_client : & RpcClient ,
375+ fee_in_lamports : u64 ,
376+ fee_token : Option < & str > ,
377+ ) -> Result < Option < f64 > , KoraError > {
378+ if let Some ( fee_token) = fee_token {
379+ let token_mint = Pubkey :: from_str ( fee_token) . map_err ( |_| {
380+ KoraError :: InvalidTransaction ( "Invalid fee token mint address" . to_string ( ) )
381+ } ) ?;
382+
383+ let config = get_config ( ) ?;
384+ let validation_config = & config. validation ;
385+
386+ if !validation_config. supports_token ( fee_token) {
387+ return Err ( KoraError :: InvalidRequest ( format ! (
388+ "Token {fee_token} is not supported"
389+ ) ) ) ;
390+ }
391+
392+ let fee_value_in_token = TokenUtil :: calculate_lamports_value_in_token (
393+ fee_in_lamports,
394+ & token_mint,
395+ & validation_config. price_source ,
396+ rpc_client,
397+ )
398+ . await ?;
399+
400+ Ok ( Some ( fee_value_in_token) )
401+ } else {
402+ Ok ( None )
403+ }
193404 }
194405
195406 /// Calculate the total outflow (SOL spending) that could occur for a fee payer account in a transaction.
@@ -991,7 +1202,7 @@ mod tests {
9911202 . unwrap ( ) ;
9921203
9931204 // Should include base fee (5000) + fee payer outflow (100_000)
994- assert_eq ! ( result, 105_000 , "Should return base fee + outflow" ) ;
1205+ assert_eq ! ( result. total_fee_lamports , 105_000 , "Should return base fee + outflow" ) ;
9951206 }
9961207
9971208 #[ tokio:: test]
@@ -1021,7 +1232,11 @@ mod tests {
10211232 . unwrap ( ) ;
10221233
10231234 // Should include base fee + kora signature fee since kora signer not in transaction signers
1024- assert_eq ! ( result, 5000 + LAMPORTS_PER_SIGNATURE , "Should add Kora signature fee" ) ;
1235+ assert_eq ! (
1236+ result. total_fee_lamports,
1237+ 5000 + LAMPORTS_PER_SIGNATURE ,
1238+ "Should add Kora signature fee"
1239+ ) ;
10251240 }
10261241
10271242 #[ tokio:: test]
@@ -1055,7 +1270,10 @@ mod tests {
10551270
10561271 // Should include base fee + fee payer outflow + payment instruction fee
10571272 let expected = 5000 + 100_000 + ESTIMATED_LAMPORTS_FOR_PAYMENT_INSTRUCTION ;
1058- assert_eq ! ( result, expected, "Should include payment instruction fee when required" ) ;
1273+ assert_eq ! (
1274+ result. total_fee_lamports, expected,
1275+ "Should include payment instruction fee when required"
1276+ ) ;
10591277 }
10601278
10611279 #[ tokio:: test]
@@ -1104,6 +1322,7 @@ mod tests {
11041322 #[ tokio:: test]
11051323 async fn test_can_estimate_transaction_fees_on_transfers_with_uninitialized_atas ( ) {
11061324 let _m = ConfigMockBuilder :: new ( ) . build_and_setup ( ) ;
1325+ let _signer = setup_or_get_test_signer ( ) ;
11071326 let cache_ctx = CacheUtil :: get_account_context ( ) ;
11081327 cache_ctx. checkpoint ( ) ;
11091328
@@ -1112,8 +1331,9 @@ mod tests {
11121331 let recipient = Keypair :: new ( ) ; // This will be a newly generated wallet
11131332 let mint = Pubkey :: new_unique ( ) ;
11141333
1115- // Mock RPC client that returns base fee
1116- let mocked_rpc_client = RpcMockBuilder :: new ( ) . with_fee_estimate ( 5000 ) . build ( ) ;
1334+ // Mock RPC client that returns base fee and handles epoch info
1335+ let mocked_rpc_client =
1336+ RpcMockBuilder :: new ( ) . with_fee_estimate ( 5000 ) . with_epoch_info_mock ( ) . build ( ) ;
11171337
11181338 // Create ATA creation instruction for recipient (this is what triggers the fee calculation)
11191339 let recipient_ata = get_associated_token_address ( & recipient. pubkey ( ) , & mint) ;
@@ -1137,18 +1357,20 @@ mod tests {
11371357 let mut resolved_transaction =
11381358 TransactionUtil :: new_unsigned_versioned_transaction_resolved ( message) ;
11391359
1140- // Setup cache responses for ATA creation instruction:
1141- // 1. Recipient ATA (doesn't exist - AccountNotFound) - this is the case we're testing
1142- // 2. Mint account exists (Ok) - needed to determine token program
1360+ // Setup cache responses - correct order based on estimate_transaction_fee execution:
1361+ // 1. ATA creation: Recipient ATA (doesn't exist - AccountNotFound) - this is expected
1362+ // 2. ATA creation: Mint account exists (Ok) - needed to determine token program
1363+ // 3. calculate_transfer_fees: Recipient ATA (doesn't exist - AccountNotFound) → skip
11431364 let responses = Arc :: new ( Mutex :: new ( VecDeque :: from ( [
1144- Err ( KoraError :: AccountNotFound ( recipient_ata. to_string ( ) ) ) , // recipient ATA doesn't exist
1365+ Err ( KoraError :: AccountNotFound ( recipient_ata. to_string ( ) ) ) , // ATA creation check
11451366 Ok ( create_mock_spl_mint_account ( 6 ) ) , // mint exists
1367+ Err ( KoraError :: AccountNotFound ( recipient_ata. to_string ( ) ) ) , // calculate_transfer_fees -> skip
11461368 ] ) ) ) ;
11471369
11481370 let responses_clone = responses. clone ( ) ;
11491371 cache_ctx
11501372 . expect ( )
1151- . times ( 2 )
1373+ . times ( 3 )
11521374 . returning ( move |_, _, _| responses_clone. lock ( ) . unwrap ( ) . pop_front ( ) . unwrap ( ) ) ;
11531375
11541376 // This should succeed without throwing InternalServerError
@@ -1162,7 +1384,8 @@ mod tests {
11621384
11631385 assert ! (
11641386 result. is_ok( ) ,
1165- "Fee estimation should succeed for transaction with uninitialized ATAs"
1387+ "Fee estimation should succeed for transaction with uninitialized ATAs: {:?}" ,
1388+ result. err( )
11661389 ) ;
11671390
11681391 let fee = result. unwrap ( ) ;
@@ -1172,8 +1395,8 @@ mod tests {
11721395 let expected_min_fee = 5000 + expected_ata_rent;
11731396
11741397 assert_eq ! (
1175- fee, expected_min_fee,
1176- "Fee should include base transaction fee plus ATA creation cost. Got: {fee }, Expected at least: {expected_min_fee}"
1398+ fee. total_fee_lamports , expected_min_fee,
1399+ "Fee should include base transaction fee plus ATA creation cost. Got: {}, Expected at least: {expected_min_fee}" , fee . total_fee_lamports
11771400 ) ;
11781401 }
11791402}
0 commit comments