From 4023cdedb201295ba39098bd9a7fea72e5f88cfe Mon Sep 17 00:00:00 2001 From: Jon C Date: Wed, 27 Nov 2024 21:16:55 +0100 Subject: [PATCH] token-2022: Take decimals into account for UI amount #### Problem The interest-bearing and scaled UI amount extensions don't take into account the mint decimals when printing the number, and they don't properly trim afterwards. This can be confusing. #### Summary of changes Update the UI amount conversion to take into account the decimals of precision, and then actually trim. Note that a lot of calculations become more precise because of this change! --- .../extension/interest_bearing_mint/mod.rs | 33 +++++++++++-------- .../src/extension/scaled_ui_amount/mod.rs | 8 +++-- token/program-2022/src/lib.rs | 13 +++++--- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/token/program-2022/src/extension/interest_bearing_mint/mod.rs b/token/program-2022/src/extension/interest_bearing_mint/mod.rs index bd2dc86c595..29fb5e250df 100644 --- a/token/program-2022/src/extension/interest_bearing_mint/mod.rs +++ b/token/program-2022/src/extension/interest_bearing_mint/mod.rs @@ -1,7 +1,10 @@ #[cfg(feature = "serde-traits")] use serde::{Deserialize, Serialize}; use { - crate::extension::{Extension, ExtensionType}, + crate::{ + extension::{Extension, ExtensionType}, + trim_ui_amount_string, + }, bytemuck::{Pod, Zeroable}, solana_program::program_error::ProgramError, spl_pod::{ @@ -81,7 +84,7 @@ impl InterestBearingConfig { } /// Convert a raw amount to its UI representation using the given decimals - /// field Excess zeroes or unneeded decimal point are trimmed. + /// field. Excess zeroes or unneeded decimal point are trimmed. pub fn amount_to_ui_amount( &self, amount: u64, @@ -90,7 +93,8 @@ impl InterestBearingConfig { ) -> Option { let scaled_amount_with_interest = (amount as f64) * self.total_scale(decimals, unix_timestamp)?; - Some(scaled_amount_with_interest.to_string()) + let ui_amount = format!("{scaled_amount_with_interest:.*}", decimals as usize); + Some(trim_ui_amount_string(ui_amount, decimals)) } /// Try to convert a UI representation of a token amount to its raw amount @@ -167,6 +171,7 @@ mod tests { #[test] fn specific_amount_to_ui_amount() { + const ONE: u64 = 1_000_000_000_000_000_000; // constant 5% let config = InterestBearingConfig { rate_authority: OptionalNonZeroPubkey::default(), @@ -177,25 +182,25 @@ mod tests { }; // 1 year at 5% gives a total of exp(0.05) = 1.0512710963760241 let ui_amount = config - .amount_to_ui_amount(1, 0, INT_SECONDS_PER_YEAR) + .amount_to_ui_amount(ONE, 18, INT_SECONDS_PER_YEAR) .unwrap(); - assert_eq!(ui_amount, "1.0512710963760241"); + assert_eq!(ui_amount, "1.051271096376024117"); // with 1 decimal place let ui_amount = config - .amount_to_ui_amount(1, 1, INT_SECONDS_PER_YEAR) + .amount_to_ui_amount(ONE, 19, INT_SECONDS_PER_YEAR) .unwrap(); - assert_eq!(ui_amount, "0.10512710963760241"); + assert_eq!(ui_amount, "0.1051271096376024117"); // with 10 decimal places let ui_amount = config - .amount_to_ui_amount(1, 10, INT_SECONDS_PER_YEAR) + .amount_to_ui_amount(ONE, 28, INT_SECONDS_PER_YEAR) .unwrap(); - assert_eq!(ui_amount, "0.00000000010512710963760242"); // different digit at the end! + assert_eq!(ui_amount, "0.0000000001051271096376024175"); // different digits at the end! // huge amount with 10 decimal places let ui_amount = config .amount_to_ui_amount(10_000_000_000, 10, INT_SECONDS_PER_YEAR) .unwrap(); - assert_eq!(ui_amount, "1.0512710963760241"); + assert_eq!(ui_amount, "1.0512710964"); // negative let config = InterestBearingConfig { @@ -207,9 +212,9 @@ mod tests { }; // 1 year at -5% gives a total of exp(-0.05) = 0.951229424500714 let ui_amount = config - .amount_to_ui_amount(1, 0, INT_SECONDS_PER_YEAR) + .amount_to_ui_amount(ONE, 18, INT_SECONDS_PER_YEAR) .unwrap(); - assert_eq!(ui_amount, "0.951229424500714"); + assert_eq!(ui_amount, "0.951229424500713905"); // net out let config = InterestBearingConfig { @@ -236,12 +241,12 @@ mod tests { let ui_amount = config .amount_to_ui_amount(u64::MAX, 0, INT_SECONDS_PER_YEAR * 2) .unwrap(); - assert_eq!(ui_amount, "20386805083448100000"); + assert_eq!(ui_amount, "20386805083448098816"); let ui_amount = config .amount_to_ui_amount(u64::MAX, 0, INT_SECONDS_PER_YEAR * 10_000) .unwrap(); // there's an underflow risk, but it works! - assert_eq!(ui_amount, "258917064265813830000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); + assert_eq!(ui_amount, "258917064265813826192025834755112557504850551118283225815045099303279643822914042296793377611277551888244755303462190670431480816358154467489350925148558569427069926786360814068189956495940285398273555561779717914539956777398245259214848"); } #[test] diff --git a/token/program-2022/src/extension/scaled_ui_amount/mod.rs b/token/program-2022/src/extension/scaled_ui_amount/mod.rs index 839caf585c5..80b635f207e 100644 --- a/token/program-2022/src/extension/scaled_ui_amount/mod.rs +++ b/token/program-2022/src/extension/scaled_ui_amount/mod.rs @@ -1,7 +1,10 @@ #[cfg(feature = "serde-traits")] use serde::{Deserialize, Serialize}; use { - crate::extension::{Extension, ExtensionType}, + crate::{ + extension::{Extension, ExtensionType}, + trim_ui_amount_string, + }, bytemuck::{Pod, Zeroable}, solana_program::program_error::ProgramError, spl_pod::{optional_keys::OptionalNonZeroPubkey, primitives::PodI64}, @@ -72,7 +75,8 @@ impl ScaledUiAmountConfig { unix_timestamp: i64, ) -> Option { let scaled_amount = (amount as f64) * self.total_multiplier(decimals, unix_timestamp); - Some(scaled_amount.to_string()) + let ui_amount = format!("{scaled_amount:.*}", decimals as usize); + Some(trim_ui_amount_string(ui_amount, decimals)) } /// Try to convert a UI representation of a token amount to its raw amount diff --git a/token/program-2022/src/lib.rs b/token/program-2022/src/lib.rs index 4d9969cc3ff..93d2e058b6f 100644 --- a/token/program-2022/src/lib.rs +++ b/token/program-2022/src/lib.rs @@ -62,12 +62,17 @@ pub fn amount_to_ui_amount_string(amount: u64, decimals: u8) -> String { /// Convert a raw amount to its UI representation using the given decimals field /// Excess zeroes or unneeded decimal point are trimmed. pub fn amount_to_ui_amount_string_trimmed(amount: u64, decimals: u8) -> String { - let mut s = amount_to_ui_amount_string(amount, decimals); + let s = amount_to_ui_amount_string(amount, decimals); + trim_ui_amount_string(s, decimals) +} + +/// Trims a string number by removing excess zeroes or unneeded decimal point +fn trim_ui_amount_string(mut ui_amount: String, decimals: u8) -> String { if decimals > 0 { - let zeros_trimmed = s.trim_end_matches('0'); - s = zeros_trimmed.trim_end_matches('.').to_string(); + let zeros_trimmed = ui_amount.trim_end_matches('0'); + ui_amount = zeros_trimmed.trim_end_matches('.').to_string(); } - s + ui_amount } /// Try to convert a UI representation of a token amount to its raw amount using