kora_lib/rpc_server/method/
sign_bundle.rs

1use crate::{
2    bundle::{BundleError, BundleProcessingMode, BundleProcessor, JitoError},
3    rpc_server::middleware_utils::default_sig_verify,
4    transaction::TransactionUtil,
5    validator::bundle_validator::BundleValidator,
6    KoraError,
7};
8use serde::{Deserialize, Serialize};
9use solana_client::nonblocking::rpc_client::RpcClient;
10use solana_keychain::SolanaSigner;
11use std::sync::Arc;
12use utoipa::ToSchema;
13
14#[cfg(not(test))]
15use crate::state::{get_config, get_request_signer_with_signer_key};
16
17#[cfg(test)]
18use crate::state::get_request_signer_with_signer_key;
19#[cfg(test)]
20use crate::tests::config_mock::mock_state::get_config;
21
22#[derive(Debug, Deserialize, ToSchema)]
23pub struct SignBundleRequest {
24    /// Array of base64-encoded transactions
25    pub transactions: Vec<String>,
26    /// Optional signer key to ensure consistency across related RPC calls
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub signer_key: Option<String>,
29    /// Whether to verify signatures during simulation (defaults to true)
30    #[serde(default = "default_sig_verify")]
31    pub sig_verify: bool,
32    /// Optional user ID for usage tracking (required when pricing is free and usage tracking is enabled)
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub user_id: Option<String>,
35    /// Optional indices of transactions to sign (defaults to all if not specified)
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub sign_only_indices: Option<Vec<usize>>,
38}
39
40#[derive(Debug, Serialize, ToSchema)]
41pub struct SignBundleResponse {
42    /// Array of base64-encoded signed transactions
43    pub signed_transactions: Vec<String>,
44    /// Public key of the signer used (for client consistency)
45    pub signer_pubkey: String,
46}
47
48pub async fn sign_bundle(
49    rpc_client: &Arc<RpcClient>,
50    request: SignBundleRequest,
51) -> Result<SignBundleResponse, 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::CheckUsage(request.user_id.as_deref()),
80    )
81    .await?;
82
83    let signed_resolved = processor.sign_all(&signer, &fee_payer, rpc_client).await?;
84
85    // Encode signed transactions
86    let encoded_signed: Vec<String> = signed_resolved
87        .iter()
88        .map(|r| TransactionUtil::encode_versioned_transaction(&r.transaction))
89        .collect::<Result<Vec<_>, _>>()?;
90
91    // Merge signed transactions back into original positions
92    let signed_transactions = BundleProcessor::merge_signed_transactions(
93        &request.transactions,
94        encoded_signed,
95        &index_to_position,
96    );
97
98    Ok(SignBundleResponse { signed_transactions, signer_pubkey: fee_payer.to_string() })
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::{
105        fee::price::{PriceConfig, PriceModel},
106        tests::{
107            common::{setup_or_get_test_signer, setup_or_get_test_usage_limiter, RpcMockBuilder},
108            config_mock::{ConfigMockBuilder, ValidationConfigBuilder},
109        },
110        transaction::TransactionUtil,
111    };
112    use solana_message::{Message, VersionedMessage};
113    use solana_sdk::pubkey::Pubkey;
114    use solana_system_interface::instruction::transfer;
115
116    #[tokio::test]
117    async fn test_sign_bundle_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 = SignBundleRequest {
124            transactions: vec![],
125            signer_key: None,
126            sig_verify: true,
127            user_id: None,
128            sign_only_indices: None,
129        };
130
131        let result = sign_bundle(&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_sign_bundle_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 = SignBundleRequest {
146            transactions: vec!["some_tx".to_string()],
147            signer_key: None,
148            sig_verify: true,
149            user_id: None,
150            sign_only_indices: None,
151        };
152
153        let result = sign_bundle(&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_sign_bundle_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 = SignBundleRequest {
171            transactions: vec!["tx".to_string(); 6],
172            signer_key: None,
173            sig_verify: true,
174            user_id: None,
175            sign_only_indices: None,
176        };
177
178        let result = sign_bundle(&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_sign_bundle_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 = SignBundleRequest {
196            transactions: vec!["some_tx".to_string()],
197            signer_key: Some("invalid_pubkey".to_string()),
198            sig_verify: true,
199            user_id: None,
200            sign_only_indices: None,
201        };
202
203        let result = sign_bundle(&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_sign_bundle_exactly_max_size() {
212        let mut validation = ValidationConfigBuilder::new()
213            .with_allowed_programs(vec!["11111111111111111111111111111111".to_string()])
214            .build();
215        validation.price = PriceConfig { model: PriceModel::Free };
216        let _m = ConfigMockBuilder::new()
217            .with_bundle_enabled(true)
218            .with_usage_limit_enabled(false)
219            .with_validation(validation)
220            .build_and_setup();
221        let signer_pubkey = setup_or_get_test_signer();
222        let _ = setup_or_get_test_usage_limiter().await;
223
224        let rpc_client = Arc::new(
225            RpcMockBuilder::new()
226                .with_fee_estimate(5000)
227                .with_blockhash()
228                .with_simulation()
229                .build(),
230        );
231
232        // Create transactions with signer as fee payer
233
234        let transactions: Vec<String> = (0..5)
235            .map(|_| {
236                let ix = transfer(&Pubkey::new_unique(), &Pubkey::new_unique(), 1000000000);
237                let message = VersionedMessage::Legacy(Message::new(&[ix], Some(&signer_pubkey)));
238                let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
239                TransactionUtil::encode_versioned_transaction(&transaction).unwrap()
240            })
241            .collect();
242
243        // Use signer_key to ensure consistency - prevents race conditions with parallel tests
244        let request = SignBundleRequest {
245            transactions,
246            signer_key: Some(signer_pubkey.to_string()),
247            sig_verify: true,
248            user_id: None,
249            sign_only_indices: None,
250        };
251
252        let result = sign_bundle(&rpc_client, request).await;
253
254        assert!(result.is_ok(), "Should succeed with valid transactions");
255        let response = result.unwrap();
256        assert_eq!(response.signed_transactions.len(), 5);
257        assert!(!response.signer_pubkey.is_empty());
258    }
259
260    #[tokio::test]
261    async fn test_sign_bundle_single_transaction() {
262        let mut validation = ValidationConfigBuilder::new()
263            .with_allowed_programs(vec!["11111111111111111111111111111111".to_string()])
264            .build();
265        validation.price = PriceConfig { model: PriceModel::Free };
266        let _m = ConfigMockBuilder::new()
267            .with_bundle_enabled(true)
268            .with_usage_limit_enabled(false)
269            .with_validation(validation)
270            .build_and_setup();
271        let signer_pubkey = setup_or_get_test_signer();
272        let _ = setup_or_get_test_usage_limiter().await;
273
274        let rpc_client = Arc::new(
275            RpcMockBuilder::new()
276                .with_fee_estimate(5000)
277                .with_blockhash()
278                .with_simulation()
279                .build(),
280        );
281
282        // Create transaction with signer as fee payer
283        let ix = transfer(&Pubkey::new_unique(), &Pubkey::new_unique(), 1000000000);
284        let message = VersionedMessage::Legacy(Message::new(&[ix], Some(&signer_pubkey)));
285        let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
286        let encoded_tx = TransactionUtil::encode_versioned_transaction(&transaction).unwrap();
287
288        // Single transaction bundle is valid
289        let request = SignBundleRequest {
290            transactions: vec![encoded_tx],
291            signer_key: Some(signer_pubkey.to_string()),
292            sig_verify: true,
293            user_id: None,
294            sign_only_indices: None,
295        };
296
297        let result = sign_bundle(&rpc_client, request).await;
298
299        assert!(result.is_ok(), "Should succeed with valid transaction");
300        let response = result.unwrap();
301        assert_eq!(response.signed_transactions.len(), 1);
302        assert!(!response.signer_pubkey.is_empty());
303    }
304
305    #[tokio::test]
306    async fn test_sign_bundle_sig_verify_default() {
307        // Test that sig_verify defaults correctly via serde (defaults to false)
308        let json = r#"{"transactions": ["tx1"]}"#;
309        let request: SignBundleRequest = serde_json::from_str(json).unwrap();
310
311        assert!(!request.sig_verify, "sig_verify should default to false");
312        assert!(request.signer_key.is_none());
313    }
314
315    #[tokio::test]
316    async fn test_sign_bundle_request_deserialization() {
317        let json = r#"{
318            "transactions": ["tx1", "tx2"],
319            "signer_key": "11111111111111111111111111111111",
320            "sig_verify": false,
321            "user_id": "test-user-456"
322        }"#;
323        let request: SignBundleRequest = serde_json::from_str(json).unwrap();
324
325        assert_eq!(request.transactions.len(), 2);
326        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
327        assert!(!request.sig_verify);
328        assert_eq!(request.user_id, Some("test-user-456".to_string()));
329        assert!(request.sign_only_indices.is_none());
330    }
331
332    #[tokio::test]
333    async fn test_sign_bundle_request_deserialization_with_sign_only_indices() {
334        let json = r#"{
335            "transactions": ["tx1", "tx2", "tx3"],
336            "signer_key": "11111111111111111111111111111111",
337            "sig_verify": false,
338            "sign_only_indices": [0, 2]
339        }"#;
340        let request: SignBundleRequest = serde_json::from_str(json).unwrap();
341
342        assert_eq!(request.transactions.len(), 3);
343        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
344        assert!(!request.sig_verify);
345        assert_eq!(request.sign_only_indices, Some(vec![0, 2]));
346    }
347}