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}