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}