From 2ccb96e0e86711f7fa5478c325e0ca71842bc6d9 Mon Sep 17 00:00:00 2001 From: 0xgleb Date: Wed, 15 Oct 2025 15:55:13 -0300 Subject: [PATCH 1/4] expose whether conversion was lossy or not --- crates/float/src/js_api.rs | 115 +++++++++++++++++++++---------------- crates/float/src/lib.rs | 99 +++++++++++++++++++++++++------ test_js/float.test.ts | 22 +++++++ 3 files changed, 166 insertions(+), 70 deletions(-) diff --git a/crates/float/src/js_api.rs b/crates/float/src/js_api.rs index a206839..7c3e5f3 100644 --- a/crates/float/src/js_api.rs +++ b/crates/float/src/js_api.rs @@ -1,11 +1,44 @@ use crate::{Float, FloatError}; use revm::primitives::{B256, U256}; +use serde::{Deserialize, Serialize}; use std::{ ops::{Add, Div, Mul, Neg, Sub}, str::FromStr, }; use wasm_bindgen_utils::prelude::{js_sys::BigInt, *}; +#[wasm_bindgen] +pub struct FromFixedDecimalLossyResult { + float: Float, + lossless: bool, +} + +#[wasm_bindgen] +impl FromFixedDecimalLossyResult { + #[wasm_bindgen(getter)] + pub fn float(&self) -> Float { + self.float + } + + #[wasm_bindgen(getter)] + pub fn lossless(&self) -> bool { + self.lossless + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct ToFixedDecimalLossyResult { + pub value: String, + pub lossless: bool, +} + +impl From for JsValue { + fn from(val: ToFixedDecimalLossyResult) -> Self { + serde_wasm_bindgen::to_value(&val).unwrap() + } +} + #[wasm_bindgen] impl Float { /// Returns the 32-byte hexadecimal string representation of the float. @@ -57,6 +90,26 @@ impl Float { pub fn from_bigint(value: BigInt) -> Float { Self::try_from_bigint(value).unwrap_throw() } + + /// Converts a fixed-point decimal value to a `Float` using the specified number of decimals lossy. + /// + /// # Returns + /// + /// FromFixedDecimalLossyResult containing the Float and lossless flag. + #[wasm_bindgen(js_name = "fromFixedDecimalLossy")] + pub fn from_fixed_decimal_lossy_wasm( + value: BigInt, + decimals: u8, + ) -> Result { + let value_str: String = value + .to_string(10) + .map(|s| s.into()) + .map_err(|e| JsValue::from(&e))?; + let val = U256::from_str(&value_str).map_err(|e| JsValue::from_str(&e.to_string()))?; + let (float, lossless) = Float::from_fixed_decimal_lossy(val, decimals) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(FromFixedDecimalLossyResult { float, lossless }) + } } #[wasm_export] @@ -175,65 +228,25 @@ impl Float { .map_err(|e| FloatError::JsSysError(e.to_string().into())) } - /// Converts a fixed-point decimal value to a `Float` using the specified number of decimals lossy. - /// - /// # Arguments - /// - /// * `value` - The fixed-point decimal value as a `string`. - /// * `decimals` - The number of decimals in the fixed-point representation. - /// - /// # Returns - /// - /// * `Ok(Float)` - The resulting `Float` value. - /// * `Err(FloatError)` - If the conversion fails. - /// - /// # Example - /// - /// ```typescript - /// const floatResult = Float.fromFixedDecimalLossy("12345", 2); - /// if (floatResult.error) { - /// console.error(floatResult.error); - /// } - /// const float = floatResult.value; - /// assert(float.format() === "123.45"); - /// ``` - #[wasm_export(js_name = "fromFixedDecimalLossy", preserve_js_class)] - pub fn from_fixed_decimal_lossy_js(value: BigInt, decimals: u8) -> Result { - let value_str: String = value.to_string(10)?.into(); - let val = U256::from_str(&value_str)?; - Self::from_fixed_decimal_lossy(val, decimals) - } - /// Converts a `Float` to a fixed-point decimal value using the specified number of decimals lossy. /// - /// # Arguments - /// - /// * `decimals` - The number of decimals in the fixed-point representation. - /// /// # Returns /// - /// * `Ok(String)` - The resulting fixed-point decimal value as a string. - /// * `Err(FloatError)` - If the conversion fails. - /// - /// # Example - /// - /// ```typescript - /// const float = Float.fromFixedDecimal(12345n, 3).value!; - /// const result = float.toFixedDecimalLossy(2); - /// if (result.error) { - /// console.error(result.error); - /// } - /// assert(result.value === "1234"); - /// ``` + /// ToFixedDecimalLossyResult containing the value and lossless flag. #[wasm_export( js_name = "toFixedDecimalLossy", preserve_js_class, - unchecked_return_type = "bigint" + unchecked_return_type = "ToFixedDecimalLossyResult" )] - pub fn to_fixed_decimal_lossy_js(&self, decimals: u8) -> Result { - let fixed = self.to_fixed_decimal_lossy(decimals)?; - BigInt::from_str(&fixed.to_string()) - .map_err(|e| FloatError::JsSysError(e.to_string().into())) + pub fn to_fixed_decimal_lossy_js( + &self, + decimals: u8, + ) -> Result { + let (fixed, lossless) = self.to_fixed_decimal_lossy(decimals)?; + Ok(ToFixedDecimalLossyResult { + value: fixed.to_string(), + lossless, + }) } /// Parses a decimal string into a `Float`. diff --git a/crates/float/src/lib.rs b/crates/float/src/lib.rs index 50becfe..e94d5ac 100644 --- a/crates/float/src/lib.rs +++ b/crates/float/src/lib.rs @@ -132,7 +132,7 @@ impl Float { /// /// # Returns /// - /// * `Ok(Float)` - The resulting `Float` value. + /// * `Ok((Float, bool))` - The resulting `Float` value and a boolean indicating if the conversion was lossless. /// * `Err(FloatError)` - If the conversion fails. /// /// # Example @@ -144,18 +144,19 @@ impl Float { /// // 123.45 with 2 decimals is represented as 12345 /// let value = U256::from(12345u64); /// let decimals = 2u8; - /// let float = Float::from_fixed_decimal_lossy(value, decimals)?; + /// let (float, lossless) = Float::from_fixed_decimal_lossy(value, decimals)?; /// assert_eq!(float.format()?, "123.45"); + /// assert!(lossless); /// /// anyhow::Ok(()) /// ``` - pub fn from_fixed_decimal_lossy(value: U256, decimals: u8) -> Result { + pub fn from_fixed_decimal_lossy(value: U256, decimals: u8) -> Result<(Self, bool), FloatError> { let calldata = DecimalFloat::fromFixedDecimalLossyCall { value, decimals }.abi_encode(); execute_call(Bytes::from(calldata), |output| { let decoded = DecimalFloat::fromFixedDecimalLossyCall::abi_decode_returns(output.as_ref())?; - Ok(Float(decoded._0)) + Ok((Float(decoded._0), decoded._1)) }) } @@ -167,7 +168,7 @@ impl Float { /// /// # Returns /// - /// * `Ok(U256)` - The resulting fixed-point decimal value. + /// * `Ok((U256, bool))` - The resulting fixed-point decimal value and a boolean indicating if the conversion was lossless. /// * `Err(FloatError)` - If the conversion fails. /// /// # Example @@ -178,19 +179,20 @@ impl Float { /// /// // 123.45 with 2 decimals becomes 12345 /// let float = Float::from_fixed_decimal(U256::from(12345), 3)?; - /// let fixed = float.to_fixed_decimal_lossy(2)?; + /// let (fixed, lossless) = float.to_fixed_decimal_lossy(2)?; /// assert_eq!(fixed, U256::from(1234u64)); + /// assert!(!lossless); /// /// anyhow::Ok(()) /// ``` - pub fn to_fixed_decimal_lossy(self, decimals: u8) -> Result { + pub fn to_fixed_decimal_lossy(self, decimals: u8) -> Result<(U256, bool), FloatError> { let Float(float) = self; let calldata = DecimalFloat::toFixedDecimalLossyCall { float, decimals }.abi_encode(); execute_call(Bytes::from(calldata), |output| { let decoded = DecimalFloat::toFixedDecimalLossyCall::abi_decode_returns(output.as_ref())?; - Ok(decoded._0) + Ok((decoded._0, decoded._1)) }) } @@ -2001,7 +2003,8 @@ mod tests { #[test] fn test_from_fixed_decimal_lossy() { - let cases = vec![ + // Test lossless conversions (values that fit in Float's precision) + let lossless_cases = vec![ (U256::from(0u128), 0u8, "0"), (U256::from(0u128), 18u8, "0"), (U256::from(1u128), 18u8, "1e-18"), @@ -2010,27 +2013,74 @@ mod tests { (U256::from(1000000000000000000u128), 18u8, "1"), ]; - for (amount, decimals, expected) in cases { - let float = Float::from_fixed_decimal_lossy(amount, decimals).expect("should convert"); + for (amount, decimals, expected) in lossless_cases { + let (float, lossless) = + Float::from_fixed_decimal_lossy(amount, decimals).expect("should convert"); let expected = Float::parse(expected.to_string()).unwrap(); assert!(float.eq(expected).unwrap()); + assert!( + lossless, + "conversion should be lossless for amount={}, decimals={}", + amount, decimals + ); } + + // Test lossy conversion with U256::MAX (too large to fit in Float's 224-bit coefficient) + let (float, lossless) = Float::from_fixed_decimal_lossy(U256::MAX, 1).unwrap(); + assert!(!lossless, "U256::MAX conversion should be lossy"); + assert!(!float.is_zero().unwrap(), "result should not be zero"); } #[test] fn test_to_fixed_decimal_lossy() { - let cases = vec![ - (U256::from(0), 0u8, 0u128), - (U256::from(0), 18u8, 0u128), + // Test lossy conversions (loss of precision) + let lossy_cases = vec![ (U256::from(1), 18u8, 0u128), (U256::from(123456789), 0u8, 12345678u128), (U256::from(123456789), 2u8, 12345678u128), ]; - for (input, decimals, expected) in cases { + for (input, decimals, expected) in lossy_cases { let float = Float::from_fixed_decimal(input, decimals + 1).unwrap(); - let fixed = float.to_fixed_decimal_lossy(decimals).unwrap(); - assert_eq!(fixed, U256::from(expected)); + let (fixed, lossless) = float.to_fixed_decimal_lossy(decimals).unwrap(); + assert_eq!( + fixed, + U256::from(expected), + "wrong value for input={}, decimals={}", + input, + decimals + ); + assert!( + !lossless, + "should be lossy for input={}, decimals={}", + input, decimals + ); + } + + // Test lossless conversions (no loss of precision) + let lossless_cases = vec![ + // Zero is always lossless + (U256::from(0), 0u8, 0u128), + (U256::from(0), 18u8, 0u128), + // Converting 12340 with 3 decimals (12.340) to 2 decimals (12.34) is lossless + (U256::from(12340), 3u8, 1234u128), + ]; + + for (input, decimals, expected) in lossless_cases { + let float = Float::from_fixed_decimal(input, decimals + 1).unwrap(); + let (fixed, lossless) = float.to_fixed_decimal_lossy(decimals).unwrap(); + assert_eq!( + fixed, + U256::from(expected), + "wrong value for input={}, decimals={}", + input, + decimals + ); + assert!( + lossless, + "should be lossless for input={}, decimals={}", + input, decimals + ); } } @@ -2042,12 +2092,23 @@ mod tests { let exponent = -(decimals as i32 + 1); let value = U256::from(coeff); - let float = Float::from_fixed_decimal_lossy(value, decimals + 1).unwrap(); + let (float, from_lossless) = Float::from_fixed_decimal_lossy(value, decimals + 1).unwrap(); let expected = Float::pack_lossless(coeff, exponent).unwrap(); prop_assert!(float.eq(expected).unwrap()); - let fixed = float.to_fixed_decimal_lossy(decimals).unwrap(); + // from_fixed_decimal_lossy should be lossless for values that fit in Float's precision + prop_assert!(from_lossless, "from_fixed_decimal_lossy should be lossless for coeff={coeff}"); + + let (fixed, to_lossless) = float.to_fixed_decimal_lossy(decimals).unwrap(); assert_eq!(fixed, value / U256::from(10)); + + // Converting from decimals+1 to decimals should be lossy unless the value is zero or + // the last digit is zero (divisible by 10) + if value == U256::ZERO || value % U256::from(10) == U256::ZERO { + prop_assert!(to_lossless, "to_fixed_decimal_lossy should be lossless when last digit is 0: value={}", value); + } else { + prop_assert!(!to_lossless, "to_fixed_decimal_lossy should be lossy when losing precision: value={}", value); + } } } diff --git a/test_js/float.test.ts b/test_js/float.test.ts index 028a19b..91ad527 100644 --- a/test_js/float.test.ts +++ b/test_js/float.test.ts @@ -41,6 +41,28 @@ describe('Test Float Bindings', () => { expect(result).toBe(originalValue); }); + it('should test fromFixedDecimalLossy with lossless conversion', () => { + const result = Float.fromFixedDecimalLossy(12345n, 2); + expect(result.float.format()?.value!).toBe('123.45'); + expect(result.lossless).toBe(true); + }); + + it('should test toFixedDecimalLossy with lossy conversion', () => { + const float = Float.fromFixedDecimal(12345n, 3)?.value!; + const result = float.toFixedDecimalLossy(2); + expect(result.error).toBeUndefined(); + expect(result.value!.value).toBe('1234'); + expect(result.value!.lossless).toBe(false); + }); + + it('should test toFixedDecimalLossy with lossless conversion', () => { + const float = Float.fromFixedDecimal(12340n, 3)?.value!; + const result = float.toFixedDecimalLossy(2); + expect(result.error).toBeUndefined(); + expect(result.value!.value).toBe('1234'); + expect(result.value!.lossless).toBe(true); + }); + it('should try from bigint', () => { const result = Float.tryFromBigint(5n); From bb4cba579853fd45992db48b9add8cc4ad566aab Mon Sep 17 00:00:00 2001 From: 0xgleb Date: Wed, 15 Oct 2025 16:42:36 -0300 Subject: [PATCH 2/4] impl_wasm_traits! --- crates/float/src/js_api.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/float/src/js_api.rs b/crates/float/src/js_api.rs index 7c3e5f3..b32bbe1 100644 --- a/crates/float/src/js_api.rs +++ b/crates/float/src/js_api.rs @@ -5,7 +5,10 @@ use std::{ ops::{Add, Div, Mul, Neg, Sub}, str::FromStr, }; -use wasm_bindgen_utils::prelude::{js_sys::BigInt, *}; +use wasm_bindgen_utils::{ + impl_wasm_traits, + prelude::{js_sys::BigInt, *}, +}; #[wasm_bindgen] pub struct FromFixedDecimalLossyResult { @@ -27,17 +30,11 @@ impl FromFixedDecimalLossyResult { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] pub struct ToFixedDecimalLossyResult { pub value: String, pub lossless: bool, } - -impl From for JsValue { - fn from(val: ToFixedDecimalLossyResult) -> Self { - serde_wasm_bindgen::to_value(&val).unwrap() - } -} +impl_wasm_traits!(ToFixedDecimalLossyResult); #[wasm_bindgen] impl Float { From 9c77e6da885c969e3959bf7c205b5c3f7a4024b4 Mon Sep 17 00:00:00 2001 From: 0xgleb Date: Wed, 15 Oct 2025 17:33:20 -0300 Subject: [PATCH 3/4] address feedback --- crates/float/src/js_api.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/float/src/js_api.rs b/crates/float/src/js_api.rs index b32bbe1..431a156 100644 --- a/crates/float/src/js_api.rs +++ b/crates/float/src/js_api.rs @@ -94,14 +94,11 @@ impl Float { /// /// FromFixedDecimalLossyResult containing the Float and lossless flag. #[wasm_bindgen(js_name = "fromFixedDecimalLossy")] - pub fn from_fixed_decimal_lossy_wasm( + pub fn from_fixed_decimal_lossy_js( value: BigInt, decimals: u8, ) -> Result { - let value_str: String = value - .to_string(10) - .map(|s| s.into()) - .map_err(|e| JsValue::from(&e))?; + let value_str: String = value.to_string(10).map_err(|e| JsValue::from(&e))?.into(); let val = U256::from_str(&value_str).map_err(|e| JsValue::from_str(&e.to_string()))?; let (float, lossless) = Float::from_fixed_decimal_lossy(val, decimals) .map_err(|e| JsValue::from_str(&e.to_string()))?; From 52d40e251b8895078271613955757a77a974fbbb Mon Sep 17 00:00:00 2001 From: 0xgleb Date: Wed, 15 Oct 2025 17:55:44 -0300 Subject: [PATCH 4/4] extend docs --- crates/float/src/js_api.rs | 40 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/float/src/js_api.rs b/crates/float/src/js_api.rs index 431a156..68c47a0 100644 --- a/crates/float/src/js_api.rs +++ b/crates/float/src/js_api.rs @@ -88,11 +88,47 @@ impl Float { Self::try_from_bigint(value).unwrap_throw() } - /// Converts a fixed-point decimal value to a `Float` using the specified number of decimals lossy. + /// Converts a fixed-point decimal value to a `Float` using the specified number of decimals, + /// allowing lossy conversions and reporting whether precision was preserved. + /// + /// This function attempts to convert a fixed-point decimal representation to a `Float`. + /// Unlike `fromFixedDecimal`, this method will not fail if precision is lost during conversion, + /// but instead reports the loss through the `lossless` flag in the result. + /// + /// # Arguments + /// + /// * `value` - The fixed-point decimal value as a bigint (e.g., 12345n for 123.45 with 2 decimals). + /// * `decimals` - The number of decimal places in the fixed-point representation (0-255). /// /// # Returns /// - /// FromFixedDecimalLossyResult containing the Float and lossless flag. + /// Returns a `FromFixedDecimalLossyResult` containing: + /// * `float` - The resulting `Float` value. + /// * `lossless` - Boolean flag indicating whether the conversion preserved all precision (true) or was lossy (false). + /// + /// # Errors + /// + /// Throws a `JsValue` error if: + /// * The bigint value cannot be converted to a string. + /// * The value string cannot be parsed as a valid U256. + /// * The underlying EVM conversion fails. + /// + /// # Example + /// + /// ```typescript + /// // Lossless conversion + /// const result = Float.fromFixedDecimalLossy(12345n, 2); + /// const float = result.float; + /// const wasLossless = result.lossless; + /// assert(float.format()?.value === "123.45"); + /// assert(wasLossless === true); + /// + /// // Potentially lossy conversion + /// const result2 = Float.fromFixedDecimalLossy(123456789012345678901234567890n, 18); + /// if (!result2.lossless) { + /// console.warn("Precision was lost during conversion"); + /// } + /// ``` #[wasm_bindgen(js_name = "fromFixedDecimalLossy")] pub fn from_fixed_decimal_lossy_js( value: BigInt,