Skip to main content

lexe_common/
ppm.rs

1//! A "parts per million" (ppm) newtype for proportional fee rates.
2//!
3//! PPM values represent a proportion where 1_000_000 ppm = 100%.
4//! Valid range: 0 to 1_000_000 inclusive.
5//!
6//! ### Calculating fees
7//!
8//! Multiply an [`Amount`](crate::ln::amount::Amount) by a
9//! [`Ppm`](crate::ppm::Ppm) to get the fee:
10//!
11//! ```
12//! # use lexe_common::ppm::Ppm;
13//! # use lexe_common::ln::amount::Amount;
14//! let amount = Amount::from_sats_u32(100_000);
15//! let fee_rate = Ppm::new(3000); // 0.3%
16//! let fee = amount * fee_rate;
17//! assert_eq!(fee, Amount::from_sats_u32(300));
18//! ```
19//!
20//! ### Defining constants
21//!
22//! Use [`Ppm::new`](crate::ppm::Ppm::new) for compile-time validated constants:
23//!
24//! ```
25//! # use lexe_common::ppm::Ppm;
26//! const MY_FEE_RATE: Ppm = Ppm::new(3000); // 0.3%
27//! ```
28//!
29//! ### Converting to a decimal rate
30//!
31//! [`Ppm::to_decimal`](crate::ppm::Ppm::to_decimal) returns a
32//! [`Decimal`](rust_decimal::Decimal) rate:
33//!
34//! ```
35//! # use lexe_common::ppm::Ppm;
36//! # use lexe_common::dec;
37//! let rate = Ppm::new(5000).to_decimal(); // 0.5%
38//! assert_eq!(rate, dec!(0.005));
39//! ```
40
41use std::{fmt, ops::Mul, str::FromStr};
42
43use anyhow::format_err;
44use rust_decimal::Decimal;
45use serde::{Deserialize, Serialize};
46
47use crate::{dec, ln::amount::Amount};
48
49/// Errors that can occur when constructing a [`Ppm`].
50#[derive(Debug, thiserror::Error)]
51pub enum Error {
52    #[error("Ppm value is negative")]
53    Negative,
54    #[error("Ppm value exceeds 1_000_000")]
55    TooLarge,
56}
57
58/// A "parts per million" value for proportional fee rates.
59///
60/// Internally stores an `i32` in the range `[0, 1_000_000]`.
61/// 1_000_000 ppm = 100%, so 5000 ppm = 0.5%.
62#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
63#[derive(Serialize, Deserialize)]
64#[serde(try_from = "i32", into = "i32")]
65pub struct Ppm(i32);
66
67impl Ppm {
68    /// The maximum [`Ppm`] value (1_000_000 = 100%).
69    pub const MAX: Self = Self(1_000_000);
70
71    /// A [`Ppm`] of zero.
72    pub const ZERO: Self = Self(0);
73
74    /// Construct a [`Ppm`] from an `i32` value.
75    ///
76    /// # Panics
77    ///
78    /// Panics at compile time (in const context) or runtime if `value` is
79    /// outside the valid range `[0, 1_000_000]`.
80    #[inline]
81    pub const fn new(value: i32) -> Self {
82        assert!(value >= 0, "Ppm value must be non-negative");
83        assert!(value <= Self::MAX.0, "Ppm value must be <= 1_000_000");
84        Self(value)
85    }
86
87    /// Returns the ppm value as an `i32`.
88    #[inline]
89    pub const fn to_i32(self) -> i32 {
90        self.0
91    }
92
93    /// Returns the ppm value as a `u32`.
94    #[inline]
95    pub const fn to_u32(self) -> u32 {
96        self.0 as u32
97    }
98
99    /// Returns the ppm value as a [`Decimal`] rate (ppm / 1_000_000).
100    ///
101    /// For example, 5000 ppm becomes `0.005` (0.5%).
102    #[inline]
103    pub fn to_decimal(self) -> Decimal {
104        Decimal::from(self.0) / dec!(1_000_000)
105    }
106
107    /// Checks bounds, returning [`Self`] if the value is valid.
108    #[inline]
109    fn try_from_inner(value: i32) -> Result<Self, Error> {
110        if value < 0 {
111            Err(Error::Negative)
112        } else if value > Self::MAX.0 {
113            Err(Error::TooLarge)
114        } else {
115            Ok(Self(value))
116        }
117    }
118}
119
120impl fmt::Display for Ppm {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        fmt::Display::fmt(&self.0, f)
123    }
124}
125
126impl FromStr for Ppm {
127    type Err = anyhow::Error;
128
129    fn from_str(s: &str) -> Result<Self, Self::Err> {
130        let value = s.parse::<i32>().map_err(|err| format_err!("{err}"))?;
131        Ok(Self::try_from_inner(value)?)
132    }
133}
134
135// --- Infallible From impls --- //
136
137impl From<u16> for Ppm {
138    /// Infallible conversion from `u16` (max 65535 < 1_000_000).
139    #[inline]
140    fn from(value: u16) -> Self {
141        Self(i32::from(value))
142    }
143}
144
145impl From<Ppm> for i32 {
146    #[inline]
147    fn from(ppm: Ppm) -> Self {
148        ppm.0
149    }
150}
151
152impl From<Ppm> for u32 {
153    #[inline]
154    fn from(ppm: Ppm) -> Self {
155        ppm.0 as u32
156    }
157}
158
159impl From<Ppm> for i64 {
160    #[inline]
161    fn from(ppm: Ppm) -> Self {
162        i64::from(ppm.0)
163    }
164}
165
166impl From<Ppm> for u64 {
167    #[inline]
168    fn from(ppm: Ppm) -> Self {
169        ppm.0 as u64
170    }
171}
172
173// --- Fallible TryFrom impls --- //
174
175impl TryFrom<i32> for Ppm {
176    type Error = Error;
177
178    fn try_from(value: i32) -> Result<Self, Self::Error> {
179        Self::try_from_inner(value)
180    }
181}
182
183impl TryFrom<u32> for Ppm {
184    type Error = Error;
185
186    fn try_from(value: u32) -> Result<Self, Self::Error> {
187        let value_i32 = i32::try_from(value).map_err(|_| Error::TooLarge)?;
188        Self::try_from_inner(value_i32)
189    }
190}
191
192impl TryFrom<Decimal> for Ppm {
193    type Error = Error;
194
195    /// Construct a [`Ppm`] from a [`Decimal`] rate.
196    ///
197    /// The decimal is multiplied by 1_000_000 and rounded to the nearest
198    /// integer. For example, `0.005` (0.5%) becomes 5000 ppm.
199    ///
200    /// Returns an error if the result is negative or exceeds 1_000_000.
201    fn try_from(rate: Decimal) -> Result<Self, Self::Error> {
202        use rust_decimal::prelude::ToPrimitive;
203
204        let ppm_dec = (rate * dec!(1_000_000)).round();
205        let ppm_i32 = ppm_dec.to_i32().ok_or(Error::TooLarge)?;
206        Self::try_from_inner(ppm_i32)
207    }
208}
209
210// --- Mul impls for fee calculation --- //
211//
212// These impls can never panic: Ppm is bounded to [0, 1_000_000] representing
213// [0%, 100%], so multiplying a valid Amount by a Ppm always produces a result
214// ≤ the original Amount.
215
216/// Amount * Ppm => Amount (fee calculation)
217impl Mul<Ppm> for Amount {
218    type Output = Self;
219
220    #[inline]
221    fn mul(self, rhs: Ppm) -> Self::Output {
222        self * rhs.to_decimal()
223    }
224}
225
226/// Ppm * Amount => Amount (fee calculation, commutative)
227impl Mul<Amount> for Ppm {
228    type Output = Amount;
229
230    #[inline]
231    fn mul(self, rhs: Amount) -> Self::Output {
232        rhs * self.to_decimal()
233    }
234}
235
236// --- Arbitrary impl --- //
237
238#[cfg(any(test, feature = "test-utils"))]
239impl proptest::arbitrary::Arbitrary for Ppm {
240    type Parameters = ();
241    type Strategy = proptest::strategy::BoxedStrategy<Self>;
242
243    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
244        use proptest::strategy::Strategy;
245        (0i32..=Self::MAX.0).prop_map(Self).boxed()
246    }
247}
248
249// --- Tests --- //
250
251#[cfg(test)]
252mod test {
253    use proptest::{arbitrary::any, prop_assert, prop_assert_eq, proptest};
254
255    use super::*;
256
257    #[test]
258    fn const_construction() {
259        /// Test const construction.
260        const TEST_PPM: Ppm = Ppm::new(3000);
261
262        assert_eq!(TEST_PPM.to_i32(), 3000);
263        assert_eq!(Ppm::ZERO.to_i32(), 0);
264        assert_eq!(Ppm::MAX.to_i32(), 1_000_000);
265    }
266
267    #[test]
268    fn to_decimal() {
269        assert_eq!(Ppm::ZERO.to_decimal(), dec!(0));
270        assert_eq!(Ppm::new(1).to_decimal(), dec!(0.000001));
271        assert_eq!(Ppm::new(1000).to_decimal(), dec!(0.001));
272        assert_eq!(Ppm::new(10_000).to_decimal(), dec!(0.01));
273        assert_eq!(Ppm::new(100_000).to_decimal(), dec!(0.1));
274        assert_eq!(Ppm::MAX.to_decimal(), dec!(1));
275    }
276
277    #[test]
278    fn try_from_decimal() {
279        // Basic conversions
280        assert_eq!(Ppm::try_from(dec!(0)).unwrap(), Ppm::ZERO);
281        assert_eq!(Ppm::try_from(dec!(0.005)).unwrap(), Ppm::new(5000));
282        assert_eq!(Ppm::try_from(dec!(0.1)).unwrap(), Ppm::new(100_000));
283        assert_eq!(Ppm::try_from(dec!(1)).unwrap(), Ppm::MAX);
284
285        // Rounding
286        assert_eq!(Ppm::try_from(dec!(0.0000014)).unwrap(), Ppm::new(1));
287        assert_eq!(Ppm::try_from(dec!(0.0000016)).unwrap(), Ppm::new(2));
288
289        // Errors
290        assert!(matches!(Ppm::try_from(dec!(-0.001)), Err(Error::Negative)));
291        assert!(matches!(
292            Ppm::try_from(dec!(1.000001)),
293            Err(Error::TooLarge)
294        ));
295    }
296
297    #[test]
298    fn try_from_rejects_invalid() {
299        assert!(matches!(Ppm::try_from(-1i32), Err(Error::Negative)));
300        assert!(matches!(Ppm::try_from(1_000_001i32), Err(Error::TooLarge)));
301        assert!(matches!(Ppm::try_from(1_000_001u32), Err(Error::TooLarge)));
302    }
303
304    #[test]
305    fn from_str() {
306        assert_eq!("0".parse::<Ppm>().unwrap(), Ppm::ZERO);
307        assert_eq!("3000".parse::<Ppm>().unwrap(), Ppm::new(3000));
308        assert_eq!("1000000".parse::<Ppm>().unwrap(), Ppm::MAX);
309
310        assert!("-1".parse::<Ppm>().is_err());
311        assert!("1000001".parse::<Ppm>().is_err());
312        assert!("abc".parse::<Ppm>().is_err());
313    }
314
315    /// Verifies JSON format is a bare integer, not an object.
316    #[test]
317    fn serde_json_format() {
318        #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
319        struct Foo {
320            ppm: Ppm,
321        }
322
323        let foo = Foo {
324            ppm: Ppm::new(3000),
325        };
326        let json = serde_json::to_string(&foo).unwrap();
327        assert_eq!(json, r#"{"ppm":3000}"#);
328        let roundtrip: Foo = serde_json::from_str(&json).unwrap();
329        assert_eq!(foo, roundtrip);
330
331        // Rejects invalid values
332        assert!(serde_json::from_str::<Ppm>("-1").is_err());
333        assert!(serde_json::from_str::<Ppm>("1000001").is_err());
334    }
335
336    #[test]
337    fn proptest_integer_conversions() {
338        proptest!(|(ppm in any::<Ppm>(), val in any::<u16>())| {
339            let i = ppm.to_i32();
340
341            // All integer conversions agree
342            prop_assert_eq!(i32::from(ppm), i);
343            prop_assert_eq!(u32::from(ppm), i as u32);
344            prop_assert_eq!(i64::from(ppm), i64::from(i));
345            prop_assert_eq!(u64::from(ppm), i as u64);
346
347            // TryFrom roundtrips
348            prop_assert_eq!(Ppm::try_from(i).unwrap(), ppm);
349            prop_assert_eq!(Ppm::try_from(i as u32).unwrap(), ppm);
350
351            // From<u16> always succeeds (max 65535 < 1_000_000)
352            let from_u16 = Ppm::from(val);
353            prop_assert_eq!(from_u16.to_i32(), i32::from(val));
354        });
355    }
356
357    #[test]
358    fn proptest_mul_amount() {
359        proptest!(|(amount in any::<Amount>(), ppm in any::<Ppm>())| {
360            // Commutative: amount * ppm == ppm * amount
361            prop_assert_eq!(amount * ppm, ppm * amount);
362
363            // Equivalent to multiplying by the decimal rate
364            prop_assert_eq!(amount * ppm, amount * ppm.to_decimal());
365        });
366    }
367
368    #[test]
369    fn proptest_serde_roundtrip() {
370        proptest!(|(ppm in any::<Ppm>())| {
371            let json = serde_json::to_string(&ppm).unwrap();
372            let roundtrip: Ppm = serde_json::from_str(&json).unwrap();
373            prop_assert_eq!(ppm, roundtrip);
374        });
375    }
376
377    #[test]
378    fn proptest_decimal_roundtrip() {
379        proptest!(|(ppm in any::<Ppm>())| {
380            let dec = ppm.to_decimal();
381
382            // Decimal is in [0, 1]
383            prop_assert!(dec >= Decimal::ZERO);
384            prop_assert!(dec <= Decimal::ONE);
385
386            // Roundtrip: Ppm -> Decimal -> Ppm
387            let roundtrip = Ppm::try_from(dec).unwrap();
388            prop_assert_eq!(ppm, roundtrip);
389        });
390    }
391}