kora_lib/rpc_server/method/
estimate_bundle_fee.rs

1use crate::{
2    bundle::{BundleError, 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}
34
35#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
36pub struct EstimateBundleFeeResponse {
37    pub fee_in_lamports: u64,
38    pub fee_in_token: Option<u64>,
39    /// Public key of the signer used for fee estimation (for client consistency)
40    pub signer_pubkey: String,
41    /// Public key of the payment destination
42    pub payment_address: String,
43}
44
45pub async fn estimate_bundle_fee(
46    rpc_client: &Arc<RpcClient>,
47    request: EstimateBundleFeeRequest,
48) -> Result<EstimateBundleFeeResponse, KoraError> {
49    let config = &get_config()?;
50
51    if !config.kora.bundle.enabled {
52        return Err(BundleError::Jito(JitoError::NotEnabled).into());
53    }
54
55    BundleValidator::validate_jito_bundle_size(&request.transactions)?;
56
57    let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?;
58    let fee_payer = signer.pubkey();
59    let payment_destination = config.kora.get_payment_address(&fee_payer)?;
60
61    let processor = BundleProcessor::process_bundle(
62        &request.transactions,
63        fee_payer,
64        &payment_destination,
65        config,
66        rpc_client,
67        request.sig_verify,
68    )
69    .await?;
70
71    let fee_in_lamports = processor.total_required_lamports;
72
73    // Calculate fee in token if requested
74    let fee_in_token = FeeConfigUtil::calculate_fee_in_token(
75        fee_in_lamports,
76        request.fee_token.as_deref(),
77        rpc_client,
78        config,
79    )
80    .await?;
81
82    Ok(EstimateBundleFeeResponse {
83        fee_in_lamports,
84        fee_in_token,
85        signer_pubkey: fee_payer.to_string(),
86        payment_address: payment_destination.to_string(),
87    })
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::tests::{
94        common::{setup_or_get_test_signer, setup_or_get_test_usage_limiter, RpcMockBuilder},
95        config_mock::ConfigMockBuilder,
96        transaction_mock::create_mock_encoded_transaction,
97    };
98
99    #[tokio::test]
100    async fn test_estimate_bundle_fee_empty_bundle() {
101        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
102        let _ = setup_or_get_test_signer();
103
104        let rpc_client = Arc::new(RpcMockBuilder::new().build());
105
106        let request = EstimateBundleFeeRequest {
107            transactions: vec![],
108            fee_token: None,
109            signer_key: None,
110            sig_verify: true,
111        };
112
113        let result = estimate_bundle_fee(&rpc_client, request).await;
114
115        assert!(result.is_err(), "Should fail with empty bundle");
116        let err = result.unwrap_err();
117        assert!(matches!(err, KoraError::InvalidTransaction(_)));
118    }
119
120    #[tokio::test]
121    async fn test_estimate_bundle_fee_disabled() {
122        let _m = ConfigMockBuilder::new().with_bundle_enabled(false).build_and_setup();
123        let _ = setup_or_get_test_signer();
124
125        let rpc_client = Arc::new(RpcMockBuilder::new().build());
126
127        let request = EstimateBundleFeeRequest {
128            transactions: vec!["some_tx".to_string()],
129            fee_token: None,
130            signer_key: None,
131            sig_verify: true,
132        };
133
134        let result = estimate_bundle_fee(&rpc_client, request).await;
135
136        assert!(result.is_err(), "Should fail when bundles disabled");
137        let err = result.unwrap_err();
138        assert!(matches!(err, KoraError::JitoError(_)));
139        if let KoraError::JitoError(msg) = err {
140            assert!(msg.contains("not enabled"));
141        }
142    }
143
144    #[tokio::test]
145    async fn test_estimate_bundle_fee_too_large() {
146        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
147        let _ = setup_or_get_test_signer();
148
149        let rpc_client = Arc::new(RpcMockBuilder::new().build());
150
151        let request = EstimateBundleFeeRequest {
152            transactions: vec!["tx".to_string(); 6],
153            fee_token: None,
154            signer_key: None,
155            sig_verify: true,
156        };
157
158        let result = estimate_bundle_fee(&rpc_client, request).await;
159
160        assert!(result.is_err(), "Should fail with too many transactions");
161        let err = result.unwrap_err();
162        assert!(matches!(err, KoraError::JitoError(_)));
163        if let KoraError::JitoError(msg) = err {
164            assert!(msg.contains("maximum size"));
165        }
166    }
167
168    #[tokio::test]
169    async fn test_estimate_bundle_fee_invalid_signer_key() {
170        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
171        let _ = setup_or_get_test_signer();
172
173        let rpc_client = Arc::new(RpcMockBuilder::new().build());
174
175        let request = EstimateBundleFeeRequest {
176            transactions: vec!["some_tx".to_string()],
177            fee_token: None,
178            signer_key: Some("invalid_pubkey".to_string()),
179            sig_verify: true,
180        };
181
182        let result = estimate_bundle_fee(&rpc_client, request).await;
183
184        assert!(result.is_err(), "Should fail with invalid signer key");
185        let err = result.unwrap_err();
186        assert!(matches!(err, KoraError::ValidationError(_)));
187    }
188
189    #[tokio::test]
190    async fn test_estimate_bundle_fee_exactly_max_size() {
191        let _m = ConfigMockBuilder::new()
192            .with_bundle_enabled(true)
193            .with_usage_limit_enabled(false)
194            .build_and_setup();
195        let _ = setup_or_get_test_signer();
196        let _ = setup_or_get_test_usage_limiter().await;
197
198        let rpc_client =
199            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
200
201        // 5 transactions is the maximum allowed
202        let transactions: Vec<String> = (0..5).map(|_| create_mock_encoded_transaction()).collect();
203
204        let request = EstimateBundleFeeRequest {
205            transactions,
206            fee_token: None,
207            signer_key: None,
208            sig_verify: true,
209        };
210
211        let result = estimate_bundle_fee(&rpc_client, request).await;
212
213        assert!(result.is_ok(), "Should succeed with valid transactions");
214        let response = result.unwrap();
215        assert!(response.fee_in_lamports > 0);
216        assert!(!response.signer_pubkey.is_empty());
217        assert!(!response.payment_address.is_empty());
218    }
219
220    #[tokio::test]
221    async fn test_estimate_bundle_fee_single_transaction() {
222        let _m = ConfigMockBuilder::new()
223            .with_bundle_enabled(true)
224            .with_usage_limit_enabled(false)
225            .build_and_setup();
226        let _ = setup_or_get_test_signer();
227        let _ = setup_or_get_test_usage_limiter().await;
228
229        let rpc_client =
230            Arc::new(RpcMockBuilder::new().with_fee_estimate(5000).with_simulation().build());
231
232        // Single transaction bundle is valid
233        let request = EstimateBundleFeeRequest {
234            transactions: vec![create_mock_encoded_transaction()],
235            fee_token: None,
236            signer_key: None,
237            sig_verify: true,
238        };
239
240        let result = estimate_bundle_fee(&rpc_client, request).await;
241
242        assert!(result.is_ok(), "Should succeed with valid transaction");
243        let response = result.unwrap();
244        assert!(response.fee_in_lamports > 0);
245        assert!(!response.signer_pubkey.is_empty());
246        assert!(!response.payment_address.is_empty());
247    }
248
249    #[tokio::test]
250    async fn test_estimate_bundle_fee_sig_verify_default() {
251        // Test that sig_verify defaults correctly via serde (defaults to false)
252        let json = r#"{"transactions": ["tx1"]}"#;
253        let request: EstimateBundleFeeRequest = serde_json::from_str(json).unwrap();
254
255        assert!(!request.sig_verify, "sig_verify should default to false");
256        assert!(request.signer_key.is_none());
257    }
258
259    #[tokio::test]
260    async fn test_estimate_bundle_fee_request_deserialization() {
261        let json = r#"{
262            "transactions": ["tx1", "tx2"],
263            "fee_token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
264            "signer_key": "11111111111111111111111111111111",
265            "sig_verify": false
266        }"#;
267        let request: EstimateBundleFeeRequest = serde_json::from_str(json).unwrap();
268
269        assert_eq!(request.transactions.len(), 2);
270        assert_eq!(
271            request.fee_token,
272            Some("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string())
273        );
274        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
275        assert!(!request.sig_verify);
276    }
277}