Skip to main content

cctp_rs/protocol/
fees.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4//! CCTP v2 transfer fee types.
5
6use alloy_primitives::U256;
7use serde::de::{self, Unexpected, Visitor};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt;
10
11use super::FinalityThreshold;
12
13const BPS_HUNDREDTH_DENOMINATOR: u64 = 1_000_000;
14const BUFFER_PERCENT_DENOMINATOR: u64 = 100;
15
16/// A CCTP transfer fee in basis points, stored as hundredths of a basis point.
17///
18/// Circle's fee API returns `minimumFee` in basis points. Some published routes
19/// use fractional values such as `1.3` bps, so this type avoids forcing callers
20/// through integer-only basis points when calculating `maxFee`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct FeeBps {
23    hundredths: u32,
24}
25
26impl FeeBps {
27    /// Creates a fee from hundredths of a basis point.
28    ///
29    /// `FeeBps::from_hundredths(130)` represents `1.3` bps.
30    #[must_use]
31    pub const fn from_hundredths(hundredths: u32) -> Self {
32        Self { hundredths }
33    }
34
35    /// Returns the fee in hundredths of a basis point.
36    #[must_use]
37    pub const fn as_hundredths(self) -> u32 {
38        self.hundredths
39    }
40
41    /// Returns the whole-basis-point component of the fee.
42    ///
43    /// Fractional basis points are truncated. Use [`Self::as_hundredths`] for
44    /// exact calculations.
45    #[must_use]
46    pub const fn whole_bps(self) -> u32 {
47        self.hundredths / 100
48    }
49
50    /// Calculates the fee amount in burn-token atomic units.
51    ///
52    /// The result is rounded up to avoid returning a cap below the route's
53    /// minimum fee when the percentage produces a fractional atomic unit.
54    #[must_use]
55    pub fn apply_to_amount(self, amount: U256) -> U256 {
56        ceil_div(
57            amount * U256::from(self.hundredths),
58            U256::from(BPS_HUNDREDTH_DENOMINATOR),
59        )
60    }
61
62    /// Calculates the fee amount with a percentage buffer.
63    ///
64    /// `buffer_percent = 20` returns `fee * 1.2`, rounded up to an atomic unit.
65    #[must_use]
66    pub fn apply_to_amount_with_buffer_percent(self, amount: U256, buffer_percent: u32) -> U256 {
67        let base_fee = self.apply_to_amount(amount);
68        let multiplier = U256::from(u64::from(buffer_percent) + BUFFER_PERCENT_DENOMINATOR);
69        ceil_div(
70            base_fee * multiplier,
71            U256::from(BUFFER_PERCENT_DENOMINATOR),
72        )
73    }
74}
75
76impl fmt::Display for FeeBps {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        let whole = self.hundredths / 100;
79        let fractional = self.hundredths % 100;
80
81        if fractional == 0 {
82            write!(f, "{whole}")
83        } else if fractional.is_multiple_of(10) {
84            write!(f, "{}.{}", whole, fractional / 10)
85        } else {
86            write!(f, "{whole}.{fractional:02}")
87        }
88    }
89}
90
91impl Serialize for FeeBps {
92    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
93    where
94        S: Serializer,
95    {
96        if self.hundredths.is_multiple_of(100) {
97            serializer.serialize_u32(self.hundredths / 100)
98        } else {
99            serializer.serialize_f64(f64::from(self.hundredths) / 100.0)
100        }
101    }
102}
103
104impl<'de> Deserialize<'de> for FeeBps {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: Deserializer<'de>,
108    {
109        struct FeeBpsVisitor;
110
111        impl Visitor<'_> for FeeBpsVisitor {
112            type Value = FeeBps;
113
114            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115                formatter.write_str("a non-negative basis point number")
116            }
117
118            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
119            where
120                E: de::Error,
121            {
122                let bps = u32::try_from(value).map_err(|_| {
123                    de::Error::invalid_value(Unexpected::Unsigned(value), &"u32-sized fee")
124                })?;
125                bps.checked_mul(100)
126                    .map(FeeBps::from_hundredths)
127                    .ok_or_else(|| de::Error::custom("fee basis points overflowed u32"))
128            }
129
130            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
131            where
132                E: de::Error,
133            {
134                let unsigned = u64::try_from(value).map_err(|_| {
135                    de::Error::invalid_value(
136                        Unexpected::Signed(value),
137                        &"a non-negative basis point number",
138                    )
139                })?;
140                self.visit_u64(unsigned)
141            }
142
143            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
144            where
145                E: de::Error,
146            {
147                if !value.is_finite() || value.is_sign_negative() {
148                    return Err(de::Error::invalid_value(
149                        Unexpected::Float(value),
150                        &"a non-negative finite basis point number",
151                    ));
152                }
153
154                parse_fee_hundredths(&format!("{value}")).map_err(de::Error::custom)
155            }
156
157            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
158            where
159                E: de::Error,
160            {
161                parse_fee_hundredths(value).map_err(de::Error::custom)
162            }
163        }
164
165        deserializer.deserialize_any(FeeBpsVisitor)
166    }
167}
168
169/// A single fee entry from Circle's CCTP v2 transfer-fee API.
170///
171/// The API returns an array of entries shaped like:
172///
173/// ```json
174/// { "finalityThreshold": 1000, "minimumFee": 1.3 }
175/// ```
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct TransferFee {
179    /// Finality threshold this fee applies to.
180    pub finality_threshold: u32,
181    /// Minimum fee in basis points.
182    pub minimum_fee: FeeBps,
183}
184
185impl TransferFee {
186    /// Creates a transfer fee entry.
187    #[must_use]
188    pub const fn new(finality_threshold: u32, minimum_fee: FeeBps) -> Self {
189        Self {
190            finality_threshold,
191            minimum_fee,
192        }
193    }
194
195    /// Returns this entry's typed finality threshold, if it is one of the
196    /// thresholds currently recognized by CCTP v2.
197    #[must_use]
198    pub const fn finality(self) -> Option<FinalityThreshold> {
199        FinalityThreshold::from_u32(self.finality_threshold)
200    }
201
202    /// Returns true when this entry applies to fast transfers.
203    #[must_use]
204    pub const fn is_fast_transfer(self) -> bool {
205        matches!(self.finality(), Some(FinalityThreshold::Fast))
206    }
207
208    /// Returns true when this entry applies to standard transfers.
209    #[must_use]
210    pub const fn is_standard_transfer(self) -> bool {
211        matches!(self.finality(), Some(FinalityThreshold::Standard))
212    }
213
214    /// Calculates a `maxFee` cap in USDC atomic units with the given buffer.
215    #[must_use]
216    pub fn max_fee_with_buffer_percent(self, amount: U256, buffer_percent: u32) -> U256 {
217        self.minimum_fee
218            .apply_to_amount_with_buffer_percent(amount, buffer_percent)
219    }
220}
221
222fn ceil_div(numerator: U256, denominator: U256) -> U256 {
223    if numerator == U256::ZERO {
224        U256::ZERO
225    } else {
226        ((numerator - U256::from(1)) / denominator) + U256::from(1)
227    }
228}
229
230fn parse_fee_hundredths(input: &str) -> Result<FeeBps, String> {
231    let input = input.trim();
232    if input.is_empty() {
233        return Err("fee cannot be empty".to_string());
234    }
235    if input.starts_with('-') {
236        return Err("fee cannot be negative".to_string());
237    }
238
239    let (whole, fractional) = input.split_once('.').unwrap_or((input, ""));
240    if whole.is_empty() && fractional.is_empty() {
241        return Err("fee must contain digits".to_string());
242    }
243    if !whole.chars().all(|c| c.is_ascii_digit()) {
244        return Err("fee whole component must be numeric".to_string());
245    }
246    if !fractional.chars().all(|c| c.is_ascii_digit()) {
247        return Err("fee fractional component must be numeric".to_string());
248    }
249
250    let whole_bps = if whole.is_empty() {
251        0
252    } else {
253        whole
254            .parse::<u32>()
255            .map_err(|_| "fee whole component overflowed u32".to_string())?
256    };
257    let whole_hundredths = whole_bps
258        .checked_mul(100)
259        .ok_or_else(|| "fee basis points overflowed u32".to_string())?;
260
261    let mut chars = fractional.chars();
262    let tenths = chars.next().and_then(|c| c.to_digit(10)).unwrap_or(0);
263    let hundredths = chars.next().and_then(|c| c.to_digit(10)).unwrap_or(0);
264    let needs_round_up = chars.any(|c| c != '0');
265
266    let fractional_hundredths = (tenths * 10) + hundredths + u32::from(needs_round_up);
267    let total = whole_hundredths
268        .checked_add(fractional_hundredths)
269        .ok_or_else(|| "fee basis points overflowed u32".to_string())?;
270
271    Ok(FeeBps::from_hundredths(total))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn transfer_fee_deserializes_circle_response_shape() {
280        let json = r#"[
281            { "finalityThreshold": 1000, "minimumFee": 1 },
282            { "finalityThreshold": 2000, "minimumFee": 0 }
283        ]"#;
284
285        let fees: Vec<TransferFee> = serde_json::from_str(json).unwrap();
286
287        assert_eq!(
288            fees,
289            vec![
290                TransferFee::new(1000, FeeBps::from_hundredths(100)),
291                TransferFee::new(2000, FeeBps::from_hundredths(0))
292            ]
293        );
294    }
295
296    #[test]
297    fn transfer_fee_deserializes_with_optional_forward_fee_fields() {
298        let json = r#"[
299            {
300                "finalityThreshold": 1000,
301                "minimumFee": 1.3,
302                "forwardFee": {
303                    "relayFee": "123",
304                    "destinationGasOverhead": "456"
305                }
306            }
307        ]"#;
308
309        let fees: Vec<TransferFee> = serde_json::from_str(json).unwrap();
310
311        assert_eq!(
312            fees,
313            vec![TransferFee::new(1000, FeeBps::from_hundredths(130))]
314        );
315    }
316
317    #[test]
318    fn fee_bps_preserves_fractional_basis_points() {
319        let fee: FeeBps = serde_json::from_str("1.3").unwrap();
320
321        assert_eq!(fee.as_hundredths(), 130);
322        assert_eq!(fee.to_string(), "1.3");
323    }
324
325    #[test]
326    fn fee_bps_rounds_tiny_extra_precision_up() {
327        let fee: FeeBps = serde_json::from_str("1.301").unwrap();
328
329        assert_eq!(fee.as_hundredths(), 131);
330    }
331
332    #[test]
333    fn fee_bps_rejects_negative_values() {
334        let result = serde_json::from_str::<FeeBps>("-1");
335
336        assert!(result.is_err());
337    }
338
339    #[test]
340    fn fee_calculation_uses_usdc_atomic_units() {
341        let amount = U256::from(10_500_000u64);
342        let fee = FeeBps::from_hundredths(100);
343
344        assert_eq!(fee.apply_to_amount(amount), U256::from(1050u64));
345        assert_eq!(
346            fee.apply_to_amount_with_buffer_percent(amount, 20),
347            U256::from(1260u64)
348        );
349    }
350
351    #[test]
352    fn fee_calculation_rounds_up_to_avoid_underquoting() {
353        let amount = U256::from(1u64);
354        let fee = FeeBps::from_hundredths(100);
355
356        assert_eq!(fee.apply_to_amount(amount), U256::from(1u64));
357    }
358
359    #[test]
360    fn fee_calculation_handles_zero_and_large_values() {
361        let large_usdc_amount = U256::from(1_000_000_000_000u64);
362        let fractional_fee = FeeBps::from_hundredths(130);
363        let zero_fee = FeeBps::from_hundredths(0);
364
365        assert_eq!(fractional_fee.apply_to_amount(U256::ZERO), U256::ZERO);
366        assert_eq!(
367            zero_fee.apply_to_amount_with_buffer_percent(large_usdc_amount, 20),
368            U256::ZERO
369        );
370        assert_eq!(
371            fractional_fee.apply_to_amount(large_usdc_amount),
372            U256::from(130_000_000u64)
373        );
374        assert_eq!(
375            fractional_fee.apply_to_amount_with_buffer_percent(large_usdc_amount, 20),
376            U256::from(156_000_000u64)
377        );
378    }
379
380    #[test]
381    fn transfer_fee_identifies_known_finality_thresholds() {
382        let fast = TransferFee::new(1000, FeeBps::from_hundredths(100));
383        let standard = TransferFee::new(2000, FeeBps::from_hundredths(0));
384        let unknown = TransferFee::new(1500, FeeBps::from_hundredths(100));
385
386        assert!(fast.is_fast_transfer());
387        assert!(standard.is_standard_transfer());
388        assert_eq!(unknown.finality(), None);
389    }
390}