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 round_with_min1(value: u64, num: u64, den: u64) -> MathResult<u64> {
71 if num == 0 || value == 0 {
72 return Ok(0);
73 }
74 if den == 0 {
75 return Err(RoshiError::DivisionByZero);
76 }
77
78 let numerator = u128::from(value)
79 .checked_mul(u128::from(num))
80 .ok_or(RoshiError::Overflow)?;
81 let denominator = u128::from(den);
82 let quotient = numerator / denominator;
83 let remainder = numerator % denominator;
84
85 let rounded = if 2 * remainder >= denominator {
88 quotient + 1
89 } else {
90 quotient
91 };
92
93 checked_u64(rounded.max(1))
94}
95
96pub fn bps_floor(amount: u64, bps: u16) -> MathResult<u64> {
97 mul_div_floor_u64(amount, u64::from(bps), u64::from(BPS_DENOMINATOR))
98}
99
100pub fn bps_ceil(amount: u64, bps: u16) -> MathResult<u64> {
101 mul_div_ceil_u64(amount, u64::from(bps), u64::from(BPS_DENOMINATOR))
102}
103
104pub fn validate_percentage_bps(bps: u16) -> MathResult<()> {
105 if bps > BPS_DENOMINATOR {
106 return Err(RoshiError::InvalidBps);
107 }
108
109 Ok(())
110}
111
112pub fn base_atoms_from_asset_atoms(
140 asset_atoms: u64,
141 asset_price: OraclePrice,
142 base_price: OraclePrice,
143 asset_decimals: u8,
144 base_decimals: u8,
145) -> MathResult<u64> {
146 if base_price.value == 0 {
147 return Err(RoshiError::DivisionByZero);
148 }
149
150 let numerator_exp = u32::from(base_decimals) + u32::from(base_price.decimals);
151 let denominator_exp = u32::from(asset_decimals) + u32::from(asset_price.decimals);
152
153 let scaled_atoms = u128::from(asset_atoms)
154 .checked_mul(asset_price.value)
155 .ok_or(RoshiError::Overflow)?;
156 let value = if numerator_exp >= denominator_exp {
157 let scale = pow10(net_decimals(numerator_exp - denominator_exp)?)?;
158 mul_div_floor(scaled_atoms, scale, base_price.value)?
159 } else {
160 let scale = pow10(net_decimals(denominator_exp - numerator_exp)?)?;
161 let denominator = base_price
162 .value
163 .checked_mul(scale)
164 .ok_or(RoshiError::Overflow)?;
165 scaled_atoms / denominator
166 };
167 checked_u64(value)
168}
169
170fn net_decimals(exponent: u32) -> MathResult<u8> {
171 u8::try_from(exponent).map_err(|_| RoshiError::InvalidDecimals)
172}
173
174pub fn virtual_share_offset(base_decimals: u8) -> MathResult<u128> {
188 let delta = SHARE_DECIMALS
189 .checked_sub(base_decimals)
190 .ok_or(RoshiError::InvalidDecimals)?;
191 pow10(delta)
192}
193
194pub fn shares_for_deposit(
195 base_atoms: u64,
196 total_assets: u64,
197 total_shares: u64,
198 base_decimals: u8,
199) -> MathResult<u64> {
200 let virtual_shares = virtual_share_offset(base_decimals)?;
201 let shares = mul_div_floor(
202 u128::from(base_atoms),
203 u128::from(total_shares) + virtual_shares,
204 u128::from(total_assets) + 1,
205 )?;
206 checked_nonzero_u64(shares)
207}
208
209pub fn assets_for_shares(
214 shares: u64,
215 total_assets: u64,
216 total_shares: u64,
217 base_decimals: u8,
218) -> MathResult<u64> {
219 if shares > total_shares {
220 return Err(RoshiError::InvalidVaultState);
221 }
222
223 let virtual_shares = virtual_share_offset(base_decimals)?;
224 let assets = mul_div_floor(
225 u128::from(shares),
226 u128::from(total_assets) + 1,
227 u128::from(total_shares) + virtual_shares,
228 )?;
229 checked_u64(assets)
230}
231
232pub fn assets_for_redeem(
233 shares: u64,
234 total_assets: u64,
235 total_shares: u64,
236 base_decimals: u8,
237) -> MathResult<u64> {
238 let assets = assets_for_shares(shares, total_assets, total_shares, base_decimals)?;
239 if assets == 0 {
240 return Err(RoshiError::ZeroOutput);
241 }
242
243 Ok(assets)
244}
245
246pub fn share_price_from_assets(total_assets: u64, total_shares: u64) -> MathResult<u64> {
247 if total_shares == 0 {
248 return Err(RoshiError::InvalidVaultState);
249 }
250
251 let share_scale = pow10(SHARE_DECIMALS)?;
252 let share_price = mul_div_floor(
253 u128::from(total_assets),
254 share_scale,
255 u128::from(total_shares),
256 )?;
257 checked_u64(share_price)
258}
259
260pub fn performance_fee_for_nav(
261 gross_total_assets: u64,
262 total_shares: u64,
263 high_watermark: u64,
264 performance_fee_bps: u16,
265) -> MathResult<(u64, u64, u64)> {
266 validate_percentage_bps(performance_fee_bps)?;
267
268 if total_shares == 0 {
269 return Ok((0, gross_total_assets, high_watermark));
270 }
271
272 let gross_share_price = share_price_from_assets(gross_total_assets, total_shares)?;
273 if high_watermark == 0 || gross_share_price <= high_watermark || performance_fee_bps == 0 {
274 return Ok((0, gross_total_assets, high_watermark.max(gross_share_price)));
275 }
276
277 let share_scale = pow10(SHARE_DECIMALS)?;
278 let high_watermark_assets = checked_u64(mul_div_ceil(
279 u128::from(high_watermark),
280 u128::from(total_shares),
281 share_scale,
282 )?)?;
283 let profit_assets = gross_total_assets
284 .checked_sub(high_watermark_assets)
285 .ok_or(RoshiError::Overflow)?;
286 let fee_assets = bps_floor(profit_assets, performance_fee_bps)?;
287 let net_total_assets = gross_total_assets
288 .checked_sub(fee_assets)
289 .ok_or(RoshiError::Overflow)?;
290 let net_share_price = share_price_from_assets(net_total_assets, total_shares)?;
291
292 Ok((
293 fee_assets,
294 net_total_assets,
295 high_watermark.max(net_share_price),
296 ))
297}
298
299fn checked_nonzero_u64(value: u128) -> MathResult<u64> {
300 let value = checked_u64(value)?;
301 if value == 0 {
302 return Err(RoshiError::ZeroOutput);
303 }
304
305 Ok(value)
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use proptest::prelude::*;
312
313 #[test]
314 fn pow10_rejects_unsupported_decimals() {
315 assert!(pow10(38).is_ok());
316 assert_eq!(pow10(39), Err(RoshiError::InvalidDecimals));
317 }
318
319 #[test]
320 fn mul_div_floor_and_ceil_handle_boundaries() {
321 assert_eq!(mul_div_floor_u64(10, 2, 4), Ok(5));
322 assert_eq!(mul_div_floor_u64(10, 2, 6), Ok(3));
323 assert_eq!(mul_div_ceil_u64(10, 2, 4), Ok(5));
324 assert_eq!(mul_div_ceil_u64(10, 2, 6), Ok(4));
325 assert_eq!(mul_div_floor_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
326 assert_eq!(mul_div_ceil_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
327 }
328
329 #[test]
330 fn mul_div_rejects_overflow_and_downcast() {
331 assert_eq!(mul_div_floor(u128::MAX, 2, 1), Err(RoshiError::Overflow));
332 assert_eq!(
333 mul_div_floor_u64(u64::MAX, u64::MAX, 1),
334 Err(RoshiError::ResultDoesNotFit)
335 );
336 }
337
338 fn klend_flash_fee(amount: u64, flash_loan_fee_sf: u64) -> Option<u64> {
344 use fixed::traits::FromFixed;
345 use fixed::types::U68F60 as Fraction;
346
347 let rate = Fraction::from_bits(u128::from(flash_loan_fee_sf));
348 if rate > Fraction::ZERO && amount > 0 {
349 let amount_f = Fraction::from_num(amount);
350 let fee_f = amount_f.checked_mul(rate)?.max(Fraction::from_num(1u64));
351 if fee_f >= amount_f {
352 return None;
353 }
354 Some(u64::from_fixed(fee_f.round()))
355 } else {
356 Some(0)
357 }
358 }
359
360 #[test]
361 fn round_with_min1_rounds_half_up_with_a_one_atom_floor() {
362 assert_eq!(round_with_min1(1_000, 1, 3), Ok(333));
366 assert_eq!(round_with_min1(2_000, 1, 3), Ok(667));
368 assert_eq!(round_with_min1(3, 1, 2), Ok(2));
369 assert_eq!(round_with_min1(1, 1, 1_000), Ok(1));
371 assert_eq!(round_with_min1(1_000_000, 0, 0), Ok(0));
374 assert_eq!(round_with_min1(0, 1, 10), Ok(0));
376 assert_eq!(round_with_min1(1, 1, 0), Err(RoshiError::DivisionByZero));
378 assert_eq!(
380 round_with_min1(u64::MAX, u64::MAX, 1),
381 Err(RoshiError::ResultDoesNotFit)
382 );
383 }
384
385 #[test]
386 fn round_with_min1_matches_klend_at_the_kamino_usdc_rate() {
387 let sf = 11_529_215_046_068u64;
391 let den = 1u64 << 60;
392 for f in [5_000_000u64, 4_999_999, 5_000_001, 7, 123_456_789] {
395 let expected = klend_flash_fee(f, sf).expect("klend charges a fee at this F");
396 assert_eq!(
397 round_with_min1(f, sf, den),
398 Ok(expected),
399 "fee mismatch at F = {f}"
400 );
401 }
402 }
403
404 #[test]
405 fn bps_helpers_use_standard_denominator() {
406 assert_eq!(bps_floor(101, 100), Ok(1));
407 assert_eq!(bps_ceil(101, 100), Ok(2));
408 assert_eq!(bps_floor(42, 10_001), Ok(42));
409 assert_eq!(bps_ceil(42, 10_001), Ok(43));
410 }
411
412 #[test]
413 fn percentage_bps_validation_caps_at_full_percentage() {
414 assert_eq!(validate_percentage_bps(0), Ok(()));
415 assert_eq!(validate_percentage_bps(10_000), Ok(()));
416 assert_eq!(validate_percentage_bps(10_001), Err(RoshiError::InvalidBps));
417 }
418
419 const fn price(value: u128, decimals: u8) -> OraclePrice {
420 OraclePrice { value, decimals }
421 }
422
423 #[test]
424 fn direct_pricing_scales_whole_token_price_by_mint_decimals() {
425 assert_eq!(
427 base_atoms_from_asset_atoms(
428 1_000_000,
429 price(2_500_000_000, 9),
430 OraclePrice::UNIT,
431 6,
432 6
433 ),
434 Ok(2_500_000)
435 );
436 assert_eq!(
439 base_atoms_from_asset_atoms(100_000_000, price(100_000, 0), OraclePrice::UNIT, 8, 6),
440 Ok(100_000_000_000)
441 );
442 assert_eq!(
444 base_atoms_from_asset_atoms(1_000_000, price(2, 0), OraclePrice::UNIT, 6, 9),
445 Ok(2_000_000_000)
446 );
447 }
448
449 #[test]
450 fn routed_pricing_composes_two_quote_legs() {
451 assert_eq!(
454 base_atoms_from_asset_atoms(
455 1_000_000_000,
456 price(150 * 100_000_000, 8),
457 price(100_000_000, 8),
458 9,
459 6,
460 ),
461 Ok(150_000_000)
462 );
463 assert_eq!(
465 base_atoms_from_asset_atoms(1_000_000_000, price(1_500, 1), price(10, 1), 9, 6),
466 Ok(150_000_000)
467 );
468 assert_eq!(
471 base_atoms_from_asset_atoms(
472 1_000_000_000,
473 price(150 * 100_000_000, 8),
474 price(50_000_000, 8),
475 9,
476 6,
477 ),
478 Ok(300_000_000)
479 );
480 }
481
482 #[test]
483 fn pricing_rejects_zero_base_leg_and_overflow() {
484 assert_eq!(
485 base_atoms_from_asset_atoms(1, price(1, 0), price(0, 0), 6, 6),
486 Err(RoshiError::DivisionByZero)
487 );
488 assert_eq!(
489 base_atoms_from_asset_atoms(u64::MAX, price(u128::MAX, 0), OraclePrice::UNIT, 6, 6),
490 Err(RoshiError::Overflow)
491 );
492 assert_eq!(
493 base_atoms_from_asset_atoms(
494 u64::MAX,
495 price(u128::from(u64::MAX), 0),
496 OraclePrice::UNIT,
497 0,
498 0,
499 ),
500 Err(RoshiError::ResultDoesNotFit)
501 );
502 }
503
504 #[test]
505 fn pricing_can_round_to_zero_without_failing() {
506 assert_eq!(
507 base_atoms_from_asset_atoms(0, price(1_000_000_000, 9), OraclePrice::UNIT, 6, 6),
508 Ok(0)
509 );
510 assert_eq!(
513 base_atoms_from_asset_atoms(1, price(1, 0), OraclePrice::UNIT, 9, 6),
514 Ok(0)
515 );
516 }
517
518 #[test]
519 fn pricing_rejects_unsupported_net_decimals() {
520 assert_eq!(
521 base_atoms_from_asset_atoms(1, price(1, 39), OraclePrice::UNIT, 0, 0),
522 Err(RoshiError::InvalidDecimals)
523 );
524 assert_eq!(
525 base_atoms_from_asset_atoms(1, price(1, 0), price(1, 39), 0, 0),
526 Err(RoshiError::InvalidDecimals)
527 );
528 }
529
530 #[test]
531 fn virtual_share_offset_matches_empty_vault_mint_ratio() {
532 assert_eq!(virtual_share_offset(6), Ok(1_000));
533 assert_eq!(virtual_share_offset(9), Ok(1));
534 assert_eq!(virtual_share_offset(10), Err(RoshiError::InvalidDecimals));
535 }
536
537 #[test]
538 fn first_deposit_scales_base_atoms_to_share_decimals() {
539 assert_eq!(shares_for_deposit(1_000_000, 0, 0, 6), Ok(1_000_000_000));
540 assert_eq!(
541 shares_for_deposit(1_000_000_000, 0, 0, 9),
542 Ok(1_000_000_000)
543 );
544 assert_eq!(shares_for_deposit(0, 0, 0, 6), Err(RoshiError::ZeroOutput));
545 }
546
547 #[test]
548 fn first_deposit_rejects_unsupported_decimals_and_downcast_overflow() {
549 assert_eq!(
550 shares_for_deposit(1, 0, 0, 10),
551 Err(RoshiError::InvalidDecimals)
552 );
553 assert_eq!(
554 shares_for_deposit(u64::MAX, 0, 0, 0),
555 Err(RoshiError::ResultDoesNotFit)
556 );
557 }
558
559 #[test]
560 fn deposit_shares_are_exact_at_par_and_floor_otherwise() {
561 assert_eq!(shares_for_deposit(100, 1_000, 1_000_000, 6), Ok(100_000));
564 assert_eq!(shares_for_deposit(101, 1_000, 1_000_000, 6), Ok(101_000));
565 assert_eq!(shares_for_deposit(100, 2_000, 1_000_000, 6), Ok(50_024));
568 assert_eq!(
569 shares_for_deposit(1, 1_000, 100, 9),
570 Err(RoshiError::ZeroOutput)
571 );
572 }
573
574 #[test]
575 fn donation_inflation_costs_the_attacker_the_offset_multiple() {
576 let attacker_shares = shares_for_deposit(1, 0, 0, 6).unwrap();
580 assert_eq!(attacker_shares, 1_000);
581
582 let donated_assets = 1 + 1_000_000_000;
583 let victim_shares =
585 shares_for_deposit(1_000_000, donated_assets, attacker_shares, 6).unwrap();
586 assert_eq!(victim_shares, 1);
587
588 let total_assets = donated_assets + 1_000_000;
591 let total_shares = attacker_shares + victim_shares;
592 let attacker_claim =
593 assets_for_redeem(attacker_shares, total_assets, total_shares, 6).unwrap();
594 let victim_claim = assets_for_redeem(victim_shares, total_assets, total_shares, 6).unwrap();
595 let attacker_cost = donated_assets - attacker_claim;
596 let victim_loss = 1_000_000 - victim_claim;
597 assert!(victim_loss < 500_000);
598 assert!(attacker_cost >= 999 * victim_loss);
599 }
600
601 #[test]
602 fn assets_for_shares_prices_dust_to_zero_without_error() {
603 assert_eq!(assets_for_shares(1, 100, 1_000, 9), Ok(0));
606 assert_eq!(
607 assets_for_redeem(1, 100, 1_000, 9),
608 Err(RoshiError::ZeroOutput)
609 );
610 assert_eq!(assets_for_shares(100_000, 1_000, 1_000_000, 6), Ok(100));
612 assert_eq!(
613 assets_for_shares(101, 100, 100, 9),
614 Err(RoshiError::InvalidVaultState)
615 );
616 }
617
618 #[test]
619 fn redeem_assets_are_floor_rounded_and_cannot_overpay() {
620 assert_eq!(assets_for_redeem(100_000, 1_000, 1_000_000, 6), Ok(100));
622 assert_eq!(
623 assets_for_redeem(1, 100, 1_000, 9),
624 Err(RoshiError::ZeroOutput)
625 );
626 assert_eq!(
627 assets_for_redeem(101, 100, 100, 9),
628 Err(RoshiError::InvalidVaultState)
629 );
630 }
631
632 #[test]
633 fn full_redeem_returns_all_assets_up_to_virtual_dust() {
634 assert_eq!(
637 assets_for_redeem(1_000_000_000, 1_000_000, 1_000_000_000, 6),
638 Ok(1_000_000)
639 );
640 assert_eq!(assets_for_redeem(u64::MAX, 123, u64::MAX, 9), Ok(123));
641 assert_eq!(assets_for_redeem(10, 1_000, 10, 9), Ok(910));
644 }
645
646 #[test]
647 fn deposit_redeem_round_trip_does_not_overpay() {
648 let shares = shares_for_deposit(1, 3, 10, 9).unwrap();
649 assert_eq!(shares, 2);
650 assert_eq!(
651 assets_for_redeem(shares, 3, 10, 9),
652 Err(RoshiError::ZeroOutput)
653 );
654
655 let shares = shares_for_deposit(100, 333, 1_000, 9).unwrap();
656 let assets = assets_for_redeem(shares, 333, 1_000, 9).unwrap();
657 assert!(assets <= 100);
658 }
659
660 #[test]
661 fn share_price_uses_fixed_share_scale() {
662 assert_eq!(
663 share_price_from_assets(1_000_000, 1_000_000_000),
664 Ok(1_000_000)
665 );
666 assert_eq!(
667 share_price_from_assets(1_100_000, 1_000_000_000),
668 Ok(1_100_000)
669 );
670 assert_eq!(
671 share_price_from_assets(1_000_000, 0),
672 Err(RoshiError::InvalidVaultState)
673 );
674 }
675
676 #[test]
677 fn performance_fee_for_nav_accrues_on_high_watermark_gains() {
678 assert_eq!(
679 performance_fee_for_nav(1_100_000, 1_000_000_000, 1_000_000, 1_000),
680 Ok((10_000, 1_090_000, 1_090_000))
681 );
682 }
683
684 #[test]
685 fn performance_fee_for_nav_sets_initial_high_watermark_without_fee() {
686 assert_eq!(
687 performance_fee_for_nav(1_000_000, 1_000_000_000, 0, 1_000),
688 Ok((0, 1_000_000, 1_000_000))
689 );
690 }
691
692 #[test]
693 fn performance_fee_for_nav_keeps_high_watermark_on_drawdown() {
694 assert_eq!(
695 performance_fee_for_nav(900_000, 1_000_000_000, 1_000_000, 1_000),
696 Ok((0, 900_000, 1_000_000))
697 );
698 }
699
700 #[test]
701 fn performance_fee_for_nav_ceil_rounds_high_watermark_assets() {
702 assert_eq!(
703 performance_fee_for_nav(2, 3, 333_333_334, 10_000),
704 Ok((0, 2, 666_666_666))
705 );
706 }
707
708 #[test]
709 fn performance_fee_for_nav_floors_indivisible_accrual() {
710 assert_eq!(
714 performance_fee_for_nav(1_000_011, 1_000_000_000, 1_000_000, 1_000),
715 Ok((1, 1_000_010, 1_000_010))
716 );
717 }
718
719 #[test]
720 fn bps_ceil_never_exceeds_amount_exhaustive_over_bps() {
721 let amounts = [
726 0u64,
727 1,
728 2,
729 3,
730 7,
731 9_999,
732 10_000,
733 10_001,
734 u64::MAX / 2,
735 u64::MAX - 1,
736 u64::MAX,
737 ];
738 for &amount in &amounts {
739 for bps in 0..=BPS_DENOMINATOR {
740 let ceil = bps_ceil(amount, bps).unwrap();
741 assert!(ceil <= amount, "ceil {ceil} > amount {amount} at bps {bps}");
742 }
743 }
744 }
745
746 #[test]
747 fn withdrawal_buffer_targets_can_round_up() {
748 assert_eq!(bps_ceil(1_001, 100), Ok(11));
749 assert_eq!(bps_floor(1_001, 100), Ok(10));
750 }
751
752 proptest! {
753 #![proptest_config(ProptestConfig::with_cases(256))]
754
755 #[test]
756 fn prop_floor_and_ceil_bound_exact_value(
757 lhs in any::<u64>(),
758 rhs in any::<u64>(),
759 denominator in 1u64..=u64::MAX,
760 ) {
761 let product = u128::from(lhs) * u128::from(rhs);
762 let denominator = u128::from(denominator);
763
764 let floor = mul_div_floor(u128::from(lhs), u128::from(rhs), denominator).unwrap();
765 let ceil = mul_div_ceil(u128::from(lhs), u128::from(rhs), denominator).unwrap();
766
767 prop_assert!(floor <= ceil);
768 prop_assert!(ceil <= floor + 1);
769 prop_assert!(floor * denominator <= product);
770 prop_assert!(product < (floor + 1) * denominator);
771 prop_assert!(ceil * denominator >= product);
772 }
773
774 #[test]
775 fn prop_round_with_min1_matches_klend_flash_fee(
776 amount in 0u64..=1_000_000_000_000,
780 flash_loan_fee_sf in 0u64..=(1u64 << 50),
781 ) {
782 let den = 1u64 << 60;
783 if let Some(expected) = klend_flash_fee(amount, flash_loan_fee_sf) {
786 prop_assert_eq!(round_with_min1(amount, flash_loan_fee_sf, den), Ok(expected));
787 }
788 }
789
790 #[test]
791 fn prop_bps_floor_and_ceil_are_ordered(
792 amount in any::<u64>(),
793 bps in 0u16..=BPS_DENOMINATOR,
794 ) {
795 let floor = bps_floor(amount, bps).unwrap();
796 let ceil = bps_ceil(amount, bps).unwrap();
797
798 prop_assert!(floor <= ceil);
799 prop_assert!(ceil <= floor + 1);
800 prop_assert!(ceil <= amount);
801 }
802
803 #[test]
804 fn prop_deposit_shares_are_monotonic(
805 total_assets in 0u64..=1_000_000_000,
806 total_shares in 0u64..=1_000_000_000,
807 base_atoms in 1u64..=1_000_000_000,
808 extra_atoms in 0u64..=1_000_000_000,
809 base_decimals in 0u8..=9,
810 ) {
811 let larger_base_atoms = base_atoms.saturating_add(extra_atoms);
812 let smaller = shares_for_deposit(base_atoms, total_assets, total_shares, base_decimals);
813 let larger =
814 shares_for_deposit(larger_base_atoms, total_assets, total_shares, base_decimals);
815
816 if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
817 prop_assert!(larger >= smaller);
818 }
819 }
820
821 #[test]
822 fn prop_redeem_assets_are_monotonic(
823 total_assets in 1u64..=1_000_000_000,
824 total_shares in 1u64..=1_000_000_000,
825 share_seed in any::<u64>(),
826 extra_seed in any::<u64>(),
827 base_decimals in 0u8..=9,
828 ) {
829 let smaller_shares = 1 + (share_seed % total_shares);
830 let remaining = total_shares - smaller_shares;
831 let larger_shares = smaller_shares + (extra_seed % (remaining + 1));
832
833 let smaller =
834 assets_for_redeem(smaller_shares, total_assets, total_shares, base_decimals);
835 let larger =
836 assets_for_redeem(larger_shares, total_assets, total_shares, base_decimals);
837
838 if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
839 prop_assert!(larger >= smaller);
840 }
841 }
842
843 #[test]
844 fn prop_deposit_then_redeem_never_overpays(
845 base_atoms in 1u64..=1_000_000_000,
846 total_assets in 0u64..=1_000_000_000,
847 total_shares in 0u64..=1_000_000_000,
848 base_decimals in 0u8..=9,
849 ) {
850 if let Ok(shares) = shares_for_deposit(base_atoms, total_assets, total_shares, base_decimals) {
851 let total_assets = total_assets + base_atoms;
853 let total_shares = total_shares + shares;
854 if let Ok(assets) = assets_for_redeem(shares, total_assets, total_shares, base_decimals) {
855 prop_assert!(assets <= base_atoms);
856 }
857 }
858 }
859
860 #[test]
861 fn prop_redeem_never_pays_more_than_total_assets(
862 total_assets in 0u64..=u64::MAX,
863 total_shares in 1u64..=u64::MAX,
864 share_seed in any::<u64>(),
865 base_decimals in 0u8..=9,
866 ) {
867 let shares = 1 + (share_seed % total_shares);
868 match assets_for_redeem(shares, total_assets, total_shares, base_decimals) {
869 Ok(assets) => prop_assert!(assets <= total_assets),
870 Err(error) => prop_assert_eq!(error, RoshiError::ZeroOutput),
871 }
872 }
873
874 #[test]
875 fn prop_par_vault_pricing_is_exact(
876 pot in 1u64..=1_000_000_000,
877 base_atoms in 1u64..=1_000_000_000,
878 share_seed in any::<u64>(),
879 base_decimals in 0u8..=9,
880 ) {
881 let ratio = u64::try_from(virtual_share_offset(base_decimals).unwrap()).unwrap();
884 let supply = pot * ratio;
885 prop_assert_eq!(
886 shares_for_deposit(base_atoms, pot, supply, base_decimals),
887 Ok(base_atoms * ratio)
888 );
889
890 let shares = 1 + (share_seed % supply);
891 let redeemed = assets_for_redeem(shares, pot, supply, base_decimals);
892 if shares / ratio == 0 {
893 prop_assert_eq!(redeemed, Err(RoshiError::ZeroOutput));
894 } else {
895 prop_assert_eq!(redeemed, Ok(shares / ratio));
896 }
897 }
898
899 #[test]
900 fn prop_full_redeem_at_or_below_par_returns_total_assets(
901 total_assets in 1u64..=1_000_000_000,
902 excess_shares in 0u64..=1_000_000_000,
903 base_decimals in 0u8..=9,
904 ) {
905 let ratio = u64::try_from(virtual_share_offset(base_decimals).unwrap()).unwrap();
906 let total_shares = total_assets * ratio + excess_shares;
907 prop_assert_eq!(
908 assets_for_redeem(total_shares, total_assets, total_shares, base_decimals),
909 Ok(total_assets)
910 );
911 }
912
913 #[test]
914 fn prop_performance_fee_conserves_and_ratchets(
915 gross in 0u64..=1_000_000_000,
916 total_shares in 0u64..=1_000_000_000,
917 high_watermark in 0u64..=1_000_000_000,
918 bps in 0u16..=BPS_DENOMINATOR,
919 ) {
920 let (fee, net, new_hwm) =
923 performance_fee_for_nav(gross, total_shares, high_watermark, bps).unwrap();
924 prop_assert!(fee <= gross);
926 prop_assert_eq!(net, gross - fee);
927 prop_assert!(new_hwm >= high_watermark);
929 if bps == 0 || total_shares == 0 {
931 prop_assert_eq!(fee, 0);
932 }
933 }
934
935 #[test]
936 fn prop_base_atoms_monotonic_in_amount_and_unit_leg_scale_invariant(
937 asset_atoms in 0u64..=1_000_000_000_000,
938 extra_atoms in 0u64..=1_000_000_000_000,
939 price_value in 1u128..=1_000_000_000_000,
940 price_decimals in 0u8..=12,
941 unit_leg_decimals in 0u8..=12,
942 asset_decimals in 0u8..=12,
943 base_decimals in 0u8..=9,
944 ) {
945 let asset_price = OraclePrice { value: price_value, decimals: price_decimals };
946 if let Ok(smaller) = base_atoms_from_asset_atoms(
949 asset_atoms, asset_price, OraclePrice::UNIT, asset_decimals, base_decimals,
950 ) {
951 let larger = base_atoms_from_asset_atoms(
952 asset_atoms.saturating_add(extra_atoms),
953 asset_price, OraclePrice::UNIT, asset_decimals, base_decimals,
954 );
955 if let Ok(larger) = larger {
956 prop_assert!(larger >= smaller);
957 }
958
959 let scaled_unit = OraclePrice {
961 value: pow10(unit_leg_decimals).unwrap(),
962 decimals: unit_leg_decimals,
963 };
964 prop_assert_eq!(
965 base_atoms_from_asset_atoms(
966 asset_atoms, asset_price, scaled_unit, asset_decimals, base_decimals,
967 ),
968 Ok(smaller)
969 );
970 }
971 }
972
973 #[test]
974 fn prop_performance_fee_is_monotonic_in_bps(
975 gross in 0u64..=1_000_000_000,
976 total_shares in 0u64..=1_000_000_000,
977 high_watermark in 0u64..=1_000_000_000,
978 bps_a in 0u16..=BPS_DENOMINATOR,
979 bps_b in 0u16..=BPS_DENOMINATOR,
980 ) {
981 let (lo, hi) = if bps_a <= bps_b { (bps_a, bps_b) } else { (bps_b, bps_a) };
985 let (fee_lo, ..) =
986 performance_fee_for_nav(gross, total_shares, high_watermark, lo).unwrap();
987 let (fee_hi, ..) =
988 performance_fee_for_nav(gross, total_shares, high_watermark, hi).unwrap();
989 prop_assert!(fee_hi >= fee_lo);
990 }
991
992 }
993}