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