Skip to main content

roshi_interface/
math.rs

1//! Shared integer accounting math for Roshi vaults.
2
3use 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
55/// Ceiling of `value * num / den` as `u128` — a committed proportional rate
56/// applied to an amount (e.g. a flash-loan `num/den` fee on a borrow). `num == 0`
57/// yields `0` without dividing, so a zero rate needs no valid denominator; a
58/// non-zero `num` over `den == 0` is `DivisionByZero`. Returns `u128` so the
59/// caller checks the `value + fee` sum against its own bound.
60pub 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
84/// Floor-rounded base atoms for `asset_atoms`, priced through two whole-token
85/// oracle legs sharing one quote currency:
86///
87/// - `asset_price`: quote units per whole asset token (e.g. an X/USD feed),
88/// - `base_price`: quote units per whole base token (e.g. a BASE/USD feed).
89///
90/// A direct asset/base feed is the degenerate case `base_price ==`
91/// [`OraclePrice::UNIT`] (the quote currency *is* the base, priced at exactly
92/// 1). Whether both legs really share a quote currency is a configuration
93/// fact the program cannot observe; it is the vault operator's contract.
94///
95/// With whole-token prices `p = value / 10^decimals`:
96///
97/// ```text
98/// base_atoms = floor(asset_atoms / 10^asset_decimals   // whole asset tokens
99///                  * (p_asset / p_base)                 // base per asset
100///                  * 10^base_decimals)                  // base atoms
101/// ```
102///
103/// which in integer form is one floor division after cancelling the shared
104/// powers of ten:
105///
106/// ```text
107/// asset_atoms * asset_value * 10^(base_decimals + base_price_decimals)
108/// --------------------------------------------------------------------
109///  base_value * 10^(asset_decimals + asset_price_decimals)
110/// ```
111pub 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
146/// The virtual share supply backing one virtual base atom:
147/// `10^(SHARE_DECIMALS - base_decimals)`, the empty-vault mint ratio.
148///
149/// Deposits and redeems price against `(total_shares + offset, total_assets + 1)`
150/// instead of the raw pair. This is the ERC-4626 virtual-offset defense against
151/// donation/first-deposit share-price inflation: the virtual position absorbs
152/// donated value, so inflating a later depositor's rounding loss costs the
153/// attacker ~`offset` times that loss. It also makes pricing continuous through
154/// the empty vault — the first deposit needs no special case.
155///
156/// Only virtual *shares* are scaled (virtual assets stay at 1): a virtual-asset
157/// offset above 1 would let a full redeem price above `total_assets`. That
158/// requires `base_decimals <= SHARE_DECIMALS`, enforced at vault initialization.
159pub 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
181/// Floor-rounded base value of `shares`. Zero is a valid result: a dust
182/// position can be worth less than one base atom, and withdrawal-ticket
183/// strikes must price it (to nothing) rather than wedge. Immediate redemption
184/// paths that must pay out should use [`assets_for_redeem`].
185pub 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        // Kamino USDC example from #21: rate 0.00001 (sf 11529215046068 / 2^60)
313        // on a 1_000_000 borrow ceils to a fee of 10.
314        assert_eq!(ceil_mul(1_000_000, 11_529_215_046_068, 1 << 60), Ok(10));
315        // Ceil, not floor: any remainder rounds up (in the lender's favour).
316        assert_eq!(ceil_mul(1_000_000, 1, 1_000_001), Ok(1));
317        // A zero numerator is a zero fee regardless of the denominator (incl. 0),
318        // reproducing #19's fee-free `delegated == F`.
319        assert_eq!(ceil_mul(1_000_000, 0, 0), Ok(0));
320        // A non-zero rate over a zero denominator is rejected, not silently zero.
321        assert_eq!(ceil_mul(1, 1, 0), Err(RoshiError::DivisionByZero));
322        // Overflow propagates rather than wrapping.
323        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        // Same mint decimals: 2.5 base per asset token, atom-for-atom.
351        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        // 8-decimal asset on a 6-decimal base at 100_000 base per token:
362        // 1 whole asset (10^8 atoms) is 100_000 whole base (10^11 atoms).
363        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        // 6-decimal asset on a 9-decimal base: the scale flips sides.
368        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        // SOL (9 dec) deposited into a USDC-base vault (6 dec): SOL/USD at
377        // 150.0 (8 dec), USDC/USD at 1.0 (8 dec). 1 SOL -> 150 USDC.
378        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        // The legs need not share a scale: same ratio at different decimals.
389        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        // A cheapened base leg buys more base atoms: USDC marked at 0.50 USD
394        // doubles the credited base amount.
395        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        // One atom of a 9-dec asset worth 1.0 base token on a 6-dec base
436        // floors to zero base atoms.
437        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        // At par (supply/assets equals the virtual ratio) the offset cancels
487        // and pricing is exact.
488        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        // Off par the result floors (and the virtual position drags a
491        // dust-sized pot toward par; at realistic pots the pull vanishes).
492        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        // Classic ERC-4626 inflation attack at 6 base decimals (offset 1000):
502        // attacker seeds 1 atom, donates 10^9 atoms, and a NAV report folds the
503        // donation into total_assets before the victim deposits 10^6 atoms.
504        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        // The victim still mints (no zero-share grief)...
509        let victim_shares =
510            shares_for_deposit(1_000_000, donated_assets, attacker_shares, 6).unwrap();
511        assert_eq!(victim_shares, 1);
512
513        // ...and the virtual position absorbs the donation: the attacker's
514        // claim comes back ~offset times further short than the victim's loss.
515        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        // Same dust input that makes assets_for_redeem fail: 1 share of a
529        // 1000-share pot worth 100 atoms floors to zero.
530        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        // The guards stay identical otherwise.
536        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        // Par: exact inverse of the deposit example.
546        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        // At or below par price the virtual position rounds away and a full
560        // redeem drains the vault exactly.
561        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        // Far above par (only reachable at dust-sized supplies) the virtual
567        // position keeps its pro-rata slice as dust.
568        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        // Profit of 11 at 1_000 bps is 1.1 units of fee. The fee must *floor*
636        // (charge 1, in the depositor's favour), never ceil to 2. The divisible
637        // example above can't tell floor from ceil; this one pins the direction.
638        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        // Ground truth: bitwuzla-via-CBMC-6.8 reported `ceil <= amount` as a
647        // FAILURE, but its SMT2 backend crashes (smt2_conv invariant violation),
648        // so that verdict is a solver artifact. This native check is exhaustive
649        // over every valid bps at the boundary-relevant amounts.
650        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                // Redeem against the post-deposit state the mint produced.
761                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            // At par the virtual offset cancels: deposits mint at exactly the
791            // empty-vault ratio and redeems pay exactly floor(shares / ratio).
792            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            // These inputs are all in-domain, so the accrual must not error;
830            // a swallowed `Err` would hide exactly the regression we want loud.
831            let (fee, net, new_hwm) =
832                performance_fee_for_nav(gross, total_shares, high_watermark, bps).unwrap();
833            // Conservation: the fee is carved out of gross, nothing created.
834            prop_assert!(fee <= gross);
835            prop_assert_eq!(net, gross - fee);
836            // The high-watermark only ever ratchets up.
837            prop_assert!(new_hwm >= high_watermark);
838            // No fee with no rate and no fee with no shares to charge against.
839            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            // Only assert when the UNIT-leg result is in u64 range; the
856            // out-of-range error paths are pinned by the example tests.
857            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                // A base leg of exactly 1.0 at any scale is the UNIT leg.
869                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            // A higher fee rate can never charge less on the same NAV report.
891            // Forces the fee-charged branch to be meaningful without the test
892            // re-deriving the fee formula (exact values live in the example tests).
893            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}