alloy_provider/provider/ccip_read/
http.rs1use super::{CcipReadGateway, CcipReadGatewayError, CcipReadRequest};
4use alloy_primitives::Bytes;
5use reqwest::{header::CONTENT_TYPE, Url};
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug)]
23pub struct HttpCcipReadGateway {
24 client: reqwest::Client,
25 max_url_length: usize,
26}
27
28impl HttpCcipReadGateway {
29 pub const fn new(client: reqwest::Client) -> Self {
31 Self { client, max_url_length: 2_097_152 }
32 }
33
34 pub const fn with_max_url_length(mut self, max_url_length: usize) -> Self {
39 self.max_url_length = max_url_length;
40 self
41 }
42
43 async fn fetch(
45 &self,
46 template: &str,
47 sender: &str,
48 data: &str,
49 max_response_size: usize,
50 ) -> Attempt {
51 let url = match expand_url(template, sender, data, self.max_url_length) {
52 Ok(url) => url,
53 Err(error) => return Attempt::Retry(error),
54 };
55 let url = match Url::parse(&url) {
56 Ok(url) if matches!(url.scheme(), "http" | "https") => url,
57 Ok(_) => {
58 return Attempt::Retry(CcipReadGatewayError::new(
59 "CCIP Read gateway URL must use http or https",
60 ))
61 }
62 Err(err) => {
63 return Attempt::Retry(CcipReadGatewayError::new(format!(
64 "invalid CCIP Read gateway URL: {err}"
65 )))
66 }
67 };
68
69 let request = if template.contains("{data}") {
70 self.client.get(url)
71 } else {
72 let body = serde_json::to_vec(&GatewayRequestBody { sender, data })
73 .expect("serializing two strings cannot fail");
74 self.client.post(url).header(CONTENT_TYPE, "application/json").body(body)
75 };
76 let response = match request.send().await {
77 Ok(response) => response,
78 Err(err) => {
79 return Attempt::Retry(CcipReadGatewayError::new(format!(
80 "gateway request failed: {err}"
81 )))
82 }
83 };
84
85 let status = response.status().as_u16();
86 let content_type = response
87 .headers()
88 .get(CONTENT_TYPE)
89 .and_then(|value| value.to_str().ok())
90 .unwrap_or_default()
91 .to_string();
92 match read_body(response, status, max_response_size).await {
93 Ok(body) => classify_response(status, &content_type, &body),
94 Err(error) => Attempt::failure(status, error),
95 }
96 }
97}
98
99fn expand_url(
101 template: &str,
102 sender: &str,
103 data: &str,
104 limit: usize,
105) -> Result<String, CcipReadGatewayError> {
106 let sender_count = template.matches("{sender}").count();
107 let data_count = template.matches("{data}").count();
108 let literal_len = template.len() - sender_count * 8 - data_count * 6;
109 let len = sender_count
110 .checked_mul(sender.len())
111 .and_then(|len| {
112 data_count.checked_mul(data.len()).and_then(|data_len| len.checked_add(data_len))
113 })
114 .and_then(|len| len.checked_add(literal_len))
115 .filter(|len| *len <= limit)
116 .ok_or_else(|| {
117 CcipReadGatewayError::new("expanded gateway URL exceeds configured size limit")
118 })?;
119 let mut url = String::with_capacity(len);
122 let mut rest = template;
123 while let Some(index) = rest.find('{') {
124 url.push_str(&rest[..index]);
125 rest = &rest[index..];
126 if let Some(suffix) = rest.strip_prefix("{sender}") {
127 url.push_str(sender);
128 rest = suffix;
129 } else if let Some(suffix) = rest.strip_prefix("{data}") {
130 url.push_str(data);
131 rest = suffix;
132 } else {
133 url.push('{');
134 rest = &rest[1..];
135 }
136 }
137 url.push_str(rest);
138 Ok(url)
139}
140
141impl Default for HttpCcipReadGateway {
142 fn default() -> Self {
143 #[cfg(not(target_family = "wasm"))]
144 let client = reqwest::Client::builder()
145 .timeout(std::time::Duration::from_secs(10))
146 .build()
147 .expect("default CCIP Read HTTP client configuration is valid");
148 #[cfg(target_family = "wasm")]
149 let client = reqwest::Client::new();
150 Self::new(client)
151 }
152}
153
154#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
155#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
156impl CcipReadGateway for HttpCcipReadGateway {
157 async fn request(
158 &self,
159 request: &CcipReadRequest,
160 max_response_size: usize,
161 ) -> Result<Bytes, CcipReadGatewayError> {
162 if request.urls.is_empty() {
163 return Err(CcipReadGatewayError::new("OffchainLookup contained no gateway URLs"));
164 }
165
166 let sender = format!("{:#x}", request.sender);
167 let data = request.data.to_string();
168 let mut last_error = None;
169 for template in &request.urls {
170 match self.fetch(template, &sender, &data, max_response_size).await {
171 Attempt::Data(data) => return Ok(data),
172 Attempt::Fatal(error) => return Err(error),
173 Attempt::Retry(error) => last_error = Some(error),
174 }
175 }
176 Err(last_error.expect("at least one URL was attempted"))
177 }
178}
179
180#[derive(Serialize)]
181struct GatewayRequestBody<'a> {
182 sender: &'a str,
183 data: &'a str,
184}
185
186#[derive(Deserialize)]
187struct GatewayResponse {
188 data: Bytes,
189}
190
191#[derive(Debug)]
193enum Attempt {
194 Data(Bytes),
196 Fatal(CcipReadGatewayError),
198 Retry(CcipReadGatewayError),
200}
201
202impl Attempt {
203 fn failure(status: u16, error: CcipReadGatewayError) -> Self {
205 if (400..500).contains(&status) {
206 Self::Fatal(error)
207 } else {
208 Self::Retry(error)
209 }
210 }
211}
212
213#[cfg(not(target_family = "wasm"))]
215async fn read_body(
216 mut response: reqwest::Response,
217 status: u16,
218 max_response_size: usize,
219) -> Result<Vec<u8>, CcipReadGatewayError> {
220 if response.content_length().is_some_and(|length| length > max_response_size as u64) {
221 return Err(size_limit_error(status));
222 }
223 let mut body = Vec::new();
224 loop {
225 match response.chunk().await {
226 Ok(Some(chunk)) => {
227 if body.len() + chunk.len() > max_response_size {
228 return Err(size_limit_error(status));
229 }
230 body.extend_from_slice(&chunk);
231 }
232 Ok(None) => return Ok(body),
233 Err(err) => {
234 return Err(CcipReadGatewayError::new(format!(
235 "failed reading gateway response: {err}"
236 )))
237 }
238 }
239 }
240}
241
242#[cfg(target_family = "wasm")]
244async fn read_body(
245 response: reqwest::Response,
246 status: u16,
247 max_response_size: usize,
248) -> Result<Vec<u8>, CcipReadGatewayError> {
249 if response.content_length().is_some_and(|length| length > max_response_size as u64) {
250 return Err(size_limit_error(status));
251 }
252 let body = response.bytes().await.map_err(|err| {
253 CcipReadGatewayError::new(format!("failed reading gateway response: {err}"))
254 })?;
255 if body.len() > max_response_size {
256 return Err(size_limit_error(status));
257 }
258 Ok(body.to_vec())
259}
260
261fn size_limit_error(status: u16) -> CcipReadGatewayError {
262 CcipReadGatewayError::http(status, "gateway response exceeded configured size limit")
263}
264
265fn classify_response(status: u16, content_type: &str, body: &[u8]) -> Attempt {
267 if (400..500).contains(&status) {
268 return Attempt::Fatal(CcipReadGatewayError::http(status, response_message(body)));
269 }
270 if !(200..300).contains(&status) {
271 return Attempt::Retry(CcipReadGatewayError::http(status, response_message(body)));
272 }
273 let is_json = content_type
274 .split(';')
275 .next()
276 .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"));
277 if !is_json {
278 return Attempt::Retry(CcipReadGatewayError::http(
279 status,
280 "gateway response was not application/json",
281 ));
282 }
283 match serde_json::from_slice::<GatewayResponse>(body) {
284 Ok(response) => Attempt::Data(response.data),
285 Err(err) => Attempt::Retry(CcipReadGatewayError::http(
286 status,
287 format!("invalid gateway response: {err}"),
288 )),
289 }
290}
291
292fn response_message(body: &[u8]) -> String {
294 const LIMIT: usize = 1_024;
295 String::from_utf8_lossy(&body[..body.len().min(LIMIT)]).into_owned()
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use alloy_primitives::{address, bytes, Address};
302
303 fn message(attempt: &Attempt) -> &str {
304 match attempt {
305 Attempt::Data(_) => "",
306 Attempt::Fatal(error) | Attempt::Retry(error) => &error.message,
307 }
308 }
309
310 #[test]
311 fn bounds_template_expansion_and_preserves_placeholders() {
312 let data = format!("0x{}", "00".repeat(4096));
313 let template = format!("https://example.test/{}", "{data}".repeat(128));
314 assert!(expand_url(&template, "0x01", &data, 1024).is_err());
315 assert!(expand_url(&"a".repeat(1025), "0x01", "0x", 1024).is_err());
316
317 for template in ["{sender}/{data}/{sender}/{data}", "{{data}}/{unknown}", "plain", ""] {
318 let expected = template.replace("{sender}", "0x01").replace("{data}", "0x02");
319 assert_eq!(expand_url(template, "0x01", "0x02", expected.len()).unwrap(), expected);
320 if !expected.is_empty() {
321 assert!(expand_url(template, "0x01", "0x02", expected.len() - 1).is_err());
322 }
323 }
324 }
325
326 #[tokio::test]
327 async fn oversized_url_is_rejected_before_network_and_falls_back() {
328 let gateway = HttpCcipReadGateway::default().with_max_url_length(1024);
329 let request = CcipReadRequest {
330 sender: Address::ZERO,
331 urls: vec![format!("https://example.test/{}", "{data}".repeat(128))],
332 data: vec![0; 4096].into(),
333 };
334 let error = gateway.request(&request, 16).await.unwrap_err();
335 assert!(error.message.contains("expanded gateway URL"));
336
337 let mut request = request;
340 request.urls.push("ftp://example.test".into());
341 let error = gateway.request(&request, 16).await.unwrap_err();
342 assert!(error.message.contains("http or https"));
343 }
344
345 #[test]
346 fn classifies_gateway_responses() {
347 let json_ok = br#"{"data":"0xdead"}"#;
348 match classify_response(200, "application/json; charset=utf-8", json_ok) {
349 Attempt::Data(data) => assert_eq!(data, bytes!("dead")),
350 other => panic!("expected data, got {other:?}"),
351 }
352
353 let attempt = classify_response(404, "text/plain", b"missing");
354 assert!(matches!(&attempt, Attempt::Fatal(error) if error.status == Some(404)));
355 assert_eq!(message(&attempt), "missing");
356
357 let attempt = classify_response(503, "application/json", json_ok);
358 assert!(matches!(&attempt, Attempt::Retry(error) if error.status == Some(503)));
359
360 let attempt = classify_response(200, "text/plain", json_ok);
361 assert!(matches!(attempt, Attempt::Retry(_)));
362 assert!(message(&attempt).contains("application/json"));
363
364 for body in [&b"{\"data\":"[..], b"not json", br#"{"data":"zz"}"#] {
365 let attempt = classify_response(200, "application/json", body);
366 assert!(matches!(attempt, Attempt::Retry(_)), "{attempt:?}");
367 assert!(message(&attempt).contains("invalid gateway response"));
368 }
369 }
370
371 #[tokio::test]
372 async fn rejects_empty_and_invalid_urls() {
373 let gateway = HttpCcipReadGateway::default();
374 let sender = address!("1111111111111111111111111111111111111111");
375 let request = |urls: Vec<String>| CcipReadRequest { sender, urls, data: Bytes::new() };
376
377 let error = gateway.request(&request(vec![]), 1024).await.unwrap_err();
378 assert!(error.message.contains("no gateway URLs"));
379
380 let error = gateway
381 .request(&request(vec!["ftp://example.test/{data}".into()]), 1024)
382 .await
383 .unwrap_err();
384 assert!(error.message.contains("http or https"));
385
386 let error = gateway.request(&request(vec!["not a url".into()]), 1024).await.unwrap_err();
387 assert!(error.message.contains("invalid CCIP Read gateway URL"));
388 }
389}