1use 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
37const BATCH_GATEWAY_SENTINEL: &str = "x-batch-gateway:true";
42
43mod abi {
44 alloy_sol_types::sol! {
45 error OffchainLookup(
47 address sender,
48 string[] urls,
49 bytes callData,
50 bytes4 callbackFunction,
51 bytes extraData
52 );
53
54 error HttpError(uint16 status, string message);
56
57 struct BatchGatewayRequest {
59 address sender;
60 string[] urls;
61 bytes data;
62 }
63
64 function query(BatchGatewayRequest[] requests)
66 external
67 view
68 returns (bool[] failures, bytes[] responses);
69 }
70}
71
72#[derive(Clone, Debug)]
74pub struct CcipReadConfig {
75 pub max_redirects: usize,
77 pub max_batch_size: usize,
79 pub max_concurrent_requests: usize,
81 pub max_total_requests: usize,
83 pub max_gateway_urls: usize,
85 pub max_revert_data_size: usize,
90 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#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct CcipReadRequest {
111 pub sender: Address,
113 pub urls: Vec<String>,
118 pub data: Bytes,
120}
121
122#[derive(Clone, Debug, thiserror::Error)]
124#[error("{message}")]
125pub struct CcipReadGatewayError {
126 pub status: Option<u16>,
128 pub message: String,
130}
131
132impl CcipReadGatewayError {
133 pub fn new(message: impl Into<String>) -> Self {
135 Self { status: None, message: message.into() }
136 }
137
138 pub fn http(status: u16, message: impl Into<String>) -> Self {
140 Self { status: Some(status), message: message.into() }
141 }
142}
143
144#[derive(Debug, thiserror::Error)]
146#[non_exhaustive]
147pub enum CcipReadError {
148 #[error(transparent)]
150 Transport(#[from] TransportError),
151 #[error("CCIP Read requires an eth_call target")]
153 MissingTarget,
154 #[error("OffchainLookup sender {sender} does not match call target {target}")]
156 SenderMismatch {
157 sender: Address,
159 target: Address,
161 },
162 #[error("CCIP Read redirect limit of {0} exceeded")]
164 TooManyRedirects(usize),
165 #[error("invalid OffchainLookup revert: {0}")]
167 InvalidOffchainLookup(alloy_sol_types::Error),
168 #[error("CCIP Read gateway request failed: {0}")]
170 Gateway(#[from] CcipReadGatewayError),
171 #[error("invalid ENSIP-21 batch request: {0}")]
173 InvalidBatch(String),
174 #[error("invalid CCIP Read configuration: {0}")]
176 InvalidConfig(String),
177 #[error("CCIP Read resource limit exceeded: {0}")]
179 ResourceLimit(String),
180}
181
182#[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 async fn request(
190 &self,
191 request: &CcipReadRequest,
192 max_response_size: usize,
193 ) -> Result<Bytes, CcipReadGatewayError>;
194}
195
196#[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#[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 pub fn new(gateway: G) -> Self {
234 Self { gateway, config: CcipReadConfig::default() }
235 }
236
237 pub const fn with_config(mut self, config: CcipReadConfig) -> Self {
239 self.config = config;
240 self
241 }
242
243 pub const fn config(&self) -> &CcipReadConfig {
245 &self.config
246 }
247
248 pub const fn gateway(&self) -> &G {
250 &self.gateway
251 }
252}
253
254impl<G: CcipReadGateway> CcipReadClient<G> {
255 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 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 transaction.set_input_kind(callback, TransactionInputKind::Both);
321 }
322 }
323
324 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 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
400struct 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 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
439fn 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 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
472fn 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
486fn 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#[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#[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 async fn call_with_ccip_read(
515 &self,
516 transaction: N::TransactionRequest,
517 ) -> Result<Bytes, CcipReadError>;
518
519 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 (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 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 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}