1use crate::{error::RoshiError, oracle::OraclePrice};
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(
99 asset_atoms: u64,
100 asset_price: OraclePrice,
101 base_price: OraclePrice,
102 asset_decimals: u8,
103 base_decimals: u8,
104) -> MathResult<u64> {
105 if base_price.value == 0 {
106 return Err(RoshiError::DivisionByZero);
107 }
108
109 let numerator_exp = u32::from(base_decimals) + u32::from(base_price.decimals);
110 let denominator_exp = u32::from(asset_decimals) + u32::from(asset_price.decimals);
111
112 let scaled_atoms = u128::from(asset_atoms)
113 .checked_mul(asset_price.value)
114 .ok_or(RoshiError::Overflow)?;
115 let value = if numerator_exp >= denominator_exp {
116 let scale = pow10(net_decimals(numerator_exp - denominator_exp)?)?;
117 mul_div_floor(scaled_atoms, scale, base_price.value)?
118 } else {
119 let scale = pow10(net_decimals(denominator_exp - numerator_exp)?)?;
120 let denominator = base_price
121 .value
122 .checked_mul(scale)
123 .ok_or(RoshiError::Overflow)?;
124 scaled_atoms / denominator
125 };
126 checked_u64(value)
127}
128
129fn net_decimals(exponent: u32) -> MathResult<u8> {
130 u8::try_from(exponent).map_err(|_| RoshiError::InvalidDecimals)
131}
132
133pub fn virtual_share_offset(base_decimals: u8) -> MathResult<u128> {
147 let delta = SHARE_DECIMALS
148 .checked_sub(base_decimals)
149 .ok_or(RoshiError::InvalidDecimals)?;
150 pow10(delta)
151}
152
153pub fn shares_for_deposit(
154 base_atoms: u64,
155 total_assets: u64,
156 total_shares: u64,
157 base_decimals: u8,
158) -> MathResult<u64> {
159 let virtual_shares = virtual_share_offset(base_decimals)?;
160 let shares = mul_div_floor(
161 u128::from(base_atoms),
162 u128::from(total_shares) + virtual_shares,
163 u128::from(total_assets) + 1,
164 )?;
165 checked_nonzero_u64(shares)
166}
167
168pub fn assets_for_shares(
173 shares: u64,
174 total_assets: u64,
175 total_shares: u64,
176 base_decimals: u8,
177) -> MathResult<u64> {
178 if shares > total_shares {
179 return Err(RoshiError::InvalidVaultState);
180 }
181
182 let virtual_shares = virtual_share_offset(base_decimals)?;
183 let assets = mul_div_floor(
184 u128::from(shares),
185 u128::from(total_assets) + 1,
186 u128::from(total_shares) + virtual_shares,
187 )?;
188 checked_u64(assets)
189}
190
191pub fn assets_for_redeem(
192 shares: u64,
193 total_assets: u64,
194 total_shares: u64,
195 base_decimals: u8,
196) -> MathResult<u64> {
197 let assets = assets_for_shares(shares, total_assets, total_shares, base_decimals)?;
198 if assets == 0 {
199 return Err(RoshiError::ZeroOutput);
200 }
201
202 Ok(assets)
203}
204
205pub fn share_price_from_assets(total_assets: u64, total_shares: u64) -> MathResult<u64> {
206 if total_shares == 0 {
207 return Err(RoshiError::InvalidVaultState);
208 }
209
210 let share_scale = pow10(SHARE_DECIMALS)?;
211 let share_price = mul_div_floor(
212 u128::from(total_assets),
213 share_scale,
214 u128::from(total_shares),
215 )?;
216 checked_u64(share_price)
217}
218
219pub fn performance_fee_for_nav(
220 gross_total_assets: u64,
221 total_shares: u64,
222 high_watermark: u64,
223 performance_fee_bps: u16,
224) -> MathResult<(u64, u64, u64)> {
225 validate_percentage_bps(performance_fee_bps)?;
226
227 if total_shares == 0 {
228 return Ok((0, gross_total_assets, high_watermark));
229 }
230
231 let gross_share_price = share_price_from_assets(gross_total_assets, total_shares)?;
232 if high_watermark == 0 || gross_share_price <= high_watermark || performance_fee_bps == 0 {
233 return Ok((0, gross_total_assets, high_watermark.max(gross_share_price)));
234 }
235
236 let share_scale = pow10(SHARE_DECIMALS)?;
237 let high_watermark_assets = checked_u64(mul_div_ceil(
238 u128::from(high_watermark),
239 u128::from(total_shares),
240 share_scale,
241 )?)?;
242 let profit_assets = gross_total_assets
243 .checked_sub(high_watermark_assets)
244 .ok_or(RoshiError::Overflow)?;
245 let fee_assets = bps_floor(profit_assets, performance_fee_bps)?;
246 let net_total_assets = gross_total_assets
247 .checked_sub(fee_assets)
248 .ok_or(RoshiError::Overflow)?;
249 let net_share_price = share_price_from_assets(net_total_assets, total_shares)?;
250
251 Ok((
252 fee_assets,
253 net_total_assets,
254 high_watermark.max(net_share_price),
255 ))
256}
257
258fn checked_nonzero_u64(value: u128) -> MathResult<u64> {
259 let value = checked_u64(value)?;
260 if value == 0 {
261 return Err(RoshiError::ZeroOutput);
262 }
263
264 Ok(value)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use proptest::prelude::*;
271
272 #[test]
273 fn pow10_rejects_unsupported_decimals() {
274 assert!(pow10(38).is_ok());
275 assert_eq!(pow10(39), Err(RoshiError::InvalidDecimals));
276 }
277
278 #[test]
279 fn mul_div_floor_and_ceil_handle_boundaries() {
280 assert_eq!(mul_div_floor_u64(10, 2, 4), Ok(5));
281 assert_eq!(mul_div_floor_u64(10, 2, 6), Ok(3));
282 assert_eq!(mul_div_ceil_u64(10, 2, 4), Ok(5));
283 assert_eq!(mul_div_ceil_u64(10, 2, 6), Ok(4));
284 assert_eq!(mul_div_floor_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
285 assert_eq!(mul_div_ceil_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
286 }
287
288 #[test]
289 fn mul_div_rejects_overflow_and_downcast() {
290 assert_eq!(mul_div_floor(u128::MAX, 2, 1), Err(RoshiError::Overflow));
291 assert_eq!(
292 mul_div_floor_u64(u64::MAX, u64::MAX, 1),
293 Err(RoshiError::ResultDoesNotFit)
294 );
295 }
296
297 #[test]
298 fn bps_helpers_use_standard_denominator() {
299 assert_eq!(bps_floor(101, 100), Ok(1));
300 assert_eq!(bps_ceil(101, 100), Ok(2));
301 assert_eq!(bps_floor(42, 10_001), Ok(42));
302 assert_eq!(bps_ceil(42, 10_001), Ok(43));
303 }
304
305 #[test]
306 fn percentage_bps_validation_caps_at_full_percentage() {
307 assert_eq!(validate_percentage_bps(0), Ok(()));
308 assert_eq!(validate_percentage_bps(10_000), Ok(()));
309 assert_eq!(validate_percentage_bps(10_001), Err(RoshiError::InvalidBps));
310 }
311
312 const fn price(value: u128, decimals: u8) -> OraclePrice {
313 OraclePrice { value, decimals }
314 }
315
316 #[test]
317 fn direct_pricing_scales_whole_token_price_by_mint_decimals() {
318 assert_eq!(
320 base_atoms_from_asset_atoms(
321 1_000_000,
322 price(2_500_000_000, 9),
323 OraclePrice::UNIT,
324 6,
325 6
326 ),
327 Ok(2_500_000)
328 );
329 assert_eq!(
332 base_atoms_from_asset_atoms(100_000_000, price(100_000, 0), OraclePrice::UNIT, 8, 6),
333 Ok(100_000_000_000)
334 );
335 assert_eq!(
337 base_atoms_from_asset_atoms(1_000_000, price(2, 0), OraclePrice::UNIT, 6, 9),
338 Ok(2_000_000_000)
339 );
340 }
341
342 #[test]
343 fn routed_pricing_composes_two_quote_legs() {
344 assert_eq!(
347 base_atoms_from_asset_atoms(
348 1_000_000_000,
349 price(150 * 100_000_000, 8),
350 price(100_000_000, 8),
351 9,
352 6,
353 ),
354 Ok(150_000_000)
355 );
356 assert_eq!(
358 base_atoms_from_asset_atoms(1_000_000_000, price(1_500, 1), price(10, 1), 9, 6),
359 Ok(150_000_000)
360 );
361 assert_eq!(
364 base_atoms_from_asset_atoms(
365 1_000_000_000,
366 price(150 * 100_000_000, 8),
367 price(50_000_000, 8),
368 9,
369 6,
370 ),
371 Ok(300_000_000)
372 );
373 }
374
375 #[test]
376 fn pricing_rejects_zero_base_leg_and_overflow() {
377 assert_eq!(
378 base_atoms_from_asset_atoms(1, price(1, 0), price(0, 0), 6, 6),
379 Err(RoshiError::DivisionByZero)
380 );
381 assert_eq!(
382 base_atoms_from_asset_atoms(u64::MAX, price(u128::MAX, 0), OraclePrice::UNIT, 6, 6),
383 Err(RoshiError::Overflow)
384 );
385 assert_eq!(
386 base_atoms_from_asset_atoms(
387 u64::MAX,
388 price(u128::from(u64::MAX), 0),
389 OraclePrice::UNIT,
390 0,
391 0,
392 ),
393 Err(RoshiError::ResultDoesNotFit)
394 );
395 }
396
397 #[test]
398 fn pricing_can_round_to_zero_without_failing() {
399 assert_eq!(
400 base_atoms_from_asset_atoms(0, price(1_000_000_000, 9), OraclePrice::UNIT, 6, 6),
401 Ok(0)
402 );
403 assert_eq!(
406 base_atoms_from_asset_atoms(1, price(1, 0), OraclePrice::UNIT, 9, 6),
407 Ok(0)
408 );
409 }
410
411 #[test]
412 fn pricing_rejects_unsupported_net_decimals() {
413 assert_eq!(
414 base_atoms_from_asset_atoms(1, price(1, 39), OraclePrice::UNIT, 0, 0),
415 Err(RoshiError::InvalidDecimals)
416 );
417 assert_eq!(
418 base_atoms_from_asset_atoms(1, price(1, 0), price(1, 39), 0, 0),
419 Err(RoshiError::InvalidDecimals)
420 );
421 }
422
423 #[test]
424 fn virtual_share_offset_matches_empty_vault_mint_ratio() {
425 assert_eq!(virtual_share_offset(6), Ok(1_000));
426 assert_eq!(virtual_share_offset(9), Ok(1));
427 assert_eq!(virtual_share_offset(10), Err(RoshiError::InvalidDecimals));
428 }
429
430 #[test]
431 fn first_deposit_scales_base_atoms_to_share_decimals() {
432 assert_eq!(shares_for_deposit(1_000_000, 0, 0, 6), Ok(1_000_000_000));
433 assert_eq!(
434 shares_for_deposit(1_000_000_000, 0, 0, 9),
435 Ok(1_000_000_000)
436 );
437 assert_eq!(shares_for_deposit(0, 0, 0, 6), Err(RoshiError::ZeroOutput));
438 }
439
440 #[test]
441 fn first_deposit_rejects_unsupported_decimals_and_downcast_overflow() {
442 assert_eq!(
443 shares_for_deposit(1, 0, 0, 10),
444 Err(RoshiError::InvalidDecimals)
445 );
446 assert_eq!(
447 shares_for_deposit(u64::MAX, 0, 0, 0),
448 Err(RoshiError::ResultDoesNotFit)
449 );
450 }
451
452 #[test]
453 fn deposit_shares_are_exact_at_par_and_floor_otherwise() {
454 assert_eq!(shares_for_deposit(100, 1_000, 1_000_000, 6), Ok(100_000));
457 assert_eq!(shares_for_deposit(101, 1_000, 1_000_000, 6), Ok(101_000));
458 assert_eq!(shares_for_deposit(100, 2_000, 1_000_000, 6), Ok(50_024));
461 assert_eq!(
462 shares_for_deposit(1, 1_000, 100, 9),
463 Err(RoshiError::ZeroOutput)
464 );
465 }
466
467 #[test]
468 fn donation_inflation_costs_the_attacker_the_offset_multiple() {
469 let attacker_shares = shares_for_deposit(1, 0, 0, 6).unwrap();
473 assert_eq!(attacker_shares, 1_000);
474
475 let donated_assets = 1 + 1_000_000_000;
476 let victim_shares =
478 shares_for_deposit(1_000_000, donated_assets, attacker_shares, 6).unwrap();
479 assert_eq!(victim_shares, 1);
480
481 let total_assets = donated_assets + 1_000_000;
484 let total_shares = attacker_shares + victim_shares;
485 let attacker_claim =
486 assets_for_redeem(attacker_shares, total_assets, total_shares, 6).unwrap();
487 let victim_claim = assets_for_redeem(victim_shares, total_assets, total_shares, 6).unwrap();
488 let attacker_cost = donated_assets - attacker_claim;
489 let victim_loss = 1_000_000 - victim_claim;
490 assert!(victim_loss < 500_000);
491 assert!(attacker_cost >= 999 * victim_loss);
492 }
493
494 #[test]
495 fn assets_for_shares_prices_dust_to_zero_without_error() {
496 assert_eq!(assets_for_shares(1, 100, 1_000, 9), Ok(0));
499 assert_eq!(
500 assets_for_redeem(1, 100, 1_000, 9),
501 Err(RoshiError::ZeroOutput)
502 );
503 assert_eq!(assets_for_shares(100_000, 1_000, 1_000_000, 6), Ok(100));
505 assert_eq!(
506 assets_for_shares(101, 100, 100, 9),
507 Err(RoshiError::InvalidVaultState)
508 );
509 }
510
511 #[test]
512 fn redeem_assets_are_floor_rounded_and_cannot_overpay() {
513 assert_eq!(assets_for_redeem(100_000, 1_000, 1_000_000, 6), Ok(100));
515 assert_eq!(
516 assets_for_redeem(1, 100, 1_000, 9),
517 Err(RoshiError::ZeroOutput)
518 );
519 assert_eq!(
520 assets_for_redeem(101, 100, 100, 9),
521 Err(RoshiError::InvalidVaultState)
522 );
523 }
524
525 #[test]
526 fn full_redeem_returns_all_assets_up_to_virtual_dust() {
527 assert_eq!(
530 assets_for_redeem(1_000_000_000, 1_000_000, 1_000_000_000, 6),
531 Ok(1_000_000)
532 );
533 assert_eq!(assets_for_redeem(u64::MAX, 123, u64::MAX, 9), Ok(123));
534 assert_eq!(assets_for_redeem(10, 1_000, 10, 9), Ok(910));
537 }
538
539 #[test]
540 fn deposit_redeem_round_trip_does_not_overpay() {
541 let shares = shares_for_deposit(1, 3, 10, 9).unwrap();
542 assert_eq!(shares, 2);
543 assert_eq!(
544 assets_for_redeem(shares, 3, 10, 9),
545 Err(RoshiError::ZeroOutput)
546 );
547
548 let shares = shares_for_deposit(100, 333, 1_000, 9).unwrap();
549 let assets = assets_for_redeem(shares, 333, 1_000, 9).unwrap();
550 assert!(assets <= 100);
551 }
552
553 #[test]
554 fn share_price_uses_fixed_share_scale() {
555 assert_eq!(
556 share_price_from_assets(1_000_000, 1_000_000_000),
557 Ok(1_000_000)
558 );
559 assert_eq!(
560 share_price_from_assets(1_100_000, 1_000_000_000),
561 Ok(1_100_000)
562 );
563 assert_eq!(
564 share_price_from_assets(1_000_000, 0),
565 Err(RoshiError::InvalidVaultState)
566 );
567 }
568
569 #[test]
570 fn performance_fee_for_nav_accrues_on_high_watermark_gains() {
571 assert_eq!(
572 performance_fee_for_nav(1_100_000, 1_000_000_000, 1_000_000, 1_000),
573 Ok((10_000, 1_090_000, 1_090_000))
574 );
575 }
576
577 #[test]
578 fn performance_fee_for_nav_sets_initial_high_watermark_without_fee() {
579 assert_eq!(
580 performance_fee_for_nav(1_000_000, 1_000_000_000, 0, 1_000),
581 Ok((0, 1_000_000, 1_000_000))
582 );
583 }
584
585 #[test]
586 fn performance_fee_for_nav_keeps_high_watermark_on_drawdown() {
587 assert_eq!(
588 performance_fee_for_nav(900_000, 1_000_000_000, 1_000_000, 1_000),
589 Ok((0, 900_000, 1_000_000))
590 );
591 }
592
593 #[test]
594 fn performance_fee_for_nav_ceil_rounds_high_watermark_assets() {
595 assert_eq!(
596 performance_fee_for_nav(2, 3, 333_333_334, 10_000),
597 Ok((0, 2, 666_666_666))
598 );
599 }
600
601 #[test]
602 fn performance_fee_for_nav_floors_indivisible_accrual() {
603 assert_eq!(
607 performance_fee_for_nav(1_000_011, 1_000_000_000, 1_000_000, 1_000),
608 Ok((1, 1_000_010, 1_000_010))
609 );
610 }
611
612 #[test]
613 fn bps_ceil_never_exceeds_amount_exhaustive_over_bps() {
614 let amounts = [
619 0u64,
620 1,
621 2,
622 3,
623 7,
624 9_999,
625 10_000,
626 10_001,
627 u64::MAX / 2,
628 u64::MAX - 1,
629 u64::MAX,
630 ];
631 for &amount in &amounts {
632 for bps in 0..=BPS_DENOMINATOR {
633 let ceil = bps_ceil(amount, bps).unwrap();
634 assert!(ceil <= amount, "ceil {ceil} > amount {amount} at bps {bps}");
635 }
636 }
637 }
638
639 #[test]
640 fn withdrawal_buffer_targets_can_round_up() {
641 assert_eq!(bps_ceil(1_001, 100), Ok(11));
642 assert_eq!(bps_floor(1_001, 100), Ok(10));
643 }
644
645 proptest! {
646 #![proptest_config(ProptestConfig::with_cases(256))]
647
648 #[test]
649 fn prop_floor_and_ceil_bound_exact_value(
650 lhs in any::<u64>(),
651 rhs in any::<u64>(),
652 denominator in 1u64..=u64::MAX,
653 ) {
654 let product = u128::from(lhs) * u128::from(rhs);
655 let denominator = u128::from(denominator);
656
657 let floor = mul_div_floor(u128::from(lhs), u128::from(rhs), denominator).unwrap();
658 let ceil = mul_div_ceil(u128::from(lhs), u128::from(rhs), denominator).unwrap();
659
660 prop_assert!(floor <= ceil);
661 prop_assert!(ceil <= floor + 1);
662 prop_assert!(floor * denominator <= product);
663 prop_assert!(product < (floor + 1) * denominator);
664 prop_assert!(ceil * denominator >= product);
665 }
666
667 #[test]
668 fn prop_bps_floor_and_ceil_are_ordered(
669 amount in any::<u64>(),
670 bps in 0u16..=BPS_DENOMINATOR,
671 ) {
672 let floor = bps_floor(amount, bps).unwrap();
673 let ceil = bps_ceil(amount, bps).unwrap();
674
675 prop_assert!(floor <= ceil);
676 prop_assert!(ceil <= floor + 1);
677 prop_assert!(ceil <= amount);
678 }
679
680 #[test]
681 fn prop_deposit_shares_are_monotonic(
682 total_assets in 0u64..=1_000_000_000,
683 total_shares in 0u64..=1_000_000_000,
684 base_atoms in 1u64..=1_000_000_000,
685 extra_atoms in 0u64..=1_000_000_000,
686 base_decimals in 0u8..=9,
687 ) {
688 let larger_base_atoms = base_atoms.saturating_add(extra_atoms);
689 let smaller = shares_for_deposit(base_atoms, total_assets, total_shares, base_decimals);
690 let larger =
691 shares_for_deposit(larger_base_atoms, total_assets, total_shares, base_decimals);
692
693 if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
694 prop_assert!(larger >= smaller);
695 }
696 }
697
698 #[test]
699 fn prop_redeem_assets_are_monotonic(
700 total_assets in 1u64..=1_000_000_000,
701 total_shares in 1u64..=1_000_000_000,
702 share_seed in any::<u64>(),
703 extra_seed in any::<u64>(),
704 base_decimals in 0u8..=9,
705 ) {
706 let smaller_shares = 1 + (share_seed % total_shares);
707 let remaining = total_shares - smaller_shares;
708 let larger_shares = smaller_shares + (extra_seed % (remaining + 1));
709
710 let smaller =
711 assets_for_redeem(smaller_shares, total_assets, total_shares, base_decimals);
712 let larger =
713 assets_for_redeem(larger_shares, total_assets, total_shares, base_decimals);
714
715 if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
716 prop_assert!(larger >= smaller);
717 }
718 }
719
720 #[test]
721 fn prop_deposit_then_redeem_never_overpays(
722 base_atoms in 1u64..=1_000_000_000,
723 total_assets in 0u64..=1_000_000_000,
724 total_shares in 0u64..=1_000_000_000,
725 base_decimals in 0u8..=9,
726 ) {
727 if let Ok(shares) = shares_for_deposit(base_atoms, total_assets, total_shares, base_decimals) {
728 let total_assets = total_assets + base_atoms;
730 let total_shares = total_shares + shares;
731 if let Ok(assets) = assets_for_redeem(shares, total_assets, total_shares, base_decimals) {
732 prop_assert!(assets <= base_atoms);
733 }
734 }
735 }
736
737 #[test]
738 fn prop_redeem_never_pays_more_than_total_assets(
739 total_assets in 0u64..=u64::MAX,
740 total_shares in 1u64..=u64::MAX,
741 share_seed in any::<u64>(),
742 base_decimals in 0u8..=9,
743 ) {
744 let shares = 1 + (share_seed % total_shares);
745 match assets_for_redeem(shares, total_assets, total_shares, base_decimals) {
746 Ok(assets) => prop_assert!(assets <= total_assets),
747 Err(error) => prop_assert_eq!(error, RoshiError::ZeroOutput),
748 }
749 }
750
751 #[test]
752 fn prop_par_vault_pricing_is_exact(
753 pot in 1u64..=1_000_000_000,
754 base_atoms in 1u64..=1_000_000_000,
755 share_seed in any::<u64>(),
756 base_decimals in 0u8..=9,
757 ) {
758 let ratio = u64::try_from(virtual_share_offset(base_decimals).unwrap()).unwrap();
761 let supply = pot * ratio;
762 prop_assert_eq!(
763 shares_for_deposit(base_atoms, pot, supply, base_decimals),
764 Ok(base_atoms * ratio)
765 );
766
767 let shares = 1 + (share_seed % supply);
768 let redeemed = assets_for_redeem(shares, pot, supply, base_decimals);
769 if shares / ratio == 0 {
770 prop_assert_eq!(redeemed, Err(RoshiError::ZeroOutput));
771 } else {
772 prop_assert_eq!(redeemed, Ok(shares / ratio));
773 }
774 }
775
776 #[test]
777 fn prop_full_redeem_at_or_below_par_returns_total_assets(
778 total_assets in 1u64..=1_000_000_000,
779 excess_shares in 0u64..=1_000_000_000,
780 base_decimals in 0u8..=9,
781 ) {
782 let ratio = u64::try_from(virtual_share_offset(base_decimals).unwrap()).unwrap();
783 let total_shares = total_assets * ratio + excess_shares;
784 prop_assert_eq!(
785 assets_for_redeem(total_shares, total_assets, total_shares, base_decimals),
786 Ok(total_assets)
787 );
788 }
789
790 #[test]
791 fn prop_performance_fee_conserves_and_ratchets(
792 gross in 0u64..=1_000_000_000,
793 total_shares in 0u64..=1_000_000_000,
794 high_watermark in 0u64..=1_000_000_000,
795 bps in 0u16..=BPS_DENOMINATOR,
796 ) {
797 let (fee, net, new_hwm) =
800 performance_fee_for_nav(gross, total_shares, high_watermark, bps).unwrap();
801 prop_assert!(fee <= gross);
803 prop_assert_eq!(net, gross - fee);
804 prop_assert!(new_hwm >= high_watermark);
806 if bps == 0 || total_shares == 0 {
808 prop_assert_eq!(fee, 0);
809 }
810 }
811
812 #[test]
813 fn prop_base_atoms_monotonic_in_amount_and_unit_leg_scale_invariant(
814 asset_atoms in 0u64..=1_000_000_000_000,
815 extra_atoms in 0u64..=1_000_000_000_000,
816 price_value in 1u128..=1_000_000_000_000,
817 price_decimals in 0u8..=12,
818 unit_leg_decimals in 0u8..=12,
819 asset_decimals in 0u8..=12,
820 base_decimals in 0u8..=9,
821 ) {
822 let asset_price = OraclePrice { value: price_value, decimals: price_decimals };
823 if let Ok(smaller) = base_atoms_from_asset_atoms(
826 asset_atoms, asset_price, OraclePrice::UNIT, asset_decimals, base_decimals,
827 ) {
828 let larger = base_atoms_from_asset_atoms(
829 asset_atoms.saturating_add(extra_atoms),
830 asset_price, OraclePrice::UNIT, asset_decimals, base_decimals,
831 );
832 if let Ok(larger) = larger {
833 prop_assert!(larger >= smaller);
834 }
835
836 let scaled_unit = OraclePrice {
838 value: pow10(unit_leg_decimals).unwrap(),
839 decimals: unit_leg_decimals,
840 };
841 prop_assert_eq!(
842 base_atoms_from_asset_atoms(
843 asset_atoms, asset_price, scaled_unit, asset_decimals, base_decimals,
844 ),
845 Ok(smaller)
846 );
847 }
848 }
849
850 #[test]
851 fn prop_performance_fee_is_monotonic_in_bps(
852 gross in 0u64..=1_000_000_000,
853 total_shares in 0u64..=1_000_000_000,
854 high_watermark in 0u64..=1_000_000_000,
855 bps_a in 0u16..=BPS_DENOMINATOR,
856 bps_b in 0u16..=BPS_DENOMINATOR,
857 ) {
858 let (lo, hi) = if bps_a <= bps_b { (bps_a, bps_b) } else { (bps_b, bps_a) };
862 let (fee_lo, ..) =
863 performance_fee_for_nav(gross, total_shares, high_watermark, lo).unwrap();
864 let (fee_hi, ..) =
865 performance_fee_for_nav(gross, total_shares, high_watermark, hi).unwrap();
866 prop_assert!(fee_hi >= fee_lo);
867 }
868
869 }
870}