Skip to main content

alloy_provider/provider/ccip_read/
mod.rs

1//! CCIP Read (ERC-3668) support for `eth_call`.
2//!
3//! [`CcipReadClient`] executes an `eth_call` and, when the callee reverts with an
4//! `OffchainLookup`, fetches the requested data from the advertised gateways through a
5//! [`CcipReadGateway`] and re-invokes the callback until the call succeeds. Requests that
6//! advertise the ENSIP-21 batch gateway sentinel (`x-batch-gateway:true`) are served locally by
7//! fanning out the batched requests.
8//!
9//! Gateway URLs come from the contract that emitted the revert and are therefore untrusted. The
10//! default HTTP gateway (`HttpCcipReadGateway`, behind the `ccip-read-http` feature) only checks
11//! that each URL uses `http` or `https`; it does not block private, link-local, loopback, or
12//! cloud-metadata addresses. Callers that need an allowlist, blocklist, or resolved-IP policy
13//! should supply a custom [`CcipReadGateway`].
14
15use crate::Provider;
16use alloy_eips::BlockId;
17use alloy_network::{Network, TransactionBuilder};
18use alloy_primitives::{Address, Bytes};
19use alloy_rpc_types_eth::TransactionInputKind;
20use alloy_sol_types::{SolCall, SolError, SolValue};
21use alloy_transport::TransportError;
22#[cfg(not(target_family = "wasm"))]
23use futures::future::BoxFuture as CcipFuture;
24#[cfg(target_family = "wasm")]
25use futures::future::LocalBoxFuture as CcipFuture;
26use futures::{stream, StreamExt};
27use std::sync::atomic::{AtomicUsize, Ordering};
28use tokio::sync::Semaphore;
29
30mod bounds;
31
32#[cfg(all(feature = "ccip-read-http", not(all(target_os = "wasi", target_env = "p1"))))]
33mod http;
34#[cfg(all(feature = "ccip-read-http", not(all(target_os = "wasi", target_env = "p1"))))]
35pub use http::HttpCcipReadGateway;
36
37/// ENSIP-21 local batch gateway sentinel.
38///
39/// If this value appears anywhere in [`CcipReadRequest::urls`], the request is served as a local
40/// ENSIP-21 batch and the other URLs in that list are not contacted.
41const BATCH_GATEWAY_SENTINEL: &str = "x-batch-gateway:true";
42
43mod abi {
44    alloy_sol_types::sol! {
45        /// The ERC-3668 revert used to request offchain data.
46        error OffchainLookup(
47            address sender,
48            string[] urls,
49            bytes callData,
50            bytes4 callbackFunction,
51            bytes extraData
52        );
53
54        /// An HTTP error returned by an ENSIP-21 local batch gateway.
55        error HttpError(uint16 status, string message);
56
57        /// A request made through the ENSIP-21 batch gateway protocol.
58        struct BatchGatewayRequest {
59            address sender;
60            string[] urls;
61            bytes data;
62        }
63
64        /// The ENSIP-21 batch gateway entry point.
65        function query(BatchGatewayRequest[] requests)
66            external
67            view
68            returns (bool[] failures, bytes[] responses);
69    }
70}
71
72/// Limits applied to a CCIP Read call.
73#[derive(Clone, Debug)]
74pub struct CcipReadConfig {
75    /// Maximum number of `OffchainLookup` redirects followed for one call.
76    pub max_redirects: usize,
77    /// Maximum number of requests accepted in one ENSIP-21 batch.
78    pub max_batch_size: usize,
79    /// Maximum number of concurrent gateway requests, including nested ENSIP-21 batches.
80    pub max_concurrent_requests: usize,
81    /// Maximum number of gateway URL attempts and batch nodes budgeted for one call.
82    pub max_total_requests: usize,
83    /// Maximum number of fallback gateway URLs accepted in one request.
84    pub max_gateway_urls: usize,
85    /// Maximum accepted `OffchainLookup` revert and nested batch data size in bytes.
86    ///
87    /// Also bounds decoded dynamic data and array offset tables before allocation. Repeated
88    /// references to the same ABI data are charged separately, including lossy UTF-8 expansion.
89    pub max_revert_data_size: usize,
90    /// Maximum accepted gateway response size in bytes.
91    pub max_response_size: usize,
92}
93
94impl Default for CcipReadConfig {
95    fn default() -> Self {
96        Self {
97            max_redirects: 4,
98            max_batch_size: 50,
99            max_concurrent_requests: 4,
100            max_total_requests: 100,
101            max_gateway_urls: 8,
102            max_revert_data_size: 1_048_576,
103            max_response_size: 1_048_576,
104        }
105    }
106}
107
108/// A gateway request described by an ERC-3668 `OffchainLookup` revert.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct CcipReadRequest {
111    /// The contract that emitted the revert.
112    pub sender: Address,
113    /// Gateway URL templates, in ERC-3668 fallback order.
114    ///
115    /// If `x-batch-gateway:true` is present anywhere in this list, the request is served as a
116    /// local ENSIP-21 batch and the other URLs are not contacted.
117    pub urls: Vec<String>,
118    /// Data supplied by the reverting contract.
119    pub data: Bytes,
120}
121
122/// An error returned while fetching data from a CCIP Read gateway.
123#[derive(Clone, Debug, thiserror::Error)]
124#[error("{message}")]
125pub struct CcipReadGatewayError {
126    /// HTTP status, when the failure came from an HTTP response.
127    pub status: Option<u16>,
128    /// A human-readable description of the failure.
129    pub message: String,
130}
131
132impl CcipReadGatewayError {
133    /// Creates an error without an HTTP status.
134    pub fn new(message: impl Into<String>) -> Self {
135        Self { status: None, message: message.into() }
136    }
137
138    /// Creates an error for an unsuccessful HTTP response.
139    pub fn http(status: u16, message: impl Into<String>) -> Self {
140        Self { status: Some(status), message: message.into() }
141    }
142}
143
144/// Errors produced while executing a CCIP Read call.
145#[derive(Debug, thiserror::Error)]
146#[non_exhaustive]
147pub enum CcipReadError {
148    /// The underlying `eth_call` failed without a valid `OffchainLookup` revert.
149    #[error(transparent)]
150    Transport(#[from] TransportError),
151    /// CCIP Read cannot be used for a contract-creation call.
152    #[error("CCIP Read requires an eth_call target")]
153    MissingTarget,
154    /// The revert's sender did not match the contract that was called.
155    #[error("OffchainLookup sender {sender} does not match call target {target}")]
156    SenderMismatch {
157        /// Sender encoded in the revert.
158        sender: Address,
159        /// Target of the `eth_call`.
160        target: Address,
161    },
162    /// The redirect limit was exceeded.
163    #[error("CCIP Read redirect limit of {0} exceeded")]
164    TooManyRedirects(usize),
165    /// The revert data had the `OffchainLookup` selector but invalid ABI data.
166    #[error("invalid OffchainLookup revert: {0}")]
167    InvalidOffchainLookup(alloy_sol_types::Error),
168    /// A gateway request failed.
169    #[error("CCIP Read gateway request failed: {0}")]
170    Gateway(#[from] CcipReadGatewayError),
171    /// An ENSIP-21 batch request was malformed or exceeded configured limits.
172    #[error("invalid ENSIP-21 batch request: {0}")]
173    InvalidBatch(String),
174    /// The CCIP Read client configuration is invalid.
175    #[error("invalid CCIP Read configuration: {0}")]
176    InvalidConfig(String),
177    /// A CCIP Read resource limit was exceeded.
178    #[error("CCIP Read resource limit exceeded: {0}")]
179    ResourceLimit(String),
180}
181
182/// Fetches offchain data for CCIP Read requests.
183#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
184#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
185pub trait CcipReadGateway: Send + Sync {
186    /// Fetches one request, trying its URL templates in order.
187    ///
188    /// Responses larger than `max_response_size` bytes must be rejected.
189    async fn request(
190        &self,
191        request: &CcipReadRequest,
192        max_response_size: usize,
193    ) -> Result<Bytes, CcipReadGatewayError>;
194}
195
196/// Placeholder for the default HTTP gateway on WASI Preview 1, where `reqwest` is unavailable.
197///
198/// Every request fails; supply a custom [`CcipReadGateway`] instead.
199#[derive(Clone, Copy, Debug, Default)]
200#[cfg(all(feature = "ccip-read-http", target_os = "wasi", target_env = "p1"))]
201pub struct HttpCcipReadGateway;
202
203#[cfg(all(feature = "ccip-read-http", target_os = "wasi", target_env = "p1"))]
204#[async_trait::async_trait(?Send)]
205impl CcipReadGateway for HttpCcipReadGateway {
206    async fn request(
207        &self,
208        _request: &CcipReadRequest,
209        _max_response_size: usize,
210    ) -> Result<Bytes, CcipReadGatewayError> {
211        Err(CcipReadGatewayError::new(
212            "the default CCIP Read HTTP gateway is unavailable on WASI Preview 1",
213        ))
214    }
215}
216
217/// Executes `eth_call` requests that follow ERC-3668 `OffchainLookup` reverts.
218#[derive(Clone, Debug)]
219pub struct CcipReadClient<G> {
220    gateway: G,
221    config: CcipReadConfig,
222}
223
224#[cfg(feature = "ccip-read-http")]
225impl Default for CcipReadClient<HttpCcipReadGateway> {
226    fn default() -> Self {
227        Self::new(HttpCcipReadGateway::default())
228    }
229}
230
231impl<G> CcipReadClient<G> {
232    /// Creates a client with the default limits.
233    pub fn new(gateway: G) -> Self {
234        Self { gateway, config: CcipReadConfig::default() }
235    }
236
237    /// Sets the limits used by this client.
238    pub const fn with_config(mut self, config: CcipReadConfig) -> Self {
239        self.config = config;
240        self
241    }
242
243    /// Returns the configured limits.
244    pub const fn config(&self) -> &CcipReadConfig {
245        &self.config
246    }
247
248    /// Returns the gateway used to fetch offchain data.
249    pub const fn gateway(&self) -> &G {
250        &self.gateway
251    }
252}
253
254impl<G: CcipReadGateway> CcipReadClient<G> {
255    /// Executes a CCIP Read enabled call against the latest block.
256    pub async fn call<P, N>(
257        &self,
258        provider: &P,
259        transaction: N::TransactionRequest,
260    ) -> Result<Bytes, CcipReadError>
261    where
262        P: Provider<N>,
263        N: Network,
264    {
265        self.call_at(provider, transaction, BlockId::latest()).await
266    }
267
268    /// Executes a CCIP Read enabled call against `block`.
269    ///
270    /// The initial call and every callback call are issued against `block`. With a block tag such
271    /// as `latest`, the underlying state can move between those calls while gateway requests are
272    /// in flight.
273    pub async fn call_at<P, N>(
274        &self,
275        provider: &P,
276        mut transaction: N::TransactionRequest,
277        block: BlockId,
278    ) -> Result<Bytes, CcipReadError>
279    where
280        P: Provider<N>,
281        N: Network,
282    {
283        if self.config.max_concurrent_requests == 0 {
284            return Err(CcipReadError::InvalidConfig(
285                "max_concurrent_requests must be greater than zero".into(),
286            ));
287        }
288        let target = transaction.to().ok_or(CcipReadError::MissingTarget)?;
289        let context = BatchContext::new(&self.config);
290
291        let mut redirects = 0;
292        loop {
293            let error = match provider.call(transaction.clone()).block(block).await {
294                Ok(result) => return Ok(result),
295                Err(error) => error,
296            };
297            let Some(revert) = extract_offchain_lookup(&error, self.config.max_revert_data_size)?
298            else {
299                return Err(CcipReadError::Transport(error));
300            };
301            if redirects == self.config.max_redirects {
302                return Err(CcipReadError::TooManyRedirects(self.config.max_redirects));
303            }
304            redirects += 1;
305
306            bounds::offchain_lookup(&revert, &self.config)?;
307            let lookup = abi::OffchainLookup::abi_decode(&revert)
308                .map_err(CcipReadError::InvalidOffchainLookup)?;
309            if lookup.sender != target {
310                return Err(CcipReadError::SenderMismatch { sender: lookup.sender, target });
311            }
312
313            let request =
314                CcipReadRequest { sender: lookup.sender, urls: lookup.urls, data: lookup.callData };
315            let response = self.fetch(request, &context).await?;
316            let mut callback = lookup.callbackFunction.to_vec();
317            callback.extend_from_slice(&(response, lookup.extraData).abi_encode_params());
318            // Keep `input` and `data` in sync: some nodes reject requests where both are set and
319            // disagree.
320            transaction.set_input_kind(callback, TransactionInputKind::Both);
321        }
322    }
323
324    /// Fetches one request, serving ENSIP-21 batches locally.
325    fn fetch<'a>(
326        &'a self,
327        request: CcipReadRequest,
328        context: &'a BatchContext<'a>,
329    ) -> CcipFuture<'a, Result<Bytes, CcipReadError>> {
330        Box::pin(async move {
331            if request.urls.len() > context.config.max_gateway_urls {
332                return Err(CcipReadError::ResourceLimit(format!(
333                    "gateway URL count {} exceeds limit {}",
334                    request.urls.len(),
335                    context.config.max_gateway_urls
336                )));
337            }
338            if request.urls.iter().any(|url| url == BATCH_GATEWAY_SENTINEL) {
339                context.reserve(1)?;
340                return self.local_batch(request.data, context).await;
341            }
342            context.reserve(request.urls.len().max(1))?;
343            let _permit =
344                context.concurrency.acquire().await.expect("CCIP Read semaphore is never closed");
345            let response = self.gateway.request(&request, context.config.max_response_size).await?;
346            if response.len() > context.config.max_response_size {
347                return Err(CcipReadError::ResourceLimit(format!(
348                    "gateway response is {} bytes; limit is {}",
349                    response.len(),
350                    context.config.max_response_size
351                )));
352            }
353            Ok(response)
354        })
355    }
356
357    /// Serves an ENSIP-21 `query` batch by fetching each request and encoding the results.
358    fn local_batch<'a>(
359        &'a self,
360        data: Bytes,
361        context: &'a BatchContext<'a>,
362    ) -> CcipFuture<'a, Result<Bytes, CcipReadError>> {
363        Box::pin(async move {
364            bounds::batch(&data, context.config)?;
365            let call = abi::queryCall::abi_decode(&data)
366                .map_err(|err| CcipReadError::InvalidBatch(err.to_string()))?;
367
368            let requests = call.requests.into_iter().map(|request| {
369                let request = CcipReadRequest {
370                    sender: request.sender,
371                    urls: request.urls,
372                    data: request.data,
373                };
374                self.fetch(request, context)
375            });
376            let (failures, responses) = stream::iter(requests)
377                .buffered(context.config.max_concurrent_requests)
378                .map(|result| match result {
379                    Ok(response) => (false, response),
380                    Err(error) => (true, encode_batch_error(&error)),
381                })
382                .unzip::<_, _, Vec<_>, Vec<_>>()
383                .await;
384
385            let encoded: Bytes =
386                abi::queryCall::abi_encode_returns(&abi::queryReturn { failures, responses })
387                    .into();
388            if encoded.len() > context.config.max_response_size {
389                return Err(CcipReadError::ResourceLimit(format!(
390                    "batch gateway response is {} bytes; limit is {}",
391                    encoded.len(),
392                    context.config.max_response_size
393                )));
394            }
395            Ok(encoded)
396        })
397    }
398}
399
400/// Per-call state shared by all gateway requests, including nested ENSIP-21 batches.
401struct BatchContext<'a> {
402    config: &'a CcipReadConfig,
403    total_requests: AtomicUsize,
404    concurrency: Semaphore,
405}
406
407impl<'a> BatchContext<'a> {
408    fn new(config: &'a CcipReadConfig) -> Self {
409        Self {
410            config,
411            total_requests: AtomicUsize::new(0),
412            concurrency: Semaphore::new(config.max_concurrent_requests),
413        }
414    }
415
416    /// Reserves `count` gateway requests from the call's total budget.
417    fn reserve(&self, count: usize) -> Result<(), CcipReadError> {
418        let limit = self.config.max_total_requests;
419        let mut current = self.total_requests.load(Ordering::Relaxed);
420        loop {
421            let Some(next) = current.checked_add(count).filter(|next| *next <= limit) else {
422                return Err(CcipReadError::ResourceLimit(format!(
423                    "total gateway request budget of {limit} exceeded"
424                )));
425            };
426            match self.total_requests.compare_exchange_weak(
427                current,
428                next,
429                Ordering::Relaxed,
430                Ordering::Relaxed,
431            ) {
432                Ok(_) => return Ok(()),
433                Err(actual) => current = actual,
434            }
435        }
436    }
437}
438
439/// Extracts `OffchainLookup` revert data from an `eth_call` error.
440///
441/// Returns `Ok(None)` if the error is not an `OffchainLookup` revert, so that it can be surfaced
442/// as the original transport error.
443fn extract_offchain_lookup(
444    error: &TransportError,
445    max_revert_data_size: usize,
446) -> Result<Option<Bytes>, CcipReadError> {
447    let Some(raw) = error.as_error_resp().and_then(|payload| payload.data.as_ref()) else {
448        return Ok(None);
449    };
450    // Avoid parsing an oversized, untrusted JSON-RPC error. Its selector cannot be established
451    // within the configured bound, so it is preserved as the original transport error rather than
452    // misclassified as CCIP Read.
453    let max_json_size = max_revert_data_size.saturating_mul(2).saturating_add(4_096);
454    if raw.get().len() > max_json_size {
455        return Ok(None);
456    }
457    let Ok(value) = serde_json::from_str(raw.get()) else {
458        return Ok(None);
459    };
460    let Some(data) = find_offchain_lookup(&value) else {
461        return Ok(None);
462    };
463    if data.len() > max_revert_data_size {
464        return Err(CcipReadError::ResourceLimit(format!(
465            "OffchainLookup revert data is {} bytes; limit is {max_revert_data_size}",
466            data.len()
467        )));
468    }
469    Ok(Some(data))
470}
471
472/// Finds the first hex string carrying the `OffchainLookup` selector in a JSON-RPC error's data,
473/// which nodes nest in different shapes.
474fn find_offchain_lookup(value: &serde_json::Value) -> Option<Bytes> {
475    match value {
476        serde_json::Value::String(value) => {
477            let data: Bytes = value.parse().ok()?;
478            data.starts_with(abi::OffchainLookup::SELECTOR.as_slice()).then_some(data)
479        }
480        serde_json::Value::Object(values) => values.values().find_map(find_offchain_lookup),
481        serde_json::Value::Array(values) => values.iter().find_map(find_offchain_lookup),
482        _ => None,
483    }
484}
485
486/// Encodes a failed batch request as ENSIP-21 error data.
487fn encode_batch_error(error: &CcipReadError) -> Bytes {
488    if let CcipReadError::Gateway(CcipReadGatewayError { status: Some(status), message }) = error {
489        return abi::HttpError { status: *status, message: message.clone() }.abi_encode().into();
490    }
491    alloy_sol_types::Revert::from(error.to_string()).abi_encode().into()
492}
493
494/// Returns a process-wide default HTTP CCIP Read client.
495///
496/// This reuses a single [`reqwest::Client`] so that connections are pooled across
497/// [`ProviderCcipReadExt`] calls.
498#[cfg(feature = "ccip-read-http")]
499pub fn shared_http_ccip_read_client() -> &'static CcipReadClient<HttpCcipReadGateway> {
500    static CLIENT: std::sync::OnceLock<CcipReadClient<HttpCcipReadGateway>> =
501        std::sync::OnceLock::new();
502    CLIENT.get_or_init(CcipReadClient::default)
503}
504
505/// Extension trait for CCIP Read enabled `eth_call` requests using the default HTTP gateway.
506///
507/// See [`CcipReadClient`] to customize the gateway or the limits.
508#[cfg(feature = "ccip-read-http")]
509#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
510#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
511pub trait ProviderCcipReadExt<N: Network>: Provider<N> {
512    /// Executes an `eth_call` against the latest block, following ERC-3668 redirects and
513    /// serving ENSIP-21 batches.
514    async fn call_with_ccip_read(
515        &self,
516        transaction: N::TransactionRequest,
517    ) -> Result<Bytes, CcipReadError>;
518
519    /// Executes a CCIP Read enabled `eth_call` against `block`.
520    async fn call_with_ccip_read_at(
521        &self,
522        transaction: N::TransactionRequest,
523        block: BlockId,
524    ) -> Result<Bytes, CcipReadError>;
525}
526
527#[cfg(feature = "ccip-read-http")]
528#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
529#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
530impl<P, N> ProviderCcipReadExt<N> for P
531where
532    P: Provider<N>,
533    N: Network,
534{
535    async fn call_with_ccip_read(
536        &self,
537        transaction: N::TransactionRequest,
538    ) -> Result<Bytes, CcipReadError> {
539        shared_http_ccip_read_client().call(self, transaction).await
540    }
541
542    async fn call_with_ccip_read_at(
543        &self,
544        transaction: N::TransactionRequest,
545        block: BlockId,
546    ) -> Result<Bytes, CcipReadError> {
547        shared_http_ccip_read_client().call_at(self, transaction, block).await
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::ProviderBuilder;
555    use alloy_json_rpc::ErrorPayload;
556    use alloy_primitives::{address, bytes, fixed_bytes, U256};
557    use alloy_rpc_types_eth::{TransactionInput, TransactionRequest};
558    use alloy_transport::mock::Asserter;
559    use std::{
560        collections::VecDeque,
561        sync::{Arc, Mutex, PoisonError},
562    };
563
564    #[derive(Clone, Debug, Default)]
565    struct MockGateway {
566        responses: Arc<Mutex<VecDeque<Result<Bytes, CcipReadGatewayError>>>>,
567        requests: Arc<Mutex<Vec<CcipReadRequest>>>,
568    }
569
570    impl MockGateway {
571        fn with_responses(
572            responses: impl IntoIterator<Item = Result<Bytes, CcipReadGatewayError>>,
573        ) -> Self {
574            Self {
575                responses: Arc::new(Mutex::new(responses.into_iter().collect())),
576                requests: Arc::default(),
577            }
578        }
579
580        fn requests(&self) -> Vec<CcipReadRequest> {
581            self.requests.lock().unwrap_or_else(PoisonError::into_inner).clone()
582        }
583    }
584
585    #[async_trait::async_trait]
586    impl CcipReadGateway for MockGateway {
587        async fn request(
588            &self,
589            request: &CcipReadRequest,
590            _max_response_size: usize,
591        ) -> Result<Bytes, CcipReadGatewayError> {
592            self.requests.lock().unwrap_or_else(PoisonError::into_inner).push(request.clone());
593            self.responses
594                .lock()
595                .unwrap_or_else(PoisonError::into_inner)
596                .pop_front()
597                .unwrap_or_else(|| Err(CcipReadGatewayError::new("no mock response")))
598        }
599    }
600
601    #[derive(Clone, Copy, Debug)]
602    struct BatchMockGateway;
603
604    #[async_trait::async_trait]
605    impl CcipReadGateway for BatchMockGateway {
606        async fn request(
607            &self,
608            request: &CcipReadRequest,
609            _max_response_size: usize,
610        ) -> Result<Bytes, CcipReadGatewayError> {
611            match request.data.as_ref() {
612                [1] => Ok(bytes!("aaaa")),
613                [2] => Err(CcipReadGatewayError::http(404, "not found")),
614                _ => Err(CcipReadGatewayError::new("unexpected request")),
615            }
616        }
617    }
618
619    fn revert_error(data: Bytes) -> ErrorPayload {
620        ErrorPayload::internal_error_with_message_and_obj(
621            "call failed".into(),
622            serde_json::value::to_raw_value(&data).unwrap(),
623        )
624    }
625
626    fn offchain_lookup(sender: Address, urls: Vec<String>, call_data: Bytes) -> Bytes {
627        abi::OffchainLookup {
628            sender,
629            urls,
630            callData: call_data,
631            callbackFunction: fixed_bytes!("12345678"),
632            extraData: bytes!("010203"),
633        }
634        .abi_encode()
635        .into()
636    }
637
638    fn word_at(data: &[u8], offset: usize) -> usize {
639        U256::from_be_slice(&data[offset..offset + 32]).to()
640    }
641
642    #[tokio::test]
643    async fn bounds_decoded_lookup_before_allocating_aliased_urls() {
644        let target = address!("1111111111111111111111111111111111111111");
645        let canonical =
646            offchain_lookup(target, vec!["a".repeat(1024), String::new()], Bytes::new());
647        let mut aliased = canonical.to_vec();
648        let array = 4 + word_at(&aliased, 4 + 32) + 32;
649        aliased.copy_within(array..array + 32, array + 32);
650
651        for (revert, limit, accepted) in [
652            (canonical.clone(), canonical.len(), true),
653            (Bytes::from(aliased.clone()), aliased.len(), false),
654            // Overlapping offsets remain supported when the decoded result fits the budget.
655            (Bytes::from(aliased), 4096, true),
656        ] {
657            let asserter = Asserter::new();
658            asserter.push_failure(revert_error(revert));
659            asserter.push_success(&bytes!("feed"));
660            let provider = ProviderBuilder::new().connect_mocked_client(asserter);
661            let gateway = MockGateway::with_responses([Ok(bytes!("01"))]);
662            let client = CcipReadClient::new(gateway.clone())
663                .with_config(CcipReadConfig { max_revert_data_size: limit, ..Default::default() });
664            let result = client.call(&provider, TransactionRequest::default().to(target)).await;
665            if accepted {
666                assert_eq!(result.unwrap(), bytes!("feed"));
667            } else {
668                assert!(matches!(result, Err(CcipReadError::ResourceLimit(_))));
669                assert!(gateway.requests().is_empty());
670            }
671        }
672    }
673
674    #[tokio::test]
675    async fn bounds_decoded_batch_before_allocating_aliased_requests() {
676        let sender = Address::ZERO;
677        let request = |data| abi::BatchGatewayRequest {
678            sender,
679            urls: vec!["https://example.test".into()],
680            data,
681        };
682        let canonical =
683            abi::queryCall { requests: vec![request(vec![1; 1024].into()), request(Bytes::new())] }
684                .abi_encode();
685        let mut aliased = canonical.clone();
686        let array = 4 + word_at(&aliased, 4) + 32;
687        aliased.copy_within(array..array + 32, array + 32);
688
689        for (data, accepted) in [(canonical, true), (aliased, false)] {
690            let gateway = MockGateway::with_responses([Ok(bytes!("01")), Ok(bytes!("02"))]);
691            let client = CcipReadClient::new(gateway.clone()).with_config(CcipReadConfig {
692                max_revert_data_size: data.len(),
693                ..Default::default()
694            });
695            let context = BatchContext::new(client.config());
696            let result = client
697                .fetch(
698                    CcipReadRequest {
699                        sender,
700                        urls: vec![BATCH_GATEWAY_SENTINEL.into()],
701                        data: data.into(),
702                    },
703                    &context,
704                )
705                .await;
706            if accepted {
707                let decoded = abi::queryCall::abi_decode_returns(&result.unwrap()).unwrap();
708                assert_eq!(decoded.failures, vec![false, false]);
709            } else {
710                assert!(matches!(result, Err(CcipReadError::ResourceLimit(_))));
711                assert!(gateway.requests().is_empty());
712            }
713        }
714    }
715
716    #[test]
717    fn checks_array_counts_offsets_and_lossy_strings_before_decoding() {
718        let config = CcipReadConfig::default();
719        let canonical = offchain_lookup(Address::ZERO, vec!["a".repeat(256)], Bytes::new());
720        let urls = 4 + word_at(&canonical, 4 + 32);
721        let mut excessive = canonical.to_vec();
722        excessive[urls..urls + 32].fill(0xff);
723        assert!(bounds::offchain_lookup(&excessive, &config).is_err());
724
725        let mut invalid_offset = canonical.to_vec();
726        invalid_offset[4 + 32..4 + 64].fill(0xff);
727        assert!(matches!(
728            bounds::offchain_lookup(&invalid_offset, &config),
729            Err(CcipReadError::InvalidOffchainLookup(_))
730        ));
731
732        let mut invalid_utf8 = canonical.to_vec();
733        let string = urls + 32 + word_at(&canonical, urls + 32) + 32;
734        invalid_utf8[string..string + 256].fill(0xff);
735        let config = CcipReadConfig { max_revert_data_size: canonical.len(), ..config };
736        bounds::offchain_lookup(&canonical, &config).unwrap();
737        assert!(matches!(
738            bounds::offchain_lookup(&invalid_utf8, &config),
739            Err(CcipReadError::ResourceLimit(_))
740        ));
741
742        let mut batch = abi::queryCall { requests: vec![] }.abi_encode();
743        let array = 4 + word_at(&batch, 4);
744        batch[array..array + 32]
745            .copy_from_slice(&U256::from(config.max_batch_size + 1).to_be_bytes::<32>());
746        assert!(matches!(bounds::batch(&batch, &config), Err(CcipReadError::ResourceLimit(_))));
747    }
748
749    #[tokio::test]
750    async fn follows_offchain_lookup_and_calls_callback() {
751        assert_eq!(abi::OffchainLookup::SELECTOR, [0x55, 0x6f, 0x18, 0x30]);
752
753        let target = address!("1111111111111111111111111111111111111111");
754        let call_data = bytes!("abcdef");
755        let urls = vec!["https://example.test/{sender}/{data}".to_string()];
756
757        let asserter = Asserter::new();
758        asserter.push_failure(revert_error(offchain_lookup(
759            target,
760            urls.clone(),
761            call_data.clone(),
762        )));
763        asserter.push_success(&bytes!("feed"));
764        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
765        let gateway = MockGateway::with_responses([Ok(bytes!("deadbeef"))]);
766        let client = CcipReadClient::new(gateway.clone());
767
768        let result =
769            client.call(&provider, TransactionRequest::default().to(target)).await.unwrap();
770
771        assert_eq!(result, bytes!("feed"));
772        assert_eq!(
773            gateway.requests(),
774            vec![CcipReadRequest { sender: target, urls, data: call_data }]
775        );
776    }
777
778    #[tokio::test]
779    async fn callback_keeps_input_and_data_in_sync() {
780        let target = address!("1111111111111111111111111111111111111111");
781        let revert =
782            offchain_lookup(target, vec!["https://example.test/{data}".into()], bytes!("abcdef"));
783
784        let asserter = Asserter::new();
785        asserter.push_failure(revert_error(revert));
786        asserter.push_success(&bytes!("feed"));
787        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
788        let gateway = MockGateway::with_responses([Ok(bytes!("deadbeef"))]);
789        let client = CcipReadClient::new(gateway);
790
791        // Requests with both `input` and `data` set must have both replaced by the callback.
792        let result = client
793            .call(
794                &provider,
795                TransactionRequest::default()
796                    .to(target)
797                    .input(TransactionInput::both(bytes!("00"))),
798            )
799            .await
800            .unwrap();
801
802        assert_eq!(result, bytes!("feed"));
803    }
804
805    #[tokio::test]
806    async fn rejects_sender_mismatch() {
807        let target = address!("1111111111111111111111111111111111111111");
808        let sender = address!("2222222222222222222222222222222222222222");
809        let revert =
810            offchain_lookup(sender, vec!["https://example.test/{data}".into()], Bytes::new());
811
812        let asserter = Asserter::new();
813        asserter.push_failure(revert_error(revert));
814        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
815        let gateway = MockGateway::default();
816
817        let error = CcipReadClient::new(gateway.clone())
818            .call(&provider, TransactionRequest::default().to(target))
819            .await
820            .unwrap_err();
821
822        assert!(matches!(
823            error,
824            CcipReadError::SenderMismatch { sender: actual_sender, target: actual_target }
825                if actual_sender == sender && actual_target == target
826        ));
827        assert!(gateway.requests().is_empty());
828    }
829
830    #[tokio::test]
831    async fn rejects_excessive_gateway_url_list() {
832        let target = address!("1111111111111111111111111111111111111111");
833        let revert =
834            offchain_lookup(target, vec!["https://example.test/{data}".into(); 9], Bytes::new());
835
836        let asserter = Asserter::new();
837        asserter.push_failure(revert_error(revert));
838        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
839        let gateway = MockGateway::default();
840
841        let error = CcipReadClient::new(gateway.clone())
842            .call(&provider, TransactionRequest::default().to(target))
843            .await
844            .unwrap_err();
845
846        assert!(matches!(error, CcipReadError::ResourceLimit(message) if message.contains("URL")));
847        assert!(gateway.requests().is_empty());
848    }
849
850    #[tokio::test]
851    async fn enforces_response_limit_for_custom_gateways() {
852        let target = address!("1111111111111111111111111111111111111111");
853        let revert =
854            offchain_lookup(target, vec!["https://example.test/{data}".into()], Bytes::new());
855
856        let asserter = Asserter::new();
857        asserter.push_failure(revert_error(revert));
858        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
859        let gateway = MockGateway::with_responses([Ok(bytes!("0102"))]);
860        let config = CcipReadConfig { max_response_size: 1, ..Default::default() };
861
862        let error = CcipReadClient::new(gateway)
863            .with_config(config)
864            .call(&provider, TransactionRequest::default().to(target))
865            .await
866            .unwrap_err();
867
868        assert!(
869            matches!(error, CcipReadError::ResourceLimit(message) if message.contains("response"))
870        );
871    }
872
873    #[tokio::test]
874    async fn rejects_oversized_revert_data_before_decoding() {
875        let target = address!("1111111111111111111111111111111111111111");
876        let revert =
877            offchain_lookup(target, vec!["https://example.test/{data}".into()], bytes!("01020304"));
878
879        let asserter = Asserter::new();
880        asserter.push_failure(revert_error(revert));
881        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
882        let config = CcipReadConfig { max_revert_data_size: 4, ..Default::default() };
883
884        let error = CcipReadClient::new(MockGateway::default())
885            .with_config(config)
886            .call(&provider, TransactionRequest::default().to(target))
887            .await
888            .unwrap_err();
889
890        assert!(
891            matches!(error, CcipReadError::ResourceLimit(message) if message.contains("revert"))
892        );
893    }
894
895    #[tokio::test]
896    async fn preserves_oversized_non_ccip_rpc_errors() {
897        let target = address!("1111111111111111111111111111111111111111");
898        let asserter = Asserter::new();
899        asserter.push_failure(revert_error(vec![0u8; 5_000].into()));
900        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
901        let config = CcipReadConfig { max_revert_data_size: 1, ..Default::default() };
902
903        let error = CcipReadClient::new(MockGateway::default())
904            .with_config(config)
905            .call(&provider, TransactionRequest::default().to(target))
906            .await
907            .unwrap_err();
908
909        assert!(matches!(error, CcipReadError::Transport(_)));
910    }
911
912    #[tokio::test]
913    async fn enforces_redirect_limit() {
914        let target = address!("1111111111111111111111111111111111111111");
915        let revert =
916            offchain_lookup(target, vec!["https://example.test/{data}".into()], Bytes::new());
917
918        let asserter = Asserter::new();
919        for _ in 0..3 {
920            asserter.push_failure(revert_error(revert.clone()));
921        }
922        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
923        let gateway =
924            MockGateway::with_responses([Ok(bytes!("01")), Ok(bytes!("02")), Ok(bytes!("03"))]);
925        let config = CcipReadConfig { max_redirects: 2, ..Default::default() };
926
927        let error = CcipReadClient::new(gateway.clone())
928            .with_config(config)
929            .call(&provider, TransactionRequest::default().to(target))
930            .await
931            .unwrap_err();
932
933        assert!(matches!(error, CcipReadError::TooManyRedirects(2)));
934        assert_eq!(gateway.requests().len(), 2);
935    }
936
937    #[tokio::test]
938    async fn executes_batch_gateway_requests_in_original_order() {
939        assert_eq!(abi::queryCall::SELECTOR, [0xa7, 0x80, 0xba, 0xb6]);
940
941        let sender = address!("1111111111111111111111111111111111111111");
942        let batch = abi::queryCall {
943            requests: vec![
944                abi::BatchGatewayRequest {
945                    sender,
946                    urls: vec!["https://one.test".into()],
947                    data: bytes!("01"),
948                },
949                abi::BatchGatewayRequest {
950                    sender,
951                    urls: vec!["https://two.test".into()],
952                    data: bytes!("02"),
953                },
954            ],
955        }
956        .abi_encode()
957        .into();
958        let client = CcipReadClient::new(BatchMockGateway);
959        let context = BatchContext::new(client.config());
960
961        let encoded = client
962            .fetch(
963                CcipReadRequest { sender, urls: vec![BATCH_GATEWAY_SENTINEL.into()], data: batch },
964                &context,
965            )
966            .await
967            .unwrap();
968        let decoded = abi::queryCall::abi_decode_returns(&encoded).unwrap();
969
970        assert_eq!(decoded.failures, vec![false, true]);
971        assert_eq!(decoded.responses[0], bytes!("aaaa"));
972        let http_error = abi::HttpError::abi_decode(&decoded.responses[1]).unwrap();
973        assert_eq!(http_error.status, 404);
974        assert_eq!(http_error.message, "not found");
975    }
976
977    #[tokio::test]
978    async fn enforces_response_limit_for_local_batch() {
979        let sender = address!("1111111111111111111111111111111111111111");
980        let batch = abi::queryCall {
981            requests: vec![abi::BatchGatewayRequest {
982                sender,
983                urls: vec!["https://one.test".into()],
984                data: bytes!("01"),
985            }],
986        }
987        .abi_encode()
988        .into();
989        // Encoded (failures=[false], responses=[["aaaa"]]) is larger than 8 bytes.
990        let config = CcipReadConfig { max_response_size: 8, ..Default::default() };
991        let client = CcipReadClient::new(BatchMockGateway).with_config(config);
992        let context = BatchContext::new(client.config());
993
994        let error = client
995            .fetch(
996                CcipReadRequest { sender, urls: vec![BATCH_GATEWAY_SENTINEL.into()], data: batch },
997                &context,
998            )
999            .await
1000            .unwrap_err();
1001
1002        assert!(matches!(
1003            error,
1004            CcipReadError::ResourceLimit(message) if message.contains("batch gateway response")
1005        ));
1006    }
1007
1008    #[tokio::test]
1009    async fn limits_nested_batch_recursion() {
1010        let sender = address!("1111111111111111111111111111111111111111");
1011        let mut data = Bytes::new();
1012        for _ in 0..3 {
1013            data = abi::queryCall {
1014                requests: vec![abi::BatchGatewayRequest {
1015                    sender,
1016                    urls: vec![BATCH_GATEWAY_SENTINEL.into()],
1017                    data,
1018                }],
1019            }
1020            .abi_encode()
1021            .into();
1022        }
1023        let config = CcipReadConfig { max_total_requests: 2, ..Default::default() };
1024        let client = CcipReadClient::new(MockGateway::default()).with_config(config);
1025        let context = BatchContext::new(client.config());
1026
1027        let encoded = client
1028            .fetch(
1029                CcipReadRequest { sender, urls: vec![BATCH_GATEWAY_SENTINEL.into()], data },
1030                &context,
1031            )
1032            .await
1033            .unwrap();
1034        let outer = abi::queryCall::abi_decode_returns(&encoded).unwrap();
1035
1036        assert_eq!(outer.failures, vec![false]);
1037        let middle = abi::queryCall::abi_decode_returns(&outer.responses[0]).unwrap();
1038        assert_eq!(middle.failures, vec![true]);
1039        assert!(alloy_sol_types::Revert::abi_decode(&middle.responses[0])
1040            .unwrap()
1041            .reason
1042            .contains("budget"));
1043    }
1044
1045    #[tokio::test]
1046    async fn rejects_zero_max_concurrent_requests_before_eth_call() {
1047        let asserter = Asserter::new();
1048        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
1049        let client = CcipReadClient::new(MockGateway::default())
1050            .with_config(CcipReadConfig { max_concurrent_requests: 0, ..Default::default() });
1051
1052        let error = client
1053            .call(
1054                &provider,
1055                TransactionRequest::default()
1056                    .to(address!("1111111111111111111111111111111111111111")),
1057            )
1058            .await
1059            .unwrap_err();
1060
1061        assert!(
1062            matches!(error, CcipReadError::InvalidConfig(message) if message.contains("max_concurrent_requests"))
1063        );
1064    }
1065}