Skip to main content

cctp_rs/bridge/
transfer_mode.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4//! CCTP v2 transfer mode selection.
5//!
6//! Circle's v2 `depositForBurn` family exposes two orthogonal choices: the
7//! finality threshold (fast vs. standard) and whether the burn carries hook
8//! data for a post-mint action on the destination chain. This module encodes
9//! the four valid combinations as a single enum so the mode is explicit at
10//! the API boundary instead of being inferred from independent flags.
11//!
12//! Both `depositForBurn` and `depositForBurnWithHook` accept `maxFee` and
13//! `minFinalityThreshold` on-chain, so pairing hooks with fast finality is
14//! supported by the protocol — see Circle's CCTP v2 reference at
15//! <https://developers.circle.com/cctp/evm-smart-contracts>.
16//!
17//! [`TransferMode::Standard`] is the default and incurs no fast-transfer fee.
18
19use alloy_primitives::{Bytes, U256};
20
21use crate::protocol::FinalityThreshold;
22
23/// Selects which CCTP v2 burn variant the bridge sends.
24///
25/// Replaces the legacy independent `fast_transfer` / `hook_data` / `max_fee`
26/// fields. The enum captures the exact wire shape of the four valid
27/// configurations and makes the relationship between fee, finality, and hook
28/// data unambiguous at the type level.
29///
30/// # Examples
31///
32/// ```rust
33/// use cctp_rs::{FinalityThreshold, TransferMode};
34/// use alloy_primitives::{Bytes, U256};
35///
36/// let standard = TransferMode::Standard;
37/// assert_eq!(standard.finality_threshold(), FinalityThreshold::Standard);
38/// assert!(standard.hook_data().is_none());
39///
40/// let fast = TransferMode::Fast { max_fee: U256::from(500) };
41/// assert_eq!(fast.finality_threshold(), FinalityThreshold::Fast);
42/// assert_eq!(fast.max_fee(), U256::from(500));
43///
44/// let fast_with_hook = TransferMode::FastWithHook {
45///     max_fee: U256::from(500),
46///     hook_data: Bytes::from(vec![0xde, 0xad]),
47/// };
48/// assert!(fast_with_hook.is_fast());
49/// assert_eq!(fast_with_hook.hook_data().map(|b| b.len()), Some(2));
50/// ```
51#[derive(Debug, Default, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum TransferMode {
54    /// Plain burn at finalized finality (threshold 2000), no hooks.
55    ///
56    /// Settlement matches v1 timing (13–19 min). No fast-transfer fee.
57    #[default]
58    Standard,
59
60    /// Fast burn at confirmed finality (threshold 1000), no hooks.
61    ///
62    /// `max_fee` caps the fast-transfer fee Circle's relayers may deduct
63    /// from the minted amount. A fee below the chain's minimum will leave
64    /// the burn pending until enough finality accrues for the standard
65    /// path.
66    Fast {
67        /// Maximum fast-transfer fee, in USDC atomic units.
68        max_fee: U256,
69    },
70
71    /// Burn carrying hook data at finalized finality.
72    ///
73    /// The hook data is opaque to CCTP and runs on the destination chain
74    /// after the mint.
75    StandardWithHook {
76        /// Hook payload forwarded to the destination chain.
77        hook_data: Bytes,
78    },
79
80    /// Burn carrying hook data at confirmed (fast) finality.
81    ///
82    /// Combines a fast-finality attestation with a post-mint hook. Both
83    /// `maxFee` and `hookData` are passed through to
84    /// `depositForBurnWithHook` on-chain.
85    FastWithHook {
86        /// Maximum fast-transfer fee, in USDC atomic units.
87        max_fee: U256,
88        /// Hook payload forwarded to the destination chain.
89        hook_data: Bytes,
90    },
91}
92
93impl TransferMode {
94    /// Returns the finality threshold this mode requests from Circle.
95    #[inline]
96    #[must_use]
97    pub const fn finality_threshold(&self) -> FinalityThreshold {
98        match self {
99            Self::Standard | Self::StandardWithHook { .. } => FinalityThreshold::Standard,
100            Self::Fast { .. } | Self::FastWithHook { .. } => FinalityThreshold::Fast,
101        }
102    }
103
104    /// Returns `true` when the mode requests fast (confirmed) finality.
105    #[inline]
106    #[must_use]
107    pub const fn is_fast(&self) -> bool {
108        match self {
109            Self::Fast { .. } | Self::FastWithHook { .. } => true,
110            Self::Standard | Self::StandardWithHook { .. } => false,
111        }
112    }
113
114    /// Returns `true` when the mode carries hook data.
115    #[inline]
116    #[must_use]
117    pub const fn has_hook(&self) -> bool {
118        match self {
119            Self::StandardWithHook { .. } | Self::FastWithHook { .. } => true,
120            Self::Standard | Self::Fast { .. } => false,
121        }
122    }
123
124    /// Returns the fast-transfer fee cap.
125    ///
126    /// Defaults to `U256::ZERO` for standard modes, which is what the
127    /// on-chain call expects when fast transfer is not requested.
128    #[inline]
129    #[must_use]
130    pub fn max_fee(&self) -> U256 {
131        match self {
132            Self::Fast { max_fee } | Self::FastWithHook { max_fee, .. } => *max_fee,
133            Self::Standard | Self::StandardWithHook { .. } => U256::ZERO,
134        }
135    }
136
137    /// Returns the hook payload, if any.
138    #[inline]
139    #[must_use]
140    pub const fn hook_data(&self) -> Option<&Bytes> {
141        match self {
142            Self::StandardWithHook { hook_data } | Self::FastWithHook { hook_data, .. } => {
143                Some(hook_data)
144            }
145            Self::Standard | Self::Fast { .. } => None,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn standard_defaults() {
156        let mode = TransferMode::default();
157        assert_eq!(mode, TransferMode::Standard);
158        assert_eq!(mode.finality_threshold(), FinalityThreshold::Standard);
159        assert!(!mode.is_fast());
160        assert!(!mode.has_hook());
161        assert_eq!(mode.max_fee(), U256::ZERO);
162        assert!(mode.hook_data().is_none());
163    }
164
165    #[test]
166    fn fast_carries_fee() {
167        let mode = TransferMode::Fast {
168            max_fee: U256::from(1234),
169        };
170        assert_eq!(mode.finality_threshold(), FinalityThreshold::Fast);
171        assert!(mode.is_fast());
172        assert!(!mode.has_hook());
173        assert_eq!(mode.max_fee(), U256::from(1234));
174        assert!(mode.hook_data().is_none());
175    }
176
177    #[test]
178    fn standard_with_hook_keeps_standard_finality() {
179        let hook = Bytes::from(vec![1, 2, 3]);
180        let mode = TransferMode::StandardWithHook {
181            hook_data: hook.clone(),
182        };
183        assert_eq!(mode.finality_threshold(), FinalityThreshold::Standard);
184        assert!(!mode.is_fast());
185        assert!(mode.has_hook());
186        assert_eq!(mode.max_fee(), U256::ZERO);
187        assert_eq!(mode.hook_data(), Some(&hook));
188    }
189
190    #[test]
191    fn fast_with_hook_combines_both() {
192        let hook = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]);
193        let mode = TransferMode::FastWithHook {
194            max_fee: U256::from(500),
195            hook_data: hook.clone(),
196        };
197        assert_eq!(mode.finality_threshold(), FinalityThreshold::Fast);
198        assert!(mode.is_fast());
199        assert!(mode.has_hook());
200        assert_eq!(mode.max_fee(), U256::from(500));
201        assert_eq!(mode.hook_data(), Some(&hook));
202    }
203}