Skip to main content

kora_lib/rpc_server/method/
sign_and_send_bundle.rs

1use crate::{
2    bundle::{BundleError, BundleProcessingMode, BundleProcessor, JitoBundleClient, 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 SignAndSendBundleRequest {
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 SignAndSendBundleResponse {
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    /// Jito bundle UUID
48    pub bundle_uuid: String,
49}
50
51pub async fn sign_and_send_bundle(
52    rpc_client: &Arc<RpcClient>,
53    request: SignAndSendBundleRequest,
54) -> Result<SignAndSendBundleResponse, KoraError> {
55    let config = &get_config()?;
56
57    if !config.kora.bundle.enabled {
58        return Err(BundleError::Jito(JitoError::NotEnabled).into());
59    }
60
61    // Validate bundle size on ALL transactions first
62    BundleValidator::validate_jito_bundle_size(&request.transactions)?;
63
64    // Extract only the transactions we need to process
65    let (transactions_to_process, index_to_position) =
66        BundleProcessor::extract_transactions_to_process(
67            &request.transactions,
68            request.sign_only_indices,
69        )?;
70
71    let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?;
72    let fee_payer = signer.pubkey();
73    let payment_destination = config.kora.get_payment_address(&fee_payer)?;
74
75    let sig_verify = request.sig_verify || config.kora.force_sig_verify;
76    let processor = BundleProcessor::process_bundle(
77        &transactions_to_process,
78        fee_payer,
79        &payment_destination,
80        config,
81        rpc_client,
82        sig_verify,
83        Some(PluginExecutionContext::SignAndSendBundle),
84        BundleProcessingMode::CheckUsage(request.user_id.as_deref()),
85    )
86    .await?;
87
88    let signed_resolved = processor.sign_all(&signer, &fee_payer, rpc_client, config, true).await?;
89
90    // Encode signed transactions
91    let encoded_signed: Vec<String> = signed_resolved
92        .iter()
93        .map(|r| TransactionUtil::encode_versioned_transaction(&r.transaction))
94        .collect::<Result<Vec<_>, _>>()?;
95
96    // Merge signed transactions back into original positions
97    let signed_transactions = BundleProcessor::merge_signed_transactions(
98        &request.transactions,
99        encoded_signed,
100        &index_to_position,
101    );
102
103    // Send the full merged bundle to Jito for atomic execution.
104    let jito_client = JitoBundleClient::new(&config.kora.bundle.jito);
105    let bundle_uuid = jito_client.send_bundle(&signed_transactions).await?;
106
107    Ok(SignAndSendBundleResponse {
108        signed_transactions,
109        signer_pubkey: fee_payer.to_string(),
110        bundle_uuid,
111    })
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::{
118        fee::price::{PriceConfig, PriceModel},
119        tests::{
120            common::{setup_or_get_test_signer, setup_or_get_test_usage_limiter, RpcMockBuilder},
121            config_mock::{mock_state::setup_config_mock, ConfigMockBuilder},
122        },
123        transaction::TransactionUtil,
124    };
125    use mockito::{Matcher, Server};
126    use serde_json::json;
127    use solana_message::{Message, VersionedMessage};
128    use solana_sdk::pubkey::Pubkey;
129    use solana_system_interface::instruction::transfer;
130
131    #[tokio::test]
132    async fn test_sign_and_send_bundle_empty_bundle() {
133        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
134        let _ = setup_or_get_test_signer();
135
136        let rpc_client = Arc::new(RpcMockBuilder::new().build());
137
138        let request = SignAndSendBundleRequest {
139            transactions: vec![],
140            signer_key: None,
141            sig_verify: true,
142            user_id: None,
143            sign_only_indices: None,
144        };
145
146        let result = sign_and_send_bundle(&rpc_client, request).await;
147
148        assert!(result.is_err(), "Should fail with empty bundle");
149        let err = result.unwrap_err();
150        assert!(matches!(err, KoraError::InvalidTransaction(_)));
151    }
152
153    #[tokio::test]
154    async fn test_sign_and_send_bundle_disabled() {
155        let _m = ConfigMockBuilder::new().with_bundle_enabled(false).build_and_setup();
156        let _ = setup_or_get_test_signer();
157
158        let rpc_client = Arc::new(RpcMockBuilder::new().build());
159
160        let request = SignAndSendBundleRequest {
161            transactions: vec!["some_tx".to_string()],
162            signer_key: None,
163            sig_verify: true,
164            user_id: None,
165            sign_only_indices: None,
166        };
167
168        let result = sign_and_send_bundle(&rpc_client, request).await;
169
170        assert!(result.is_err(), "Should fail when bundles disabled");
171        let err = result.unwrap_err();
172        assert!(matches!(err, KoraError::JitoError(_)));
173        if let KoraError::JitoError(msg) = err {
174            assert!(msg.contains("not enabled"));
175        }
176    }
177
178    #[tokio::test]
179    async fn test_sign_and_send_bundle_too_large() {
180        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
181        let _ = setup_or_get_test_signer();
182
183        let rpc_client = Arc::new(RpcMockBuilder::new().build());
184
185        let request = SignAndSendBundleRequest {
186            transactions: vec!["tx".to_string(); 6],
187            signer_key: None,
188            sig_verify: true,
189            user_id: None,
190            sign_only_indices: None,
191        };
192
193        let result = sign_and_send_bundle(&rpc_client, request).await;
194
195        assert!(result.is_err(), "Should fail with too many transactions");
196        let err = result.unwrap_err();
197        assert!(matches!(err, KoraError::JitoError(_)));
198        if let KoraError::JitoError(msg) = err {
199            assert!(msg.contains("maximum size"));
200        }
201    }
202
203    #[tokio::test]
204    async fn test_sign_and_send_bundle_invalid_signer_key() {
205        let _m = ConfigMockBuilder::new().with_bundle_enabled(true).build_and_setup();
206        let _ = setup_or_get_test_signer();
207
208        let rpc_client = Arc::new(RpcMockBuilder::new().build());
209
210        let request = SignAndSendBundleRequest {
211            transactions: vec!["some_tx".to_string()],
212            signer_key: Some("invalid_pubkey".to_string()),
213            sig_verify: true,
214            user_id: None,
215            sign_only_indices: None,
216        };
217
218        let result = sign_and_send_bundle(&rpc_client, request).await;
219
220        assert!(result.is_err(), "Should fail with invalid signer key");
221        let err = result.unwrap_err();
222        assert!(matches!(err, KoraError::ValidationError(_)));
223    }
224
225    #[tokio::test]
226    async fn test_sign_and_send_bundle_sends_full_bundle_when_sign_only_indices_set() {
227        let mut server = Server::new_async().await;
228        let mock = server
229            .mock("POST", "/api/v1/bundles")
230            .match_header("content-type", "application/json")
231            .match_body(Matcher::AllOf(vec![
232                Matcher::PartialJson(json!({"method": "sendBundle"})),
233                Matcher::Regex(
234                    r#""params":\[\[(?:"[^"]+",){2}"[^"]+"\],\{"encoding":"base64"\}\]"#
235                        .to_string(),
236                ),
237            ]))
238            .with_status(200)
239            .with_header("content-type", "application/json")
240            .with_body(r#"{"jsonrpc":"2.0","id":1,"result":"bundle-uuid-full-3"}"#)
241            .create();
242
243        let mut config = ConfigMockBuilder::new()
244            .with_bundle_enabled(true)
245            .with_usage_limit_enabled(false)
246            .build();
247        config.validation.price = PriceConfig { model: PriceModel::Free };
248        config.kora.bundle.jito.block_engine_url = server.url();
249        let _m = setup_config_mock(config);
250        let _ = setup_or_get_test_usage_limiter().await;
251
252        let signer_pubkey = setup_or_get_test_signer();
253        let rpc_client = Arc::new(
254            RpcMockBuilder::new()
255                .with_fee_estimate(5000)
256                .with_blockhash()
257                .with_simulation()
258                .build(),
259        );
260
261        let transactions: Vec<String> = (0..3)
262            .map(|_| {
263                let ix = transfer(&Pubkey::new_unique(), &Pubkey::new_unique(), 1000000000);
264                let message = VersionedMessage::Legacy(Message::new(&[ix], Some(&signer_pubkey)));
265                let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
266                TransactionUtil::encode_versioned_transaction(&transaction).unwrap()
267            })
268            .collect();
269
270        let request = SignAndSendBundleRequest {
271            transactions,
272            signer_key: Some(signer_pubkey.to_string()),
273            sig_verify: true,
274            user_id: None,
275            sign_only_indices: Some(vec![1]),
276        };
277
278        let result = sign_and_send_bundle(&rpc_client, request).await;
279
280        assert!(
281            result.is_ok(),
282            "Expected full bundle to be sent to Jito, got error: {:?}",
283            result.as_ref().err()
284        );
285        mock.assert();
286        let response = result.unwrap();
287        assert_eq!(response.bundle_uuid, "bundle-uuid-full-3");
288        assert_eq!(response.signed_transactions.len(), 3);
289    }
290
291    #[tokio::test]
292    async fn test_sign_and_send_bundle_request_deserialization() {
293        let json = r#"{
294            "transactions": ["tx1", "tx2", "tx3"],
295            "signer_key": "11111111111111111111111111111111",
296            "sig_verify": false,
297            "user_id": "test-user-123"
298        }"#;
299        let request: SignAndSendBundleRequest = serde_json::from_str(json).unwrap();
300
301        assert_eq!(request.transactions.len(), 3);
302        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
303        assert!(!request.sig_verify);
304        assert_eq!(request.user_id, Some("test-user-123".to_string()));
305        assert!(request.sign_only_indices.is_none());
306    }
307
308    #[tokio::test]
309    async fn test_sign_and_send_bundle_request_deserialization_with_sign_only_indices() {
310        let json = r#"{
311            "transactions": ["tx1", "tx2", "tx3"],
312            "signer_key": "11111111111111111111111111111111",
313            "sig_verify": false,
314            "sign_only_indices": [1, 2]
315        }"#;
316        let request: SignAndSendBundleRequest = serde_json::from_str(json).unwrap();
317
318        assert_eq!(request.transactions.len(), 3);
319        assert_eq!(request.signer_key, Some("11111111111111111111111111111111".to_string()));
320        assert!(!request.sig_verify);
321        assert_eq!(request.sign_only_indices, Some(vec![1, 2]));
322    }
323
324    #[tokio::test]
325    async fn test_sign_and_send_bundle_sig_verify_default() {
326        // sig_verify defaults to false
327        let json = r#"{"transactions": ["tx1"]}"#;
328        let request: SignAndSendBundleRequest = serde_json::from_str(json).unwrap();
329
330        assert!(!request.sig_verify, "sig_verify should default to false");
331        assert!(request.signer_key.is_none());
332    }
333
334    #[test]
335    fn test_sign_and_send_bundle_response_serialization() {
336        let response = SignAndSendBundleResponse {
337            signed_transactions: vec!["signed_tx1".to_string(), "signed_tx2".to_string()],
338            signer_pubkey: "11111111111111111111111111111111".to_string(),
339            bundle_uuid: "bundle-uuid-12345".to_string(),
340        };
341
342        let json = serde_json::to_string(&response).unwrap();
343
344        assert!(json.contains("signed_transactions"));
345        assert!(json.contains("signer_pubkey"));
346        assert!(json.contains("bundle_uuid"));
347        assert!(json.contains("bundle-uuid-12345"));
348    }
349}