Skip to content

Commit f69cc98

Browse files
authored
scaled-ui: Floor all conversions to 0 (#159)
* scaled-ui: Floor all conversions to 0 #### Problem As pointed out at anza-xyz/agave#4663 (comment), the current scaling behavior is problematic for money amounts, because it uses the default Rust formatting of "round to nearest, ties to even" when producing a UI amount. This behavior can "overvalue" what's in someone's account. #### Summary of changes Floor all conversions to 0 (AKA truncate). * Remove negative tests
1 parent 14c06ee commit f69cc98

File tree

1 file changed

+45
-24
lines changed
  • program/src/extension/scaled_ui_amount

1 file changed

+45
-24
lines changed

program/src/extension/scaled_ui_amount/mod.rs

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,30 +57,40 @@ pub struct ScaledUiAmountConfig {
5757
pub new_multiplier: PodF64,
5858
}
5959
impl ScaledUiAmountConfig {
60-
fn total_multiplier(&self, decimals: u8, unix_timestamp: i64) -> f64 {
61-
let multiplier = if unix_timestamp >= self.new_multiplier_effective_timestamp.into() {
62-
self.new_multiplier
60+
fn current_multiplier(&self, unix_timestamp: i64) -> f64 {
61+
if unix_timestamp >= self.new_multiplier_effective_timestamp.into() {
62+
self.new_multiplier.into()
6363
} else {
64-
self.multiplier
65-
};
66-
f64::from(multiplier) / 10_f64.powi(decimals as i32)
64+
self.multiplier.into()
65+
}
66+
}
67+
68+
fn total_multiplier(&self, decimals: u8, unix_timestamp: i64) -> f64 {
69+
self.current_multiplier(unix_timestamp) / 10_f64.powi(decimals as i32)
6770
}
6871

6972
/// Convert a raw amount to its UI representation using the given decimals
70-
/// field. Excess zeroes or unneeded decimal point are trimmed.
73+
/// field.
74+
///
75+
/// The value is converted to a float and then truncated towards 0. Excess
76+
/// zeroes or unneeded decimal point are trimmed.
7177
pub fn amount_to_ui_amount(
7278
&self,
7379
amount: u64,
7480
decimals: u8,
7581
unix_timestamp: i64,
7682
) -> Option<String> {
77-
let scaled_amount = (amount as f64) * self.total_multiplier(decimals, unix_timestamp);
78-
let ui_amount = format!("{scaled_amount:.*}", decimals as usize);
83+
let scaled_amount = (amount as f64) * self.current_multiplier(unix_timestamp);
84+
let truncated_amount = scaled_amount.trunc() / 10_f64.powi(decimals as i32);
85+
let ui_amount = format!("{truncated_amount:.*}", decimals as usize);
7986
Some(trim_ui_amount_string(ui_amount, decimals))
8087
}
8188

8289
/// Try to convert a UI representation of a token amount to its raw amount
83-
/// using the given decimals field
90+
/// using the given decimals field.
91+
///
92+
/// The string is parsed to a float, scaled, and then truncated towards 0
93+
/// before being converted to a fixed-point number.
8494
pub fn try_ui_amount_into_amount(
8595
&self,
8696
ui_amount: &str,
@@ -94,9 +104,9 @@ impl ScaledUiAmountConfig {
94104
if amount > (u64::MAX as f64) || amount < (u64::MIN as f64) || amount.is_nan() {
95105
Err(ProgramError::InvalidArgument)
96106
} else {
97-
// this is important, if you round earlier, you'll get wrong "inf"
107+
// this is important, if you truncate earlier, you'll get wrong "inf"
98108
// answers
99-
Ok(amount.round() as u64)
109+
Ok(amount.trunc() as u64)
100110
}
101111
}
102112
}
@@ -116,12 +126,12 @@ mod tests {
116126
let new_multiplier = 10.0;
117127
let new_multiplier_effective_timestamp = 1;
118128
let config = ScaledUiAmountConfig {
119-
authority: OptionalNonZeroPubkey::default(),
120129
multiplier: PodF64::from(multiplier),
121130
new_multiplier: PodF64::from(new_multiplier),
122131
new_multiplier_effective_timestamp: UnixTimestamp::from(
123132
new_multiplier_effective_timestamp,
124133
),
134+
..Default::default()
125135
};
126136
assert_eq!(
127137
config.total_multiplier(0, new_multiplier_effective_timestamp),
@@ -140,7 +150,6 @@ mod tests {
140150
fn specific_amount_to_ui_amount() {
141151
// 5x
142152
let config = ScaledUiAmountConfig {
143-
authority: OptionalNonZeroPubkey::default(),
144153
multiplier: PodF64::from(5.0),
145154
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
146155
..Default::default()
@@ -160,20 +169,28 @@ mod tests {
160169

161170
// huge values
162171
let config = ScaledUiAmountConfig {
163-
authority: OptionalNonZeroPubkey::default(),
164172
multiplier: PodF64::from(f64::MAX),
165173
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
166174
..Default::default()
167175
};
168176
let ui_amount = config.amount_to_ui_amount(u64::MAX, 0, 0).unwrap();
169177
assert_eq!(ui_amount, "inf");
178+
179+
// truncation
180+
let config = ScaledUiAmountConfig {
181+
multiplier: PodF64::from(0.99),
182+
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
183+
..Default::default()
184+
};
185+
// This is really 0.99999... but it gets truncated
186+
let ui_amount = config.amount_to_ui_amount(101, 2, 0).unwrap();
187+
assert_eq!(ui_amount, "0.99");
170188
}
171189

172190
#[test]
173191
fn specific_ui_amount_to_amount() {
174192
// constant 5x
175193
let config = ScaledUiAmountConfig {
176-
authority: OptionalNonZeroPubkey::default(),
177194
multiplier: 5.0.into(),
178195
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
179196
..Default::default()
@@ -199,7 +216,6 @@ mod tests {
199216

200217
// huge values
201218
let config = ScaledUiAmountConfig {
202-
authority: OptionalNonZeroPubkey::default(),
203219
multiplier: 5.0.into(),
204220
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
205221
..Default::default()
@@ -209,7 +225,6 @@ mod tests {
209225
.unwrap();
210226
assert_eq!(amount, u64::MAX);
211227
let config = ScaledUiAmountConfig {
212-
authority: OptionalNonZeroPubkey::default(),
213228
multiplier: f64::MAX.into(),
214229
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
215230
..Default::default()
@@ -220,7 +235,6 @@ mod tests {
220235
.unwrap();
221236
assert_eq!(amount, 1);
222237
let config = ScaledUiAmountConfig {
223-
authority: OptionalNonZeroPubkey::default(),
224238
multiplier: 9.745314011399998e288.into(),
225239
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
226240
..Default::default()
@@ -237,7 +251,6 @@ mod tests {
237251

238252
// this is unfortunate, but underflows can happen due to floats
239253
let config = ScaledUiAmountConfig {
240-
authority: OptionalNonZeroPubkey::default(),
241254
multiplier: 1.0.into(),
242255
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
243256
..Default::default()
@@ -251,7 +264,6 @@ mod tests {
251264

252265
// overflow u64 fail
253266
let config = ScaledUiAmountConfig {
254-
authority: OptionalNonZeroPubkey::default(),
255267
multiplier: 0.1.into(),
256268
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
257269
..Default::default()
@@ -267,12 +279,23 @@ mod tests {
267279
config.try_ui_amount_into_amount(fail_ui_amount, 0, 0)
268280
);
269281
}
282+
283+
// truncation
284+
let config = ScaledUiAmountConfig {
285+
multiplier: PodF64::from(0.99),
286+
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
287+
..Default::default()
288+
};
289+
// There are a few possibilities for what "0.99" means, it could be 101
290+
// or 100 underlying tokens, but the result gives the fewest possible
291+
// tokens that give that UI amount.
292+
let amount = config.try_ui_amount_into_amount("0.99", 2, 0).unwrap();
293+
assert_eq!(amount, 100);
270294
}
271295

272296
#[test]
273297
fn specific_amount_to_ui_amount_no_scale() {
274298
let config = ScaledUiAmountConfig {
275-
authority: OptionalNonZeroPubkey::default(),
276299
multiplier: 1.0.into(),
277300
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
278301
..Default::default()
@@ -288,7 +311,6 @@ mod tests {
288311
#[test]
289312
fn specific_ui_amount_to_amount_no_scale() {
290313
let config = ScaledUiAmountConfig {
291-
authority: OptionalNonZeroPubkey::default(),
292314
multiplier: 1.0.into(),
293315
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
294316
..Default::default()
@@ -333,7 +355,6 @@ mod tests {
333355
decimals in 0u8..20u8,
334356
) {
335357
let config = ScaledUiAmountConfig {
336-
authority: OptionalNonZeroPubkey::default(),
337358
multiplier: scale.into(),
338359
new_multiplier_effective_timestamp: UnixTimestamp::from(1),
339360
..Default::default()

0 commit comments

Comments
 (0)