239 lines
9.4 KiB
Rust
239 lines
9.4 KiB
Rust
// file: crates/ksp-offchain-transport-lib/src/market_price_decimal.rs
|
|
// version: 4
|
|
|
|
/// Maximum accepted UTF-8 byte length for one textual decimal input.
|
|
pub const MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES: usize = 96;
|
|
/// Maximum scale retained by the canonical exact decimal representation.
|
|
pub const MARKET_PRICE_DECIMAL_MAX_SCALE: u8 = 18;
|
|
|
|
/// Exact positive decimal value used for one successful V1 SOL/USD observation.
|
|
///
|
|
/// The value is represented as a positive `u128` coefficient plus a bounded decimal scale. Trailing fractional zeroes are removed during construction, and
|
|
/// Serde serialization always emits the canonical decimal string instead of an IEEE-754 number.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
|
pub struct MarketPriceDecimal {
|
|
coefficient: u128,
|
|
scale: u8,
|
|
}
|
|
|
|
impl MarketPriceDecimal {
|
|
/// Parses a positive decimal or bounded scientific-notation value without converting through `f64`.
|
|
pub fn parse(source: &str) -> ksp_core_lib::Result<Self> {
|
|
if source.is_empty() || source.len() > crate::MARKET_PRICE_DECIMAL_MAX_INPUT_BYTES || source.trim() != source {
|
|
return std::result::Result::Err(invalid_decimal_error());
|
|
}
|
|
let (significand, exponent) = match split_exponent(source) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
let (digits, fractional_digits) = match significand_digits(significand) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(()) => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
let mut coefficient = match digits.parse::<u128>() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
if coefficient == 0 {
|
|
return std::result::Result::Err(invalid_decimal_error());
|
|
}
|
|
let mut effective_scale = i32::from(fractional_digits) - exponent;
|
|
if effective_scale < 0 {
|
|
let multiplication_power = match u32::try_from(-effective_scale) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
coefficient = match checked_multiply_power_of_ten(coefficient, multiplication_power) {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
effective_scale = 0;
|
|
}
|
|
if effective_scale > i32::from(crate::MARKET_PRICE_DECIMAL_MAX_SCALE) {
|
|
return std::result::Result::Err(invalid_decimal_error());
|
|
}
|
|
let mut scale = match u8::try_from(effective_scale) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
while scale > 0 && coefficient % 10 == 0 {
|
|
coefficient /= 10;
|
|
scale -= 1;
|
|
}
|
|
return std::result::Result::Ok(Self { coefficient, scale });
|
|
}
|
|
|
|
/// Parses one provider JSON number or string while preserving the original numeric lexeme.
|
|
pub(crate) fn parse_json_raw(raw: &serde_json::value::RawValue) -> ksp_core_lib::Result<Self> {
|
|
let source = raw.get();
|
|
if source.starts_with('"') {
|
|
let decoded = match serde_json::from_str::<std::string::String>(source) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(invalid_decimal_error()),
|
|
};
|
|
return crate::MarketPriceDecimal::parse(decoded.as_str());
|
|
}
|
|
return crate::MarketPriceDecimal::parse(source);
|
|
}
|
|
|
|
/// Returns the normalized integer coefficient.
|
|
#[must_use]
|
|
pub const fn coefficient(&self) -> u128 {
|
|
return self.coefficient;
|
|
}
|
|
|
|
/// Returns the normalized decimal scale.
|
|
#[must_use]
|
|
pub const fn scale(&self) -> u8 {
|
|
return self.scale;
|
|
}
|
|
|
|
/// Returns the canonical non-scientific decimal representation.
|
|
#[must_use]
|
|
pub fn to_canonical_string(&self) -> std::string::String {
|
|
let digits = self.coefficient.to_string();
|
|
if self.scale == 0 {
|
|
return digits;
|
|
}
|
|
let scale = usize::from(self.scale);
|
|
if digits.len() > scale {
|
|
let split = digits.len() - scale;
|
|
return std::format!("{}.{}", &digits[..split], &digits[split..]);
|
|
}
|
|
let zero_count = scale - digits.len();
|
|
return std::format!("0.{}{}", "0".repeat(zero_count), digits);
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for MarketPriceDecimal {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
return formatter.write_str(self.to_canonical_string().as_str());
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for MarketPriceDecimal {
|
|
type Err = ksp_core_lib::Error;
|
|
|
|
fn from_str(source: &str) -> std::result::Result<Self, Self::Err> {
|
|
return crate::MarketPriceDecimal::parse(source);
|
|
}
|
|
}
|
|
|
|
impl serde::Serialize for MarketPriceDecimal {
|
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
return serializer.serialize_str(self.to_canonical_string().as_str());
|
|
}
|
|
}
|
|
|
|
impl<'de> serde::Deserialize<'de> for MarketPriceDecimal {
|
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
return deserializer.deserialize_str(MarketPriceDecimalVisitor);
|
|
}
|
|
}
|
|
|
|
struct MarketPriceDecimalVisitor;
|
|
|
|
impl<'de> serde::de::Visitor<'de> for MarketPriceDecimalVisitor {
|
|
type Value = crate::MarketPriceDecimal;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
return formatter.write_str("a canonicalizable positive decimal string");
|
|
}
|
|
|
|
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
|
|
where
|
|
E: serde::de::Error,
|
|
{
|
|
return match crate::MarketPriceDecimal::parse(value) {
|
|
std::result::Result::Ok(decimal) => std::result::Result::Ok(decimal),
|
|
std::result::Result::Err(_) => std::result::Result::Err(E::custom("invalid KSP market-price decimal")),
|
|
};
|
|
}
|
|
}
|
|
|
|
fn checked_multiply_power_of_ten(mut value: u128, exponent: u32) -> std::option::Option<u128> {
|
|
let mut remaining = exponent;
|
|
while remaining > 0 {
|
|
value = match value.checked_mul(10) {
|
|
std::option::Option::Some(next) => next,
|
|
std::option::Option::None => return std::option::Option::None,
|
|
};
|
|
remaining -= 1;
|
|
}
|
|
return std::option::Option::Some(value);
|
|
}
|
|
|
|
fn invalid_decimal_error() -> ksp_core_lib::Error {
|
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, "invalid exact market-price decimal");
|
|
}
|
|
|
|
fn significand_digits(significand: &str) -> std::result::Result<(std::string::String, u8), ()> {
|
|
if significand.is_empty() || significand.starts_with('-') || significand.starts_with('+') {
|
|
return std::result::Result::Err(());
|
|
}
|
|
let mut digits = std::string::String::with_capacity(significand.len());
|
|
let mut fractional_digits: usize = 0;
|
|
let mut decimal_seen = false;
|
|
let mut digit_seen = false;
|
|
for character in significand.chars() {
|
|
if character.is_ascii_digit() {
|
|
digits.push(character);
|
|
digit_seen = true;
|
|
if decimal_seen {
|
|
fractional_digits += 1;
|
|
}
|
|
} else if character == '.' && !decimal_seen {
|
|
decimal_seen = true;
|
|
} else {
|
|
return std::result::Result::Err(());
|
|
}
|
|
}
|
|
if !digit_seen || significand.ends_with('.') || significand.starts_with('.') {
|
|
return std::result::Result::Err(());
|
|
}
|
|
let fractional_digits = match u8::try_from(fractional_digits) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(()),
|
|
};
|
|
return std::result::Result::Ok((digits, fractional_digits));
|
|
}
|
|
|
|
fn split_exponent(source: &str) -> std::result::Result<(&str, i32), ()> {
|
|
let mut separator_index = std::option::Option::None;
|
|
for (index, character) in source.char_indices() {
|
|
if character == 'e' || character == 'E' {
|
|
if separator_index.is_some() {
|
|
return std::result::Result::Err(());
|
|
}
|
|
separator_index = std::option::Option::Some(index);
|
|
}
|
|
}
|
|
let index = match separator_index {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::result::Result::Ok((source, 0)),
|
|
};
|
|
let significand = &source[..index];
|
|
let exponent_source = &source[index + 1..];
|
|
if exponent_source.is_empty() || exponent_source.len() > 4 {
|
|
return std::result::Result::Err(());
|
|
}
|
|
let exponent = match exponent_source.parse::<i32>() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::result::Result::Err(()),
|
|
};
|
|
if !(-128..=128).contains(&exponent) {
|
|
return std::result::Result::Err(());
|
|
}
|
|
return std::result::Result::Ok((significand, exponent));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../unit_tests/market_price_decimal.rs"]
|
|
mod tests;
|