Skip to main content

r402_protocol/payment/
overrides.rs

1//! Settlement amount overrides for partial settlement (upto).
2
3use std::str::FromStr;
4
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Official wire shape for partial settlement.
10#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12#[non_exhaustive]
13pub struct SettlementOverrides {
14    /// Amount to settle. Formats: atomic `"1000"`, percent `"50%"`, dollar `"$0.05"`.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub amount: Option<String>,
17}
18
19impl SettlementOverrides {
20    /// Builds overrides with a single amount string.
21    #[must_use]
22    pub fn amount(amount: impl Into<String>) -> Self {
23        Self {
24            amount: Some(amount.into()),
25        }
26    }
27}
28
29/// Failure resolving a settlement override amount string.
30#[derive(Debug, Clone, PartialEq, Eq, Error)]
31pub enum SettlementOverrideError {
32    /// Authorized maximum is not a base-10 integer.
33    #[error("invalid requirements amount: {0}")]
34    InvalidRequirementsAmount(String),
35    /// Percent or dollar override failed to parse / convert.
36    #[error("invalid settlement override amount: {0}")]
37    InvalidOverride(String),
38    /// Intermediate arithmetic overflowed.
39    #[error("settlement override arithmetic overflow")]
40    Overflow,
41}
42
43/// Default ERC-20 style decimals when `extra.decimals` is absent.
44pub const DEFAULT_ASSET_DECIMALS: u32 = 6;
45
46/// Reads `decimals` from payment-requirements `extra` (number or numeric string).
47///
48/// Falls back to [`DEFAULT_ASSET_DECIMALS`].
49#[must_use]
50pub fn asset_decimals_from_extra(extra: Option<&serde_json::Value>) -> u32 {
51    let Some(extra) = extra else {
52        return DEFAULT_ASSET_DECIMALS;
53    };
54    match extra.get("decimals") {
55        Some(serde_json::Value::Number(n)) => n
56            .as_u64()
57            .and_then(|v| u32::try_from(v).ok())
58            .unwrap_or(DEFAULT_ASSET_DECIMALS),
59        Some(serde_json::Value::String(s)) => s.parse().unwrap_or(DEFAULT_ASSET_DECIMALS),
60        _ => DEFAULT_ASSET_DECIMALS,
61    }
62}
63
64/// Resolves a settlement override amount to atomic units (decimal string).
65///
66/// # Errors
67///
68/// Returns [`SettlementOverrideError`] when the override or authorized max
69/// cannot be parsed, or intermediate arithmetic overflows `u128`.
70pub fn resolve_settlement_override_amount(
71    raw_amount: &str,
72    authorized_max: &str,
73    decimals: u32,
74) -> Result<String, SettlementOverrideError> {
75    let raw = raw_amount.trim();
76    if let Some(percent_body) = raw.strip_suffix('%') {
77        return resolve_percent(percent_body.trim(), authorized_max);
78    }
79    if let Some(dollar_body) = raw.strip_prefix('$') {
80        return resolve_dollar(dollar_body.trim(), decimals);
81    }
82    if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
83        return Err(SettlementOverrideError::InvalidOverride(raw.to_owned()));
84    }
85    Ok(raw.to_owned())
86}
87
88fn resolve_percent(percent: &str, authorized_max: &str) -> Result<String, SettlementOverrideError> {
89    let (int_part, frac_part) = match percent.split_once('.') {
90        Some((i, f)) => (i, f),
91        None => (percent, ""),
92    };
93    if int_part.is_empty()
94        || !int_part.bytes().all(|b| b.is_ascii_digit())
95        || frac_part.len() > 2
96        || !frac_part.bytes().all(|b| b.is_ascii_digit())
97    {
98        return Err(SettlementOverrideError::InvalidOverride(format!(
99            "{percent}%"
100        )));
101    }
102    let int_val: u128 = int_part
103        .parse()
104        .map_err(|_| SettlementOverrideError::InvalidOverride(format!("{percent}%")))?;
105    let frac_padded = format!("{frac_part:0<2}");
106    let frac_val: u128 = if frac_padded.is_empty() {
107        0
108    } else {
109        frac_padded
110            .parse()
111            .map_err(|_| SettlementOverrideError::InvalidOverride(format!("{percent}%")))?
112    };
113    let scaled_percent = int_val
114        .checked_mul(100)
115        .and_then(|v| v.checked_add(frac_val))
116        .ok_or(SettlementOverrideError::Overflow)?;
117
118    let base = parse_u128_amount(authorized_max)?;
119    let product = base
120        .checked_mul(scaled_percent)
121        .ok_or(SettlementOverrideError::Overflow)?;
122    Ok((product / 10_000).to_string())
123}
124
125fn resolve_dollar(dollar: &str, decimals: u32) -> Result<String, SettlementOverrideError> {
126    let dollar_dec = Decimal::from_str(dollar)
127        .map_err(|_| SettlementOverrideError::InvalidOverride(format!("${dollar}")))?;
128    if dollar_dec.is_sign_negative() {
129        return Err(SettlementOverrideError::InvalidOverride(format!(
130            "${dollar}"
131        )));
132    }
133    let scale = Decimal::from(10u64.pow(decimals.min(18)));
134    let atomic = dollar_dec
135        .checked_mul(scale)
136        .ok_or(SettlementOverrideError::Overflow)?;
137    let truncated = atomic.trunc();
138    let s = truncated.normalize().to_string();
139    Ok(s.split('.').next().unwrap_or("0").to_owned())
140}
141
142fn parse_u128_amount(raw: &str) -> Result<u128, SettlementOverrideError> {
143    if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
144        return Err(SettlementOverrideError::InvalidRequirementsAmount(
145            raw.to_owned(),
146        ));
147    }
148    raw.parse()
149        .map_err(|_| SettlementOverrideError::InvalidRequirementsAmount(raw.to_owned()))
150}
151
152#[cfg(test)]
153#[allow(clippy::unwrap_used, reason = "unit tests panic on assertion failure")]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn atomic_passthrough() {
159        assert_eq!(
160            resolve_settlement_override_amount("500", "1000000", 6).unwrap(),
161            "500"
162        );
163    }
164
165    #[test]
166    fn percent_half() {
167        assert_eq!(
168            resolve_settlement_override_amount("50%", "1000000", 6).unwrap(),
169            "500000"
170        );
171    }
172
173    #[test]
174    fn percent_with_fraction() {
175        assert_eq!(
176            resolve_settlement_override_amount("12.5%", "1000", 6).unwrap(),
177            "125"
178        );
179    }
180
181    #[test]
182    fn dollar_default_decimals() {
183        assert_eq!(
184            resolve_settlement_override_amount("$0.001", "1000000", 6).unwrap(),
185            "1000"
186        );
187    }
188
189    #[test]
190    fn rejects_empty_atomic() {
191        assert!(resolve_settlement_override_amount("", "1", 6).is_err());
192    }
193
194    #[test]
195    fn decimals_from_extra() {
196        let v = serde_json::json!({"decimals": 18});
197        assert_eq!(asset_decimals_from_extra(Some(&v)), 18);
198        assert_eq!(asset_decimals_from_extra(None), 6);
199    }
200}