Skip to main content

cctp_rs/protocol/
domain_id.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4//! CCTP domain ID types for identifying blockchain networks
5//!
6//! Circle's Cross-Chain Transfer Protocol uses domain IDs as unique identifiers
7//! for each supported blockchain network. This module provides a strongly-typed
8//! enum to prevent invalid domain IDs at compile time.
9//!
10//! Reference: <https://developers.circle.com/stablecoins/evm-smart-contracts>
11
12use serde::{Deserialize, Serialize};
13use std::fmt;
14use thiserror::Error;
15
16/// CCTP domain identifier for blockchain networks
17///
18/// Each blockchain network supported by Circle's CCTP has a unique domain ID.
19/// This enum provides type-safe representation of these identifiers.
20///
21/// # CCTP Version Support
22///
23/// - Domains 0-10: Supported in CCTP v1 and v2
24/// - Domains 11+: Only supported in CCTP v2
25///
26/// # Serialization Compatibility
27///
28/// This enum serializes as `snake_case` strings such as `"ethereum"` and `"base"`.
29/// Because the enum is `#[non_exhaustive]`, future releases may add new variants.
30/// Older versions of the crate will reject JSON containing a domain string they do
31/// not yet know about.
32///
33/// # Example
34///
35/// ```rust
36/// use cctp_rs::DomainId;
37///
38/// let ethereum_domain = DomainId::Ethereum;
39/// let domain_value: u32 = ethereum_domain.into();
40/// assert_eq!(domain_value, 0);
41/// ```
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[repr(u32)]
45#[non_exhaustive]
46pub enum DomainId {
47    /// Ethereum mainnet and Sepolia testnet (Domain ID: 0)
48    Ethereum = 0,
49    /// Avalanche C-Chain (Domain ID: 1)
50    Avalanche = 1,
51    /// Optimism (Domain ID: 2)
52    Optimism = 2,
53    /// Arbitrum One and Arbitrum Sepolia (Domain ID: 3)
54    Arbitrum = 3,
55    /// Solana (Domain ID: 5) - Non-EVM chain, v2 only
56    Solana = 5,
57    /// Base and Base Sepolia (Domain ID: 6)
58    Base = 6,
59    /// Polygon `PoS` (Domain ID: 7)
60    Polygon = 7,
61    /// Unichain (Domain ID: 10)
62    Unichain = 10,
63    /// Linea (Domain ID: 11) - v2 only
64    Linea = 11,
65    /// Codex (Domain ID: 12) - v2 only
66    Codex = 12,
67    /// Sonic (Domain ID: 13) - v2 only
68    Sonic = 13,
69    /// World Chain (Domain ID: 14) - v2 only
70    WorldChain = 14,
71    /// Monad (Domain ID: 15) - v2 only
72    Monad = 15,
73    /// Sei (Domain ID: 16) - v2 only
74    Sei = 16,
75    /// BNB Smart Chain (Domain ID: 17) - v2 only
76    BnbSmartChain = 17,
77    /// XDC Network (Domain ID: 18) - v2 only
78    Xdc = 18,
79    /// `HyperEVM` (Domain ID: 19) - v2 only
80    HyperEvm = 19,
81    /// Ink (Domain ID: 21) - v2 only
82    Ink = 21,
83    /// Plume (Domain ID: 22) - v2 only
84    Plume = 22,
85    /// Starknet Testnet (Domain ID: 25) - Non-EVM chain, v2 only
86    StarknetTestnet = 25,
87    /// Arc Testnet (Domain ID: 26) - v2 only
88    ArcTestnet = 26,
89}
90
91impl DomainId {
92    /// Returns the numeric domain ID value
93    ///
94    /// # Example
95    ///
96    /// ```rust
97    /// use cctp_rs::DomainId;
98    ///
99    /// assert_eq!(DomainId::Ethereum.as_u32(), 0);
100    /// assert_eq!(DomainId::Arbitrum.as_u32(), 3);
101    /// ```
102    #[inline]
103    #[must_use]
104    pub const fn as_u32(self) -> u32 {
105        self as u32
106    }
107
108    /// Attempts to create a `DomainId` from a u32 value
109    ///
110    /// # Example
111    ///
112    /// ```rust
113    /// use cctp_rs::DomainId;
114    ///
115    /// assert_eq!(DomainId::from_u32(0), Some(DomainId::Ethereum));
116    /// assert_eq!(DomainId::from_u32(3), Some(DomainId::Arbitrum));
117    /// assert_eq!(DomainId::from_u32(11), Some(DomainId::Linea));
118    /// assert_eq!(DomainId::from_u32(999), None);
119    /// ```
120    #[inline]
121    #[must_use]
122    pub const fn from_u32(value: u32) -> Option<Self> {
123        match value {
124            0 => Some(Self::Ethereum),
125            1 => Some(Self::Avalanche),
126            2 => Some(Self::Optimism),
127            3 => Some(Self::Arbitrum),
128            5 => Some(Self::Solana),
129            6 => Some(Self::Base),
130            7 => Some(Self::Polygon),
131            10 => Some(Self::Unichain),
132            11 => Some(Self::Linea),
133            12 => Some(Self::Codex),
134            13 => Some(Self::Sonic),
135            14 => Some(Self::WorldChain),
136            15 => Some(Self::Monad),
137            16 => Some(Self::Sei),
138            17 => Some(Self::BnbSmartChain),
139            18 => Some(Self::Xdc),
140            19 => Some(Self::HyperEvm),
141            21 => Some(Self::Ink),
142            22 => Some(Self::Plume),
143            25 => Some(Self::StarknetTestnet),
144            26 => Some(Self::ArcTestnet),
145            _ => None,
146        }
147    }
148
149    /// Returns the chain name as a string
150    ///
151    /// # Example
152    ///
153    /// ```rust
154    /// use cctp_rs::DomainId;
155    ///
156    /// assert_eq!(DomainId::Ethereum.name(), "Ethereum");
157    /// assert_eq!(DomainId::Arbitrum.name(), "Arbitrum");
158    /// assert_eq!(DomainId::Linea.name(), "Linea");
159    /// ```
160    #[inline]
161    #[must_use]
162    pub const fn name(self) -> &'static str {
163        match self {
164            Self::Ethereum => "Ethereum",
165            Self::Avalanche => "Avalanche",
166            Self::Optimism => "Optimism",
167            Self::Arbitrum => "Arbitrum",
168            Self::Solana => "Solana",
169            Self::Base => "Base",
170            Self::Polygon => "Polygon",
171            Self::Unichain => "Unichain",
172            Self::Linea => "Linea",
173            Self::Codex => "Codex",
174            Self::Sonic => "Sonic",
175            Self::WorldChain => "World Chain",
176            Self::Monad => "Monad",
177            Self::Sei => "Sei",
178            Self::BnbSmartChain => "BNB Smart Chain",
179            Self::Xdc => "XDC",
180            Self::HyperEvm => "HyperEVM",
181            Self::Ink => "Ink",
182            Self::Plume => "Plume",
183            Self::StarknetTestnet => "Starknet Testnet",
184            Self::ArcTestnet => "Arc Testnet",
185        }
186    }
187
188    /// Returns true if this domain currently uses the SDK's EVM address conventions.
189    ///
190    /// This is primarily useful when interpreting `bytes32` address fields from
191    /// canonical CCTP v2 messages. Non-EVM domains may use a different encoding.
192    #[inline]
193    #[must_use]
194    pub const fn is_evm(self) -> bool {
195        !matches!(self, Self::Solana | Self::StarknetTestnet)
196    }
197}
198
199impl From<DomainId> for u32 {
200    #[inline]
201    fn from(domain: DomainId) -> Self {
202        domain.as_u32()
203    }
204}
205
206impl TryFrom<u32> for DomainId {
207    type Error = InvalidDomainId;
208
209    #[inline]
210    fn try_from(value: u32) -> Result<Self, Self::Error> {
211        Self::from_u32(value).ok_or(InvalidDomainId(value))
212    }
213}
214
215impl fmt::Display for DomainId {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "{} ({})", self.name(), self.as_u32())
218    }
219}
220
221/// Error returned when attempting to convert an invalid u32 to a `DomainId`
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
223#[error("invalid CCTP domain ID: {0}")]
224pub struct InvalidDomainId(pub u32);
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_domain_id_values() {
232        // v1 and v2 chains
233        assert_eq!(DomainId::Ethereum.as_u32(), 0);
234        assert_eq!(DomainId::Avalanche.as_u32(), 1);
235        assert_eq!(DomainId::Optimism.as_u32(), 2);
236        assert_eq!(DomainId::Arbitrum.as_u32(), 3);
237        assert_eq!(DomainId::Base.as_u32(), 6);
238        assert_eq!(DomainId::Polygon.as_u32(), 7);
239        assert_eq!(DomainId::Unichain.as_u32(), 10);
240
241        // v2 only chains
242        assert_eq!(DomainId::Solana.as_u32(), 5);
243        assert_eq!(DomainId::Linea.as_u32(), 11);
244        assert_eq!(DomainId::Codex.as_u32(), 12);
245        assert_eq!(DomainId::Sonic.as_u32(), 13);
246        assert_eq!(DomainId::WorldChain.as_u32(), 14);
247        assert_eq!(DomainId::Monad.as_u32(), 15);
248        assert_eq!(DomainId::Sei.as_u32(), 16);
249        assert_eq!(DomainId::BnbSmartChain.as_u32(), 17);
250        assert_eq!(DomainId::Xdc.as_u32(), 18);
251        assert_eq!(DomainId::HyperEvm.as_u32(), 19);
252        assert_eq!(DomainId::Ink.as_u32(), 21);
253        assert_eq!(DomainId::Plume.as_u32(), 22);
254        assert_eq!(DomainId::StarknetTestnet.as_u32(), 25);
255        assert_eq!(DomainId::ArcTestnet.as_u32(), 26);
256    }
257
258    #[test]
259    fn test_from_u32_valid() {
260        // v1 and v2 chains
261        assert_eq!(DomainId::from_u32(0), Some(DomainId::Ethereum));
262        assert_eq!(DomainId::from_u32(1), Some(DomainId::Avalanche));
263        assert_eq!(DomainId::from_u32(2), Some(DomainId::Optimism));
264        assert_eq!(DomainId::from_u32(3), Some(DomainId::Arbitrum));
265        assert_eq!(DomainId::from_u32(6), Some(DomainId::Base));
266        assert_eq!(DomainId::from_u32(7), Some(DomainId::Polygon));
267        assert_eq!(DomainId::from_u32(10), Some(DomainId::Unichain));
268
269        // v2 only chains - priority chains
270        assert_eq!(DomainId::from_u32(11), Some(DomainId::Linea));
271        assert_eq!(DomainId::from_u32(13), Some(DomainId::Sonic));
272        assert_eq!(DomainId::from_u32(16), Some(DomainId::Sei));
273        assert_eq!(DomainId::from_u32(17), Some(DomainId::BnbSmartChain));
274
275        // v2 only chains - other
276        assert_eq!(DomainId::from_u32(5), Some(DomainId::Solana));
277        assert_eq!(DomainId::from_u32(12), Some(DomainId::Codex));
278        assert_eq!(DomainId::from_u32(14), Some(DomainId::WorldChain));
279        assert_eq!(DomainId::from_u32(15), Some(DomainId::Monad));
280        assert_eq!(DomainId::from_u32(18), Some(DomainId::Xdc));
281        assert_eq!(DomainId::from_u32(19), Some(DomainId::HyperEvm));
282        assert_eq!(DomainId::from_u32(21), Some(DomainId::Ink));
283        assert_eq!(DomainId::from_u32(22), Some(DomainId::Plume));
284        assert_eq!(DomainId::from_u32(25), Some(DomainId::StarknetTestnet));
285        assert_eq!(DomainId::from_u32(26), Some(DomainId::ArcTestnet));
286    }
287
288    #[test]
289    fn test_from_u32_invalid() {
290        // Test gaps in domain ID space
291        assert_eq!(DomainId::from_u32(4), None); // Gap
292        assert_eq!(DomainId::from_u32(8), None); // Gap
293        assert_eq!(DomainId::from_u32(9), None); // Gap
294        assert_eq!(DomainId::from_u32(20), None); // Gap
295        assert_eq!(DomainId::from_u32(23), None); // Gap
296        assert_eq!(DomainId::from_u32(24), None); // Gap
297        assert_eq!(DomainId::from_u32(27), None); // Beyond current
298        assert_eq!(DomainId::from_u32(999), None); // Way beyond
299    }
300
301    #[test]
302    fn test_try_from_valid() {
303        assert_eq!(DomainId::try_from(0).unwrap(), DomainId::Ethereum);
304        assert_eq!(DomainId::try_from(3).unwrap(), DomainId::Arbitrum);
305    }
306
307    #[test]
308    fn test_try_from_invalid() {
309        assert!(DomainId::try_from(999).is_err());
310        let err = DomainId::try_from(999).unwrap_err();
311        assert_eq!(err, InvalidDomainId(999));
312    }
313
314    #[test]
315    fn test_display() {
316        assert_eq!(format!("{}", DomainId::Ethereum), "Ethereum (0)");
317        assert_eq!(format!("{}", DomainId::Arbitrum), "Arbitrum (3)");
318        assert_eq!(format!("{}", DomainId::Base), "Base (6)");
319    }
320
321    #[test]
322    fn test_name() {
323        assert_eq!(DomainId::Ethereum.name(), "Ethereum");
324        assert_eq!(DomainId::Arbitrum.name(), "Arbitrum");
325        assert_eq!(DomainId::Avalanche.name(), "Avalanche");
326    }
327
328    #[test]
329    fn test_is_evm() {
330        assert!(DomainId::Ethereum.is_evm());
331        assert!(DomainId::Base.is_evm());
332        assert!(!DomainId::Solana.is_evm());
333        assert!(!DomainId::StarknetTestnet.is_evm());
334    }
335
336    #[test]
337    fn test_conversion_roundtrip() {
338        for domain in [
339            // v1 and v2 chains
340            DomainId::Ethereum,
341            DomainId::Avalanche,
342            DomainId::Optimism,
343            DomainId::Arbitrum,
344            DomainId::Base,
345            DomainId::Polygon,
346            DomainId::Unichain,
347            // v2 only chains
348            DomainId::Solana,
349            DomainId::Linea,
350            DomainId::Codex,
351            DomainId::Sonic,
352            DomainId::WorldChain,
353            DomainId::Monad,
354            DomainId::Sei,
355            DomainId::BnbSmartChain,
356            DomainId::Xdc,
357            DomainId::HyperEvm,
358            DomainId::Ink,
359            DomainId::Plume,
360            DomainId::StarknetTestnet,
361            DomainId::ArcTestnet,
362        ] {
363            let value: u32 = domain.into();
364            let parsed = DomainId::try_from(value).unwrap();
365            assert_eq!(domain, parsed);
366        }
367    }
368}