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 processor = BundleProcessor::process_bundle(
73        &transactions_to_process,
74        fee_payer,
75        &payment_destination,
76        config,
77        rpc_client,
78        request.sig_verify,
79        BundleProcessingMode::SkipUsage,
80    )
81    .await?;
82
83    let fee_in_lamports = processor.total_required_lamports;
84
85    // Calculate fee in token if requested
86    let fee_in_token = FeeConfigUtil::calculate_fee_in_token(
87        fee_in_lamports,
88        request.fee_token.as_deref(),
89        rpc_client,
90        config,
91    )
92    .await?;
93
94    Ok(EstimateBundleFeeResponse {
95        fee_in_lamports,
96        fee_in_token,
97        signer_pubkey: fee_payer.to_string(),
98        payment_address: payment_destination.to_string(),
99    })
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::tests::{
106        common::{setup_or_get_test_signer, setup_or_get_test_usage_limiter, RpcMockBuilder},
107        config_mock::ConfigMockBuilder,
108        transaction_mock::create_mock_encoded_transaction,
109    };
110
111    #[tokio::test]
112    async fn test_estimate_bundle_fee_empty_bundle() {
113        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
114        let _ = setup_or_get_test_signer();
115
116        let rpc_client = Arc::new(RpcMockBuilder::new().build());
117
118        let request = EstimateBundleFeeRequest {
119            transactions: vec![],
120            fee_token: None,
121            signer_key: None,
122            sig_verify: true,
123            sign_only_indices: None,
124        };
125
126        let result = estimate_bundle_fee(&rpc_client, request).await;
127
128        assert!(result.is_err(), "Should fail with empty bundle");
129        let err = result.unwrap_err();
130        assert!(matches!(err, KoraError::InvalidTransaction(_)));
131    }
132
133    #[tokio::test]
134    async fn test_estimate_bundle_fee_disabled() {
135        let _m = ConfigMockBuilder::new().with_bundle_enabled(false).build_and_setup();
136        let _ = setup_or_get_test_signer();
137
138        let rpc_client = Arc::new(RpcMockBuilder::new().build());
139
140        let request = EstimateBundleFeeRequest {
141            transactions: vec!["some_tx".to_string()],
142            fee_token: None,
143            signer_key: None,
144            sig_verify: true,
145            sign_only_indices: None,
146        };
147
148        let result = estimate_bundle_fee(&rpc_client, request).await;
149
150        assert!(result.is_err(), "Should fail when bundles disabled");
151        let err = result.unwrap_err();
152        assert!(matches!(err, KoraError::JitoError(_)));
153        if let KoraError::JitoError(msg) = err {
154            assert!(msg.contains("not enabled"));
155        }
156    }
157
158    #[tokio::test]
159    async fn test_estimate_bundle_fee_too_large() {
160        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
161        let _ = setup_or_get_test_signer();
162
163        let rpc_client = Arc::new(RpcMockBuilder::new().build());
164
165        let request = EstimateBundleFeeRequest {
166            transactions: vec!["tx".to_string(); 6],
167            fee_token: None,
168            signer_key: None,
169            sig_verify: true,
170            sign_only_indices: None,
171        };
172
173        let result = estimate_bundle_fee(&rpc_client, request).await;
174
175        assert!(result.is_err(), "Should fail with too many transactions");
176        let err = result.unwrap_err();
177        assert!(matches!(err, KoraError::JitoError(_)));
178        if let KoraError::JitoError(msg) = err {
179            assert!(msg.contains("maximum size"));
180        }
181    }
182
183    #[tokio::test]
184    async fn test_estimate_bundle_fee_invalid_signer_key() {
185        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
186        let _ = setup_or_get_test_signer();
187
188        let rpc_client = Arc::new(RpcMockBuilder::new().build());
189
190        let request = EstimateBundleFeeRequest {
191            transactions: vec!["some_tx".to_string()],
192            fee_token: None,
193            signer_key: Some("invalid_pubkey".to_string()),
194            sig_verify: true,
195            sign_only_indices: None,
196        };
197
198        let result = estimate_bundle_fee(&rpc_client, request).await;
199
200        assert!(result.is_err(), "Should fail with invalid signer key");
201        let err = result.unwrap_err();
202        assert!(matches!(err, KoraError::ValidationError(_)));
203    }
204
205    #[tokio::test]
206    async fn test_estimate_bundle_fee_exactly_max_size() {
207        let _m = ConfigMockBuilder::new()
208            .with_bundle_enabled(true)
209            .with_usage_limit_enabled(false)
210            .build_and_setup();
211        let _ = setup_or_get_test_signer();
212        let _ = setup_or_get_test_usage_limiter().await;
213
214        let rpc_client =
215            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
216
217        // 5 transactions is the maximum allowed
218        let transactions: Vec<String> = (0..5).map(|_| create_mock_encoded_transaction()).collect();
219
220        let request = EstimateBundleFeeRequest {
221            transactions,
222            fee_token: None,
223            signer_key: None,
224            sig_verify: true,
225            sign_only_indices: None,
226        };
227
228        let result = estimate_bundle_fee(&rpc_client, request).await;
229
230        assert!(result.is_ok(), "Should succeed with valid transactions");
231        let response = result.unwrap();
232        assert!(response.fee_in_lamports > 0);
233        assert!(!response.signer_pubkey.is_empty());
234        assert!(!response.payment_address.is_empty());
235    }
236
237    #[tokio::test]
238    async fn test_estimate_bundle_fee_single_transaction() {
239        let _m = ConfigMockBuilder::new()
240            .with_bundle_enabled(true)
241            .with_usage_limit_enabled(false)
242            .build_and_setup();
243        let _ = setup_or_get_test_signer();
244        let _ = setup_or_get_test_usage_limiter().await;
245
246        let rpc_client =
247            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
248
249        // Single transaction bundle is valid
250        let request = EstimateBundleFeeRequest {
251            transactions: vec![create_mock_encoded_transaction()],
252            fee_token: None,
253            signer_key: None,
254            sig_verify: true,
255            sign_only_indices: None,
256        };
257
258        let result = estimate_bundle_fee(&rpc_client, request).await;
259
260        assert!(result.is_ok(), "Should succeed with valid transaction");
261        let response = result.unwrap();
262        assert!(response.fee_in_lamports > 0);
263        assert!(!response.signer_pubkey.is_empty());
264        assert!(!response.payment_address.is_empty());
265    }
266
267    #[tokio::test]
268    async fn test_estimate_bundle_fee_sig_verify_default() {
269        // Test that sig_verify defaults correctly via serde (defaults to false)
270        let json = r#"{"transactions": ["tx1"]}"#;
271        let request: EstimateBundleFeeRequest = serde_json::from_str(json).unwrap();
272
273        assert!(!request.sig_verify, "sig_verify should default to false");
274        assert!(request.signer_key.is_none());
275    }
276
277    #[tokio::test]
278    async fn test_estimate_bundle_fee_request_deserialization() {
279        let json = r#"{
280            "transactions": ["tx1", "tx2"],
281            "fee_token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
282            "signer_key": "11111111111111111111111111111111",
283            "sig_verify": false
284        }"#;
285        let request: EstimateBundleFeeRequest = serde_json::from_str(json).unwrap();
286
287        assert_eq!(request.transactions.len(), 2);
288        assert_eq!(
289            request.fee_token,
290            Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string())
291        );
292        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
293        assert!(!request.sig_verify);
294    }
295}