1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct FeeBps {
23 hundredths: u32,
24}
25
26impl FeeBps {
27 #[must_use]
31 pub const fn from_hundredths(hundredths: u32) -> Self {
32 Self { hundredths }
33 }
34
35 #[must_use]
37 pub const fn as_hundredths(self) -> u32 {
38 self.hundredths
39 }
40
41 #[must_use]
46 pub const fn whole_bps(self) -> u32 {
47 self.hundredths / 100
48 }
49
50 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct TransferFee {
179 pub finality_threshold: u32,
181 pub minimum_fee: FeeBps,
183}
184
185impl TransferFee {
186 #[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 #[must_use]
198 pub const fn finality(self) -> Option<FinalityThreshold> {
199 FinalityThreshold::from_u32(self.finality_threshold)
200 }
201
202 #[must_use]
204 pub const fn is_fast_transfer(self) -> bool {
205 matches!(self.finality(), Some(FinalityThreshold::Fast))
206 }
207
208 #[must_use]
210 pub const fn is_standard_transfer(self) -> bool {
211 matches!(self.finality(), Some(FinalityThreshold::Standard))
212 }
213
214 #[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}