Skip to main content

cctp_rs/chain/
v2.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4//! CCTP v2 chain configuration trait
5//!
6//! This module defines the `CctpV2` trait which provides v2-specific
7//! chain capabilities including Fast Transfer support, hooks, and
8//! v2 contract addresses.
9
10use alloy_chains::NamedChain;
11use alloy_primitives::Address;
12
13use super::addresses::{
14    CCTP_V2_MESSAGE_TRANSMITTER_MAINNET, CCTP_V2_MESSAGE_TRANSMITTER_TESTNET,
15    CCTP_V2_TOKEN_MESSENGER_MAINNET, CCTP_V2_TOKEN_MESSENGER_TESTNET,
16};
17use crate::{CctpError, DomainId, Result};
18
19/// Static Fast Transfer fee metadata for a CCTP v2 chain, in basis points.
20///
21/// This enum is retained for chain-level/static metadata. Current CCTP v2
22/// fees are route-aware and should be fetched with
23/// [`CctpV2Bridge::get_transfer_fees`](crate::CctpV2Bridge::get_transfer_fees)
24/// or
25/// [`CctpV2Bridge::calculate_fast_transfer_max_fee`](crate::CctpV2Bridge::calculate_fast_transfer_max_fee)
26/// before quoting or sending a Fast Transfer. Until a chain's static fee has
27/// been confirmed against an authoritative source, this SDK represents it as
28/// [`FastTransferFee::Unknown`] rather than asserting a numeric value.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum FastTransferFee {
32    /// Fee is confirmed at this value in basis points (0-14).
33    ///
34    /// A `Known(0)` is a sourced zero fee, semantically distinct from
35    /// [`FastTransferFee::Unknown`].
36    Known(u32),
37    /// Fee data has not yet been confirmed for this chain.
38    ///
39    /// Callers must not assume zero. Coercing `Unknown` to zero would
40    /// reintroduce the placeholder behavior this enum exists to
41    /// prevent — Circle's published range is 0-14 bps, so a default
42    /// of zero is plausible enough to mask a real fee from downstream
43    /// consumers.
44    Unknown,
45}
46
47/// CCTP v2 chain configuration trait
48///
49/// Implemented on `alloy_chains::NamedChain` to provide v2-specific
50/// configuration for each supported blockchain network.
51///
52/// # v2 Features
53///
54/// - **Fast Transfer**: Chains that support fast transfer (finality threshold 1000)
55/// - **Dynamic Fees**: Some chains charge fees for fast transfer (0-14 bps)
56/// - **v2 Contracts**: Updated contract addresses for `TokenMessengerV2` and `MessageTransmitterV2`
57/// - **Expanded Chains**: Bridge SDK routes 11 v2-capable chain families
58///   (the 7 v1 families plus Linea, Sonic, Sei, HyperEVM) with testnets,
59///   versus the 7 v1 chain families. Note that this trait covers bridge
60///   SDK reach — Circle has announced 21 CCTP v2 domain IDs in total,
61///   which the protocol parser (`DomainId`, `ParsedV2Message`) can decode
62///   independently of bridge support.
63///
64/// # Example
65///
66/// ```rust
67/// use cctp_rs::CctpV2;
68/// use alloy_chains::NamedChain;
69///
70/// let chain = NamedChain::Mainnet;
71/// assert!(chain.supports_cctp_v2());
72/// assert!(chain.supports_fast_transfer().unwrap());
73/// ```
74pub trait CctpV2 {
75    /// Returns true if this chain supports CCTP v2
76    ///
77    /// All v1 chains support v2, plus 19 additional v2-only chains.
78    fn supports_cctp_v2(&self) -> bool;
79
80    /// Returns true if this chain supports Fast Transfer
81    ///
82    /// Fast Transfer enables ~30 second settlement times vs 13-19 minutes.
83    fn supports_fast_transfer(&self) -> Result<bool>;
84
85    /// Reports whether static fast transfer fee metadata has been sourced for
86    /// this chain, and if so its value in basis points.
87    ///
88    /// This helper is not the production path for current route fees. CCTP v2
89    /// fees are route-aware; use
90    /// [`CctpV2Bridge::get_transfer_fees`](crate::CctpV2Bridge::get_transfer_fees),
91    /// [`CctpV2Bridge::get_fast_transfer_fee`](crate::CctpV2Bridge::get_fast_transfer_fee),
92    /// or
93    /// [`CctpV2Bridge::calculate_fast_transfer_max_fee`](crate::CctpV2Bridge::calculate_fast_transfer_max_fee)
94    /// when preparing a `maxFee` for user funds.
95    ///
96    /// Returns [`FastTransferFee::Unknown`] when the chain-level/static fee has
97    /// not been confirmed against an authoritative source. This is the current
98    /// state for every v2 chain in this SDK. Callers handling user funds must
99    /// not coerce `Unknown` to zero.
100    ///
101    /// Errors if the chain doesn't support CCTP v2.
102    #[must_use = "ignoring the fast transfer fee can mis-quote a transfer; \
103                  Unknown must not be coerced to zero"]
104    fn fast_transfer_fee_bps(&self) -> Result<FastTransferFee>;
105
106    /// Returns the `TokenMessengerV2` contract address for this chain
107    ///
108    /// Returns an error if the chain doesn't support CCTP v2 or if
109    /// contracts haven't been deployed yet.
110    fn token_messenger_v2_address(&self) -> Result<Address>;
111
112    /// Returns the `MessageTransmitterV2` contract address for this chain
113    ///
114    /// Returns an error if the chain doesn't support CCTP v2 or if
115    /// contracts haven't been deployed yet.
116    fn message_transmitter_v2_address(&self) -> Result<Address>;
117
118    /// Returns the CCTP domain ID for this chain
119    ///
120    /// Note: Domain IDs are the same in v1 and v2 for chains that
121    /// existed in v1. New v2-only chains have domain IDs >= 11.
122    fn cctp_v2_domain_id(&self) -> Result<DomainId>;
123
124    /// Returns the average Fast Transfer attestation time in seconds
125    ///
126    /// Fast Transfer uses a lower finality threshold (≤1000) to achieve
127    /// rapid attestations at the cost of a small fee on some chains.
128    ///
129    /// Typical times:
130    /// - Ethereum: ~20 seconds (2 block confirmations)
131    /// - Most L2s and alt-L1s: ~8 seconds (1 block confirmation)
132    /// - High-performance chains (Sonic, Sei): ~5 seconds
133    ///
134    /// See: <https://developers.circle.com/stablecoins/required-block-confirmations>
135    fn fast_transfer_confirmation_time_seconds(&self) -> Result<u64>;
136
137    /// Returns the average Standard Transfer attestation time in seconds
138    ///
139    /// Standard Transfer waits for full chain finality before Circle's Iris
140    /// service provides an attestation. This is the default behavior.
141    ///
142    /// Typical times:
143    /// - Ethereum + L2s settling to Ethereum: 13-19 minutes (~65 ETH blocks)
144    /// - Avalanche, Polygon: 5-20 seconds (native finality)
145    /// - Sei, Sonic: ~5 seconds (high-performance chains)
146    /// - Linea: 6-32 hours (zkEVM proof generation)
147    ///
148    /// See: <https://developers.circle.com/stablecoins/required-block-confirmations>
149    fn standard_transfer_confirmation_time_seconds(&self) -> Result<u64>;
150}
151
152impl CctpV2 for NamedChain {
153    fn supports_cctp_v2(&self) -> bool {
154        matches!(
155            self,
156            // v1 chains (all support v2)
157            Self::Mainnet
158                | Self::Sepolia
159                | Self::Arbitrum
160                | Self::ArbitrumSepolia
161                | Self::Base
162                | Self::BaseSepolia
163                | Self::Optimism
164                | Self::OptimismSepolia
165                | Self::Avalanche
166                | Self::AvalancheFuji
167                | Self::Polygon
168                | Self::PolygonAmoy
169                | Self::Unichain
170                // v2-only priority chains
171                // (BNB Smart Chain / domain 17 omitted: USYC-only on this domain)
172                | Self::Linea
173                | Self::Sonic
174                | Self::Sei
175                | Self::Hyperliquid
176        )
177    }
178
179    fn supports_fast_transfer(&self) -> Result<bool> {
180        if !self.supports_cctp_v2() {
181            return Err(CctpError::UnsupportedChain(*self));
182        }
183
184        // All v2 chains support fast transfer
185        Ok(true)
186    }
187
188    fn fast_transfer_fee_bps(&self) -> Result<FastTransferFee> {
189        if !self.supports_cctp_v2() {
190            return Err(CctpError::UnsupportedChain(*self));
191        }
192
193        // Per-chain fees have not been sourced against Circle's
194        // published values; until they are, every chain reports
195        // Unknown rather than a placeholder zero.
196        Ok(FastTransferFee::Unknown)
197    }
198
199    fn token_messenger_v2_address(&self) -> Result<Address> {
200        if !self.supports_cctp_v2() {
201            return Err(CctpError::UnsupportedChain(*self));
202        }
203
204        // V2 uses unified addresses across all chains within each environment
205        Ok(if self.is_testnet() {
206            CCTP_V2_TOKEN_MESSENGER_TESTNET
207        } else {
208            CCTP_V2_TOKEN_MESSENGER_MAINNET
209        })
210    }
211
212    fn message_transmitter_v2_address(&self) -> Result<Address> {
213        if !self.supports_cctp_v2() {
214            return Err(CctpError::UnsupportedChain(*self));
215        }
216
217        // V2 uses unified addresses across all chains within each environment
218        Ok(if self.is_testnet() {
219            CCTP_V2_MESSAGE_TRANSMITTER_TESTNET
220        } else {
221            CCTP_V2_MESSAGE_TRANSMITTER_MAINNET
222        })
223    }
224
225    fn cctp_v2_domain_id(&self) -> Result<DomainId> {
226        if !self.supports_cctp_v2() {
227            return Err(CctpError::UnsupportedChain(*self));
228        }
229
230        Ok(match self {
231            // v1 and v2 chains
232            Self::Mainnet | Self::Sepolia => DomainId::Ethereum,
233            Self::Avalanche | Self::AvalancheFuji => DomainId::Avalanche,
234            Self::Optimism | Self::OptimismSepolia => DomainId::Optimism,
235            Self::Arbitrum | Self::ArbitrumSepolia => DomainId::Arbitrum,
236            Self::Base | Self::BaseSepolia => DomainId::Base,
237            Self::Polygon | Self::PolygonAmoy => DomainId::Polygon,
238            Self::Unichain => DomainId::Unichain,
239            // v2-only priority chains
240            Self::Linea => DomainId::Linea,
241            Self::Sonic => DomainId::Sonic,
242            Self::Sei => DomainId::Sei,
243            Self::Hyperliquid => DomainId::HyperEvm,
244            // This is unreachable due to supports_cctp_v2() check above
245            _ => return Err(CctpError::UnsupportedChain(*self)),
246        })
247    }
248
249    fn fast_transfer_confirmation_time_seconds(&self) -> Result<u64> {
250        if !self.supports_cctp_v2() {
251            return Err(CctpError::UnsupportedChain(*self));
252        }
253
254        // Fast Transfer attestation times (1-2 block confirmations)
255        // Based on Circle docs: https://developers.circle.com/stablecoins/required-block-confirmations
256        Ok(match self {
257            // Ethereum: ~20 seconds (2 block confirmations)
258            Self::Mainnet | Self::Sepolia => 20,
259            // Arbitrum: ~8 seconds (1 block confirmation)
260            Self::Arbitrum | Self::ArbitrumSepolia => 8,
261            // Base: ~8 seconds (1 block confirmation)
262            Self::Base | Self::BaseSepolia => 8,
263            // Optimism: ~8 seconds (1 block confirmation)
264            Self::Optimism | Self::OptimismSepolia => 8,
265            // Avalanche: ~8 seconds (1 block confirmation)
266            Self::Avalanche | Self::AvalancheFuji => 8,
267            // Polygon: ~8 seconds (1 block confirmation)
268            Self::Polygon | Self::PolygonAmoy => 8,
269            // Unichain: ~8 seconds (1 block confirmation)
270            Self::Unichain => 8,
271            // Linea: ~8 seconds (vs 6-32 hours for Standard!)
272            Self::Linea => 8,
273            // Sonic: ~5 seconds (high-performance chain)
274            Self::Sonic => 5,
275            // Sei: ~5 seconds (parallel EVM)
276            Self::Sei => 5,
277            // HyperEVM: ~5 seconds (1 block confirmation, high-performance chain)
278            Self::Hyperliquid => 5,
279            _ => return Err(CctpError::UnsupportedChain(*self)),
280        })
281    }
282
283    fn standard_transfer_confirmation_time_seconds(&self) -> Result<u64> {
284        if !self.supports_cctp_v2() {
285            return Err(CctpError::UnsupportedChain(*self));
286        }
287
288        // Standard Transfer attestation times (full finality)
289        // Based on Circle docs: https://developers.circle.com/stablecoins/required-block-confirmations
290        Ok(match self {
291            // Ethereum L1 + L2s settling to Ethereum: 13-19 minutes (~65 ETH blocks)
292            Self::Mainnet | Self::Sepolia => 19 * 60,
293            Self::Arbitrum | Self::ArbitrumSepolia => 19 * 60,
294            Self::Base | Self::BaseSepolia => 19 * 60,
295            Self::Optimism | Self::OptimismSepolia => 19 * 60,
296            Self::Unichain => 19 * 60,
297            // Avalanche: ~20 seconds (native finality)
298            Self::Avalanche | Self::AvalancheFuji => 20,
299            // Polygon: ~8 minutes (PoS finality)
300            Self::Polygon | Self::PolygonAmoy => 8 * 60,
301            // Linea: 6-32 hours (zkEVM proof generation) - use conservative 8 hours
302            Self::Linea => 8 * 60 * 60,
303            // Sonic: ~5 seconds (high-performance chain, native finality)
304            Self::Sonic => 5,
305            // Sei: ~5 seconds (parallel EVM, native finality)
306            Self::Sei => 5,
307            // HyperEVM: ~5 seconds (1 block confirmation, native finality)
308            Self::Hyperliquid => 5,
309            _ => return Err(CctpError::UnsupportedChain(*self)),
310        })
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use rstest::rstest;
317
318    use super::*;
319
320    #[rstest]
321    #[case(NamedChain::Mainnet, true)]
322    #[case(NamedChain::Arbitrum, true)]
323    #[case(NamedChain::Base, true)]
324    #[case(NamedChain::Linea, true)]
325    #[case(NamedChain::Sonic, true)]
326    #[case(NamedChain::Sei, true)]
327    #[case(NamedChain::Hyperliquid, true)]
328    #[case(NamedChain::BinanceSmartChain, false)]
329    #[case(NamedChain::Moonbeam, false)]
330    fn test_v2_chain_support(#[case] chain: NamedChain, #[case] expected: bool) {
331        assert_eq!(chain.supports_cctp_v2(), expected);
332    }
333
334    #[test]
335    fn test_fast_transfer_support() {
336        // All v2 chains support fast transfer
337        assert!(NamedChain::Mainnet.supports_fast_transfer().unwrap());
338        assert!(NamedChain::Linea.supports_fast_transfer().unwrap());
339        assert!(NamedChain::Sonic.supports_fast_transfer().unwrap());
340
341        // Unsupported chain returns error
342        assert!(NamedChain::Moonbeam.supports_fast_transfer().is_err());
343    }
344
345    #[rstest]
346    // v1 mainnets that also support v2
347    #[case(NamedChain::Mainnet)]
348    #[case(NamedChain::Arbitrum)]
349    #[case(NamedChain::Base)]
350    #[case(NamedChain::Optimism)]
351    #[case(NamedChain::Avalanche)]
352    #[case(NamedChain::Polygon)]
353    #[case(NamedChain::Unichain)]
354    // v2-only mainnets
355    #[case(NamedChain::Linea)]
356    #[case(NamedChain::Sonic)]
357    #[case(NamedChain::Sei)]
358    #[case(NamedChain::Hyperliquid)]
359    // v1 testnets that also support v2
360    #[case(NamedChain::Sepolia)]
361    #[case(NamedChain::ArbitrumSepolia)]
362    #[case(NamedChain::BaseSepolia)]
363    #[case(NamedChain::OptimismSepolia)]
364    #[case(NamedChain::AvalancheFuji)]
365    #[case(NamedChain::PolygonAmoy)]
366    fn fast_transfer_fee_is_unknown_until_sourced(#[case] chain: NamedChain) {
367        // Per-chain values are not sourced yet, so every supported v2
368        // chain must report Unknown — never a placeholder Known(0)
369        // that would look like a confirmed fee to callers. Exhaustive
370        // over every variant matched by `supports_cctp_v2()` so a
371        // future variant that accidentally returns Err is caught.
372        assert_eq!(
373            chain.fast_transfer_fee_bps().unwrap(),
374            FastTransferFee::Unknown
375        );
376    }
377
378    #[test]
379    fn fast_transfer_fee_unknown_is_distinct_from_known_zero() {
380        // Sanity: Known(0) and Unknown are not equal. This is the
381        // invariant issue #215 cared about — a confirmed-zero fee must
382        // not collide with the "we haven't sourced this" state.
383        assert_ne!(FastTransferFee::Known(0), FastTransferFee::Unknown);
384    }
385
386    #[test]
387    fn fast_transfer_fee_errors_for_unsupported_chain() {
388        assert!(NamedChain::Moonbeam.fast_transfer_fee_bps().is_err());
389    }
390
391    #[test]
392    fn test_domain_id_mapping() {
393        // v1 chains
394        assert_eq!(
395            NamedChain::Mainnet.cctp_v2_domain_id().unwrap(),
396            DomainId::Ethereum
397        );
398        assert_eq!(
399            NamedChain::Arbitrum.cctp_v2_domain_id().unwrap(),
400            DomainId::Arbitrum
401        );
402
403        // v2-only chains
404        assert_eq!(
405            NamedChain::Linea.cctp_v2_domain_id().unwrap(),
406            DomainId::Linea
407        );
408        assert_eq!(
409            NamedChain::Sonic.cctp_v2_domain_id().unwrap(),
410            DomainId::Sonic
411        );
412        assert_eq!(NamedChain::Sei.cctp_v2_domain_id().unwrap(), DomainId::Sei);
413        assert_eq!(
414            NamedChain::Hyperliquid.cctp_v2_domain_id().unwrap(),
415            DomainId::HyperEvm
416        );
417    }
418
419    #[test]
420    fn test_contract_addresses() {
421        // Mainnet chains should return mainnet addresses
422        let linea_tm = NamedChain::Linea.token_messenger_v2_address().unwrap();
423        let linea_mt = NamedChain::Linea.message_transmitter_v2_address().unwrap();
424        assert_eq!(linea_tm, CCTP_V2_TOKEN_MESSENGER_MAINNET);
425        assert_eq!(linea_mt, CCTP_V2_MESSAGE_TRANSMITTER_MAINNET);
426
427        let sonic_tm = NamedChain::Sonic.token_messenger_v2_address().unwrap();
428        let sonic_mt = NamedChain::Sonic.message_transmitter_v2_address().unwrap();
429        assert_eq!(sonic_tm, CCTP_V2_TOKEN_MESSENGER_MAINNET);
430        assert_eq!(sonic_mt, CCTP_V2_MESSAGE_TRANSMITTER_MAINNET);
431
432        // All mainnet chains should have the same v2 addresses
433        assert_eq!(linea_tm, sonic_tm);
434        assert_eq!(linea_mt, sonic_mt);
435    }
436
437    #[test]
438    fn test_fast_transfer_confirmation_times() {
439        // Fast Transfer: 1-2 block confirmations
440        // Ethereum: 20 seconds (2 blocks)
441        assert_eq!(
442            NamedChain::Mainnet
443                .fast_transfer_confirmation_time_seconds()
444                .unwrap(),
445            20
446        );
447        // L2s and most chains: 8 seconds (1 block)
448        assert_eq!(
449            NamedChain::Arbitrum
450                .fast_transfer_confirmation_time_seconds()
451                .unwrap(),
452            8
453        );
454        assert_eq!(
455            NamedChain::Linea
456                .fast_transfer_confirmation_time_seconds()
457                .unwrap(),
458            8
459        );
460        // High-performance chains: 5 seconds
461        assert_eq!(
462            NamedChain::Sonic
463                .fast_transfer_confirmation_time_seconds()
464                .unwrap(),
465            5
466        );
467        assert_eq!(
468            NamedChain::Sei
469                .fast_transfer_confirmation_time_seconds()
470                .unwrap(),
471            5
472        );
473        assert_eq!(
474            NamedChain::Hyperliquid
475                .fast_transfer_confirmation_time_seconds()
476                .unwrap(),
477            5
478        );
479    }
480
481    #[test]
482    fn test_standard_transfer_confirmation_times() {
483        // Standard Transfer: full finality required
484        // Ethereum + L2s: 19 minutes (~65 ETH blocks)
485        assert_eq!(
486            NamedChain::Mainnet
487                .standard_transfer_confirmation_time_seconds()
488                .unwrap(),
489            19 * 60
490        );
491        assert_eq!(
492            NamedChain::Arbitrum
493                .standard_transfer_confirmation_time_seconds()
494                .unwrap(),
495            19 * 60
496        );
497        assert_eq!(
498            NamedChain::Base
499                .standard_transfer_confirmation_time_seconds()
500                .unwrap(),
501            19 * 60
502        );
503        // Avalanche: 20 seconds (native finality)
504        assert_eq!(
505            NamedChain::Avalanche
506                .standard_transfer_confirmation_time_seconds()
507                .unwrap(),
508            20
509        );
510        // Polygon: 8 minutes
511        assert_eq!(
512            NamedChain::Polygon
513                .standard_transfer_confirmation_time_seconds()
514                .unwrap(),
515            8 * 60
516        );
517        // Linea: 8 hours (zkEVM proof generation)
518        assert_eq!(
519            NamedChain::Linea
520                .standard_transfer_confirmation_time_seconds()
521                .unwrap(),
522            8 * 60 * 60
523        );
524        // High-performance chains: same as fast (already fast natively)
525        assert_eq!(
526            NamedChain::Sonic
527                .standard_transfer_confirmation_time_seconds()
528                .unwrap(),
529            5
530        );
531        assert_eq!(
532            NamedChain::Sei
533                .standard_transfer_confirmation_time_seconds()
534                .unwrap(),
535            5
536        );
537        assert_eq!(
538            NamedChain::Hyperliquid
539                .standard_transfer_confirmation_time_seconds()
540                .unwrap(),
541            5
542        );
543    }
544}