Skip to main content

kora_lib/rpc_server/method/
estimate_bundle_fee.rs

1use crate::{
2    bundle::{BundleError, BundleProcessingMode, BundleProcessor, JitoError},
3    error::KoraError,
4    fee::fee::FeeConfigUtil,
5    rpc_server::middleware_utils::default_sig_verify,
6    state::get_request_signer_with_signer_key,
7    validator::bundle_validator::BundleValidator,
8};
9use serde::{Deserialize, Serialize};
10use solana_client::nonblocking::rpc_client::RpcClient;
11use solana_keychain::SolanaSigner;
12use std::sync::Arc;
13use utoipa::ToSchema;
14
15#[cfg(not(test))]
16use crate::state::get_config;
17
18#[cfg(test)]
19use crate::tests::config_mock::mock_state::get_config;
20
21#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
22pub struct EstimateBundleFeeRequest {
23    /// Array of base64-encoded transactions
24    pub transactions: Vec<String>,
25    #[serde(default)]
26    pub fee_token: Option<String>,
27    /// Optional signer signer_key to ensure consistency across related RPC calls
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub signer_key: Option<String>,
30    /// Whether to verify signatures during simulation (defaults to false)
31    #[serde(default = "default_sig_verify")]
32    pub sig_verify: bool,
33    /// Optional indices of transactions to estimate fees for (defaults to all if not specified)
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub sign_only_indices: Option<Vec<usize>>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
39pub struct EstimateBundleFeeResponse {
40    pub fee_in_lamports: u64,
41    pub fee_in_token: Option<u64>,
42    /// Public key of the signer used for fee estimation (for client consistency)
43    pub signer_pubkey: String,
44    /// Public key of the payment destination
45    pub payment_address: String,
46}
47
48pub async fn estimate_bundle_fee(
49    rpc_client: &Arc<RpcClient>,
50    request: EstimateBundleFeeRequest,
51) -> Result<EstimateBundleFeeResponse, KoraError> {
52    let config = &get_config()?;
53
54    if !config.kora.bundle.enabled {
55        return Err(BundleError::Jito(JitoError::NotEnabled).into());
56    }
57
58    // Validate bundle size on ALL transactions first
59    BundleValidator::validate_jito_bundle_size(&request.transactions)?;
60
61    // Extract only the transactions we need to process
62    let (transactions_to_process, _index_to_position) =
63        BundleProcessor::extract_transactions_to_process(
64            &request.transactions,
65            request.sign_only_indices,
66        )?;
67
68    let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?;
69    let fee_payer = signer.pubkey();
70    let payment_destination = config.kora.get_payment_address(&fee_payer)?;
71
72    let sig_verify = request.sig_verify || config.kora.force_sig_verify;
73    let processor = BundleProcessor::process_bundle(
74        &transactions_to_process,
75        fee_payer,
76        &payment_destination,
77        config,
78        rpc_client,
79        sig_verify,
80        None,
81        BundleProcessingMode::SkipUsage,
82    )
83    .await?;
84
85    let fee_in_lamports = processor.total_required_lamports;
86
87    // Calculate fee in token if requested
88    let fee_in_token = FeeConfigUtil::calculate_fee_in_token(
89        fee_in_lamports,
90        request.fee_token.as_deref(),
91        rpc_client,
92        config,
93    )
94    .await?;
95
96    Ok(EstimateBundleFeeResponse {
97        fee_in_lamports,
98        fee_in_token,
99        signer_pubkey: fee_payer.to_string(),
100        payment_address: payment_destination.to_string(),
101    })
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::{
108        config::TransactionPluginType,
109        tests::{
110            common::{setup_or_get_test_signer, setup_or_get_test_usage_limiter, RpcMockBuilder},
111            config_mock::{mock_state::setup_config_mock, ConfigMockBuilder},
112            transaction_mock::create_mock_encoded_transaction,
113        },
114    };
115
116    #[tokio::test]
117    async fn test_estimate_bundle_fee_empty_bundle() {
118        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
119        let _ = setup_or_get_test_signer();
120
121        let rpc_client = Arc::new(RpcMockBuilder::new().build());
122
123        let request = EstimateBundleFeeRequest {
124            transactions: vec![],
125            fee_token: None,
126            signer_key: None,
127            sig_verify: true,
128            sign_only_indices: None,
129        };
130
131        let result = estimate_bundle_fee(&rpc_client, request).await;
132
133        assert!(result.is_err(), "Should fail with empty bundle");
134        let err = result.unwrap_err();
135        assert!(matches!(err, KoraError::InvalidTransaction(_)));
136    }
137
138    #[tokio::test]
139    async fn test_estimate_bundle_fee_disabled() {
140        let _m = ConfigMockBuilder::new().with_bundle_enabled(false).build_and_setup();
141        let _ = setup_or_get_test_signer();
142
143        let rpc_client = Arc::new(RpcMockBuilder::new().build());
144
145        let request = EstimateBundleFeeRequest {
146            transactions: vec!["some_tx".to_string()],
147            fee_token: None,
148            signer_key: None,
149            sig_verify: true,
150            sign_only_indices: None,
151        };
152
153        let result = estimate_bundle_fee(&rpc_client, request).await;
154
155        assert!(result.is_err(), "Should fail when bundles disabled");
156        let err = result.unwrap_err();
157        assert!(matches!(err, KoraError::JitoError(_)));
158        if let KoraError::JitoError(msg) = err {
159            assert!(msg.contains("not enabled"));
160        }
161    }
162
163    #[tokio::test]
164    async fn test_estimate_bundle_fee_too_large() {
165        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
166        let _ = setup_or_get_test_signer();
167
168        let rpc_client = Arc::new(RpcMockBuilder::new().build());
169
170        let request = EstimateBundleFeeRequest {
171            transactions: vec!["tx".to_string(); 6],
172            fee_token: None,
173            signer_key: None,
174            sig_verify: true,
175            sign_only_indices: None,
176        };
177
178        let result = estimate_bundle_fee(&rpc_client, request).await;
179
180        assert!(result.is_err(), "Should fail with too many transactions");
181        let err = result.unwrap_err();
182        assert!(matches!(err, KoraError::JitoError(_)));
183        if let KoraError::JitoError(msg) = err {
184            assert!(msg.contains("maximum size"));
185        }
186    }
187
188    #[tokio::test]
189    async fn test_estimate_bundle_fee_invalid_signer_key() {
190        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
191        let _ = setup_or_get_test_signer();
192
193        let rpc_client = Arc::new(RpcMockBuilder::new().build());
194
195        let request = EstimateBundleFeeRequest {
196            transactions: vec!["some_tx".to_string()],
197            fee_token: None,
198            signer_key: Some("invalid_pubkey".to_string()),
199            sig_verify: true,
200            sign_only_indices: None,
201        };
202
203        let result = estimate_bundle_fee(&rpc_client, request).await;
204
205        assert!(result.is_err(), "Should fail with invalid signer key");
206        let err = result.unwrap_err();
207        assert!(matches!(err, KoraError::ValidationError(_)));
208    }
209
210    #[tokio::test]
211    async fn test_estimate_bundle_fee_exactly_max_size() {
212        let _m = ConfigMockBuilder::new()
213            .with_bundle_enabled(true)
214            .with_usage_limit_enabled(false)
215            .build_and_setup();
216        let _ = setup_or_get_test_signer();
217        let _ = setup_or_get_test_usage_limiter().await;
218
219        let rpc_client =
220            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
221
222        // 5 transactions is the maximum allowed
223        let transactions: Vec<String> = (0..5).map(|_| create_mock_encoded_transaction()).collect();
224
225        let request = EstimateBundleFeeRequest {
226            transactions,
227            fee_token: None,
228            signer_key: None,
229            sig_verify: true,
230            sign_only_indices: None,
231        };
232
233        let result = estimate_bundle_fee(&rpc_client, request).await;
234
235        assert!(result.is_ok(), "Should succeed with valid transactions");
236        let response = result.unwrap();
237        assert!(response.fee_in_lamports > 0);
238        assert!(!response.signer_pubkey.is_empty());
239        assert!(!response.payment_address.is_empty());
240    }
241
242    #[tokio::test]
243    async fn test_estimate_bundle_fee_single_transaction() {
244        let _m = ConfigMockBuilder::new()
245            .with_bundle_enabled(true)
246            .with_usage_limit_enabled(false)
247            .build_and_setup();
248        let _ = setup_or_get_test_signer();
249        let _ = setup_or_get_test_usage_limiter().await;
250
251        let rpc_client =
252            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
253
254        // Single transaction bundle is valid
255        let request = EstimateBundleFeeRequest {
256            transactions: vec![create_mock_encoded_transaction()],
257            fee_token: None,
258            signer_key: None,
259            sig_verify: true,
260            sign_only_indices: None,
261        };
262
263        let result = estimate_bundle_fee(&rpc_client, request).await;
264
265        assert!(result.is_ok(), "Should succeed with valid transaction");
266        let response = result.unwrap();
267        assert!(response.fee_in_lamports > 0);
268        assert!(!response.signer_pubkey.is_empty());
269        assert!(!response.payment_address.is_empty());
270    }
271
272    #[tokio::test]
273    async fn test_estimate_bundle_fee_sig_verify_default() {
274        // Test that sig_verify defaults correctly via serde (defaults to false)
275        let json = r#"{"transactions": ["tx1"]}"#;
276        let request: EstimateBundleFeeRequest = serde_json::from_str(json).unwrap();
277
278        assert!(!request.sig_verify, "sig_verify should default to false");
279        assert!(request.signer_key.is_none());
280    }
281
282    #[tokio::test]
283    async fn test_estimate_bundle_fee_request_deserialization() {
284        let json = r#"{
285            "transactions": ["tx1", "tx2"],
286            "fee_token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
287            "signer_key": "11111111111111111111111111111111",
288            "sig_verify": false
289        }"#;
290        let request: EstimateBundleFeeRequest = serde_json::from_str(json).unwrap();
291
292        assert_eq!(request.transactions.len(), 2);
293        assert_eq!(
294            request.fee_token,
295            Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string())
296        );
297        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
298        assert!(!request.sig_verify);
299    }
300
301    #[tokio::test]
302    async fn test_estimate_bundle_fee_skips_plugins() {
303        let mut config = ConfigMockBuilder::new()
304            .with_bundle_enabled(true)
305            .with_usage_limit_enabled(false)
306            .build();
307        config.kora.plugins.enabled = vec![TransactionPluginType::GasSwap];
308        let _m = setup_config_mock(config);
309
310        let _ = setup_or_get_test_signer();
311        let _ = setup_or_get_test_usage_limiter().await;
312
313        let rpc_client =
314            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
315
316        // Not gas_swap-compatible shape; would fail if plugins ran during estimate.
317        let request = EstimateBundleFeeRequest {
318            transactions: vec![create_mock_encoded_transaction()],
319            fee_token: None,
320            signer_key: None,
321            sig_verify: false,
322            sign_only_indices: None,
323        };
324
325        let result = estimate_bundle_fee(&rpc_client, request).await;
326        assert!(result.is_ok(), "estimateBundleFee should skip transaction plugins");
327    }
328}