Skip to main content

kora_lib/rpc_server/method/
sign_bundle.rs

1use crate::{
2    bundle::{BundleError, BundleProcessingMode, BundleProcessor, JitoError},
3    plugin::PluginExecutionContext,
4    rpc_server::middleware_utils::default_sig_verify,
5    transaction::TransactionUtil,
6    validator::bundle_validator::BundleValidator,
7    KoraError,
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, get_request_signer_with_signer_key};
17
18#[cfg(test)]
19use crate::state::get_request_signer_with_signer_key;
20#[cfg(test)]
21use crate::tests::config_mock::mock_state::get_config;
22
23#[derive(Debug, Deserialize, ToSchema)]
24pub struct SignBundleRequest {
25    /// Array of base64-encoded transactions
26    pub transactions: Vec<String>,
27    /// Optional 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 user ID for usage tracking (required when pricing is free and usage tracking is enabled)
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub user_id: Option<String>,
36    /// Optional indices of transactions to sign (defaults to all if not specified)
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub sign_only_indices: Option<Vec<usize>>,
39}
40
41#[derive(Debug, Serialize, ToSchema)]
42pub struct SignBundleResponse {
43    /// Array of base64-encoded signed transactions
44    pub signed_transactions: Vec<String>,
45    /// Public key of the signer used (for client consistency)
46    pub signer_pubkey: String,
47}
48
49pub async fn sign_bundle(
50    rpc_client: &Arc<RpcClient>,
51    request: SignBundleRequest,
52) -> Result<SignBundleResponse, KoraError> {
53    let config = &get_config()?;
54
55    if !config.kora.bundle.enabled {
56        return Err(BundleError::Jito(JitoError::NotEnabled).into());
57    }
58
59    // Validate bundle size on ALL transactions first
60    BundleValidator::validate_jito_bundle_size(&request.transactions)?;
61
62    // Extract only the transactions we need to process
63    let (transactions_to_process, index_to_position) =
64        BundleProcessor::extract_transactions_to_process(
65            &request.transactions,
66            request.sign_only_indices,
67        )?;
68
69    let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?;
70    let fee_payer = signer.pubkey();
71    let payment_destination = config.kora.get_payment_address(&fee_payer)?;
72
73    let sig_verify = request.sig_verify || config.kora.force_sig_verify;
74    let processor = BundleProcessor::process_bundle(
75        &transactions_to_process,
76        fee_payer,
77        &payment_destination,
78        config,
79        rpc_client,
80        sig_verify,
81        Some(PluginExecutionContext::SignBundle),
82        BundleProcessingMode::CheckUsage(request.user_id.as_deref()),
83    )
84    .await?;
85
86    let signed_resolved =
87        processor.sign_all(&signer, &fee_payer, rpc_client, config, false).await?;
88
89    // Encode signed transactions
90    let encoded_signed: Vec<String> = signed_resolved
91        .iter()
92        .map(|r| TransactionUtil::encode_versioned_transaction(&r.transaction))
93        .collect::<Result<Vec<_>, _>>()?;
94
95    // Merge signed transactions back into original positions
96    let signed_transactions = BundleProcessor::merge_signed_transactions(
97        &request.transactions,
98        encoded_signed,
99        &index_to_position,
100    );
101
102    Ok(SignBundleResponse { signed_transactions, signer_pubkey: fee_payer.to_string() })
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::{
109        fee::price::{PriceConfig, PriceModel},
110        tests::{
111            common::{setup_or_get_test_signer, setup_or_get_test_usage_limiter, RpcMockBuilder},
112            config_mock::{ConfigMockBuilder, ValidationConfigBuilder},
113        },
114        transaction::TransactionUtil,
115    };
116    use solana_message::{Message, VersionedMessage};
117    use solana_sdk::pubkey::Pubkey;
118    use solana_system_interface::instruction::transfer;
119
120    #[tokio::test]
121    async fn test_sign_bundle_empty_bundle() {
122        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
123        let _ = setup_or_get_test_signer();
124
125        let rpc_client = Arc::new(RpcMockBuilder::new().build());
126
127        let request = SignBundleRequest {
128            transactions: vec![],
129            signer_key: None,
130            sig_verify: true,
131            user_id: None,
132            sign_only_indices: None,
133        };
134
135        let result = sign_bundle(&rpc_client, request).await;
136
137        assert!(result.is_err(), "Should fail with empty bundle");
138        let err = result.unwrap_err();
139        assert!(matches!(err, KoraError::InvalidTransaction(_)));
140    }
141
142    #[tokio::test]
143    async fn test_sign_bundle_disabled() {
144        let _m = ConfigMockBuilder::new().with_bundle_enabled(false).build_and_setup();
145        let _ = setup_or_get_test_signer();
146
147        let rpc_client = Arc::new(RpcMockBuilder::new().build());
148
149        let request = SignBundleRequest {
150            transactions: vec!["some_tx".to_string()],
151            signer_key: None,
152            sig_verify: true,
153            user_id: None,
154            sign_only_indices: None,
155        };
156
157        let result = sign_bundle(&rpc_client, request).await;
158
159        assert!(result.is_err(), "Should fail when bundles disabled");
160        let err = result.unwrap_err();
161        assert!(matches!(err, KoraError::JitoError(_)));
162        if let KoraError::JitoError(msg) = err {
163            assert!(msg.contains("not enabled"));
164        }
165    }
166
167    #[tokio::test]
168    async fn test_sign_bundle_too_large() {
169        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
170        let _ = setup_or_get_test_signer();
171
172        let rpc_client = Arc::new(RpcMockBuilder::new().build());
173
174        let request = SignBundleRequest {
175            transactions: vec!["tx".to_string(); 6],
176            signer_key: None,
177            sig_verify: true,
178            user_id: None,
179            sign_only_indices: None,
180        };
181
182        let result = sign_bundle(&rpc_client, request).await;
183
184        assert!(result.is_err(), "Should fail with too many transactions");
185        let err = result.unwrap_err();
186        assert!(matches!(err, KoraError::JitoError(_)));
187        if let KoraError::JitoError(msg) = err {
188            assert!(msg.contains("maximum size"));
189        }
190    }
191
192    #[tokio::test]
193    async fn test_sign_bundle_invalid_signer_key() {
194        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
195        let _ = setup_or_get_test_signer();
196
197        let rpc_client = Arc::new(RpcMockBuilder::new().build());
198
199        let request = SignBundleRequest {
200            transactions: vec!["some_tx".to_string()],
201            signer_key: Some("invalid_pubkey".to_string()),
202            sig_verify: true,
203            user_id: None,
204            sign_only_indices: None,
205        };
206
207        let result = sign_bundle(&rpc_client, request).await;
208
209        assert!(result.is_err(), "Should fail with invalid signer key");
210        let err = result.unwrap_err();
211        assert!(matches!(err, KoraError::ValidationError(_)));
212    }
213
214    #[tokio::test]
215    async fn test_sign_bundle_exactly_max_size() {
216        let mut validation = ValidationConfigBuilder::new()
217            .with_allowed_programs(vec!["11111111111111111111111111111111".to_string()])
218            .build();
219        validation.price = PriceConfig { model: PriceModel::Free };
220        let _m = ConfigMockBuilder::new()
221            .with_bundle_enabled(true)
222            .with_usage_limit_enabled(false)
223            .with_validation(validation)
224            .build_and_setup();
225        let signer_pubkey = setup_or_get_test_signer();
226        let _ = setup_or_get_test_usage_limiter().await;
227
228        let rpc_client = Arc::new(
229            RpcMockBuilder::new()
230                .with_fee_estimate(5000)
231                .with_blockhash()
232                .with_simulation()
233                .build(),
234        );
235
236        // Create transactions with signer as fee payer
237
238        let transactions: Vec<String> = (0..5)
239            .map(|_| {
240                let ix = transfer(&Pubkey::new_unique(), &Pubkey::new_unique(), 1000000000);
241                let message = VersionedMessage::Legacy(Message::new(&[ix], Some(&signer_pubkey)));
242                let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
243                TransactionUtil::encode_versioned_transaction(&transaction).unwrap()
244            })
245            .collect();
246
247        // Use signer_key to ensure consistency - prevents race conditions with parallel tests
248        let request = SignBundleRequest {
249            transactions,
250            signer_key: Some(signer_pubkey.to_string()),
251            sig_verify: true,
252            user_id: None,
253            sign_only_indices: None,
254        };
255
256        let result = sign_bundle(&rpc_client, request).await;
257
258        assert!(result.is_ok(), "Should succeed with valid transactions");
259        let response = result.unwrap();
260        assert_eq!(response.signed_transactions.len(), 5);
261        assert!(!response.signer_pubkey.is_empty());
262    }
263
264    #[tokio::test]
265    async fn test_sign_bundle_single_transaction() {
266        let mut validation = ValidationConfigBuilder::new()
267            .with_allowed_programs(vec!["11111111111111111111111111111111".to_string()])
268            .build();
269        validation.price = PriceConfig { model: PriceModel::Free };
270        let _m = ConfigMockBuilder::new()
271            .with_bundle_enabled(true)
272            .with_usage_limit_enabled(false)
273            .with_validation(validation)
274            .build_and_setup();
275        let signer_pubkey = setup_or_get_test_signer();
276        let _ = setup_or_get_test_usage_limiter().await;
277
278        let rpc_client = Arc::new(
279            RpcMockBuilder::new()
280                .with_fee_estimate(5000)
281                .with_blockhash()
282                .with_simulation()
283                .build(),
284        );
285
286        // Create transaction with signer as fee payer
287        let ix = transfer(&Pubkey::new_unique(), &Pubkey::new_unique(), 1000000000);
288        let message = VersionedMessage::Legacy(Message::new(&[ix], Some(&signer_pubkey)));
289        let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
290        let encoded_tx = TransactionUtil::encode_versioned_transaction(&transaction).unwrap();
291
292        // Single transaction bundle is valid
293        let request = SignBundleRequest {
294            transactions: vec![encoded_tx],
295            signer_key: Some(signer_pubkey.to_string()),
296            sig_verify: true,
297            user_id: None,
298            sign_only_indices: None,
299        };
300
301        let result = sign_bundle(&rpc_client, request).await;
302
303        assert!(result.is_ok(), "Should succeed with valid transaction");
304        let response = result.unwrap();
305        assert_eq!(response.signed_transactions.len(), 1);
306        assert!(!response.signer_pubkey.is_empty());
307    }
308
309    #[tokio::test]
310    async fn test_sign_bundle_sig_verify_default() {
311        // Test that sig_verify defaults correctly via serde (defaults to false)
312        let json = r#"{"transactions": ["tx1"]}"#;
313        let request: SignBundleRequest = serde_json::from_str(json).unwrap();
314
315        assert!(!request.sig_verify, "sig_verify should default to false");
316        assert!(request.signer_key.is_none());
317    }
318
319    #[tokio::test]
320    async fn test_sign_bundle_request_deserialization() {
321        let json = r#"{
322            "transactions": ["tx1", "tx2"],
323            "signer_key": "11111111111111111111111111111111",
324            "sig_verify": false,
325            "user_id": "test-user-456"
326        }"#;
327        let request: SignBundleRequest = serde_json::from_str(json).unwrap();
328
329        assert_eq!(request.transactions.len(), 2);
330        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
331        assert!(!request.sig_verify);
332        assert_eq!(request.user_id, Some("test-user-456".to_string()));
333        assert!(request.sign_only_indices.is_none());
334    }
335
336    #[tokio::test]
337    async fn test_sign_bundle_request_deserialization_with_sign_only_indices() {
338        let json = r#"{
339            "transactions": ["tx1", "tx2", "tx3"],
340            "signer_key": "11111111111111111111111111111111",
341            "sig_verify": false,
342            "sign_only_indices": [0, 2]
343        }"#;
344        let request: SignBundleRequest = serde_json::from_str(json).unwrap();
345
346        assert_eq!(request.transactions.len(), 3);
347        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
348        assert!(!request.sig_verify);
349        assert_eq!(request.sign_only_indices, Some(vec![0, 2]));
350    }
351}