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    use crate::ppm;
370
371    #[test]
372    fn const_construction() {
373        /// Test const construction.
374        const TEST_PPM: Ppm = Ppm::new(3000);
375
376        assert_eq!(TEST_PPM.to_i32(), 3000);
377        assert_eq!(Ppm::ZERO.to_i32(), 0);
378        assert_eq!(Ppm::MAX.to_i32(), 1_000_000);
379    }
380
381    #[test]
382    fn macros() {
383        assert_eq!(ppm!(0), Ppm::ZERO);
384        assert_eq!(ppm!(1230), Ppm::new(1230));
385        assert_eq!(ppm!(1_000_000), Ppm::new(1_000_000));
386
387        assert_eq!(ppm!(0%), Ppm::ZERO);
388        assert_eq!(ppm!(0.0%), Ppm::ZERO);
389        assert_eq!(ppm!(0.123%), Ppm::new(1230));
390        assert_eq!(ppm!(0.1230%), Ppm::new(1230));
391        assert_eq!(ppm!(0.12300%), Ppm::new(1230));
392        assert_eq!(ppm!(0.3%), Ppm::new(3000));
393        assert_eq!(ppm!(1%), Ppm::new(10_000));
394        assert_eq!(ppm!(1.0%), Ppm::new(10_000));
395        assert_eq!(ppm!(50%), Ppm::new(500_000));
396        assert_eq!(ppm!(100%), Ppm::MAX);
397        assert_eq!(ppm!(100.0%), Ppm::MAX);
398        assert_eq!(ppm!(0.0001%), Ppm::new(1));
399    }
400
401    #[test]
402    fn to_decimal() {
403        assert_eq!(Ppm::ZERO.to_decimal(), dec!(0));
404        assert_eq!(Ppm::new(1).to_decimal(), dec!(0.000001));
405        assert_eq!(Ppm::new(1000).to_decimal(), dec!(0.001));
406        assert_eq!(Ppm::new(10_000).to_decimal(), dec!(0.01));
407        assert_eq!(Ppm::new(100_000).to_decimal(), dec!(0.1));
408        assert_eq!(Ppm::MAX.to_decimal(), dec!(1));
409    }
410
411    #[test]
412    fn to_percent() {
413        assert_eq!(Ppm::ZERO.to_percent(), dec!(0));
414        assert_eq!(Ppm::new(1).to_percent(), dec!(0.0001));
415        assert_eq!(Ppm::new(1000).to_percent(), dec!(0.1));
416        assert_eq!(Ppm::new(3000).to_percent(), dec!(0.3));
417        assert_eq!(Ppm::new(10_000).to_percent(), dec!(1));
418        assert_eq!(Ppm::new(100_000).to_percent(), dec!(10));
419        assert_eq!(Ppm::MAX.to_percent(), dec!(100));
420    }
421
422    #[test]
423    fn try_from_decimal() {
424        // Basic conversions
425        assert_eq!(Ppm::try_from(dec!(0)).unwrap(), Ppm::ZERO);
426        assert_eq!(Ppm::try_from(dec!(0.005)).unwrap(), Ppm::new(5000));
427        assert_eq!(Ppm::try_from(dec!(0.1)).unwrap(), Ppm::new(100_000));
428        assert_eq!(Ppm::try_from(dec!(1)).unwrap(), Ppm::MAX);
429
430        // Rounding
431        assert_eq!(Ppm::try_from(dec!(0.0000014)).unwrap(), Ppm::new(1));
432        assert_eq!(Ppm::try_from(dec!(0.0000016)).unwrap(), Ppm::new(2));
433
434        // Errors
435        assert!(matches!(Ppm::try_from(dec!(-0.001)), Err(Error::Negative)));
436        assert!(matches!(
437            Ppm::try_from(dec!(1.000001)),
438            Err(Error::TooLarge)
439        ));
440    }
441
442    #[test]
443    fn try_from_rejects_invalid() {
444        assert!(matches!(Ppm::try_from(-1i32), Err(Error::Negative)));
445        assert!(matches!(Ppm::try_from(1_000_001i32), Err(Error::TooLarge)));
446        assert!(matches!(Ppm::try_from(1_000_001u32), Err(Error::TooLarge)));
447    }
448
449    #[test]
450    fn from_str() {
451        assert_eq!("0".parse::<Ppm>().unwrap(), Ppm::ZERO);
452        assert_eq!("3000".parse::<Ppm>().unwrap(), Ppm::new(3000));
453        assert_eq!("1000000".parse::<Ppm>().unwrap(), Ppm::MAX);
454
455        assert!("-1".parse::<Ppm>().is_err());
456        assert!("1000001".parse::<Ppm>().is_err());
457        assert!("abc".parse::<Ppm>().is_err());
458    }
459
460    /// Verifies JSON format is a bare integer, not an object.
461    #[test]
462    fn serde_json_format() {
463        #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
464        struct Foo {
465            ppm: Ppm,
466        }
467
468        let foo = Foo {
469            ppm: Ppm::new(3000),
470        };
471        let json = serde_json::to_string(&foo).unwrap();
472        assert_eq!(json, r#"{"ppm":3000}"#);
473        let roundtrip: Foo = serde_json::from_str(&json).unwrap();
474        assert_eq!(foo, roundtrip);
475
476        // Rejects invalid values
477        assert!(serde_json::from_str::<Ppm>("-1").is_err());
478        assert!(serde_json::from_str::<Ppm>("1000001").is_err());
479    }
480
481    #[test]
482    fn proptest_integer_conversions() {
483        proptest!(|(ppm in any::<Ppm>(), val in any::<u16>())| {
484            let i = ppm.to_i32();
485
486            // All integer conversions agree
487            prop_assert_eq!(i32::from(ppm), i);
488            prop_assert_eq!(u32::from(ppm), i as u32);
489            prop_assert_eq!(i64::from(ppm), i64::from(i));
490            prop_assert_eq!(u64::from(ppm), i as u64);
491
492            // TryFrom roundtrips
493            prop_assert_eq!(Ppm::try_from(i).unwrap(), ppm);
494            prop_assert_eq!(Ppm::try_from(i as u32).unwrap(), ppm);
495
496            // From<u16> always succeeds (max 65535 < 1_000_000)
497            let from_u16 = Ppm::from(val);
498            prop_assert_eq!(from_u16.to_i32(), i32::from(val));
499        });
500    }
501
502    #[test]
503    fn proptest_mul_amount() {
504        proptest!(|(amount in any::<Amount>(), ppm in any::<Ppm>())| {
505            // Commutative: amount * ppm == ppm * amount
506            prop_assert_eq!(amount * ppm, ppm * amount);
507
508            // Equivalent to multiplying by the decimal rate
509            prop_assert_eq!(amount * ppm, amount * ppm.to_decimal());
510        });
511    }
512
513    #[test]
514    fn proptest_serde_roundtrip() {
515        proptest!(|(ppm in any::<Ppm>())| {
516            let json = serde_json::to_string(&ppm).unwrap();
517            let roundtrip: Ppm = serde_json::from_str(&json).unwrap();
518            prop_assert_eq!(ppm, roundtrip);
519        });
520    }
521
522    #[test]
523    fn proptest_decimal_roundtrip() {
524        proptest!(|(ppm in any::<Ppm>())| {
525            let dec = ppm.to_decimal();
526
527            // Decimal is in [0, 1]
528            prop_assert!(dec >= Decimal::ZERO);
529            prop_assert!(dec <= Decimal::ONE);
530
531            // Roundtrip: Ppm -> Decimal -> Ppm
532            prop_assert_eq!(ppm, Ppm::try_from(dec).unwrap());
533            prop_assert_eq!(ppm, Ppm::try_from_decimal(dec).unwrap());
534            prop_assert_eq!(ppm, Ppm::const_from_decimal(dec));
535        });
536    }
537
538    #[test]
539    fn proptest_percent_roundtrip() {
540        proptest!(|(ppm in any::<Ppm>())| {
541            let pct = ppm.to_percent();
542
543            // Percent is in [0, 100]
544            prop_assert!(pct >= Decimal::ZERO);
545            prop_assert!(pct <= dec!(100));
546
547            // Roundtrip: Ppm -> percent -> Ppm
548            prop_assert_eq!(ppm, Ppm::try_from_percent(pct).unwrap());
549            prop_assert_eq!(ppm, Ppm::const_from_percent(pct));
550
551            prop_assert_eq!(pct, ppm.to_decimal() * dec!(100.0));
552        });
553    }
554}