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, 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 auth token sent as `x-jito-auth`.
16#[derive(Debug, Clone, Eq, PartialEq)]
17pub struct JitoAuthToken(String);
18
19impl JitoAuthToken {
20    /// Creates a validated auth token wrapper.
21    #[must_use]
22    pub fn new(token: impl Into<String>) -> Self {
23        Self(token.into())
24    }
25
26    /// Returns the raw token string.
27    #[must_use]
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32
33/// Typed Jito block-engine endpoint.
34#[derive(Debug, Clone, Eq, PartialEq)]
35pub enum JitoBlockEngineEndpoint {
36    /// Default Jito mainnet block-engine endpoint.
37    Mainnet,
38    /// Custom parsed block-engine base URL.
39    Custom(Url),
40}
41
42impl JitoBlockEngineEndpoint {
43    /// Returns the default mainnet block-engine endpoint.
44    #[must_use]
45    pub const fn mainnet() -> Self {
46        Self::Mainnet
47    }
48
49    /// Creates a custom block-engine endpoint from a parsed URL.
50    #[must_use]
51    pub const fn custom(url: Url) -> Self {
52        Self::Custom(url)
53    }
54
55    /// Returns the base URL.
56    #[must_use]
57    pub fn as_url(&self) -> &str {
58        match self {
59            Self::Mainnet => DEFAULT_JITO_BLOCK_ENGINE_URL,
60            Self::Custom(url) => url.as_str(),
61        }
62    }
63}
64
65impl Default for JitoBlockEngineEndpoint {
66    fn default() -> Self {
67        Self::mainnet()
68    }
69}
70
71/// Transport-level Jito block-engine settings.
72#[derive(Debug, Clone, Eq, PartialEq)]
73pub struct JitoTransportConfig {
74    /// Target Jito block-engine endpoint.
75    pub endpoint: JitoBlockEngineEndpoint,
76    /// HTTP timeout applied to block-engine requests.
77    pub request_timeout: Duration,
78    /// Optional auth token sent as `x-jito-auth`.
79    pub auth_token: Option<JitoAuthToken>,
80}
81
82impl Default for JitoTransportConfig {
83    fn default() -> Self {
84        Self {
85            endpoint: JitoBlockEngineEndpoint::default(),
86            request_timeout: Duration::from_secs(10),
87            auth_token: None,
88        }
89    }
90}
91
92/// Jito block-engine JSON-RPC transport using `/api/v1/transactions`.
93#[derive(Debug, Clone)]
94pub struct JitoJsonRpcTransport {
95    /// HTTP client used for block-engine calls.
96    client: reqwest::Client,
97    /// Transport-level request settings.
98    transport_config: JitoTransportConfig,
99}
100
101impl JitoJsonRpcTransport {
102    /// Creates a Jito block-engine transport.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`SubmitTransportError::Config`] when HTTP client creation fails.
107    pub fn new() -> Result<Self, SubmitTransportError> {
108        Self::with_config(JitoTransportConfig::default())
109    }
110
111    /// Creates a Jito block-engine transport for one typed endpoint.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`SubmitTransportError::Config`] when HTTP client creation fails.
116    pub fn with_endpoint(endpoint: JitoBlockEngineEndpoint) -> Result<Self, SubmitTransportError> {
117        Self::with_config(JitoTransportConfig {
118            endpoint,
119            ..JitoTransportConfig::default()
120        })
121    }
122
123    /// Creates a Jito block-engine transport with explicit transport settings.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`SubmitTransportError::Config`] when HTTP client creation fails.
128    pub fn with_config(
129        transport_config: JitoTransportConfig,
130    ) -> Result<Self, SubmitTransportError> {
131        let client = reqwest::Client::builder()
132            .timeout(transport_config.request_timeout)
133            .build()
134            .map_err(|error| SubmitTransportError::Config {
135                message: error.to_string(),
136            })?;
137        Ok(Self {
138            client,
139            transport_config,
140        })
141    }
142
143    /// Builds the per-request endpoint URL with optional revert protection.
144    fn request_url(&self, config: &JitoSubmitConfig) -> String {
145        let mut url = self
146            .transport_config
147            .endpoint
148            .as_url()
149            .trim_end_matches('/')
150            .to_owned();
151        url.push_str("/api/v1/transactions");
152        if config.bundle_only {
153            url.push_str("?bundleOnly=true");
154        }
155        url
156    }
157}
158
159/// JSON-RPC envelope.
160#[derive(Debug, Deserialize)]
161struct JsonRpcResponse {
162    /// Result value for successful calls.
163    result: Option<String>,
164    /// Error payload for failed calls.
165    error: Option<JsonRpcError>,
166}
167
168/// JSON-RPC error object.
169#[derive(Debug, Deserialize)]
170struct JsonRpcError {
171    /// JSON-RPC error code.
172    code: i64,
173    /// Human-readable message.
174    message: String,
175}
176
177#[async_trait]
178impl JitoSubmitTransport for JitoJsonRpcTransport {
179    async fn submit_jito(
180        &self,
181        tx_bytes: &[u8],
182        config: &JitoSubmitConfig,
183    ) -> Result<String, SubmitTransportError> {
184        #[derive(Debug, Serialize)]
185        struct JitoRpcConfig<'config> {
186            /// Transaction encoding format.
187            encoding: &'config str,
188        }
189
190        let encoded_tx = BASE64_STANDARD.encode(tx_bytes);
191        let payload = serde_json::json!({
192            "jsonrpc": "2.0",
193            "id": 1,
194            "method": "sendTransaction",
195            "params": [
196                encoded_tx,
197                JitoRpcConfig { encoding: "base64" }
198            ]
199        });
200
201        let mut request = self.client.post(self.request_url(config)).json(&payload);
202        if let Some(auth_token) = &self.transport_config.auth_token {
203            request = request.header("x-jito-auth", auth_token.as_str());
204        }
205
206        let response = request
207            .send()
208            .await
209            .map_err(|error| SubmitTransportError::Failure {
210                message: error.to_string(),
211            })?;
212
213        let response =
214            response
215                .error_for_status()
216                .map_err(|error| SubmitTransportError::Failure {
217                    message: error.to_string(),
218                })?;
219
220        let parsed: JsonRpcResponse =
221            response
222                .json()
223                .await
224                .map_err(|error| SubmitTransportError::Failure {
225                    message: error.to_string(),
226                })?;
227
228        if let Some(signature) = parsed.result {
229            return Ok(signature);
230        }
231        if let Some(error) = parsed.error {
232            return Err(SubmitTransportError::Failure {
233                message: format!("jito error {}: {}", error.code, error.message),
234            });
235        }
236
237        Err(SubmitTransportError::Failure {
238            message: "jito returned neither result nor error".to_owned(),
239        })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn request_url_uses_transactions_path() {
249        let transport_result = JitoJsonRpcTransport::new();
250        assert!(transport_result.is_ok());
251        let Some(transport) = transport_result.ok() else {
252            return;
253        };
254
255        let url = transport.request_url(&JitoSubmitConfig::default());
256
257        assert_eq!(
258            url,
259            "https://mainnet.block-engine.jito.wtf/api/v1/transactions"
260        );
261    }
262
263    #[test]
264    fn request_url_appends_bundle_only_query() {
265        let parsed_url_result = Url::parse("https://mainnet.block-engine.jito.wtf/");
266        assert!(parsed_url_result.is_ok());
267        let Some(parsed_url) = parsed_url_result.ok() else {
268            return;
269        };
270        let transport_result =
271            JitoJsonRpcTransport::with_endpoint(JitoBlockEngineEndpoint::custom(parsed_url));
272        assert!(transport_result.is_ok());
273        let Some(transport) = transport_result.ok() else {
274            return;
275        };
276
277        let url = transport.request_url(&JitoSubmitConfig { bundle_only: true });
278
279        assert_eq!(
280            url,
281            "https://mainnet.block-engine.jito.wtf/api/v1/transactions?bundleOnly=true"
282        );
283    }
284
285    #[test]
286    fn transport_config_defaults_are_stable() {
287        let config = JitoTransportConfig::default();
288
289        assert_eq!(config.endpoint, JitoBlockEngineEndpoint::mainnet());
290        assert_eq!(config.request_timeout, Duration::from_secs(10));
291        assert_eq!(config.auth_token, None);
292    }
293}