1use crate::error::RoshiError;
4
5pub const SHARE_DECIMALS: u8 = 9;
6pub const BPS_DENOMINATOR: u16 = 10_000;
7
8pub type MathResult<T> = Result<T, RoshiError>;
9
10pub fn pow10(decimals: u8) -> MathResult<u128> {
11 10u128
12 .checked_pow(u32::from(decimals))
13 .ok_or(RoshiError::InvalidDecimals)
14}
15
16pub fn mul_div_floor(lhs: u128, rhs: u128, denominator: u128) -> MathResult<u128> {
17 if denominator == 0 {
18 return Err(RoshiError::DivisionByZero);
19 }
20
21 lhs.checked_mul(rhs)
22 .ok_or(RoshiError::Overflow)
23 .map(|product| product / denominator)
24}
25
26pub fn mul_div_ceil(lhs: u128, rhs: u128, denominator: u128) -> MathResult<u128> {
27 if denominator == 0 {
28 return Err(RoshiError::DivisionByZero);
29 }
30
31 let product = lhs.checked_mul(rhs).ok_or(RoshiError::Overflow)?;
32 let quotient = product / denominator;
33
34 if product % denominator == 0 {
35 Ok(quotient)
36 } else {
37 quotient.checked_add(1).ok_or(RoshiError::Overflow)
38 }
39}
40
41pub fn checked_u64(value: u128) -> MathResult<u64> {
42 u64::try_from(value).map_err(|_| RoshiError::ResultDoesNotFit)
43}
44
45pub fn mul_div_floor_u64(lhs: u64, rhs: u64, denominator: u64) -> MathResult<u64> {
46 let value = mul_div_floor(u128::from(lhs), u128::from(rhs), u128::from(denominator))?;
47 checked_u64(value)
48}
49
50pub fn mul_div_ceil_u64(lhs: u64, rhs: u64, denominator: u64) -> MathResult<u64> {
51 let value = mul_div_ceil(u128::from(lhs), u128::from(rhs), u128::from(denominator))?;
52 checked_u64(value)
53}
54
55pub fn bps_floor(amount: u64, bps: u16) -> MathResult<u64> {
56 mul_div_floor_u64(amount, u64::from(bps), u64::from(BPS_DENOMINATOR))
57}
58
59pub fn bps_ceil(amount: u64, bps: u16) -> MathResult<u64> {
60 mul_div_ceil_u64(amount, u64::from(bps), u64::from(BPS_DENOMINATOR))
61}
62
63pub fn validate_percentage_bps(bps: u16) -> MathResult<()> {
64 if bps > BPS_DENOMINATOR {
65 return Err(RoshiError::InvalidBps);
66 }
67
68 Ok(())
69}
70
71pub fn base_atoms_from_asset_atoms(
72 asset_atoms: u64,
73 price_value: u128,
74 price_decimals: u8,
75) -> MathResult<u64> {
76 let scale = pow10(price_decimals)?;
77 let value = mul_div_floor(u128::from(asset_atoms), price_value, scale)?;
78 checked_u64(value)
79}
80
81pub fn initial_shares_from_base_atoms(base_atoms: u64, base_decimals: u8) -> MathResult<u64> {
82 let share_scale = pow10(SHARE_DECIMALS)?;
83 let base_scale = pow10(base_decimals)?;
84 let shares = mul_div_floor(u128::from(base_atoms), share_scale, base_scale)?;
85 checked_nonzero_u64(shares)
86}
87
88pub fn shares_for_deposit(
89 base_atoms: u64,
90 total_assets: u64,
91 total_shares: u64,
92) -> MathResult<u64> {
93 if total_assets == 0 || total_shares == 0 {
94 return Err(RoshiError::InvalidVaultState);
95 }
96
97 let shares = mul_div_floor(
98 u128::from(base_atoms),
99 u128::from(total_shares),
100 u128::from(total_assets),
101 )?;
102 checked_nonzero_u64(shares)
103}
104
105pub fn assets_for_redeem(shares: u64, total_assets: u64, total_shares: u64) -> MathResult<u64> {
106 if total_assets == 0 || total_shares == 0 || shares > total_shares {
107 return Err(RoshiError::InvalidVaultState);
108 }
109
110 let assets = mul_div_floor(
111 u128::from(shares),
112 u128::from(total_assets),
113 u128::from(total_shares),
114 )?;
115 checked_nonzero_u64(assets)
116}
117
118pub fn share_price_from_assets(total_assets: u64, total_shares: u64) -> MathResult<u64> {
119 if total_shares == 0 {
120 return Err(RoshiError::InvalidVaultState);
121 }
122
123 let share_scale = pow10(SHARE_DECIMALS)?;
124 let share_price = mul_div_floor(
125 u128::from(total_assets),
126 share_scale,
127 u128::from(total_shares),
128 )?;
129 checked_u64(share_price)
130}
131
132pub fn performance_fee_for_nav(
133 gross_total_assets: u64,
134 total_shares: u64,
135 high_watermark: u64,
136 performance_fee_bps: u16,
137) -> MathResult<(u64, u64, u64)> {
138 validate_percentage_bps(performance_fee_bps)?;
139
140 if total_shares == 0 {
141 return Ok((0, gross_total_assets, high_watermark));
142 }
143
144 let gross_share_price = share_price_from_assets(gross_total_assets, total_shares)?;
145 if high_watermark == 0 || gross_share_price <= high_watermark || performance_fee_bps == 0 {
146 return Ok((0, gross_total_assets, high_watermark.max(gross_share_price)));
147 }
148
149 let share_scale = pow10(SHARE_DECIMALS)?;
150 let high_watermark_assets = checked_u64(mul_div_ceil(
151 u128::from(high_watermark),
152 u128::from(total_shares),
153 share_scale,
154 )?)?;
155 let profit_assets = gross_total_assets
156 .checked_sub(high_watermark_assets)
157 .ok_or(RoshiError::Overflow)?;
158 let fee_assets = bps_floor(profit_assets, performance_fee_bps)?;
159 let net_total_assets = gross_total_assets
160 .checked_sub(fee_assets)
161 .ok_or(RoshiError::Overflow)?;
162 let net_share_price = share_price_from_assets(net_total_assets, total_shares)?;
163
164 Ok((
165 fee_assets,
166 net_total_assets,
167 high_watermark.max(net_share_price),
168 ))
169}
170
171fn checked_nonzero_u64(value: u128) -> MathResult<u64> {
172 let value = checked_u64(value)?;
173 if value == 0 {
174 return Err(RoshiError::ZeroOutput);
175 }
176
177 Ok(value)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use proptest::prelude::*;
184
185 #[test]
186 fn pow10_rejects_unsupported_decimals() {
187 assert!(pow10(38).is_ok());
188 assert_eq!(pow10(39), Err(RoshiError::InvalidDecimals));
189 }
190
191 #[test]
192 fn mul_div_floor_and_ceil_handle_boundaries() {
193 assert_eq!(mul_div_floor_u64(10, 2, 4), Ok(5));
194 assert_eq!(mul_div_floor_u64(10, 2, 6), Ok(3));
195 assert_eq!(mul_div_ceil_u64(10, 2, 4), Ok(5));
196 assert_eq!(mul_div_ceil_u64(10, 2, 6), Ok(4));
197 assert_eq!(mul_div_floor_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
198 assert_eq!(mul_div_ceil_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
199 }
200
201 #[test]
202 fn mul_div_rejects_overflow_and_downcast() {
203 assert_eq!(mul_div_floor(u128::MAX, 2, 1), Err(RoshiError::Overflow));
204 assert_eq!(
205 mul_div_floor_u64(u64::MAX, u64::MAX, 1),
206 Err(RoshiError::ResultDoesNotFit)
207 );
208 }
209
210 #[test]
211 fn bps_helpers_use_standard_denominator() {
212 assert_eq!(bps_floor(101, 100), Ok(1));
213 assert_eq!(bps_ceil(101, 100), Ok(2));
214 assert_eq!(bps_floor(42, 10_001), Ok(42));
215 assert_eq!(bps_ceil(42, 10_001), Ok(43));
216 }
217
218 #[test]
219 fn percentage_bps_validation_caps_at_full_percentage() {
220 assert_eq!(validate_percentage_bps(0), Ok(()));
221 assert_eq!(validate_percentage_bps(10_000), Ok(()));
222 assert_eq!(validate_percentage_bps(10_001), Err(RoshiError::InvalidBps));
223 }
224
225 #[test]
226 fn normalizes_oracle_values_into_base_atoms() {
227 assert_eq!(
228 base_atoms_from_asset_atoms(1_000_000, 2_500_000_000, 9),
229 Ok(2_500_000)
230 );
231 assert_eq!(
232 base_atoms_from_asset_atoms(u64::MAX, u128::from(u64::MAX), 0),
233 Err(RoshiError::ResultDoesNotFit)
234 );
235 }
236
237 #[test]
238 fn normalization_can_round_to_zero_without_failing() {
239 assert_eq!(base_atoms_from_asset_atoms(0, 1_000_000_000, 9), Ok(0));
240 assert_eq!(base_atoms_from_asset_atoms(1, 1, 9), Ok(0));
241 }
242
243 #[test]
244 fn normalization_rejects_invalid_price_decimals() {
245 assert_eq!(
246 base_atoms_from_asset_atoms(1, 1, 39),
247 Err(RoshiError::InvalidDecimals)
248 );
249 }
250
251 #[test]
252 fn initial_share_scale_uses_fixed_share_decimals_and_base_decimals() {
253 assert_eq!(
254 initial_shares_from_base_atoms(1_000_000, 6),
255 Ok(1_000_000_000)
256 );
257 assert_eq!(
258 initial_shares_from_base_atoms(1_000_000_000, 9),
259 Ok(1_000_000_000)
260 );
261 assert_eq!(
262 initial_shares_from_base_atoms(1, 12),
263 Err(RoshiError::ZeroOutput)
264 );
265 }
266
267 #[test]
268 fn initial_share_scale_rejects_invalid_decimals_and_downcast_overflow() {
269 assert_eq!(
270 initial_shares_from_base_atoms(1, 39),
271 Err(RoshiError::InvalidDecimals)
272 );
273 assert_eq!(
274 initial_shares_from_base_atoms(u64::MAX, 0),
275 Err(RoshiError::ResultDoesNotFit)
276 );
277 }
278
279 #[test]
280 fn deposit_shares_are_floor_rounded_and_monotonic() {
281 assert_eq!(shares_for_deposit(100, 1_000, 10_000), Ok(1_000));
282 assert_eq!(shares_for_deposit(101, 1_000, 10_000), Ok(1_010));
283 assert_eq!(
284 shares_for_deposit(1, 1_000, 100),
285 Err(RoshiError::ZeroOutput)
286 );
287 assert_eq!(
288 shares_for_deposit(1, 0, 100),
289 Err(RoshiError::InvalidVaultState)
290 );
291 assert_eq!(
292 shares_for_deposit(1, 100, 0),
293 Err(RoshiError::InvalidVaultState)
294 );
295 }
296
297 #[test]
298 fn deposit_shares_preserve_exact_proportions() {
299 assert_eq!(shares_for_deposit(250, 1_000, 4_000), Ok(1_000));
300 assert_eq!(shares_for_deposit(333, 999, 3_000), Ok(1_000));
301 }
302
303 #[test]
304 fn redeem_assets_are_floor_rounded_and_cannot_overpay() {
305 assert_eq!(assets_for_redeem(1_000, 1_000, 10_000), Ok(100));
306 assert_eq!(assets_for_redeem(1_010, 1_000, 10_000), Ok(101));
307 assert_eq!(
308 assets_for_redeem(1, 100, 1_000),
309 Err(RoshiError::ZeroOutput)
310 );
311 assert_eq!(
312 assets_for_redeem(1, 0, 100),
313 Err(RoshiError::InvalidVaultState)
314 );
315 assert_eq!(
316 assets_for_redeem(101, 100, 100),
317 Err(RoshiError::InvalidVaultState)
318 );
319 }
320
321 #[test]
322 fn redeeming_all_shares_returns_all_assets() {
323 assert_eq!(assets_for_redeem(10_000, 1_000, 10_000), Ok(1_000));
324 assert_eq!(assets_for_redeem(u64::MAX, 123, u64::MAX), Ok(123));
325 }
326
327 #[test]
328 fn deposit_redeem_round_trip_does_not_overpay() {
329 let shares = shares_for_deposit(1, 3, 10).unwrap();
330 assert_eq!(shares, 3);
331 assert_eq!(
332 assets_for_redeem(shares, 3, 10),
333 Err(RoshiError::ZeroOutput)
334 );
335
336 let shares = shares_for_deposit(100, 333, 1_000).unwrap();
337 let assets = assets_for_redeem(shares, 333, 1_000).unwrap();
338 assert!(assets <= 100);
339 }
340
341 #[test]
342 fn share_price_uses_fixed_share_scale() {
343 assert_eq!(
344 share_price_from_assets(1_000_000, 1_000_000_000),
345 Ok(1_000_000)
346 );
347 assert_eq!(
348 share_price_from_assets(1_100_000, 1_000_000_000),
349 Ok(1_100_000)
350 );
351 assert_eq!(
352 share_price_from_assets(1_000_000, 0),
353 Err(RoshiError::InvalidVaultState)
354 );
355 }
356
357 #[test]
358 fn performance_fee_for_nav_accrues_on_high_watermark_gains() {
359 assert_eq!(
360 performance_fee_for_nav(1_100_000, 1_000_000_000, 1_000_000, 1_000),
361 Ok((10_000, 1_090_000, 1_090_000))
362 );
363 }
364
365 #[test]
366 fn performance_fee_for_nav_sets_initial_high_watermark_without_fee() {
367 assert_eq!(
368 performance_fee_for_nav(1_000_000, 1_000_000_000, 0, 1_000),
369 Ok((0, 1_000_000, 1_000_000))
370 );
371 }
372
373 #[test]
374 fn performance_fee_for_nav_keeps_high_watermark_on_drawdown() {
375 assert_eq!(
376 performance_fee_for_nav(900_000, 1_000_000_000, 1_000_000, 1_000),
377 Ok((0, 900_000, 1_000_000))
378 );
379 }
380
381 #[test]
382 fn performance_fee_for_nav_ceil_rounds_high_watermark_assets() {
383 assert_eq!(
384 performance_fee_for_nav(2, 3, 333_333_334, 10_000),
385 Ok((0, 2, 666_666_666))
386 );
387 }
388
389 #[test]
390 fn withdrawal_buffer_targets_can_round_up() {
391 assert_eq!(bps_ceil(1_001, 100), Ok(11));
392 assert_eq!(bps_floor(1_001, 100), Ok(10));
393 }
394
395 proptest! {
396 #![proptest_config(ProptestConfig::with_cases(256))]
397
398 #[test]
399 fn prop_floor_and_ceil_bound_exact_value(
400 lhs in any::<u64>(),
401 rhs in any::<u64>(),
402 denominator in 1u64..=u64::MAX,
403 ) {
404 let product = u128::from(lhs) * u128::from(rhs);
405 let denominator = u128::from(denominator);
406
407 let floor = mul_div_floor(u128::from(lhs), u128::from(rhs), denominator).unwrap();
408 let ceil = mul_div_ceil(u128::from(lhs), u128::from(rhs), denominator).unwrap();
409
410 prop_assert!(floor <= ceil);
411 prop_assert!(ceil <= floor + 1);
412 prop_assert!(floor * denominator <= product);
413 prop_assert!(product < (floor + 1) * denominator);
414 prop_assert!(ceil * denominator >= product);
415 }
416
417 #[test]
418 fn prop_bps_floor_and_ceil_are_ordered(
419 amount in any::<u64>(),
420 bps in 0u16..=BPS_DENOMINATOR,
421 ) {
422 let floor = bps_floor(amount, bps).unwrap();
423 let ceil = bps_ceil(amount, bps).unwrap();
424
425 prop_assert!(floor <= ceil);
426 prop_assert!(ceil <= floor + 1);
427 prop_assert!(ceil <= amount);
428 }
429
430 #[test]
431 fn prop_deposit_shares_are_monotonic(
432 total_assets in 1u64..=1_000_000_000,
433 total_shares in 1u64..=1_000_000_000,
434 base_atoms in 1u64..=1_000_000_000,
435 extra_atoms in 0u64..=1_000_000_000,
436 ) {
437 let larger_base_atoms = base_atoms.saturating_add(extra_atoms);
438 let smaller = shares_for_deposit(base_atoms, total_assets, total_shares);
439 let larger = shares_for_deposit(larger_base_atoms, total_assets, total_shares);
440
441 if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
442 prop_assert!(larger >= smaller);
443 }
444 }
445
446 #[test]
447 fn prop_redeem_assets_are_monotonic(
448 total_assets in 1u64..=1_000_000_000,
449 total_shares in 1u64..=1_000_000_000,
450 share_seed in any::<u64>(),
451 extra_seed in any::<u64>(),
452 ) {
453 let smaller_shares = 1 + (share_seed % total_shares);
454 let remaining = total_shares - smaller_shares;
455 let larger_shares = smaller_shares + (extra_seed % (remaining + 1));
456
457 let smaller = assets_for_redeem(smaller_shares, total_assets, total_shares);
458 let larger = assets_for_redeem(larger_shares, total_assets, total_shares);
459
460 if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
461 prop_assert!(larger >= smaller);
462 }
463 }
464
465 #[test]
466 fn prop_deposit_then_redeem_never_overpays(
467 base_atoms in 1u64..=1_000_000_000,
468 total_assets in 1u64..=1_000_000_000,
469 total_shares in 1u64..=1_000_000_000,
470 ) {
471 if let Ok(shares) = shares_for_deposit(base_atoms, total_assets, total_shares) {
472 if let Ok(assets) = assets_for_redeem(shares, total_assets, total_shares) {
473 prop_assert!(assets <= base_atoms);
474 }
475 }
476 }
477
478 #[test]
479 fn prop_full_redeem_returns_total_assets(
480 total_assets in 1u64..=u64::MAX,
481 total_shares in 1u64..=u64::MAX,
482 ) {
483 prop_assert_eq!(
484 assets_for_redeem(total_shares, total_assets, total_shares),
485 Ok(total_assets)
486 );
487 }
488
489 }
490}