Skip to main content

alloy_provider/provider/ccip_read/
http.rs

1//! The default `reqwest`-backed [`CcipReadGateway`].
2
3use super::{CcipReadGateway, CcipReadGatewayError, CcipReadRequest};
4use alloy_primitives::Bytes;
5use reqwest::{header::CONTENT_TYPE, Url};
6use serde::{Deserialize, Serialize};
7
8/// The default HTTP implementation of [`CcipReadGateway`].
9///
10/// Requests follow ERC-3668: URL templates containing `{data}` are fetched with `GET`, all
11/// others receive a `POST` with a JSON body carrying `sender` and `data`. The URLs of a request
12/// are tried in order; a `4xx` response aborts the request, while any other failure falls through
13/// to the next URL.
14///
15/// # URL trust
16///
17/// Templates in [`CcipReadRequest::urls`] are chosen by the callee contract. This gateway only
18/// checks that each URL uses `http` or `https` and will follow a URL to a private, link-local,
19/// loopback, or cloud-metadata address. To constrain destinations, construct
20/// [`HttpCcipReadGateway::new`] with a custom [`reqwest::Client`], or implement
21/// [`CcipReadGateway`] with your own allowlist, blocklist, or DNS/IP policy.
22#[derive(Clone, Debug)]
23pub struct HttpCcipReadGateway {
24    client: reqwest::Client,
25    max_url_length: usize,
26}
27
28impl HttpCcipReadGateway {
29    /// Creates a gateway using an existing HTTP client, keeping its timeout and redirect policy.
30    pub const fn new(client: reqwest::Client) -> Self {
31        Self { client, max_url_length: 2_097_152 }
32    }
33
34    /// Sets the maximum expanded gateway URL length in bytes (default: 2 MiB).
35    ///
36    /// Expansion is checked before allocating the URL. This limit is independent of the
37    /// response size limit, so a large request may still retrieve a small response.
38    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    /// Sends one gateway request for `template`.
44    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
99/// Computes the final length before substituting contract-controlled URL placeholders.
100fn 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    // The replacement values are hexadecimal and cannot introduce new placeholders. Build the
120    // URL in one pass to avoid an intermediate expansion larger than the final bounded result.
121    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/// Outcome of one gateway URL attempt.
192#[derive(Debug)]
193enum Attempt {
194    /// The gateway returned data.
195    Data(Bytes),
196    /// The request failed with a client error, so the remaining URLs are not tried.
197    Fatal(CcipReadGatewayError),
198    /// The request failed; the next URL is tried.
199    Retry(CcipReadGatewayError),
200}
201
202impl Attempt {
203    /// Classifies a failed attempt by HTTP status: `4xx` responses are fatal per ERC-3668.
204    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/// Reads the response body, rejecting bodies larger than `max_response_size`.
214#[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/// Reads the response body, rejecting bodies larger than `max_response_size`.
243#[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
265/// Classifies a complete gateway response by status, content type, and body.
266fn 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
292/// Returns the beginning of an error response body as a message.
293fn 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        // An invalid fallback is sufficient to show that the oversized first URL is retryable,
338        // without issuing a network request.
339        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}