use crate::error::{AttestationFailureKind, CctpError, Result};
use crate::protocol::{AttestationBytes, FinalityThreshold};
use crate::{
spans, AttestationStatus, CctpV2 as CctpV2Trait, DomainId, V2AttestationResponse, V2Message,
};
use alloy_chains::NamedChain;
use alloy_network::Ethereum;
use alloy_primitives::{hex, Address, Bytes, FixedBytes, TxHash, U256};
use alloy_provider::Provider;
use alloy_sol_types::SolEvent;
use async_trait::async_trait;
use bon::Builder;
use reqwest::{Client, Response};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, error, info, Instrument};
use url::Url;
use super::transfer_mode::TransferMode;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", content = "tx_hash", rename_all = "snake_case")]
pub enum MintResult {
Minted(TxHash),
AlreadyRelayed,
}
use super::bridge_trait::CctpBridge;
use super::config::{iris_api_url, PollingConfig, MESSAGES_PATH_V2};
use crate::contracts::erc20::Erc20Contract;
use crate::contracts::message_transmitter::MessageTransmitter::MessageSent;
use crate::contracts::v2::{MessageTransmitterV2Contract, TokenMessengerV2Contract};
#[derive(Builder, Clone, Debug)]
pub struct CctpV2<P: Provider<Ethereum> + Clone> {
source_provider: P,
destination_provider: P,
source_chain: NamedChain,
destination_chain: NamedChain,
recipient: Address,
#[builder(default)]
transfer_mode: TransferMode,
api_url_override: Option<Url>,
}
impl<P: Provider<Ethereum> + Clone> CctpV2<P> {
pub fn api_url(&self) -> Url {
if let Some(url) = &self.api_url_override {
return url.clone();
}
iris_api_url(self.source_chain)
}
pub fn source_chain(&self) -> &NamedChain {
&self.source_chain
}
pub fn destination_chain(&self) -> &NamedChain {
&self.destination_chain
}
pub fn destination_domain_id(&self) -> Result<DomainId> {
self.destination_chain.cctp_v2_domain_id()
}
pub fn source_provider(&self) -> &P {
&self.source_provider
}
pub fn destination_provider(&self) -> &P {
&self.destination_provider
}
pub fn token_messenger_v2_contract(&self) -> Result<Address> {
self.source_chain.token_messenger_v2_address()
}
pub fn message_transmitter_v2_contract(&self) -> Result<Address> {
self.destination_chain.message_transmitter_v2_address()
}
pub fn recipient(&self) -> &Address {
&self.recipient
}
pub fn transfer_mode(&self) -> &TransferMode {
&self.transfer_mode
}
pub fn is_fast_transfer(&self) -> bool {
self.transfer_mode.is_fast()
}
pub fn hook_data(&self) -> Option<&Bytes> {
self.transfer_mode.hook_data()
}
pub fn max_fee(&self) -> Option<U256> {
if self.transfer_mode.is_fast() {
Some(self.transfer_mode.max_fee())
} else {
None
}
}
pub fn finality_threshold(&self) -> FinalityThreshold {
self.transfer_mode.finality_threshold()
}
pub async fn get_message_sent_event(
&self,
tx_hash: TxHash,
) -> Result<(Vec<u8>, FixedBytes<32>)> {
let span =
spans::get_message_sent_event(tx_hash, &self.source_chain, &self.destination_chain);
async move {
let tx_receipt = match self.source_provider.get_transaction_receipt(tx_hash).await {
Ok(receipt) => receipt,
Err(e) => {
spans::record_error_with_context(
"ReceiptRetrievalFailed",
&format!("Failed to get transaction receipt: {e}"),
Some("RPC call to get_transaction_receipt failed"),
);
error!(
error = %e,
event = "transaction_receipt_retrieval_failed"
);
return Err(e.into());
}
};
if let Some(tx_receipt) = tx_receipt {
let message_sent_topic = alloy_primitives::keccak256(b"MessageSent(bytes)");
let message_sent_log = tx_receipt
.inner
.logs()
.iter()
.find(|log| {
log.topics()
.first()
.is_some_and(|topic| topic.as_slice() == message_sent_topic)
})
.ok_or_else(|| {
spans::record_error_with_context(
"MessageSentEventNotFound",
"MessageSent event not found in transaction logs",
Some(&format!(
"Transaction contained {} logs but none matched MessageSent signature",
tx_receipt.inner.logs().len()
)),
);
error!(
available_logs = tx_receipt.inner.logs().len(),
event = "message_sent_event_not_found"
);
CctpError::MessageSentEventMissing { tx_hash }
})?;
let decoded = MessageSent::abi_decode_data(&message_sent_log.data().data)?;
let message_sent_event = decoded.0.to_vec();
let message_hash = alloy_primitives::keccak256(&message_sent_event);
info!(
message_hash = %hex::encode(message_hash),
message_length_bytes = message_sent_event.len(),
version = "v2",
fast_transfer = self.transfer_mode.is_fast(),
has_hooks = self.transfer_mode.has_hook(),
event = "message_sent_event_extracted"
);
Ok((message_sent_event, message_hash))
} else {
spans::record_error_with_context(
"TransactionNotFound",
"Transaction receipt not found",
Some("The transaction may not have been mined yet or the RPC node doesn't have it"),
);
error!(event = "transaction_not_found");
Err(CctpError::TransactionNotFound { tx_hash })
}
}
.instrument(span)
.await
}
pub async fn get_attestation(
&self,
tx_hash: TxHash,
polling_config: PollingConfig,
) -> Result<(Vec<u8>, AttestationBytes)> {
polling_config.validate()?;
let max_attempts = polling_config.max_attempts;
let poll_interval = polling_config.poll_interval_secs;
let total_timeout_secs = polling_config.total_timeout_secs();
let span = spans::get_v2_attestation_with_retry(
tx_hash,
&self.source_chain,
&self.destination_chain,
max_attempts,
poll_interval,
);
async move {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(CctpError::Network)?;
let url = self.create_url(tx_hash)?;
info!(
url = %url,
tx_hash = %tx_hash,
version = "v2",
fast_transfer = self.transfer_mode.is_fast(),
finality_threshold = %self.finality_threshold(),
event = "attestation_polling_started"
);
for attempt in 1..=max_attempts {
let attempt_span = spans::get_attestation(&url, attempt);
let attempt_result: Result<Option<(Vec<u8>, AttestationBytes)>> = async {
let response = match self.fetch_attestation_response(&client, &url).await {
Ok(r) => r,
Err(e) => {
spans::record_error_with_context(
"HttpRequestFailed",
&format!("Failed to fetch attestation: {e}"),
Some(&format!("Attempt {attempt}/{max_attempts}")),
);
error!(
error = %e,
attempt = attempt,
event = "attestation_http_request_failed"
);
return Err(e);
}
};
let status_code = response.status().as_u16();
let process_span = spans::process_attestation_response(status_code, attempt);
async {
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let secs = 5 * 60;
debug!(sleep_secs = secs, event = "rate_limit_exceeded");
sleep(Duration::from_secs(secs)).await;
return Ok(None);
}
if response.status() == reqwest::StatusCode::NOT_FOUND {
debug!(event = "attestation_not_found");
sleep(Duration::from_secs(poll_interval)).await;
return Ok(None);
}
response.error_for_status_ref()?;
let response_text = response.text().await?;
let v2_response: V2AttestationResponse =
match serde_json::from_str(&response_text) {
Ok(response) => response,
Err(e) => {
error!(
error = %e,
response_body = %response_text,
tx_hash = %tx_hash,
attempt = attempt,
event = "attestation_decode_failed"
);
sleep(Duration::from_secs(poll_interval)).await;
return Ok(None);
}
};
let message_count = v2_response.messages.len();
match select_v2_attestation(&v2_response.messages) {
V2AttestationOutcome::Ready {
message: message_bytes,
attestation: attestation_bytes,
} => {
info!(
message_length_bytes = message_bytes.len(),
attestation_length_bytes = attestation_bytes.len(),
message_count = message_count,
version = "v2",
fast_transfer = self.transfer_mode.is_fast(),
event = "attestation_complete"
);
Ok(Some((message_bytes, attestation_bytes)))
}
V2AttestationOutcome::Failed => {
Err(super::attestation_api_reported_failed())
}
V2AttestationOutcome::AttestationMissing => {
Err(super::attestation_data_missing())
}
V2AttestationOutcome::MessageMissing => {
spans::record_error_with_context(
"MessageDataMissing",
"Attestation status is complete but message field is null",
Some("This indicates an unexpected API response format"),
);
error!(event = "message_data_missing");
Err(CctpError::AttestationFailed(
AttestationFailureKind::MessageMissing,
))
}
V2AttestationOutcome::Pending => {
debug!(
message_count = message_count,
event = "attestation_pending"
);
sleep(Duration::from_secs(poll_interval)).await;
Ok(None)
}
V2AttestationOutcome::Empty => {
debug!(event = "no_messages_in_response");
sleep(Duration::from_secs(poll_interval)).await;
Ok(None)
}
}
}
.instrument(process_span)
.await
}
.instrument(attempt_span)
.await;
match attempt_result {
Ok(Some(pair)) => return Ok(pair),
Ok(None) => continue,
Err(e) => return Err(e),
}
}
spans::record_error_with_context(
"AttestationTimeout",
&format!("Attestation polling timed out after {max_attempts} attempts"),
Some(&format!("Total duration: {total_timeout_secs} seconds")),
);
error!(
total_duration_secs = total_timeout_secs,
event = "attestation_timeout"
);
Err(CctpError::AttestationTimeout)
}
.instrument(span)
.await
}
pub async fn burn(
&self,
amount: U256,
from: Address,
token_address: Address,
) -> Result<TxHash> {
let token_messenger_address = self.token_messenger_v2_contract()?;
let destination_domain = self.destination_domain_id()?;
let token_messenger =
TokenMessengerV2Contract::new(token_messenger_address, self.source_provider.clone());
let max_fee = self.transfer_mode.max_fee();
let min_finality_threshold = self.transfer_mode.finality_threshold().as_u32();
let tx_request = match &self.transfer_mode {
TransferMode::Standard => token_messenger.deposit_for_burn_transaction(
from,
self.recipient,
destination_domain,
token_address,
amount,
min_finality_threshold,
),
TransferMode::Fast { .. } => token_messenger.deposit_for_burn_fast_transaction(
from,
self.recipient,
destination_domain,
token_address,
amount,
max_fee,
min_finality_threshold,
),
TransferMode::StandardWithHook { hook_data }
| TransferMode::FastWithHook { hook_data, .. } => token_messenger
.deposit_for_burn_with_hooks_transaction(
from,
self.recipient,
destination_domain,
token_address,
amount,
max_fee,
min_finality_threshold,
hook_data.clone(),
),
};
info!(
from = %from,
amount = %amount,
token_address = %token_address,
destination_domain = %destination_domain,
fast_transfer = self.transfer_mode.is_fast(),
has_hooks = self.transfer_mode.has_hook(),
finality_threshold = %self.transfer_mode.finality_threshold(),
version = "v2",
event = "burn_transaction_initiated"
);
let pending_tx = self.source_provider.send_transaction(tx_request).await?;
let tx_hash = *pending_tx.tx_hash();
info!(
tx_hash = %tx_hash,
version = "v2",
event = "burn_transaction_sent"
);
Ok(tx_hash)
}
pub async fn mint(
&self,
message_bytes: Vec<u8>,
attestation: AttestationBytes,
from: Address,
) -> Result<TxHash> {
let message_transmitter_address = self.message_transmitter_v2_contract()?;
let message_transmitter = MessageTransmitterV2Contract::new(
message_transmitter_address,
self.destination_provider.clone(),
);
let tx_request = message_transmitter.receive_message_transaction(
Bytes::from(message_bytes.clone()),
Bytes::from(attestation.clone()),
from,
);
info!(
from = %from,
message_len = message_bytes.len(),
attestation_len = attestation.len(),
version = "v2",
event = "mint_transaction_initiated"
);
let pending_tx = self
.destination_provider
.send_transaction(tx_request)
.await?;
let tx_hash = *pending_tx.tx_hash();
info!(
tx_hash = %tx_hash,
version = "v2",
event = "mint_transaction_sent"
);
Ok(tx_hash)
}
pub async fn is_message_received(&self, message: &[u8]) -> Result<bool> {
let message_transmitter_address = self.message_transmitter_v2_contract()?;
let message_transmitter = MessageTransmitterV2Contract::new(
message_transmitter_address,
self.destination_provider.clone(),
);
let message_hash: [u8; 32] = alloy_primitives::keccak256(message).into();
debug!(
message_hash = %hex::encode(message_hash),
version = "v2",
event = "checking_message_received_status"
);
Ok(message_transmitter
.is_message_received(message_hash)
.await?)
}
pub async fn wait_for_receive(
&self,
message: &[u8],
max_attempts: Option<u32>,
poll_interval: Option<u64>,
) -> Result<()> {
if matches!(max_attempts, Some(0)) {
return Err(CctpError::InvalidConfig(
"wait_for_receive max_attempts must be greater than 0".to_string(),
));
}
if matches!(poll_interval, Some(0)) {
return Err(CctpError::InvalidConfig(
"wait_for_receive poll_interval must be greater than 0".to_string(),
));
}
let max_attempts = max_attempts.unwrap_or(60);
let poll_interval = poll_interval.unwrap_or_else(|| {
if self.transfer_mode.is_fast() {
self.destination_chain
.fast_transfer_confirmation_time_seconds()
.unwrap_or(5)
} else {
self.destination_chain
.standard_transfer_confirmation_time_seconds()
.unwrap_or(60)
}
});
let message_hash: FixedBytes<32> = alloy_primitives::keccak256(message);
let span = spans::wait_for_receive(
&message_hash,
&self.source_chain,
&self.destination_chain,
max_attempts,
poll_interval,
);
async move {
info!(
max_attempts = max_attempts,
poll_interval_secs = poll_interval,
fast_transfer = self.transfer_mode.is_fast(),
version = "v2",
event = "wait_for_receive_started"
);
for attempt in 1..=max_attempts {
if self.is_message_received(message).await? {
info!(
attempt = attempt,
version = "v2",
event = "message_received_confirmed"
);
return Ok(());
}
debug!(
attempt = attempt,
max_attempts = max_attempts,
version = "v2",
event = "message_not_yet_received"
);
sleep(Duration::from_secs(poll_interval)).await;
}
spans::record_error_with_context(
"ReceiveTimeout",
&format!(
"wait_for_receive polling timed out after {max_attempts} attempts"
),
Some(&format!(
"Poll interval: {poll_interval} seconds; destination chain never reported receipt"
)),
);
error!(
max_attempts = max_attempts,
poll_interval_secs = poll_interval,
version = "v2",
error_type = "ReceiveTimeout",
event = "wait_for_receive_timeout"
);
Err(CctpError::ReceiveTimeout)
}
.instrument(span)
.await
}
pub async fn mint_if_needed(
&self,
message_bytes: Vec<u8>,
attestation: AttestationBytes,
from: Address,
) -> Result<MintResult> {
if self.is_message_received(&message_bytes).await? {
info!(version = "v2", event = "mint_skipped_already_relayed");
return Ok(MintResult::AlreadyRelayed);
}
match self.mint(message_bytes.clone(), attestation, from).await {
Ok(tx_hash) => {
info!(
tx_hash = %tx_hash,
version = "v2",
event = "mint_if_needed_successful"
);
Ok(MintResult::Minted(tx_hash))
}
Err(e) => {
if e.is_already_relayed() {
info!(
original_error = %e,
version = "v2",
event = "mint_raced_by_relayer"
);
Ok(MintResult::AlreadyRelayed)
} else {
Err(e)
}
}
}
}
pub async fn get_allowance(&self, token_address: Address, owner: Address) -> Result<U256> {
let spender = self.token_messenger_v2_contract()?;
let erc20 = Erc20Contract::new(token_address, self.source_provider.clone());
Ok(erc20.allowance(owner, spender).await?)
}
pub async fn approve(
&self,
token_address: Address,
owner: Address,
amount: U256,
) -> Result<TxHash> {
let spender = self.token_messenger_v2_contract()?;
let erc20 = Erc20Contract::new(token_address, self.source_provider.clone());
let tx_request = erc20.approve_transaction(owner, spender, amount);
info!(
owner = %owner,
spender = %spender,
amount = %amount,
token_address = %token_address,
version = "v2",
event = "approval_transaction_initiated"
);
let pending_tx = self.source_provider.send_transaction(tx_request).await?;
let tx_hash = *pending_tx.tx_hash();
info!(
tx_hash = %tx_hash,
version = "v2",
event = "approval_transaction_sent"
);
Ok(tx_hash)
}
pub async fn ensure_approval(
&self,
token_address: Address,
owner: Address,
amount: U256,
) -> Result<Option<TxHash>> {
let current_allowance = self.get_allowance(token_address, owner).await?;
if current_allowance >= amount {
info!(
owner = %owner,
current_allowance = %current_allowance,
required_amount = %amount,
token_address = %token_address,
version = "v2",
event = "approval_not_needed"
);
return Ok(None);
}
info!(
owner = %owner,
current_allowance = %current_allowance,
required_amount = %amount,
token_address = %token_address,
version = "v2",
event = "approval_needed"
);
let tx_hash = self.approve(token_address, owner, amount).await?;
Ok(Some(tx_hash))
}
pub async fn transfer(
&self,
amount: U256,
from: Address,
token_address: Address,
) -> Result<(TxHash, TxHash)> {
info!(
amount = %amount,
from = %from,
token_address = %token_address,
source_chain = ?self.source_chain,
destination_chain = ?self.destination_chain,
fast_transfer = self.transfer_mode.is_fast(),
has_hooks = self.transfer_mode.has_hook(),
version = "v2",
event = "full_transfer_initiated"
);
let burn_tx_hash = self.burn(amount, from, token_address).await?;
info!(
burn_tx_hash = %burn_tx_hash,
event = "waiting_for_message_sent_event"
);
let polling_config = if self.transfer_mode.is_fast() {
PollingConfig::fast_transfer()
} else {
PollingConfig::default()
};
let (message_bytes, attestation) =
self.get_attestation(burn_tx_hash, polling_config).await?;
info!(
burn_tx_hash = %burn_tx_hash,
message_len = message_bytes.len(),
attestation_len = attestation.len(),
event = "attestation_received"
);
let mint_tx_hash = self.mint(message_bytes, attestation, from).await?;
info!(
burn_tx_hash = %burn_tx_hash,
mint_tx_hash = %mint_tx_hash,
version = "v2",
event = "full_transfer_completed"
);
Ok((burn_tx_hash, mint_tx_hash))
}
pub fn create_url(&self, tx_hash: TxHash) -> Result<Url> {
let source_domain = self.source_chain.cctp_v2_domain_id()?.as_u32();
Ok(self.api_url().join(&format!(
"{MESSAGES_PATH_V2}{source_domain}?transactionHash={tx_hash}"
))?)
}
async fn fetch_attestation_response(&self, client: &Client, url: &Url) -> Result<Response> {
client
.get(url.as_str())
.send()
.await
.map_err(CctpError::Network)
}
}
#[async_trait]
impl<P: Provider<Ethereum> + Clone> CctpBridge for CctpV2<P> {
fn source_chain(&self) -> NamedChain {
self.source_chain
}
fn destination_chain(&self) -> NamedChain {
self.destination_chain
}
fn recipient(&self) -> Address {
self.recipient
}
async fn get_message_sent_event(&self, tx_hash: TxHash) -> Result<(Vec<u8>, FixedBytes<32>)> {
self.get_message_sent_event(tx_hash).await
}
fn supports_fast_transfer(&self) -> bool {
self.transfer_mode.is_fast()
}
fn supports_hooks(&self) -> bool {
self.transfer_mode.has_hook()
}
fn finality_threshold(&self) -> Option<FinalityThreshold> {
Some(self.finality_threshold())
}
}
#[derive(Debug, PartialEq, Eq)]
enum V2AttestationOutcome {
Ready {
message: Vec<u8>,
attestation: AttestationBytes,
},
Pending,
AttestationMissing,
MessageMissing,
Failed,
Empty,
}
fn select_v2_attestation(messages: &[V2Message]) -> V2AttestationOutcome {
if let Some((message, attestation)) = messages.iter().find_map(|message| {
if message.status != AttestationStatus::Complete {
return None;
}
Some((
message.message.as_ref()?.to_vec(),
message.attestation.as_ref()?.to_vec(),
))
}) {
return V2AttestationOutcome::Ready {
message,
attestation,
};
}
if messages.iter().any(|message| {
matches!(
message.status,
AttestationStatus::Pending | AttestationStatus::PendingConfirmations
)
}) {
return V2AttestationOutcome::Pending;
}
if let Some(message) = messages
.iter()
.find(|message| message.status == AttestationStatus::Complete)
{
return if message.attestation.is_none() {
V2AttestationOutcome::AttestationMissing
} else {
V2AttestationOutcome::MessageMissing
};
}
if messages
.iter()
.any(|message| message.status == AttestationStatus::Failed)
{
return V2AttestationOutcome::Failed;
}
V2AttestationOutcome::Empty
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_chains::NamedChain;
use alloy_primitives::{Address, FixedBytes};
use alloy_provider::ProviderBuilder;
use rstest::rstest;
#[rstest]
#[case(NamedChain::Mainnet, NamedChain::Linea)]
#[case(NamedChain::Arbitrum, NamedChain::Sonic)]
#[case(NamedChain::Base, NamedChain::Sei)]
#[case(NamedChain::Sepolia, NamedChain::BaseSepolia)]
fn test_v2_cross_chain_compatibility(
#[case] source: NamedChain,
#[case] destination: NamedChain,
) {
assert!(source.supports_cctp_v2());
assert!(destination.supports_cctp_v2());
assert!(source.cctp_v2_domain_id().is_ok());
assert!(destination.cctp_v2_domain_id().is_ok());
assert!(source.token_messenger_v2_address().is_ok());
assert!(destination.message_transmitter_v2_address().is_ok());
}
#[test]
fn test_v2_unsupported_chain_error() {
let result = NamedChain::Moonbeam.token_messenger_v2_address();
assert!(result.is_err());
}
#[test]
fn test_v2_messages_url_format_mainnet() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
let test_tx_hash: TxHash = FixedBytes::from([0x12; 32]);
let url = bridge.create_url(test_tx_hash).unwrap();
insta::assert_snapshot!(url.as_str(), @"https://iris-api.circle.com/v2/messages/0?transactionHash=0x1212121212121212121212121212121212121212121212121212121212121212");
}
#[test]
fn test_v2_messages_url_format_sepolia() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Sepolia)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
let test_tx_hash: TxHash = FixedBytes::from([0x12; 32]);
let url = bridge.create_url(test_tx_hash).unwrap();
insta::assert_snapshot!(url.as_str(), @"https://iris-api-sandbox.circle.com/v2/messages/0?transactionHash=0x1212121212121212121212121212121212121212121212121212121212121212");
}
#[test]
fn test_v2_fast_transfer_flag() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let standard = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.build();
assert!(!standard.is_fast_transfer());
assert_eq!(standard.finality_threshold(), FinalityThreshold::Standard);
assert!(!standard.supports_fast_transfer());
let fast = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast {
max_fee: U256::ZERO,
})
.build();
assert!(fast.is_fast_transfer());
assert_eq!(fast.finality_threshold(), FinalityThreshold::Fast);
assert!(fast.supports_fast_transfer());
}
#[test]
fn test_v2_hooks_support() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let no_hooks = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.build();
assert!(!no_hooks.supports_hooks());
assert!(no_hooks.hook_data().is_none());
let hook_data = Bytes::from(vec![1, 2, 3, 4]);
let with_hooks = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::StandardWithHook {
hook_data: hook_data.clone(),
})
.build();
assert!(with_hooks.supports_hooks());
assert_eq!(with_hooks.hook_data(), Some(&hook_data));
}
#[test]
fn test_v2_max_fee() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let max_fee = U256::from(1000);
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast { max_fee })
.build();
assert_eq!(bridge.max_fee(), Some(max_fee));
}
#[test]
fn test_v2_unified_addresses() {
let linea_tm = NamedChain::Linea.token_messenger_v2_address().unwrap();
let sonic_tm = NamedChain::Sonic.token_messenger_v2_address().unwrap();
let mainnet_tm = NamedChain::Mainnet.token_messenger_v2_address().unwrap();
assert_eq!(linea_tm, sonic_tm);
assert_eq!(linea_tm, mainnet_tm);
let linea_mt = NamedChain::Linea.message_transmitter_v2_address().unwrap();
let sonic_mt = NamedChain::Sonic.message_transmitter_v2_address().unwrap();
let mainnet_mt = NamedChain::Mainnet
.message_transmitter_v2_address()
.unwrap();
assert_eq!(linea_mt, sonic_mt);
assert_eq!(linea_mt, mainnet_mt);
}
#[test]
fn test_v2_builder_pattern() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::FastWithHook {
max_fee: U256::from(500),
hook_data: Bytes::from(vec![1, 2, 3]),
})
.build();
assert_eq!(bridge.source_chain(), &NamedChain::Mainnet);
assert_eq!(bridge.destination_chain(), &NamedChain::Linea);
assert_eq!(bridge.recipient(), &Address::ZERO);
assert!(bridge.is_fast_transfer());
assert_eq!(bridge.max_fee(), Some(U256::from(500)));
assert!(bridge.hook_data().is_some());
}
#[test]
fn test_v2_contract_method_selection_standard() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
assert!(!bridge.is_fast_transfer());
assert!(bridge.hook_data().is_none());
assert_eq!(bridge.finality_threshold(), FinalityThreshold::Standard);
assert_eq!(bridge.finality_threshold().as_u32(), 2000);
}
#[test]
fn test_v2_contract_method_selection_fast() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast {
max_fee: U256::from(1000),
})
.build();
assert!(bridge.is_fast_transfer());
assert!(bridge.hook_data().is_none());
assert_eq!(bridge.finality_threshold(), FinalityThreshold::Fast);
assert_eq!(bridge.finality_threshold().as_u32(), 1000);
assert_eq!(bridge.max_fee(), Some(U256::from(1000)));
}
#[test]
fn test_v2_contract_method_selection_hooks() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let hook_data = Bytes::from(vec![1, 2, 3, 4]);
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::StandardWithHook {
hook_data: hook_data.clone(),
})
.build();
assert!(!bridge.is_fast_transfer());
assert_eq!(bridge.hook_data(), Some(&hook_data));
assert_eq!(bridge.finality_threshold(), FinalityThreshold::Standard);
}
#[test]
fn test_v2_fast_with_hook_uses_fast_finality_and_fee() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let hook_data = Bytes::from(vec![1, 2, 3, 4]);
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::FastWithHook {
max_fee: U256::from(1000),
hook_data: hook_data.clone(),
})
.build();
assert!(bridge.is_fast_transfer());
assert_eq!(bridge.hook_data(), Some(&hook_data));
assert_eq!(bridge.finality_threshold(), FinalityThreshold::Fast);
assert_eq!(bridge.max_fee(), Some(U256::from(1000)));
assert!(matches!(
bridge.transfer_mode(),
TransferMode::FastWithHook { .. }
));
}
#[test]
fn test_v2_fast_with_hook_calldata_carries_fast_finality_and_max_fee() {
use crate::contracts::v2::TokenMessengerV2;
use alloy_sol_types::SolCall;
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let hook_data = Bytes::from(vec![0xde, 0xad]);
let max_fee = U256::from(1000);
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.transfer_mode(TransferMode::FastWithHook {
max_fee,
hook_data: hook_data.clone(),
})
.build();
let from = Address::repeat_byte(0xaa);
let token_address = Address::repeat_byte(0xbb);
let amount = U256::from(1_000_000_u64);
let destination_domain = bridge.destination_domain_id().unwrap();
let token_messenger_address = bridge.source_chain().token_messenger_v2_address().unwrap();
let token_messenger = TokenMessengerV2Contract::new(token_messenger_address, provider);
let mode = bridge.transfer_mode();
let mode_max_fee = mode.max_fee();
let mode_finality_threshold = mode.finality_threshold().as_u32();
let mode_hook_data = mode
.hook_data()
.expect("FastWithHook carries hook data")
.clone();
let tx_request = token_messenger.deposit_for_burn_with_hooks_transaction(
from,
*bridge.recipient(),
destination_domain,
token_address,
amount,
mode_max_fee,
mode_finality_threshold,
mode_hook_data,
);
let calldata = tx_request
.input
.input()
.expect("transaction request carries calldata");
let decoded = TokenMessengerV2::depositForBurnWithHookCall::abi_decode(calldata)
.expect("calldata decodes as depositForBurnWithHook");
assert_eq!(decoded.minFinalityThreshold, 1000);
assert_eq!(decoded.maxFee, max_fee);
assert_eq!(decoded.amount, amount);
assert_eq!(decoded.destinationDomain, destination_domain.as_u32());
assert_eq!(decoded.hookData, hook_data);
}
#[rstest]
#[case(NamedChain::Mainnet, NamedChain::Linea)]
#[case(NamedChain::Arbitrum, NamedChain::Sonic)]
#[case(NamedChain::Base, NamedChain::Sei)]
#[case(NamedChain::Sepolia, NamedChain::BaseSepolia)]
fn test_v2_fast_transfer_chain_support(
#[case] source: NamedChain,
#[case] destination: NamedChain,
) {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(source)
.destination_chain(destination)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast {
max_fee: U256::ZERO,
})
.build();
assert!(bridge.supports_fast_transfer());
assert_eq!(bridge.finality_threshold(), FinalityThreshold::Fast);
}
#[test]
fn test_v2_domain_id_resolution() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
let source_domain = bridge.source_chain().cctp_v2_domain_id().unwrap();
let dest_domain = bridge.destination_domain_id().unwrap();
assert_eq!(source_domain, DomainId::Ethereum);
assert_eq!(dest_domain, DomainId::Linea);
assert_eq!(source_domain.as_u32(), 0);
assert_eq!(dest_domain.as_u32(), 11);
}
#[test]
fn test_v2_contract_address_resolution() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
let token_messenger = bridge.token_messenger_v2_contract().unwrap();
let message_transmitter = bridge.message_transmitter_v2_contract().unwrap();
assert_eq!(
token_messenger,
"0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d"
.parse::<Address>()
.unwrap()
);
assert_eq!(
message_transmitter,
"0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"
.parse::<Address>()
.unwrap()
);
}
#[test]
fn test_v2_api_url_construction() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let mainnet_bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.build();
let test_tx_hash: TxHash = FixedBytes::from([0xab; 32]);
let mainnet_url = mainnet_bridge.create_url(test_tx_hash).unwrap();
assert!(mainnet_url.as_str().contains("iris-api.circle.com"));
assert!(mainnet_url.as_str().contains("/v2/messages/"));
assert!(mainnet_url.as_str().contains("transactionHash="));
let testnet_bridge = CctpV2::builder()
.source_chain(NamedChain::Sepolia)
.destination_chain(NamedChain::BaseSepolia)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
let testnet_url = testnet_bridge.create_url(test_tx_hash).unwrap();
assert!(testnet_url.as_str().contains("iris-api-sandbox.circle.com"));
assert!(testnet_url.as_str().contains("/v2/messages/"));
assert!(testnet_url.as_str().contains("transactionHash="));
}
#[test]
fn test_v2_finality_threshold_mapping() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let standard = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.build();
assert_eq!(standard.finality_threshold(), FinalityThreshold::Standard);
assert_eq!(standard.finality_threshold().as_u32(), 2000);
assert!(standard.finality_threshold().is_standard());
assert!(!standard.finality_threshold().is_fast());
let fast = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast {
max_fee: U256::ZERO,
})
.build();
assert_eq!(fast.finality_threshold(), FinalityThreshold::Fast);
assert_eq!(fast.finality_threshold().as_u32(), 1000);
assert!(!fast.finality_threshold().is_standard());
assert!(fast.finality_threshold().is_fast());
}
#[rstest]
#[case(NamedChain::Mainnet, NamedChain::Linea)]
#[case(NamedChain::Arbitrum, NamedChain::Sonic)]
#[case(NamedChain::Base, NamedChain::Sei)]
#[case(NamedChain::Optimism, NamedChain::Polygon)]
fn test_v2_cross_chain_integration(
#[case] source: NamedChain,
#[case] destination: NamedChain,
) {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let bridge = CctpV2::builder()
.source_chain(source)
.destination_chain(destination)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
assert!(bridge.source_chain().cctp_v2_domain_id().is_ok());
assert!(bridge.destination_domain_id().is_ok());
assert!(bridge.token_messenger_v2_contract().is_ok());
assert!(bridge.message_transmitter_v2_contract().is_ok());
if !source.is_testnet() && !destination.is_testnet() {
let token_messenger = bridge.token_messenger_v2_contract().unwrap();
let message_transmitter = bridge.message_transmitter_v2_contract().unwrap();
assert_eq!(
token_messenger,
"0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d"
.parse::<Address>()
.unwrap()
);
assert_eq!(
message_transmitter,
"0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"
.parse::<Address>()
.unwrap()
);
}
}
#[test]
fn test_v2_error_handling_unsupported_chain() {
let result = NamedChain::Moonbeam.token_messenger_v2_address();
assert!(result.is_err());
let result = NamedChain::Moonbeam.message_transmitter_v2_address();
assert!(result.is_err());
let result = NamedChain::Moonbeam.cctp_v2_domain_id();
assert!(result.is_err());
}
#[test]
fn test_v2_recipient_address_validation() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let recipient = "0x742d35Cc6634C0532925a3b844Bc9e7595f8fA0d"
.parse::<Address>()
.unwrap();
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(recipient)
.build();
assert_eq!(bridge.recipient(), &recipient);
}
#[test]
fn test_v2_max_fee_defaults() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let zero_fee = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast {
max_fee: U256::ZERO,
})
.build();
assert_eq!(zero_fee.max_fee(), Some(U256::ZERO));
let standard = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider.clone())
.recipient(Address::ZERO)
.build();
assert_eq!(standard.max_fee(), None);
let with_fee = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::Fast {
max_fee: U256::from(500),
})
.build();
assert_eq!(with_fee.max_fee(), Some(U256::from(500)));
}
#[test]
fn test_v2_hooks_data_validation() {
let provider =
ProviderBuilder::new().connect_http("http://localhost:8545".parse().unwrap());
let hook_data = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]);
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(TransferMode::StandardWithHook {
hook_data: hook_data.clone(),
})
.build();
assert_eq!(bridge.hook_data(), Some(&hook_data));
assert_eq!(bridge.hook_data().unwrap().len(), 4);
assert_eq!(bridge.hook_data().unwrap()[0], 0xde);
}
mod select_v2_attestation {
use super::super::{select_v2_attestation, V2AttestationOutcome};
use super::*;
fn message(
status: AttestationStatus,
message: Option<&[u8]>,
attestation: Option<&[u8]>,
) -> V2Message {
V2Message {
status,
message: message.map(Bytes::copy_from_slice),
attestation: attestation.map(Bytes::copy_from_slice),
}
}
fn complete(msg: &[u8], attestation: &[u8]) -> V2Message {
message(AttestationStatus::Complete, Some(msg), Some(attestation))
}
#[test]
fn empty_array_keeps_polling() {
assert_eq!(select_v2_attestation(&[]), V2AttestationOutcome::Empty);
}
#[test]
fn single_complete_message_is_ready() {
let messages = vec![complete(&[0xde, 0xad], &[0xbe, 0xef])];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Ready {
message: vec![0xde, 0xad],
attestation: vec![0xbe, 0xef],
}
);
}
#[test]
fn complete_behind_pending_is_selected() {
let messages = vec![
message(AttestationStatus::Pending, None, None),
complete(&[0x01, 0x02], &[0x03, 0x04]),
];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Ready {
message: vec![0x01, 0x02],
attestation: vec![0x03, 0x04],
}
);
}
#[test]
fn complete_behind_failed_is_selected() {
let messages = vec![
message(AttestationStatus::Failed, None, None),
complete(&[0xaa], &[0xbb]),
];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Ready {
message: vec![0xaa],
attestation: vec![0xbb],
}
);
}
#[test]
fn first_complete_message_wins_when_several_are_ready() {
let messages = vec![complete(&[0x11], &[0x22]), complete(&[0x33], &[0x44])];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Ready {
message: vec![0x11],
attestation: vec![0x22],
}
);
}
#[test]
fn pending_sibling_outranks_failed_when_none_complete() {
let messages = vec![
message(AttestationStatus::Failed, None, None),
message(AttestationStatus::PendingConfirmations, None, None),
];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Pending
);
}
#[test]
fn all_failed_fails() {
let messages = vec![
message(AttestationStatus::Failed, None, None),
message(AttestationStatus::Failed, None, None),
];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Failed
);
}
#[test]
fn complete_missing_attestation_reports_attestation_missing() {
let messages = vec![message(
AttestationStatus::Complete,
Some(&[0xde, 0xad]),
None,
)];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::AttestationMissing
);
}
#[test]
fn complete_missing_message_reports_message_missing() {
let messages = vec![message(
AttestationStatus::Complete,
None,
Some(&[0xbe, 0xef]),
)];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::MessageMissing
);
}
#[test]
fn pending_outranks_malformed_complete() {
let messages = vec![
message(AttestationStatus::Complete, None, None),
message(AttestationStatus::Pending, None, None),
];
assert_eq!(
select_v2_attestation(&messages),
V2AttestationOutcome::Pending
);
}
}
mod burn_dispatch_wire {
use super::*;
use alloy_json_rpc::{RequestPacket, Response, ResponsePacket, ResponsePayload};
use alloy_provider::ProviderBuilder;
use alloy_rpc_client::RpcClient;
use alloy_rpc_types::TransactionRequest;
use alloy_sol_types::SolCall;
use rstest::rstest;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use tower::Service;
use crate::contracts::v2::TokenMessengerV2;
#[derive(Clone, Default)]
struct CapturingTransport {
requests: Arc<Mutex<Vec<alloy_json_rpc::SerializedRequest>>>,
}
impl Service<RequestPacket> for CapturingTransport {
type Response = ResponsePacket;
type Error = alloy_transport::TransportError;
type Future = alloy_transport::TransportFut<'static>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: RequestPacket) -> Self::Future {
let requests = self.requests.clone();
Box::pin(async move {
let RequestPacket::Single(req) = req else {
panic!("burn() does not issue batch requests");
};
let id = req.id().clone();
requests.lock().expect("captured-request mutex").push(req);
let hash_json =
"\"0x0101010101010101010101010101010101010101010101010101010101010101\"";
let raw = serde_json::value::RawValue::from_string(hash_json.to_string())
.expect("static tx-hash JSON parses");
Ok(ResponsePacket::Single(Response {
id,
payload: ResponsePayload::Success(raw),
}))
})
}
}
fn build_bridge(
transfer_mode: TransferMode,
) -> (
CctpV2<alloy_provider::RootProvider<Ethereum>>,
Arc<Mutex<Vec<alloy_json_rpc::SerializedRequest>>>,
) {
let transport = CapturingTransport::default();
let captured = transport.requests.clone();
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_client(RpcClient::new(transport, true));
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.transfer_mode(transfer_mode)
.build();
(bridge, captured)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExpectedCall {
DepositForBurn,
DepositForBurnWithHook,
}
#[rstest]
#[case::standard(
TransferMode::Standard,
ExpectedCall::DepositForBurn,
2000_u32,
U256::ZERO,
None
)]
#[case::fast(
TransferMode::Fast { max_fee: U256::from(1500_u64) },
ExpectedCall::DepositForBurn,
1000_u32,
U256::from(1500_u64),
None,
)]
#[case::standard_with_hook(
TransferMode::StandardWithHook { hook_data: Bytes::from_static(&[0xab, 0xcd]) },
ExpectedCall::DepositForBurnWithHook,
2000_u32,
U256::ZERO,
Some(Bytes::from_static(&[0xab, 0xcd])),
)]
#[case::fast_with_hook(
TransferMode::FastWithHook {
max_fee: U256::from(2500_u64),
hook_data: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
},
ExpectedCall::DepositForBurnWithHook,
1000_u32,
U256::from(2500_u64),
Some(Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])),
)]
#[tokio::test]
async fn burn_emits_expected_calldata(
#[case] transfer_mode: TransferMode,
#[case] expected_call: ExpectedCall,
#[case] expected_finality_threshold: u32,
#[case] expected_max_fee: U256,
#[case] expected_hook_data: Option<Bytes>,
) {
let (bridge, captured) = build_bridge(transfer_mode);
let from = Address::repeat_byte(0xaa);
let token = Address::repeat_byte(0xbb);
let amount = U256::from(1_000_000_u64);
let destination_domain = bridge.destination_domain_id().unwrap().as_u32();
let recipient = *bridge.recipient();
bridge
.burn(amount, from, token)
.await
.expect("burn submits one eth_sendTransaction to the capturing transport");
let requests = captured.lock().expect("captured-request mutex");
assert_eq!(
requests.len(),
1,
"burn() should issue exactly one RPC call when fillers are disabled"
);
assert_eq!(requests[0].method(), "eth_sendTransaction");
let params = requests[0]
.params()
.expect("eth_sendTransaction request carries params");
let (tx_request,): (TransactionRequest,) = serde_json::from_str(params.get())
.expect("params decode as a single TransactionRequest");
assert_eq!(
tx_request.from,
Some(from),
"calldata must originate from the address the bridge was asked to burn for"
);
let calldata = tx_request
.input
.input()
.expect("transaction request carries calldata");
let expected_destination_caller = Address::ZERO.into_word();
match expected_call {
ExpectedCall::DepositForBurn => {
let decoded = TokenMessengerV2::depositForBurnCall::abi_decode(calldata)
.expect("calldata decodes as depositForBurn");
assert_eq!(decoded.amount, amount);
assert_eq!(decoded.destinationDomain, destination_domain);
assert_eq!(decoded.mintRecipient, recipient.into_word());
assert_eq!(decoded.burnToken, token);
assert_eq!(decoded.destinationCaller, expected_destination_caller);
assert_eq!(decoded.maxFee, expected_max_fee);
assert_eq!(decoded.minFinalityThreshold, expected_finality_threshold);
assert!(
expected_hook_data.is_none(),
"no-hook variants must not carry hook data on the wire"
);
}
ExpectedCall::DepositForBurnWithHook => {
let decoded =
TokenMessengerV2::depositForBurnWithHookCall::abi_decode(calldata)
.expect("calldata decodes as depositForBurnWithHook");
assert_eq!(decoded.amount, amount);
assert_eq!(decoded.destinationDomain, destination_domain);
assert_eq!(decoded.mintRecipient, recipient.into_word());
assert_eq!(decoded.burnToken, token);
assert_eq!(decoded.destinationCaller, expected_destination_caller);
assert_eq!(decoded.maxFee, expected_max_fee);
assert_eq!(decoded.minFinalityThreshold, expected_finality_threshold);
assert_eq!(
decoded.hookData,
expected_hook_data.expect("hook variants carry hook data")
);
}
}
}
}
mod wait_for_receive_timeout {
use super::*;
use alloy_json_rpc::{RequestPacket, Response, ResponsePacket, ResponsePayload};
use alloy_provider::ProviderBuilder;
use alloy_rpc_client::RpcClient;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use tower::Service;
#[derive(Clone, Default)]
struct AlwaysFalseEthCallTransport {
call_count: Arc<Mutex<u32>>,
}
impl Service<RequestPacket> for AlwaysFalseEthCallTransport {
type Response = ResponsePacket;
type Error = alloy_transport::TransportError;
type Future = alloy_transport::TransportFut<'static>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: RequestPacket) -> Self::Future {
let call_count = self.call_count.clone();
Box::pin(async move {
let RequestPacket::Single(req) = req else {
panic!("wait_for_receive does not issue batch requests");
};
let id = req.id().clone();
assert_eq!(
req.method(),
"eth_call",
"wait_for_receive's polling loop only issues eth_call",
);
*call_count.lock().expect("call-count mutex") += 1;
let unused_nonce_json =
"\"0x0000000000000000000000000000000000000000000000000000000000000000\"";
let raw =
serde_json::value::RawValue::from_string(unused_nonce_json.to_string())
.expect("static unused-nonce JSON parses");
Ok(ResponsePacket::Single(Response {
id,
payload: ResponsePayload::Success(raw),
}))
})
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn returns_receive_timeout_when_polling_exhausts() {
let transport = AlwaysFalseEthCallTransport::default();
let call_count = transport.call_count.clone();
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_client(RpcClient::new(transport, true));
let bridge = CctpV2::builder()
.source_chain(NamedChain::Mainnet)
.destination_chain(NamedChain::Linea)
.source_provider(provider.clone())
.destination_provider(provider)
.recipient(Address::ZERO)
.build();
let err = bridge
.wait_for_receive(b"any message bytes", Some(3), Some(1))
.await
.expect_err("polling against a destination that never reports receipt must error");
assert!(
matches!(err, CctpError::ReceiveTimeout),
"wait_for_receive must return ReceiveTimeout, not AttestationTimeout: {err:?}",
);
assert_eq!(
*call_count.lock().expect("call-count mutex"),
3,
"polling must exhaust the configured attempt budget before timing out",
);
}
}
}