lib.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. //! Monetary amounts for the Kuatia ledger.
  2. //!
  3. //! [`Cent`] is a signed amount in an asset's smallest unit. It wraps an integer
  4. //! whose width is an internal detail: the public API never names the backing
  5. //! type, and no serialized form reveals it. The backing is chosen once at
  6. //! compile time through the [`Backing`] alias, which defaults to `i64` and
  7. //! switches to `i128` under the `i128` cargo feature. Adding a new width is a
  8. //! single [`CentBacking`] impl plus one line on [`Backing`]; nothing downstream
  9. //! changes.
  10. //!
  11. //! All arithmetic is checked: addition, subtraction and negation return
  12. //! [`OverflowError`] rather than wrapping, so the ledger's conservation sum can
  13. //! never silently round or overflow.
  14. use serde::{Deserialize, Deserializer, Serialize, Serializer};
  15. use std::fmt;
  16. use std::str::FromStr;
  17. // ---------------------------------------------------------------------------
  18. // Backing selection
  19. // ---------------------------------------------------------------------------
  20. /// The integer type backing every [`Cent`]. `i64` by default; `i128` under the
  21. /// `i128` cargo feature. This is the single point where the money width is
  22. /// chosen, and it is never named in a public signature.
  23. #[cfg(not(feature = "i128"))]
  24. pub type Backing = i64;
  25. /// The integer type backing every [`Cent`]. `i64` by default; `i128` under the
  26. /// `i128` cargo feature. This is the single point where the money width is
  27. /// chosen, and it is never named in a public signature.
  28. #[cfg(feature = "i128")]
  29. pub type Backing = i128;
  30. // ---------------------------------------------------------------------------
  31. // CentBacking — the swap surface
  32. // ---------------------------------------------------------------------------
  33. /// The contract an integer must satisfy to back a [`Cent`]. Implemented for
  34. /// `i64` and `i128`; implement it for another integer to add a new money width.
  35. ///
  36. /// It carries only the width-dependent primitives [`Cent`] needs (canonical
  37. /// 16-byte widening, decimal scaling, parsing, and absolute division). Plain
  38. /// checked add/sub/neg are used directly on the concrete backing.
  39. pub trait CentBacking: Copy + Ord + Default + fmt::Display {
  40. /// The additive identity (zero) for this backing.
  41. const ZERO: Self;
  42. /// Ten raised to `exp`, or `None` on overflow. Used to scale decimals.
  43. fn ten_pow(exp: u32) -> Option<Self>;
  44. /// Parse a base-10 signed integer string, or `None` if it is not valid.
  45. fn parse_str(s: &str) -> Option<Self>;
  46. /// Widen to `i128` for the fixed-width canonical encoding.
  47. fn to_i128(self) -> i128;
  48. /// Narrow from `i128`, or `None` if the value does not fit this backing.
  49. fn try_from_i128(v: i128) -> Option<Self>;
  50. /// Divide the absolute value of `self` by the absolute value of `d`,
  51. /// returning `(quotient, remainder)` as unsigned `u128`. Returns `(0, 0)`
  52. /// when `d` is zero. Used to split a value into whole and fractional parts
  53. /// for display.
  54. fn div_rem_abs(self, d: Self) -> (u128, u128);
  55. }
  56. impl CentBacking for i64 {
  57. const ZERO: Self = 0;
  58. fn ten_pow(exp: u32) -> Option<Self> {
  59. 10i64.checked_pow(exp)
  60. }
  61. fn parse_str(s: &str) -> Option<Self> {
  62. s.parse().ok()
  63. }
  64. fn to_i128(self) -> i128 {
  65. self as i128
  66. }
  67. fn try_from_i128(v: i128) -> Option<Self> {
  68. i64::try_from(v).ok()
  69. }
  70. fn div_rem_abs(self, d: Self) -> (u128, u128) {
  71. let dd = d.unsigned_abs() as u128;
  72. if dd == 0 {
  73. return (0, 0);
  74. }
  75. let a = self.unsigned_abs() as u128;
  76. (a / dd, a % dd)
  77. }
  78. }
  79. impl CentBacking for i128 {
  80. const ZERO: Self = 0;
  81. fn ten_pow(exp: u32) -> Option<Self> {
  82. 10i128.checked_pow(exp)
  83. }
  84. fn parse_str(s: &str) -> Option<Self> {
  85. s.parse().ok()
  86. }
  87. fn to_i128(self) -> i128 {
  88. self
  89. }
  90. fn try_from_i128(v: i128) -> Option<Self> {
  91. Some(v)
  92. }
  93. fn div_rem_abs(self, d: Self) -> (u128, u128) {
  94. let dd = d.unsigned_abs();
  95. if dd == 0 {
  96. return (0, 0);
  97. }
  98. let a = self.unsigned_abs();
  99. (a / dd, a % dd)
  100. }
  101. }
  102. // ---------------------------------------------------------------------------
  103. // Cent — stored monetary amount
  104. // ---------------------------------------------------------------------------
  105. /// A monetary amount in the smallest unit of one asset (cents, satoshis, …).
  106. ///
  107. /// The backing integer is private and its width is hidden: read a `Cent` with
  108. /// [`Display`](fmt::Display)/[`to_string`](ToString::to_string) or parse one
  109. /// with [`FromStr`], compare with [`Ord`], and do arithmetic only through the
  110. /// checked methods. Serde round-trips it as a string, so no serialized form
  111. /// reveals the width.
  112. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
  113. pub struct Cent(Backing);
  114. /// Returned when a [`Cent`] arithmetic operation would overflow or underflow,
  115. /// or when a value does not fit the active backing.
  116. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  117. pub struct OverflowError;
  118. impl fmt::Display for OverflowError {
  119. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  120. write!(f, "monetary amount overflow")
  121. }
  122. }
  123. impl std::error::Error for OverflowError {}
  124. /// Returned when a string cannot be parsed into a [`Cent`].
  125. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  126. pub struct ParseCentError;
  127. impl fmt::Display for ParseCentError {
  128. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  129. write!(f, "invalid monetary amount")
  130. }
  131. }
  132. impl std::error::Error for ParseCentError {}
  133. impl Cent {
  134. /// The zero amount.
  135. pub const ZERO: Cent = Cent(0);
  136. /// Returns `true` if the amount is strictly positive.
  137. pub fn is_positive(self) -> bool {
  138. self.0 > 0
  139. }
  140. /// Returns `true` if the amount is strictly negative.
  141. pub fn is_negative(self) -> bool {
  142. self.0 < 0
  143. }
  144. /// Returns `true` if the amount is zero.
  145. pub fn is_zero(self) -> bool {
  146. self.0 == 0
  147. }
  148. /// Checked addition, returning [`OverflowError`] on overflow.
  149. pub fn checked_add(self, rhs: Self) -> Result<Self, OverflowError> {
  150. self.0.checked_add(rhs.0).map(Cent).ok_or(OverflowError)
  151. }
  152. /// Checked subtraction, returning [`OverflowError`] on underflow.
  153. pub fn checked_sub(self, rhs: Self) -> Result<Self, OverflowError> {
  154. self.0.checked_sub(rhs.0).map(Cent).ok_or(OverflowError)
  155. }
  156. /// Checked negation, returning [`OverflowError`] at the backing's minimum.
  157. pub fn checked_neg(self) -> Result<Self, OverflowError> {
  158. self.0.checked_neg().map(Cent).ok_or(OverflowError)
  159. }
  160. /// Sum an iterator of `Cent` values with overflow checking.
  161. pub fn checked_sum(iter: impl IntoIterator<Item = Self>) -> Result<Self, OverflowError> {
  162. let mut sum = Cent::ZERO;
  163. for x in iter {
  164. sum = sum.checked_add(x)?;
  165. }
  166. Ok(sum)
  167. }
  168. /// The canonical 16-byte big-endian encoding (sign-extended), used for
  169. /// content-addressed hashing. The width is fixed regardless of the backing,
  170. /// so the same amount hashes identically under any backing.
  171. pub fn to_canonical_bytes(self) -> [u8; 16] {
  172. self.0.to_i128().to_be_bytes()
  173. }
  174. /// Decode a [`Cent`] from its canonical 16-byte encoding, returning
  175. /// [`OverflowError`] if the value does not fit the active backing.
  176. pub fn from_canonical_bytes(bytes: &[u8; 16]) -> Result<Self, OverflowError> {
  177. Backing::try_from_i128(i128::from_be_bytes(*bytes))
  178. .map(Cent)
  179. .ok_or(OverflowError)
  180. }
  181. }
  182. impl From<i64> for Cent {
  183. fn from(v: i64) -> Self {
  184. Cent(v as Backing)
  185. }
  186. }
  187. impl From<i32> for Cent {
  188. fn from(v: i32) -> Self {
  189. Cent(v as Backing)
  190. }
  191. }
  192. impl From<u32> for Cent {
  193. fn from(v: u32) -> Self {
  194. Cent(v as Backing)
  195. }
  196. }
  197. impl From<u8> for Cent {
  198. fn from(v: u8) -> Self {
  199. Cent(v as Backing)
  200. }
  201. }
  202. impl From<i8> for Cent {
  203. fn from(v: i8) -> Self {
  204. Cent(v as Backing)
  205. }
  206. }
  207. impl fmt::Debug for Cent {
  208. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  209. write!(f, "Cent({})", self.0)
  210. }
  211. }
  212. impl fmt::Display for Cent {
  213. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  214. write!(f, "{}", self.0)
  215. }
  216. }
  217. impl FromStr for Cent {
  218. type Err = ParseCentError;
  219. fn from_str(s: &str) -> Result<Self, Self::Err> {
  220. Backing::parse_str(s).map(Cent).ok_or(ParseCentError)
  221. }
  222. }
  223. impl Serialize for Cent {
  224. fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
  225. where
  226. S: Serializer,
  227. {
  228. serializer.collect_str(&self.0)
  229. }
  230. }
  231. impl<'de> Deserialize<'de> for Cent {
  232. fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
  233. where
  234. D: Deserializer<'de>,
  235. {
  236. let s = String::deserialize(deserializer)?;
  237. s.parse().map_err(serde::de::Error::custom)
  238. }
  239. }
  240. // ---------------------------------------------------------------------------
  241. // Amount — human-friendly parser/formatter (not stored)
  242. // ---------------------------------------------------------------------------
  243. /// Parses and formats human-readable amounts with a fixed number of decimal
  244. /// places. NOT stored anywhere — used only to convert between strings and
  245. /// [`Cent`] values.
  246. pub struct Amount {
  247. decimals: u8,
  248. }
  249. /// Error returned when parsing an amount string fails.
  250. #[derive(Debug, Clone, PartialEq, Eq)]
  251. pub enum ParseAmountError {
  252. /// The input string is not a valid number.
  253. InvalidFormat(String),
  254. /// Too many decimal places for the configured precision.
  255. TooManyDecimals {
  256. /// Maximum allowed decimal places.
  257. max: u8,
  258. /// Number of decimal places found in the input.
  259. found: usize,
  260. },
  261. }
  262. impl fmt::Display for ParseAmountError {
  263. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  264. match self {
  265. Self::InvalidFormat(s) => write!(f, "invalid amount format: {s}"),
  266. Self::TooManyDecimals { max, found } => {
  267. write!(f, "too many decimals: max {max}, found {found}")
  268. }
  269. }
  270. }
  271. }
  272. impl std::error::Error for ParseAmountError {}
  273. impl Amount {
  274. /// Create an `Amount` formatter with the given number of decimal places.
  275. pub fn new(decimals: u8) -> Self {
  276. Self { decimals }
  277. }
  278. /// Parses a decimal string into a [`Cent`] value.
  279. pub fn parse(&self, s: &str) -> Result<Cent, ParseAmountError> {
  280. let s = s.trim();
  281. let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
  282. (true, rest)
  283. } else {
  284. (false, s)
  285. };
  286. let (whole_str, frac_str) = if let Some((w, f)) = s.split_once('.') {
  287. (w, f)
  288. } else {
  289. (s, "")
  290. };
  291. if whole_str.is_empty() && frac_str.is_empty() {
  292. return Err(ParseAmountError::InvalidFormat(s.to_string()));
  293. }
  294. let whole: Backing = if whole_str.is_empty() {
  295. Backing::ZERO
  296. } else {
  297. Backing::parse_str(whole_str)
  298. .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
  299. };
  300. if frac_str.len() > self.decimals as usize {
  301. return Err(ParseAmountError::TooManyDecimals {
  302. max: self.decimals,
  303. found: frac_str.len(),
  304. });
  305. }
  306. if !frac_str.is_empty() && !frac_str.chars().all(|c| c.is_ascii_digit()) {
  307. return Err(ParseAmountError::InvalidFormat(s.to_string()));
  308. }
  309. let frac: Backing = if frac_str.is_empty() {
  310. Backing::ZERO
  311. } else {
  312. let padded = format!("{:0<width$}", frac_str, width = self.decimals as usize);
  313. Backing::parse_str(&padded)
  314. .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
  315. };
  316. let multiplier = Backing::ten_pow(self.decimals as u32)
  317. .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?;
  318. let value = whole
  319. .checked_mul(multiplier)
  320. .and_then(|v| v.checked_add(frac))
  321. .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?;
  322. let value = if negative {
  323. value
  324. .checked_neg()
  325. .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
  326. } else {
  327. value
  328. };
  329. Ok(Cent(value))
  330. }
  331. /// Formats a [`Cent`] value as a decimal string.
  332. pub fn format(&self, cent: Cent) -> String {
  333. if self.decimals == 0 {
  334. return cent.to_string();
  335. }
  336. let Some(multiplier) = Backing::ten_pow(self.decimals as u32) else {
  337. return cent.to_string();
  338. };
  339. let negative = cent.is_negative();
  340. let (whole, frac) = cent.0.div_rem_abs(multiplier);
  341. let sign = if negative { "-" } else { "" };
  342. format!(
  343. "{sign}{whole}.{frac:0>width$}",
  344. width = self.decimals as usize
  345. )
  346. }
  347. }
  348. #[cfg(test)]
  349. mod tests {
  350. use super::*;
  351. #[test]
  352. fn checked_math_overflows_to_error() {
  353. let max = Cent::from_str(&Backing::MAX.to_string()).unwrap();
  354. assert_eq!(max.checked_add(Cent::from(1)), Err(OverflowError));
  355. let min = Cent::from_str(&Backing::MIN.to_string()).unwrap();
  356. assert_eq!(min.checked_neg(), Err(OverflowError));
  357. assert_eq!(Cent::from(2).checked_sub(Cent::from(5)), Ok(Cent::from(-3)));
  358. }
  359. #[test]
  360. fn string_round_trip() {
  361. for v in [-1234i64, 0, 1, 500, i64::from(i32::MAX)] {
  362. let c = Cent::from(v);
  363. assert_eq!(Cent::from_str(&c.to_string()), Ok(c));
  364. }
  365. }
  366. #[test]
  367. fn serde_is_a_string() {
  368. let c = Cent::from(-50);
  369. let json = serde_json::to_string(&c).unwrap();
  370. assert_eq!(json, "\"-50\"");
  371. assert_eq!(serde_json::from_str::<Cent>(&json).unwrap(), c);
  372. }
  373. #[test]
  374. fn canonical_bytes_are_16_and_round_trip() {
  375. let c = Cent::from(500);
  376. let bytes = c.to_canonical_bytes();
  377. assert_eq!(bytes.len(), 16);
  378. assert_eq!(Cent::from_canonical_bytes(&bytes), Ok(c));
  379. }
  380. #[test]
  381. fn canonical_bytes_are_width_independent() {
  382. // 500 encodes the same 16 bytes whether the backing is i64 or i128.
  383. let expected = 500i128.to_be_bytes();
  384. assert_eq!(Cent::from(500).to_canonical_bytes(), expected);
  385. }
  386. #[test]
  387. fn from_canonical_bytes_rejects_out_of_range() {
  388. // A value larger than i64::MAX only fits when the backing is i128.
  389. let big = (i128::from(i64::MAX)) + 1;
  390. let bytes = big.to_be_bytes();
  391. let decoded = Cent::from_canonical_bytes(&bytes);
  392. if cfg!(feature = "i128") {
  393. assert!(decoded.is_ok());
  394. } else {
  395. assert_eq!(decoded, Err(OverflowError));
  396. }
  397. }
  398. #[test]
  399. fn amount_parse_format_round_trip() {
  400. let amt = Amount::new(2);
  401. assert_eq!(amt.parse("12.34").unwrap(), Cent::from(1234));
  402. assert_eq!(amt.parse("-0.05").unwrap(), Cent::from(-5));
  403. assert_eq!(amt.format(Cent::from(1234)), "12.34");
  404. assert_eq!(amt.format(Cent::from(-5)), "-0.05");
  405. assert_eq!(Amount::new(0).format(Cent::from(700)), "700");
  406. }
  407. }