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
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
71/// Floor-rounded base atoms for `asset_atoms`, priced through two whole-token
72/// oracle legs sharing one quote currency:
73///
74/// - `asset_price`: quote units per whole asset token (e.g. an X/USD feed),
75/// - `base_price`: quote units per whole base token (e.g. a BASE/USD feed).
76///
77/// A direct asset/base feed is the degenerate case `base_price ==`
78/// [`OraclePrice::UNIT`] (the quote currency *is* the base, priced at exactly
79/// 1). Whether both legs really share a quote currency is a configuration
80/// fact the program cannot observe; it is the vault operator's contract.
81///
82/// With whole-token prices `p = value / 10^decimals`:
83///
84/// ```text
85/// base_atoms = floor(asset_atoms / 10^asset_decimals   // whole asset tokens
86///                  * (p_asset / p_base)                 // base per asset
87///                  * 10^base_decimals)                  // base atoms
88/// ```
89///
90/// which in integer form is one floor division after cancelling the shared
91/// powers of ten:
92///
93/// ```text
94/// asset_atoms * asset_value * 10^(base_decimals + base_price_decimals)
95/// --------------------------------------------------------------------
96///  base_value * 10^(asset_decimals + asset_price_decimals)
97/// ```
98pub 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
133/// The virtual share supply backing one virtual base atom:
134/// `10^(SHARE_DECIMALS - base_decimals)`, the empty-vault mint ratio.
135///
136/// Deposits and redeems price against `(total_shares + offset, total_assets + 1)`
137/// instead of the raw pair. This is the ERC-4626 virtual-offset defense against
138/// donation/first-deposit share-price inflation: the virtual position absorbs
139/// donated value, so inflating a later depositor's rounding loss costs the
140/// attacker ~`offset` times that loss. It also makes pricing continuous through
141/// the empty vault — the first deposit needs no special case.
142///
143/// Only virtual *shares* are scaled (virtual assets stay at 1): a virtual-asset
144/// offset above 1 would let a full redeem price above `total_assets`. That
145/// requires `base_decimals <= SHARE_DECIMALS`, enforced at vault initialization.
146pub 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
168/// Floor-rounded base value of `shares`. Zero is a valid result: a dust
169/// position can be worth less than one base atom, and withdrawal-ticket
170/// strikes must price it (to nothing) rather than wedge. Immediate redemption
171/// paths that must pay out should use [`assets_for_redeem`].
172pub 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        // Same mint decimals: 2.5 base per asset token, atom-for-atom.
319        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        // 8-decimal asset on a 6-decimal base at 100_000 base per token:
330        // 1 whole asset (10^8 atoms) is 100_000 whole base (10^11 atoms).
331        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        // 6-decimal asset on a 9-decimal base: the scale flips sides.
336        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        // SOL (9 dec) deposited into a USDC-base vault (6 dec): SOL/USD at
345        // 150.0 (8 dec), USDC/USD at 1.0 (8 dec). 1 SOL -> 150 USDC.
346        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        // The legs need not share a scale: same ratio at different decimals.
357        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        // A cheapened base leg buys more base atoms: USDC marked at 0.50 USD
362        // doubles the credited base amount.
363        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        // One atom of a 9-dec asset worth 1.0 base token on a 6-dec base
404        // floors to zero base atoms.
405        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        // At par (supply/assets equals the virtual ratio) the offset cancels
455        // and pricing is exact.
456        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        // Off par the result floors (and the virtual position drags a
459        // dust-sized pot toward par; at realistic pots the pull vanishes).
460        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        // Classic ERC-4626 inflation attack at 6 base decimals (offset 1000):
470        // attacker seeds 1 atom, donates 10^9 atoms, and a NAV report folds the
471        // donation into total_assets before the victim deposits 10^6 atoms.
472        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        // The victim still mints (no zero-share grief)...
477        let victim_shares =
478            shares_for_deposit(1_000_000, donated_assets, attacker_shares, 6).unwrap();
479        assert_eq!(victim_shares, 1);
480
481        // ...and the virtual position absorbs the donation: the attacker's
482        // claim comes back ~offset times further short than the victim's loss.
483        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        // Same dust input that makes assets_for_redeem fail: 1 share of a
497        // 1000-share pot worth 100 atoms floors to zero.
498        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        // The guards stay identical otherwise.
504        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        // Par: exact inverse of the deposit example.
514        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        // At or below par price the virtual position rounds away and a full
528        // redeem drains the vault exactly.
529        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        // Far above par (only reachable at dust-sized supplies) the virtual
535        // position keeps its pro-rata slice as dust.
536        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        // Profit of 11 at 1_000 bps is 1.1 units of fee. The fee must *floor*
604        // (charge 1, in the depositor's favour), never ceil to 2. The divisible
605        // example above can't tell floor from ceil; this one pins the direction.
606        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        // Ground truth: bitwuzla-via-CBMC-6.8 reported `ceil <= amount` as a
615        // FAILURE, but its SMT2 backend crashes (smt2_conv invariant violation),
616        // so that verdict is a solver artifact. This native check is exhaustive
617        // over every valid bps at the boundary-relevant amounts.
618        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                // Redeem against the post-deposit state the mint produced.
729                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            // At par the virtual offset cancels: deposits mint at exactly the
759            // empty-vault ratio and redeems pay exactly floor(shares / ratio).
760            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            // These inputs are all in-domain, so the accrual must not error;
798            // a swallowed `Err` would hide exactly the regression we want loud.
799            let (fee, net, new_hwm) =
800                performance_fee_for_nav(gross, total_shares, high_watermark, bps).unwrap();
801            // Conservation: the fee is carved out of gross, nothing created.
802            prop_assert!(fee <= gross);
803            prop_assert_eq!(net, gross - fee);
804            // The high-watermark only ever ratchets up.
805            prop_assert!(new_hwm >= high_watermark);
806            // No fee with no rate and no fee with no shares to charge against.
807            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            // Only assert when the UNIT-leg result is in u64 range; the
824            // out-of-range error paths are pinned by the example tests.
825            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                // A base leg of exactly 1.0 at any scale is the UNIT leg.
837                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            // A higher fee rate can never charge less on the same NAV report.
859            // Forces the fee-charged branch to be meaningful without the test
860            // re-deriving the fee formula (exact values live in the example tests).
861            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}