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 the [`ppm!`] macro for convenient compile-time validated constants:
23//!
24//! ```
25//! # use lexe_common::{ppm, ppm::Ppm};
26//! # use rust_decimal::Decimal;
27//! const A_FEE_RATE_PPM: Ppm = ppm!(3000); // 0.3%
28//! const B_FEE_RATE_PPM: Ppm = ppm!(0.3%); // 0.3%
29//! const C_FEE_RATE_DEC: Decimal = ppm!(3000).to_decimal(); // 0.3%
30//! ```
31//!
32//! ### Converting to a decimal rate or percentage
33//!
34//! [`Ppm::to_decimal`](crate::ppm::Ppm::to_decimal) returns a
35//! [`Decimal`](rust_decimal::Decimal) rate, while
36//! [`Ppm::to_percent`](crate::ppm::Ppm::to_percent) returns a percentage:
37//!
38//! ```
39//! # use lexe_common::{ppm, ppm::Ppm};
40//! # use lexe_common::dec;
41//! let ppm = ppm!(5000);
42//! assert_eq!(ppm.to_decimal(), dec!(0.005));
43//! assert_eq!(ppm.to_percent(), dec!(0.5));
44//! ```
45
46use std::{fmt, ops::Mul, str::FromStr};
47
48use anyhow::format_err;
49use rust_decimal::Decimal;
50use serde::{Deserialize, Serialize};
51
52use crate::{dec, ln::amount::Amount};
53
54/// A convenient, const-friendly way to build a [`Ppm`] from a ppm literal
55/// or percent literal.
56///
57/// Ex: `ppm!(1230)` -> `Ppm::new(1230)`
58/// Ex: `ppm!(0.123%)` -> `Ppm::new(1230)`
59#[macro_export]
60macro_rules! ppm {
61    ($whole:tt . $frac:tt %) => {
62        const { $crate::ppm::Ppm::const_from_percent($crate::dec!($whole . $frac)) }
63    };
64    ($whole:tt %) => {
65        const { $crate::ppm::Ppm::const_from_percent($crate::dec!($whole)) }
66    };
67    ($amount:expr) => {
68        const { $crate::ppm::Ppm::new($amount) }
69    }
70}
71
72/// Errors that can occur when constructing a [`Ppm`].
73#[derive(Debug, thiserror::Error)]
74pub enum Error {
75    #[error("Ppm value is negative")]
76    Negative,
77    #[error("Ppm value exceeds 1_000_000")]
78    TooLarge,
79}
80
81/// A "parts per million" value for proportional fee rates.
82///
83/// Internally stores an `i32` in the range `[0, 1_000_000]`.
84/// 1_000_000 ppm = 100%, so 5000 ppm = 0.5%.
85#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
86#[derive(Serialize, Deserialize)]
87#[serde(try_from = "i32", into = "i32")]
88pub struct Ppm(i32);
89
90impl Ppm {
91    /// The maximum [`Ppm`] value (1_000_000 = 100%).
92    pub const MAX: Self = Self(1_000_000);
93
94    /// A [`Ppm`] of zero.
95    pub const ZERO: Self = Self(0);
96
97    /// Construct a [`Ppm`] from an `i32` value.
98    ///
99    /// # Panics
100    ///
101    /// Panics at compile time (in const context) or runtime if `value` is
102    /// outside the valid range `[0, 1_000_000]`.
103    #[inline]
104    pub const fn new(value: i32) -> Self {
105        assert!(value >= 0, "Ppm value must be non-negative");
106        assert!(value <= Self::MAX.0, "Ppm value must be <= 1_000_000");
107        Self(value)
108    }
109
110    /// Construct a [`Ppm`] from a [`Decimal`] percentage value.
111    ///
112    /// # Panics
113    ///
114    /// Panics at compile time (in const context) or runtime if `pct` is
115    /// outside the valid range `[0.0000, 100.0000]`, or if it is not exactly
116    /// representable as a whole PPM value (e.g., `0.00001%`).
117    #[doc(hidden)]
118    pub const fn const_from_percent(pct: Decimal) -> Self {
119        // `scale + 2` == "move the decimal left 2 places" == `pct / 100.0`
120        let lo = pct.mantissa() as u32;
121        let mid = 0;
122        let hi = 0;
123        Self::const_from_decimal(Decimal::from_parts(
124            lo,
125            mid,
126            hi,
127            false,
128            pct.scale() + 2,
129        ))
130    }
131
132    /// Construct a [`Ppm`] from a [`Decimal`] value.
133    ///
134    /// # Panics
135    ///
136    /// Panics at compile time (in const context) or runtime if `dec` is
137    /// outside the valid range `[0.000000, 1.000000]` and is not exactly
138    /// representable as a PPM (e.g., `0.0000001` requires too much precision).
139    #[doc(hidden)]
140    const fn const_from_decimal(dec: Decimal) -> Self {
141        let scale = dec.scale();
142        // If scale <= 6, then `dec` has <= 6 digits after the decimal point
143        if scale <= 6 {
144            let exp = 6 - scale;
145            let base = 10_i128.pow(exp);
146            let ppm = dec.mantissa() * base;
147            Ppm::new(ppm as i32)
148        } else {
149            // `dec` has >6 digits after the decimal point (though the extra
150            // digits may be `0`)
151            let exp = scale - 6;
152            let base = 10_i128.pow(exp);
153            let mantissa = dec.mantissa();
154
155            // If there is a remainder, then `dec` has extra non-zero digits
156            // and so requires too much precision for a whole PPM repr
157            assert!(mantissa % base == 0);
158
159            let ppm = mantissa / base;
160            Ppm::new(ppm as i32)
161        }
162    }
163
164    /// Construct a [`Ppm`] from a [`Decimal`] value.
165    ///
166    /// The decimal is multiplied by 1_000_000 and rounded to the nearest
167    /// integer. For example, `0.005` (0.5%) becomes 5000 ppm.
168    ///
169    /// Returns an error if the input is negative or exceeds `1.0`.
170    pub fn try_from_decimal(rate: Decimal) -> Result<Self, Error> {
171        use rust_decimal::prelude::ToPrimitive;
172
173        let ppm_dec = (rate * dec!(1_000_000)).round();
174        let ppm_i32 = ppm_dec.to_i32().ok_or(Error::TooLarge)?;
175        Self::try_from_inner(ppm_i32)
176    }
177
178    /// Construct a [`Ppm`] from a [`Decimal`] percentage value.
179    ///
180    /// The decimal is multiplied by 10_000 and rounded to the nearest
181    /// integer. For example, `0.5` (0.5%) becomes 5000 ppm.
182    ///
183    /// Returns an error if the input is negative or exceeds `100.0` (100%).
184    pub fn try_from_percent(pct: Decimal) -> Result<Self, Error> {
185        Self::try_from_decimal(pct / dec!(100))
186    }
187
188    /// Returns the ppm value as an `i32`.
189    #[inline]
190    pub const fn to_i32(self) -> i32 {
191        self.0
192    }
193
194    /// Returns the ppm value as a `u32`.
195    #[inline]
196    pub const fn to_u32(self) -> u32 {
197        self.0 as u32
198    }
199
200    /// Returns the ppm value as a [`Decimal`] rate (ppm / 1_000_000).
201    ///
202    /// For example, 5000 ppm becomes `0.005` (0.5%).
203    #[inline]
204    pub const fn to_decimal(self) -> Decimal {
205        // This is `Decimal::from(self.0) / dec!(1_000_000)` but works in a
206        // `const` context.
207        let lo = self.to_u32();
208        let mid = 0;
209        let hi = 0;
210        let negative = false;
211        let scale = 6;
212        Decimal::from_parts(lo, mid, hi, negative, scale)
213    }
214
215    /// Returns the ppm value as a [`Decimal`] percentage (ppm / 10_000).
216    ///
217    /// For example, 5000 ppm becomes `0.5` (0.5%).
218    #[inline]
219    pub const fn to_percent(self) -> Decimal {
220        let lo = self.to_u32();
221        let mid = 0;
222        let hi = 0;
223        let negative = false;
224        let scale = 4;
225        Decimal::from_parts(lo, mid, hi, negative, scale)
226    }
227
228    /// Checks bounds, returning [`Self`] if the value is valid.
229    #[inline]
230    fn try_from_inner(value: i32) -> Result<Self, Error> {
231        if value < 0 {
232            Err(Error::Negative)
233        } else if value > Self::MAX.0 {
234            Err(Error::TooLarge)
235        } else {
236            Ok(Self(value))
237        }
238    }
239}
240
241impl fmt::Display for Ppm {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        fmt::Display::fmt(&self.0, f)
244    }
245}
246
247impl FromStr for Ppm {
248    type Err = anyhow::Error;
249
250    fn from_str(s: &str) -> Result<Self, Self::Err> {
251        let value = s.parse::<i32>().map_err(|err| format_err!("{err}"))?;
252        Ok(Self::try_from_inner(value)?)
253    }
254}
255
256// --- Infallible From impls --- //
257
258impl From<u16> for Ppm {
259    /// Infallible conversion from `u16` (max 65535 < 1_000_000).
260    #[inline]
261    fn from(value: u16) -> Self {
262        Self(i32::from(value))
263    }
264}
265
266impl From<Ppm> for i32 {
267    #[inline]
268    fn from(ppm: Ppm) -> Self {
269        ppm.0
270    }
271}
272
273impl From<Ppm> for u32 {
274    #[inline]
275    fn from(ppm: Ppm) -> Self {
276        ppm.0 as u32
277    }
278}
279
280impl From<Ppm> for i64 {
281    #[inline]
282    fn from(ppm: Ppm) -> Self {
283        i64::from(ppm.0)
284    }
285}
286
287impl From<Ppm> for u64 {
288    #[inline]
289    fn from(ppm: Ppm) -> Self {
290        ppm.0 as u64
291    }
292}
293
294// --- Fallible TryFrom impls --- //
295
296impl TryFrom<i32> for Ppm {
297    type Error = Error;
298
299    #[inline]
300    fn try_from(value: i32) -> Result<Self, Self::Error> {
301        Self::try_from_inner(value)
302    }
303}
304
305impl TryFrom<u32> for Ppm {
306    type Error = Error;
307
308    fn try_from(value: u32) -> Result<Self, Self::Error> {
309        let value_i32 = i32::try_from(value).map_err(|_| Error::TooLarge)?;
310        Self::try_from_inner(value_i32)
311    }
312}
313
314impl TryFrom<Decimal> for Ppm {
315    type Error = Error;
316
317    #[inline]
318    fn try_from(rate: Decimal) -> Result<Self, Self::Error> {
319        Self::try_from_decimal(rate)
320    }
321}
322
323// --- Mul impls for fee calculation --- //
324//
325// These impls can never panic: Ppm is bounded to [0, 1_000_000] representing
326// [0%, 100%], so multiplying a valid Amount by a Ppm always produces a result
327// ≤ the original Amount.
328
329/// Amount * Ppm => Amount (fee calculation)
330impl Mul<Ppm> for Amount {
331    type Output = Self;
332
333    #[inline]
334    fn mul(self, rhs: Ppm) -> Self::Output {
335        self * rhs.to_decimal()
336    }
337}
338
339/// Ppm * Amount => Amount (fee calculation, commutative)
340impl Mul<Amount> for Ppm {
341    type Output = Amount;
342
343    #[inline]
344    fn mul(self, rhs: Amount) -> Self::Output {
345        rhs * self.to_decimal()
346    }
347}
348
349// --- Arbitrary impl --- //
350
351#[cfg(any(test, feature = "test-utils"))]
352impl proptest::arbitrary::Arbitrary for Ppm {
353    type Parameters = ();
354    type Strategy = proptest::strategy::BoxedStrategy<Self>;
355
356    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
357        use proptest::strategy::Strategy;
358        (0i32..=Self::MAX.0).prop_map(Self).boxed()
359    }
360}
361
362// --- Tests --- //
363
364#[cfg(test)]
365mod test {
366    use proptest::{arbitrary::any, prop_assert, prop_assert_eq, proptest};
367
368    use super::*;
369
370    #[test]
371    fn const_construction() {
372        /// Test const construction.
373        const TEST_PPM: Ppm = Ppm::new(3000);
374
375        assert_eq!(TEST_PPM.to_i32(), 3000);
376        assert_eq!(Ppm::ZERO.to_i32(), 0);
377        assert_eq!(Ppm::MAX.to_i32(), 1_000_000);
378    }
379
380    #[test]
381    fn macros() {
382        assert_eq!(ppm!(0), Ppm::ZERO);
383        assert_eq!(ppm!(1230), Ppm::new(1230));
384        assert_eq!(ppm!(1_000_000), Ppm::new(1_000_000));
385
386        assert_eq!(ppm!(0%), Ppm::ZERO);
387        assert_eq!(ppm!(0.0%), Ppm::ZERO);
388        assert_eq!(ppm!(0.123%), Ppm::new(1230));
389        assert_eq!(ppm!(0.1230%), Ppm::new(1230));
390        assert_eq!(ppm!(0.12300%), Ppm::new(1230));
391        assert_eq!(ppm!(0.3%), Ppm::new(3000));
392        assert_eq!(ppm!(1%), Ppm::new(10_000));
393        assert_eq!(ppm!(1.0%), Ppm::new(10_000));
394        assert_eq!(ppm!(50%), Ppm::new(500_000));
395        assert_eq!(ppm!(100%), Ppm::MAX);
396        assert_eq!(ppm!(100.0%), Ppm::MAX);
397        assert_eq!(ppm!(0.0001%), Ppm::new(1));
398    }
399
400    #[test]
401    fn to_decimal() {
402        assert_eq!(Ppm::ZERO.to_decimal(), dec!(0));
403        assert_eq!(Ppm::new(1).to_decimal(), dec!(0.000001));
404        assert_eq!(Ppm::new(1000).to_decimal(), dec!(0.001));
405        assert_eq!(Ppm::new(10_000).to_decimal(), dec!(0.01));
406        assert_eq!(Ppm::new(100_000).to_decimal(), dec!(0.1));
407        assert_eq!(Ppm::MAX.to_decimal(), dec!(1));
408    }
409
410    #[test]
411    fn to_percent() {
412        assert_eq!(Ppm::ZERO.to_percent(), dec!(0));
413        assert_eq!(Ppm::new(1).to_percent(), dec!(0.0001));
414        assert_eq!(Ppm::new(1000).to_percent(), dec!(0.1));
415        assert_eq!(Ppm::new(3000).to_percent(), dec!(0.3));
416        assert_eq!(Ppm::new(10_000).to_percent(), dec!(1));
417        assert_eq!(Ppm::new(100_000).to_percent(), dec!(10));
418        assert_eq!(Ppm::MAX.to_percent(), dec!(100));
419    }
420
421    #[test]
422    fn try_from_decimal() {
423        // Basic conversions
424        assert_eq!(Ppm::try_from(dec!(0)).unwrap(), Ppm::ZERO);
425        assert_eq!(Ppm::try_from(dec!(0.005)).unwrap(), Ppm::new(5000));
426        assert_eq!(Ppm::try_from(dec!(0.1)).unwrap(), Ppm::new(100_000));
427        assert_eq!(Ppm::try_from(dec!(1)).unwrap(), Ppm::MAX);
428
429        // Rounding
430        assert_eq!(Ppm::try_from(dec!(0.0000014)).unwrap(), Ppm::new(1));
431        assert_eq!(Ppm::try_from(dec!(0.0000016)).unwrap(), Ppm::new(2));
432
433        // Errors
434        assert!(matches!(Ppm::try_from(dec!(-0.001)), Err(Error::Negative)));
435        assert!(matches!(
436            Ppm::try_from(dec!(1.000001)),
437            Err(Error::TooLarge)
438        ));
439    }
440
441    #[test]
442    fn try_from_rejects_invalid() {
443        assert!(matches!(Ppm::try_from(-1i32), Err(Error::Negative)));
444        assert!(matches!(Ppm::try_from(1_000_001i32), Err(Error::TooLarge)));
445        assert!(matches!(Ppm::try_from(1_000_001u32), Err(Error::TooLarge)));
446    }
447
448    #[test]
449    fn from_str() {
450        assert_eq!("0".parse::<Ppm>().unwrap(), Ppm::ZERO);
451        assert_eq!("3000".parse::<Ppm>().unwrap(), Ppm::new(3000));
452        assert_eq!("1000000".parse::<Ppm>().unwrap(), Ppm::MAX);
453
454        assert!("-1".parse::<Ppm>().is_err());
455        assert!("1000001".parse::<Ppm>().is_err());
456        assert!("abc".parse::<Ppm>().is_err());
457    }
458
459    /// Verifies JSON format is a bare integer, not an object.
460    #[test]
461    fn serde_json_format() {
462        #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
463        struct Foo {
464            ppm: Ppm,
465        }
466
467        let foo = Foo {
468            ppm: Ppm::new(3000),
469        };
470        let json = serde_json::to_string(&foo).unwrap();
471        assert_eq!(json, r#"{"ppm":3000}"#);
472        let roundtrip: Foo = serde_json::from_str(&json).unwrap();
473        assert_eq!(foo, roundtrip);
474
475        // Rejects invalid values
476        assert!(serde_json::from_str::<Ppm>("-1").is_err());
477        assert!(serde_json::from_str::<Ppm>("1000001").is_err());
478    }
479
480    #[test]
481    fn proptest_integer_conversions() {
482        proptest!(|(ppm in any::<Ppm>(), val in any::<u16>())| {
483            let i = ppm.to_i32();
484
485            // All integer conversions agree
486            prop_assert_eq!(i32::from(ppm), i);
487            prop_assert_eq!(u32::from(ppm), i as u32);
488            prop_assert_eq!(i64::from(ppm), i64::from(i));
489            prop_assert_eq!(u64::from(ppm), i as u64);
490
491            // TryFrom roundtrips
492            prop_assert_eq!(Ppm::try_from(i).unwrap(), ppm);
493            prop_assert_eq!(Ppm::try_from(i as u32).unwrap(), ppm);
494
495            // From<u16> always succeeds (max 65535 < 1_000_000)
496            let from_u16 = Ppm::from(val);
497            prop_assert_eq!(from_u16.to_i32(), i32::from(val));
498        });
499    }
500
501    #[test]
502    fn proptest_mul_amount() {
503        proptest!(|(amount in any::<Amount>(), ppm in any::<Ppm>())| {
504            // Commutative: amount * ppm == ppm * amount
505            prop_assert_eq!(amount * ppm, ppm * amount);
506
507            // Equivalent to multiplying by the decimal rate
508            prop_assert_eq!(amount * ppm, amount * ppm.to_decimal());
509        });
510    }
511
512    #[test]
513    fn proptest_serde_roundtrip() {
514        proptest!(|(ppm in any::<Ppm>())| {
515            let json = serde_json::to_string(&ppm).unwrap();
516            let roundtrip: Ppm = serde_json::from_str(&json).unwrap();
517            prop_assert_eq!(ppm, roundtrip);
518        });
519    }
520
521    #[test]
522    fn proptest_decimal_roundtrip() {
523        proptest!(|(ppm in any::<Ppm>())| {
524            let dec = ppm.to_decimal();
525
526            // Decimal is in [0, 1]
527            prop_assert!(dec >= Decimal::ZERO);
528            prop_assert!(dec <= Decimal::ONE);
529
530            // Roundtrip: Ppm -> Decimal -> Ppm
531            prop_assert_eq!(ppm, Ppm::try_from(dec).unwrap());
532            prop_assert_eq!(ppm, Ppm::try_from_decimal(dec).unwrap());
533            prop_assert_eq!(ppm, Ppm::const_from_decimal(dec));
534        });
535    }
536
537    #[test]
538    fn proptest_percent_roundtrip() {
539        proptest!(|(ppm in any::<Ppm>())| {
540            let pct = ppm.to_percent();
541
542            // Percent is in [0, 100]
543            prop_assert!(pct >= Decimal::ZERO);
544            prop_assert!(pct <= dec!(100));
545
546            // Roundtrip: Ppm -> percent -> Ppm
547            prop_assert_eq!(ppm, Ppm::try_from_percent(pct).unwrap());
548            prop_assert_eq!(ppm, Ppm::const_from_percent(pct));
549
550            prop_assert_eq!(pct, ppm.to_decimal() * dec!(100.0));
551        });
552    }
553}