r402_protocol/payment/
overrides.rs1use std::str::FromStr;
4
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12#[non_exhaustive]
13pub struct SettlementOverrides {
14 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub amount: Option<String>,
17}
18
19impl SettlementOverrides {
20 #[must_use]
22 pub fn amount(amount: impl Into<String>) -> Self {
23 Self {
24 amount: Some(amount.into()),
25 }
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Error)]
31pub enum SettlementOverrideError {
32 #[error("invalid requirements amount: {0}")]
34 InvalidRequirementsAmount(String),
35 #[error("invalid settlement override amount: {0}")]
37 InvalidOverride(String),
38 #[error("settlement override arithmetic overflow")]
40 Overflow,
41}
42
43pub const DEFAULT_ASSET_DECIMALS: u32 = 6;
45
46#[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
64pub 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}