Skip to content

Commit 29ce0b9

Browse files
dev-jodeeamilz
andauthored
feat: (PRO-268) Enhance fee estimation with transfer fee calculation… (#212)
* feat: (PRO-268) Enhance fee estimation with transfer fee calculations and Kora's price model. Also removed check for non transferable and cpi guard, as they will fail at the transaction level instead. - Introduced `calculate_transfer_fees` method to compute transfer fees for token transactions. - Updated `estimate_transaction_fee` to return a `TotalFeeCalculation` struct, encapsulating all fee components. - Removed forced non transferable and cpi guard check, will be validated by the chain when the transaction is submitted. --------- Co-authored-by: amilz <[email protected]>
1 parent eef7515 commit 29ce0b9

File tree

11 files changed

+353
-391
lines changed

11 files changed

+353
-391
lines changed

crates/lib/src/fee/fee.rs

Lines changed: 252 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
use std::str::FromStr;
2+
13
use 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};
2331
use spl_associated_token_account::get_associated_token_address;
2432
use 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+
2645
pub struct FeeConfigUtil {}
2746

2847
impl 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

Comments
 (0)