cctp_rs/bridge/
multicall.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Batch call helpers for efficient RPC operations.
6//!
7//! This module provides utilities for batching multiple contract calls into
8//! parallel RPC requests, reducing latency when fetching multiple values.
9//!
10//! # Benefits
11//!
12//! - Reduced latency: Multiple calls execute concurrently
13//! - Better throughput: Multiple requests can be in-flight simultaneously
14//! - Simpler code: Fetch related data in one logical operation
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use cctp_rs::batch_token_checks;
20//!
21//! // Fetch balance and allowance in parallel
22//! let (allowance, balance) = batch_token_checks(
23//!     &provider,
24//!     usdc_address,
25//!     owner_address,
26//!     token_messenger_address,
27//! ).await?;
28//!
29//! if allowance < amount && balance >= amount {
30//!     // Need to approve before burning
31//! }
32//! ```
33//!
34//! # Implementation Note
35//!
36//! These helpers use `tokio::join!` for parallel execution rather than
37//! on-chain Multicall3. This achieves similar latency benefits without
38//! requiring the Multicall3 contract to be deployed on all chains.
39
40use crate::contracts::erc20::Erc20Contract;
41use crate::error::{CctpError, Result};
42use alloy_network::Ethereum;
43use alloy_primitives::{Address, U256};
44use alloy_provider::Provider;
45use tracing::{debug, info};
46
47/// Batch check token allowance and balance in parallel RPC calls.
48///
49/// This is more efficient than making sequential `allowance()` and `balanceOf()`
50/// calls when you need both values, as the calls execute concurrently.
51///
52/// # Arguments
53///
54/// * `provider` - The Ethereum provider
55/// * `token` - The ERC20 token contract address (e.g., USDC)
56/// * `owner` - The address that owns the tokens
57/// * `spender` - The address to check allowance for (e.g., TokenMessenger)
58///
59/// # Returns
60///
61/// A tuple of `(allowance, balance)` where both are `U256`.
62///
63/// # Example
64///
65/// ```rust,ignore
66/// use cctp_rs::batch_token_checks;
67///
68/// let (allowance, balance) = batch_token_checks(
69///     &provider,
70///     usdc,
71///     sender,
72///     token_messenger,
73/// ).await?;
74///
75/// if balance >= amount {
76///     if allowance < amount {
77///         // Need approval first
78///         bridge.approve(usdc, sender, amount).await?;
79///     }
80///     // Can burn
81///     bridge.burn(amount, sender, usdc).await?;
82/// }
83/// ```
84pub async fn batch_token_checks<P>(
85    provider: &P,
86    token: Address,
87    owner: Address,
88    spender: Address,
89) -> Result<(U256, U256)>
90where
91    P: Provider<Ethereum> + Clone,
92{
93    debug!(
94        token = %token,
95        owner = %owner,
96        spender = %spender,
97        event = "batch_token_checks_started"
98    );
99
100    let erc20 = Erc20Contract::new(token, provider.clone());
101
102    // Execute both calls in parallel using tokio::join!
103    let (allowance_result, balance_result) =
104        tokio::join!(erc20.allowance(owner, spender), erc20.balance_of(owner));
105
106    let allowance = allowance_result
107        .map_err(|e| CctpError::ContractCall(format!("Failed to get allowance: {e}")))?;
108    let balance = balance_result
109        .map_err(|e| CctpError::ContractCall(format!("Failed to get balance: {e}")))?;
110
111    info!(
112        token = %token,
113        owner = %owner,
114        spender = %spender,
115        allowance = %allowance,
116        balance = %balance,
117        event = "batch_token_checks_completed"
118    );
119
120    Ok((allowance, balance))
121}
122
123/// Token state containing balance and allowance information.
124///
125/// Returned by [`batch_token_state`] to provide a structured view
126/// of an account's token state.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct TokenState {
129    /// The token balance of the owner
130    pub balance: U256,
131    /// The allowance granted to the spender
132    pub allowance: U256,
133}
134
135impl TokenState {
136    /// Check if the owner can transfer the specified amount.
137    ///
138    /// Returns `true` if balance >= amount AND allowance >= amount.
139    pub fn can_transfer(&self, amount: U256) -> bool {
140        self.balance >= amount && self.allowance >= amount
141    }
142
143    /// Check if approval is needed for the specified amount.
144    ///
145    /// Returns `true` if allowance < amount.
146    pub fn needs_approval(&self, amount: U256) -> bool {
147        self.allowance < amount
148    }
149
150    /// Check if the owner has sufficient balance.
151    pub fn has_sufficient_balance(&self, amount: U256) -> bool {
152        self.balance >= amount
153    }
154}
155
156/// Batch check token state (balance and allowance) returning a structured result.
157///
158/// This is a convenience wrapper around [`batch_token_checks`] that returns
159/// a [`TokenState`] struct with helper methods.
160///
161/// # Example
162///
163/// ```rust,ignore
164/// let state = batch_token_state(&provider, usdc, sender, token_messenger).await?;
165///
166/// if !state.has_sufficient_balance(amount) {
167///     return Err("Insufficient USDC balance".into());
168/// }
169///
170/// if state.needs_approval(amount) {
171///     bridge.approve(usdc, sender, amount).await?;
172/// }
173///
174/// // Now safe to burn
175/// bridge.burn(amount, sender, usdc).await?;
176/// ```
177pub async fn batch_token_state<P>(
178    provider: &P,
179    token: Address,
180    owner: Address,
181    spender: Address,
182) -> Result<TokenState>
183where
184    P: Provider<Ethereum> + Clone,
185{
186    let (allowance, balance) = batch_token_checks(provider, token, owner, spender).await?;
187    Ok(TokenState { balance, allowance })
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_token_state_can_transfer() {
196        let state = TokenState {
197            balance: U256::from(1000),
198            allowance: U256::from(500),
199        };
200
201        assert!(state.can_transfer(U256::from(500)));
202        assert!(state.can_transfer(U256::from(100)));
203        assert!(!state.can_transfer(U256::from(501))); // exceeds allowance
204        assert!(!state.can_transfer(U256::from(1001))); // exceeds balance
205    }
206
207    #[test]
208    fn test_token_state_needs_approval() {
209        let state = TokenState {
210            balance: U256::from(1000),
211            allowance: U256::from(500),
212        };
213
214        assert!(!state.needs_approval(U256::from(500)));
215        assert!(!state.needs_approval(U256::from(100)));
216        assert!(state.needs_approval(U256::from(501)));
217        assert!(state.needs_approval(U256::from(1000)));
218    }
219
220    #[test]
221    fn test_token_state_has_sufficient_balance() {
222        let state = TokenState {
223            balance: U256::from(1000),
224            allowance: U256::from(500),
225        };
226
227        assert!(state.has_sufficient_balance(U256::from(1000)));
228        assert!(state.has_sufficient_balance(U256::from(100)));
229        assert!(!state.has_sufficient_balance(U256::from(1001)));
230    }
231
232    #[test]
233    fn test_token_state_zero_allowance() {
234        let state = TokenState {
235            balance: U256::from(1000),
236            allowance: U256::ZERO,
237        };
238
239        assert!(!state.can_transfer(U256::from(1)));
240        assert!(state.needs_approval(U256::from(1)));
241        assert!(state.has_sufficient_balance(U256::from(1000)));
242    }
243}