kora_lib/rpc_server/method/
sign_and_send_bundle.rs1use 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 pub transactions: Vec<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub signer_key: Option<String>,
30 #[serde(default = "default_sig_verify")]
32 pub sig_verify: bool,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub user_id: Option<String>,
36 #[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 pub signed_transactions: Vec<String>,
45 pub signer_pubkey: String,
47 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 BundleValidator::validate_jito_bundle_size(&request.transactions)?;
63
64 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 let encoded_signed: Vec<String> = signed_resolved
92 .iter()
93 .map(|r| TransactionUtil::encode_versioned_transaction(&r.transaction))
94 .collect::<Result<Vec<_>, _>>()?;
95
96 let signed_transactions = BundleProcessor::merge_signed_transactions(
98 &request.transactions,
99 encoded_signed,
100 &index_to_position,
101 );
102
103 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 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}