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}