Skip to main content

sof_tx/submit/
jito.rs

1//! Jito block-engine submit transport implementation.
2
3use 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
12/// Default Jito mainnet block-engine base URL.
13const DEFAULT_JITO_BLOCK_ENGINE_URL: &str = "https://mainnet.block-engine.jito.wtf";
14
15/// Typed Jito mainnet region.
16#[derive(Debug, Clone, Copy, Eq, PartialEq)]
17pub enum JitoBlockEngineRegion {
18    /// Amsterdam region.
19    Amsterdam,
20    /// Dublin region.
21    Dublin,
22    /// Frankfurt region.
23    Frankfurt,
24    /// London region.
25    London,
26    /// New York region.
27    NewYork,
28    /// Salt Lake City region.
29    SaltLakeCity,
30    /// Singapore region.
31    Singapore,
32    /// Tokyo region.
33    Tokyo,
34}
35
36/// Typed Jito block-engine endpoint.
37#[derive(Debug, Clone, Eq, PartialEq)]
38pub enum JitoBlockEngineEndpoint {
39    /// Default Jito mainnet block-engine endpoint.
40    Mainnet,
41    /// Region-specific Jito mainnet block-engine endpoint.
42    MainnetRegion(JitoBlockEngineRegion),
43    /// Custom parsed block-engine base URL.
44    Custom(Url),
45}
46
47impl JitoBlockEngineEndpoint {
48    /// Returns the default mainnet block-engine endpoint.
49    #[must_use]
50    pub const fn mainnet() -> Self {
51        Self::Mainnet
52    }
53
54    /// Returns one typed regional mainnet block-engine endpoint.
55    #[must_use]
56    pub const fn mainnet_region(region: JitoBlockEngineRegion) -> Self {
57        Self::MainnetRegion(region)
58    }
59
60    /// Creates a custom block-engine endpoint from a parsed URL.
61    #[must_use]
62    pub const fn custom(url: Url) -> Self {
63        Self::Custom(url)
64    }
65
66    /// Returns the base URL.
67    #[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/// Transport-level Jito block-engine settings.
99#[derive(Debug, Clone, Eq, PartialEq)]
100pub struct JitoTransportConfig {
101    /// Target Jito block-engine endpoint.
102    pub endpoint: JitoBlockEngineEndpoint,
103    /// HTTP timeout applied to block-engine requests.
104    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/// Jito block-engine JSON-RPC transport using `/api/v1/transactions`.
117#[derive(Debug, Clone)]
118pub struct JitoJsonRpcTransport {
119    /// HTTP client used for block-engine calls.
120    client: reqwest::Client,
121    /// Transport-level request settings.
122    transport_config: JitoTransportConfig,
123}
124
125impl JitoJsonRpcTransport {
126    /// Creates a Jito block-engine transport.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`SubmitTransportError::Config`] when HTTP client creation fails.
131    pub fn new() -> Result<Self, SubmitTransportError> {
132        Self::with_config(JitoTransportConfig::default())
133    }
134
135    /// Creates a Jito block-engine transport for one typed endpoint.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`SubmitTransportError::Config`] when HTTP client creation fails.
140    pub fn with_endpoint(endpoint: JitoBlockEngineEndpoint) -> Result<Self, SubmitTransportError> {
141        Self::with_config(JitoTransportConfig {
142            endpoint,
143            ..JitoTransportConfig::default()
144        })
145    }
146
147    /// Creates a Jito block-engine transport with explicit transport settings.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`SubmitTransportError::Config`] when HTTP client creation fails.
152    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    /// Builds the per-request endpoint URL with optional revert protection.
168    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/// JSON-RPC envelope.
184#[derive(Debug, Deserialize)]
185struct JsonRpcResponse {
186    /// Result value for successful calls.
187    result: Option<String>,
188    /// Error payload for failed calls.
189    error: Option<JsonRpcError>,
190}
191
192/// JSON-RPC error object.
193#[derive(Debug, Deserialize)]
194struct JsonRpcError {
195    /// JSON-RPC error code.
196    code: i64,
197    /// Human-readable message.
198    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            /// Transaction encoding format.
211            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}