use std::str::FromStr;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SettlementOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<String>,
}
impl SettlementOverrides {
#[must_use]
pub fn amount(amount: impl Into<String>) -> Self {
Self {
amount: Some(amount.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SettlementOverrideError {
#[error("invalid requirements amount: {0}")]
InvalidRequirementsAmount(String),
#[error("invalid settlement override amount: {0}")]
InvalidOverride(String),
#[error("settlement override arithmetic overflow")]
Overflow,
}
pub const DEFAULT_ASSET_DECIMALS: u32 = 6;
#[must_use]
pub fn asset_decimals_from_extra(extra: Option<&serde_json::Value>) -> u32 {
let Some(extra) = extra else {
return DEFAULT_ASSET_DECIMALS;
};
match extra.get("decimals") {
Some(serde_json::Value::Number(n)) => n
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.unwrap_or(DEFAULT_ASSET_DECIMALS),
Some(serde_json::Value::String(s)) => s.parse().unwrap_or(DEFAULT_ASSET_DECIMALS),
_ => DEFAULT_ASSET_DECIMALS,
}
}
pub fn resolve_settlement_override_amount(
raw_amount: &str,
authorized_max: &str,
decimals: u32,
) -> Result<String, SettlementOverrideError> {
let raw = raw_amount.trim();
if let Some(percent_body) = raw.strip_suffix('%') {
return resolve_percent(percent_body.trim(), authorized_max);
}
if let Some(dollar_body) = raw.strip_prefix('$') {
return resolve_dollar(dollar_body.trim(), decimals);
}
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(SettlementOverrideError::InvalidOverride(raw.to_owned()));
}
Ok(raw.to_owned())
}
fn resolve_percent(percent: &str, authorized_max: &str) -> Result<String, SettlementOverrideError> {
let (int_part, frac_part) = match percent.split_once('.') {
Some((i, f)) => (i, f),
None => (percent, ""),
};
if int_part.is_empty()
|| !int_part.bytes().all(|b| b.is_ascii_digit())
|| frac_part.len() > 2
|| !frac_part.bytes().all(|b| b.is_ascii_digit())
{
return Err(SettlementOverrideError::InvalidOverride(format!(
"{percent}%"
)));
}
let int_val: u128 = int_part
.parse()
.map_err(|_| SettlementOverrideError::InvalidOverride(format!("{percent}%")))?;
let frac_padded = format!("{frac_part:0<2}");
let frac_val: u128 = if frac_padded.is_empty() {
0
} else {
frac_padded
.parse()
.map_err(|_| SettlementOverrideError::InvalidOverride(format!("{percent}%")))?
};
let scaled_percent = int_val
.checked_mul(100)
.and_then(|v| v.checked_add(frac_val))
.ok_or(SettlementOverrideError::Overflow)?;
let base = parse_u128_amount(authorized_max)?;
let product = base
.checked_mul(scaled_percent)
.ok_or(SettlementOverrideError::Overflow)?;
Ok((product / 10_000).to_string())
}
fn resolve_dollar(dollar: &str, decimals: u32) -> Result<String, SettlementOverrideError> {
let dollar_dec = Decimal::from_str(dollar)
.map_err(|_| SettlementOverrideError::InvalidOverride(format!("${dollar}")))?;
if dollar_dec.is_sign_negative() {
return Err(SettlementOverrideError::InvalidOverride(format!(
"${dollar}"
)));
}
let scale = Decimal::from(10u64.pow(decimals.min(18)));
let atomic = dollar_dec
.checked_mul(scale)
.ok_or(SettlementOverrideError::Overflow)?;
let truncated = atomic.trunc();
let s = truncated.normalize().to_string();
Ok(s.split('.').next().unwrap_or("0").to_owned())
}
fn parse_u128_amount(raw: &str) -> Result<u128, SettlementOverrideError> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(SettlementOverrideError::InvalidRequirementsAmount(
raw.to_owned(),
));
}
raw.parse()
.map_err(|_| SettlementOverrideError::InvalidRequirementsAmount(raw.to_owned()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "unit tests panic on assertion failure")]
mod tests {
use super::*;
#[test]
fn atomic_passthrough() {
assert_eq!(
resolve_settlement_override_amount("500", "1000000", 6).unwrap(),
"500"
);
}
#[test]
fn percent_half() {
assert_eq!(
resolve_settlement_override_amount("50%", "1000000", 6).unwrap(),
"500000"
);
}
#[test]
fn percent_with_fraction() {
assert_eq!(
resolve_settlement_override_amount("12.5%", "1000", 6).unwrap(),
"125"
);
}
#[test]
fn dollar_default_decimals() {
assert_eq!(
resolve_settlement_override_amount("$0.001", "1000000", 6).unwrap(),
"1000"
);
}
#[test]
fn rejects_empty_atomic() {
assert!(resolve_settlement_override_amount("", "1", 6).is_err());
}
#[test]
fn decimals_from_extra() {
let v = serde_json::json!({"decimals": 18});
assert_eq!(asset_decimals_from_extra(Some(&v)), 18);
assert_eq!(asset_decimals_from_extra(None), 6);
}
}