1use std::time::Duration;
4
5use async_trait::async_trait;
6use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
7use reqwest::Url;
8use serde::{Deserialize, Serialize};
9
10use super::{JitoSubmitConfig, JitoSubmitResponse, JitoSubmitTransport, SubmitTransportError};
11
12const DEFAULT_JITO_BLOCK_ENGINE_URL: &str = "https://mainnet.block-engine.jito.wtf";
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq)]
17pub enum JitoBlockEngineRegion {
18 Amsterdam,
20 Dublin,
22 Frankfurt,
24 London,
26 NewYork,
28 SaltLakeCity,
30 Singapore,
32 Tokyo,
34}
35
36#[derive(Debug, Clone, Eq, PartialEq)]
38pub enum JitoBlockEngineEndpoint {
39 Mainnet,
41 MainnetRegion(JitoBlockEngineRegion),
43 Custom(Url),
45}
46
47impl JitoBlockEngineEndpoint {
48 #[must_use]
50 pub const fn mainnet() -> Self {
51 Self::Mainnet
52 }
53
54 #[must_use]
56 pub const fn mainnet_region(region: JitoBlockEngineRegion) -> Self {
57 Self::MainnetRegion(region)
58 }
59
60 #[must_use]
62 pub const fn custom(url: Url) -> Self {
63 Self::Custom(url)
64 }
65
66 #[must_use]
68 pub fn as_url(&self) -> &str {
69 match self {
70 Self::Mainnet => DEFAULT_JITO_BLOCK_ENGINE_URL,
71 Self::MainnetRegion(region) => match region {
72 JitoBlockEngineRegion::Amsterdam => {
73 "https://amsterdam.mainnet.block-engine.jito.wtf"
74 }
75 JitoBlockEngineRegion::Dublin => "https://dublin.mainnet.block-engine.jito.wtf",
76 JitoBlockEngineRegion::Frankfurt => {
77 "https://frankfurt.mainnet.block-engine.jito.wtf"
78 }
79 JitoBlockEngineRegion::London => "https://london.mainnet.block-engine.jito.wtf",
80 JitoBlockEngineRegion::NewYork => "https://ny.mainnet.block-engine.jito.wtf",
81 JitoBlockEngineRegion::SaltLakeCity => "https://slc.mainnet.block-engine.jito.wtf",
82 JitoBlockEngineRegion::Singapore => {
83 "https://singapore.mainnet.block-engine.jito.wtf"
84 }
85 JitoBlockEngineRegion::Tokyo => "https://tokyo.mainnet.block-engine.jito.wtf",
86 },
87 Self::Custom(url) => url.as_str(),
88 }
89 }
90}
91
92impl Default for JitoBlockEngineEndpoint {
93 fn default() -> Self {
94 Self::mainnet()
95 }
96}
97
98#[derive(Debug, Clone, Eq, PartialEq)]
100pub struct JitoTransportConfig {
101 pub endpoint: JitoBlockEngineEndpoint,
103 pub request_timeout: Duration,
105}
106
107impl Default for JitoTransportConfig {
108 fn default() -> Self {
109 Self {
110 endpoint: JitoBlockEngineEndpoint::default(),
111 request_timeout: Duration::from_secs(10),
112 }
113 }
114}
115
116#[derive(Debug, Clone)]
118pub struct JitoJsonRpcTransport {
119 client: reqwest::Client,
121 transport_config: JitoTransportConfig,
123}
124
125impl JitoJsonRpcTransport {
126 pub fn new() -> Result<Self, SubmitTransportError> {
132 Self::with_config(JitoTransportConfig::default())
133 }
134
135 pub fn with_endpoint(endpoint: JitoBlockEngineEndpoint) -> Result<Self, SubmitTransportError> {
141 Self::with_config(JitoTransportConfig {
142 endpoint,
143 ..JitoTransportConfig::default()
144 })
145 }
146
147 pub fn with_config(
153 transport_config: JitoTransportConfig,
154 ) -> Result<Self, SubmitTransportError> {
155 let client = reqwest::Client::builder()
156 .timeout(transport_config.request_timeout)
157 .build()
158 .map_err(|error| SubmitTransportError::Config {
159 message: error.to_string(),
160 })?;
161 Ok(Self {
162 client,
163 transport_config,
164 })
165 }
166
167 fn request_url(&self, config: &JitoSubmitConfig) -> String {
169 let mut url = self
170 .transport_config
171 .endpoint
172 .as_url()
173 .trim_end_matches('/')
174 .to_owned();
175 url.push_str("/api/v1/transactions");
176 if config.bundle_only {
177 url.push_str("?bundleOnly=true");
178 }
179 url
180 }
181}
182
183#[derive(Debug, Deserialize)]
185struct JsonRpcResponse {
186 result: Option<String>,
188 error: Option<JsonRpcError>,
190}
191
192#[derive(Debug, Deserialize)]
194struct JsonRpcError {
195 code: i64,
197 message: String,
199}
200
201#[async_trait]
202impl JitoSubmitTransport for JitoJsonRpcTransport {
203 async fn submit_jito(
204 &self,
205 tx_bytes: &[u8],
206 config: &JitoSubmitConfig,
207 ) -> Result<JitoSubmitResponse, SubmitTransportError> {
208 #[derive(Debug, Serialize)]
209 struct JitoRpcConfig<'config> {
210 encoding: &'config str,
212 }
213
214 let encoded_tx = BASE64_STANDARD.encode(tx_bytes);
215 let payload = serde_json::json!({
216 "jsonrpc": "2.0",
217 "id": 1,
218 "method": "sendTransaction",
219 "params": [
220 encoded_tx,
221 JitoRpcConfig { encoding: "base64" }
222 ]
223 });
224
225 let response = self
226 .client
227 .post(self.request_url(config))
228 .json(&payload)
229 .send()
230 .await
231 .map_err(|error| SubmitTransportError::Failure {
232 message: error.to_string(),
233 })?;
234
235 let response =
236 response
237 .error_for_status()
238 .map_err(|error| SubmitTransportError::Failure {
239 message: error.to_string(),
240 })?;
241
242 let parsed: JsonRpcResponse =
243 response
244 .json()
245 .await
246 .map_err(|error| SubmitTransportError::Failure {
247 message: error.to_string(),
248 })?;
249
250 if let Some(signature) = parsed.result {
251 return Ok(JitoSubmitResponse {
252 transaction_signature: Some(signature),
253 bundle_id: None,
254 });
255 }
256 if let Some(error) = parsed.error {
257 return Err(SubmitTransportError::Failure {
258 message: format!("jito error {}: {}", error.code, error.message),
259 });
260 }
261
262 Err(SubmitTransportError::Failure {
263 message: "jito returned neither result nor error".to_owned(),
264 })
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn request_url_uses_transactions_path() {
274 let transport_result = JitoJsonRpcTransport::new();
275 assert!(transport_result.is_ok());
276 let Some(transport) = transport_result.ok() else {
277 return;
278 };
279
280 let url = transport.request_url(&JitoSubmitConfig::default());
281
282 assert_eq!(
283 url,
284 "https://mainnet.block-engine.jito.wtf/api/v1/transactions"
285 );
286 }
287
288 #[test]
289 fn request_url_appends_bundle_only_query() {
290 let parsed_url_result = Url::parse("https://mainnet.block-engine.jito.wtf/");
291 assert!(parsed_url_result.is_ok());
292 let Some(parsed_url) = parsed_url_result.ok() else {
293 return;
294 };
295 let transport_result =
296 JitoJsonRpcTransport::with_endpoint(JitoBlockEngineEndpoint::custom(parsed_url));
297 assert!(transport_result.is_ok());
298 let Some(transport) = transport_result.ok() else {
299 return;
300 };
301
302 let url = transport.request_url(&JitoSubmitConfig { bundle_only: true });
303
304 assert_eq!(
305 url,
306 "https://mainnet.block-engine.jito.wtf/api/v1/transactions?bundleOnly=true"
307 );
308 }
309
310 #[test]
311 fn transport_config_defaults_are_stable() {
312 let config = JitoTransportConfig::default();
313
314 assert_eq!(config.endpoint, JitoBlockEngineEndpoint::mainnet());
315 assert_eq!(config.request_timeout, Duration::from_secs(10));
316 }
317
318 #[test]
319 fn regional_endpoint_uses_documented_slug() {
320 let endpoint = JitoBlockEngineEndpoint::mainnet_region(JitoBlockEngineRegion::Frankfurt);
321
322 assert_eq!(
323 endpoint.as_url(),
324 "https://frankfurt.mainnet.block-engine.jito.wtf"
325 );
326 }
327}