Skip to main content

roshi_interface/
math.rs

1//! Shared integer accounting math for Roshi vaults.
2
3use crate::error::RoshiError;
4
5pub const SHARE_DECIMALS: u8 = 9;
6pub const BPS_DENOMINATOR: u16 = 10_000;
7
8pub type MathResult<T> = Result<T, RoshiError>;
9
10pub fn pow10(decimals: u8) -> MathResult<u128> {
11    10u128
12        .checked_pow(u32::from(decimals))
13        .ok_or(RoshiError::InvalidDecimals)
14}
15
16pub fn mul_div_floor(lhs: u128, rhs: u128, denominator: u128) -> MathResult<u128> {
17    if denominator == 0 {
18        return Err(RoshiError::DivisionByZero);
19    }
20
21    lhs.checked_mul(rhs)
22        .ok_or(RoshiError::Overflow)
23        .map(|product| product / denominator)
24}
25
26pub fn mul_div_ceil(lhs: u128, rhs: u128, denominator: u128) -> MathResult<u128> {
27    if denominator == 0 {
28        return Err(RoshiError::DivisionByZero);
29    }
30
31    let product = lhs.checked_mul(rhs).ok_or(RoshiError::Overflow)?;
32    let quotient = product / denominator;
33
34    if product % denominator == 0 {
35        Ok(quotient)
36    } else {
37        quotient.checked_add(1).ok_or(RoshiError::Overflow)
38    }
39}
40
41pub fn checked_u64(value: u128) -> MathResult<u64> {
42    u64::try_from(value).map_err(|_| RoshiError::ResultDoesNotFit)
43}
44
45pub fn mul_div_floor_u64(lhs: u64, rhs: u64, denominator: u64) -> MathResult<u64> {
46    let value = mul_div_floor(u128::from(lhs), u128::from(rhs), u128::from(denominator))?;
47    checked_u64(value)
48}
49
50pub fn mul_div_ceil_u64(lhs: u64, rhs: u64, denominator: u64) -> MathResult<u64> {
51    let value = mul_div_ceil(u128::from(lhs), u128::from(rhs), u128::from(denominator))?;
52    checked_u64(value)
53}
54
55pub fn bps_floor(amount: u64, bps: u16) -> MathResult<u64> {
56    mul_div_floor_u64(amount, u64::from(bps), u64::from(BPS_DENOMINATOR))
57}
58
59pub fn bps_ceil(amount: u64, bps: u16) -> MathResult<u64> {
60    mul_div_ceil_u64(amount, u64::from(bps), u64::from(BPS_DENOMINATOR))
61}
62
63pub fn validate_percentage_bps(bps: u16) -> MathResult<()> {
64    if bps > BPS_DENOMINATOR {
65        return Err(RoshiError::InvalidBps);
66    }
67
68    Ok(())
69}
70
71pub fn base_atoms_from_asset_atoms(
72    asset_atoms: u64,
73    price_value: u128,
74    price_decimals: u8,
75) -> MathResult<u64> {
76    let scale = pow10(price_decimals)?;
77    let value = mul_div_floor(u128::from(asset_atoms), price_value, scale)?;
78    checked_u64(value)
79}
80
81pub fn initial_shares_from_base_atoms(base_atoms: u64, base_decimals: u8) -> MathResult<u64> {
82    let share_scale = pow10(SHARE_DECIMALS)?;
83    let base_scale = pow10(base_decimals)?;
84    let shares = mul_div_floor(u128::from(base_atoms), share_scale, base_scale)?;
85    checked_nonzero_u64(shares)
86}
87
88pub fn shares_for_deposit(
89    base_atoms: u64,
90    total_assets: u64,
91    total_shares: u64,
92) -> MathResult<u64> {
93    if total_assets == 0 || total_shares == 0 {
94        return Err(RoshiError::InvalidVaultState);
95    }
96
97    let shares = mul_div_floor(
98        u128::from(base_atoms),
99        u128::from(total_shares),
100        u128::from(total_assets),
101    )?;
102    checked_nonzero_u64(shares)
103}
104
105pub fn assets_for_redeem(shares: u64, total_assets: u64, total_shares: u64) -> MathResult<u64> {
106    if total_assets == 0 || total_shares == 0 || shares > total_shares {
107        return Err(RoshiError::InvalidVaultState);
108    }
109
110    let assets = mul_div_floor(
111        u128::from(shares),
112        u128::from(total_assets),
113        u128::from(total_shares),
114    )?;
115    checked_nonzero_u64(assets)
116}
117
118pub fn share_price_from_assets(total_assets: u64, total_shares: u64) -> MathResult<u64> {
119    if total_shares == 0 {
120        return Err(RoshiError::InvalidVaultState);
121    }
122
123    let share_scale = pow10(SHARE_DECIMALS)?;
124    let share_price = mul_div_floor(
125        u128::from(total_assets),
126        share_scale,
127        u128::from(total_shares),
128    )?;
129    checked_u64(share_price)
130}
131
132pub fn performance_fee_for_nav(
133    gross_total_assets: u64,
134    total_shares: u64,
135    high_watermark: u64,
136    performance_fee_bps: u16,
137) -> MathResult<(u64, u64, u64)> {
138    validate_percentage_bps(performance_fee_bps)?;
139
140    if total_shares == 0 {
141        return Ok((0, gross_total_assets, high_watermark));
142    }
143
144    let gross_share_price = share_price_from_assets(gross_total_assets, total_shares)?;
145    if high_watermark == 0 || gross_share_price <= high_watermark || performance_fee_bps == 0 {
146        return Ok((0, gross_total_assets, high_watermark.max(gross_share_price)));
147    }
148
149    let share_scale = pow10(SHARE_DECIMALS)?;
150    let high_watermark_assets = checked_u64(mul_div_ceil(
151        u128::from(high_watermark),
152        u128::from(total_shares),
153        share_scale,
154    )?)?;
155    let profit_assets = gross_total_assets
156        .checked_sub(high_watermark_assets)
157        .ok_or(RoshiError::Overflow)?;
158    let fee_assets = bps_floor(profit_assets, performance_fee_bps)?;
159    let net_total_assets = gross_total_assets
160        .checked_sub(fee_assets)
161        .ok_or(RoshiError::Overflow)?;
162    let net_share_price = share_price_from_assets(net_total_assets, total_shares)?;
163
164    Ok((
165        fee_assets,
166        net_total_assets,
167        high_watermark.max(net_share_price),
168    ))
169}
170
171fn checked_nonzero_u64(value: u128) -> MathResult<u64> {
172    let value = checked_u64(value)?;
173    if value == 0 {
174        return Err(RoshiError::ZeroOutput);
175    }
176
177    Ok(value)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use proptest::prelude::*;
184
185    #[test]
186    fn pow10_rejects_unsupported_decimals() {
187        assert!(pow10(38).is_ok());
188        assert_eq!(pow10(39), Err(RoshiError::InvalidDecimals));
189    }
190
191    #[test]
192    fn mul_div_floor_and_ceil_handle_boundaries() {
193        assert_eq!(mul_div_floor_u64(10, 2, 4), Ok(5));
194        assert_eq!(mul_div_floor_u64(10, 2, 6), Ok(3));
195        assert_eq!(mul_div_ceil_u64(10, 2, 4), Ok(5));
196        assert_eq!(mul_div_ceil_u64(10, 2, 6), Ok(4));
197        assert_eq!(mul_div_floor_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
198        assert_eq!(mul_div_ceil_u64(1, 1, 0), Err(RoshiError::DivisionByZero));
199    }
200
201    #[test]
202    fn mul_div_rejects_overflow_and_downcast() {
203        assert_eq!(mul_div_floor(u128::MAX, 2, 1), Err(RoshiError::Overflow));
204        assert_eq!(
205            mul_div_floor_u64(u64::MAX, u64::MAX, 1),
206            Err(RoshiError::ResultDoesNotFit)
207        );
208    }
209
210    #[test]
211    fn bps_helpers_use_standard_denominator() {
212        assert_eq!(bps_floor(101, 100), Ok(1));
213        assert_eq!(bps_ceil(101, 100), Ok(2));
214        assert_eq!(bps_floor(42, 10_001), Ok(42));
215        assert_eq!(bps_ceil(42, 10_001), Ok(43));
216    }
217
218    #[test]
219    fn percentage_bps_validation_caps_at_full_percentage() {
220        assert_eq!(validate_percentage_bps(0), Ok(()));
221        assert_eq!(validate_percentage_bps(10_000), Ok(()));
222        assert_eq!(validate_percentage_bps(10_001), Err(RoshiError::InvalidBps));
223    }
224
225    #[test]
226    fn normalizes_oracle_values_into_base_atoms() {
227        assert_eq!(
228            base_atoms_from_asset_atoms(1_000_000, 2_500_000_000, 9),
229            Ok(2_500_000)
230        );
231        assert_eq!(
232            base_atoms_from_asset_atoms(u64::MAX, u128::from(u64::MAX), 0),
233            Err(RoshiError::ResultDoesNotFit)
234        );
235    }
236
237    #[test]
238    fn normalization_can_round_to_zero_without_failing() {
239        assert_eq!(base_atoms_from_asset_atoms(0, 1_000_000_000, 9), Ok(0));
240        assert_eq!(base_atoms_from_asset_atoms(1, 1, 9), Ok(0));
241    }
242
243    #[test]
244    fn normalization_rejects_invalid_price_decimals() {
245        assert_eq!(
246            base_atoms_from_asset_atoms(1, 1, 39),
247            Err(RoshiError::InvalidDecimals)
248        );
249    }
250
251    #[test]
252    fn initial_share_scale_uses_fixed_share_decimals_and_base_decimals() {
253        assert_eq!(
254            initial_shares_from_base_atoms(1_000_000, 6),
255            Ok(1_000_000_000)
256        );
257        assert_eq!(
258            initial_shares_from_base_atoms(1_000_000_000, 9),
259            Ok(1_000_000_000)
260        );
261        assert_eq!(
262            initial_shares_from_base_atoms(1, 12),
263            Err(RoshiError::ZeroOutput)
264        );
265    }
266
267    #[test]
268    fn initial_share_scale_rejects_invalid_decimals_and_downcast_overflow() {
269        assert_eq!(
270            initial_shares_from_base_atoms(1, 39),
271            Err(RoshiError::InvalidDecimals)
272        );
273        assert_eq!(
274            initial_shares_from_base_atoms(u64::MAX, 0),
275            Err(RoshiError::ResultDoesNotFit)
276        );
277    }
278
279    #[test]
280    fn deposit_shares_are_floor_rounded_and_monotonic() {
281        assert_eq!(shares_for_deposit(100, 1_000, 10_000), Ok(1_000));
282        assert_eq!(shares_for_deposit(101, 1_000, 10_000), Ok(1_010));
283        assert_eq!(
284            shares_for_deposit(1, 1_000, 100),
285            Err(RoshiError::ZeroOutput)
286        );
287        assert_eq!(
288            shares_for_deposit(1, 0, 100),
289            Err(RoshiError::InvalidVaultState)
290        );
291        assert_eq!(
292            shares_for_deposit(1, 100, 0),
293            Err(RoshiError::InvalidVaultState)
294        );
295    }
296
297    #[test]
298    fn deposit_shares_preserve_exact_proportions() {
299        assert_eq!(shares_for_deposit(250, 1_000, 4_000), Ok(1_000));
300        assert_eq!(shares_for_deposit(333, 999, 3_000), Ok(1_000));
301    }
302
303    #[test]
304    fn redeem_assets_are_floor_rounded_and_cannot_overpay() {
305        assert_eq!(assets_for_redeem(1_000, 1_000, 10_000), Ok(100));
306        assert_eq!(assets_for_redeem(1_010, 1_000, 10_000), Ok(101));
307        assert_eq!(
308            assets_for_redeem(1, 100, 1_000),
309            Err(RoshiError::ZeroOutput)
310        );
311        assert_eq!(
312            assets_for_redeem(1, 0, 100),
313            Err(RoshiError::InvalidVaultState)
314        );
315        assert_eq!(
316            assets_for_redeem(101, 100, 100),
317            Err(RoshiError::InvalidVaultState)
318        );
319    }
320
321    #[test]
322    fn redeeming_all_shares_returns_all_assets() {
323        assert_eq!(assets_for_redeem(10_000, 1_000, 10_000), Ok(1_000));
324        assert_eq!(assets_for_redeem(u64::MAX, 123, u64::MAX), Ok(123));
325    }
326
327    #[test]
328    fn deposit_redeem_round_trip_does_not_overpay() {
329        let shares = shares_for_deposit(1, 3, 10).unwrap();
330        assert_eq!(shares, 3);
331        assert_eq!(
332            assets_for_redeem(shares, 3, 10),
333            Err(RoshiError::ZeroOutput)
334        );
335
336        let shares = shares_for_deposit(100, 333, 1_000).unwrap();
337        let assets = assets_for_redeem(shares, 333, 1_000).unwrap();
338        assert!(assets <= 100);
339    }
340
341    #[test]
342    fn share_price_uses_fixed_share_scale() {
343        assert_eq!(
344            share_price_from_assets(1_000_000, 1_000_000_000),
345            Ok(1_000_000)
346        );
347        assert_eq!(
348            share_price_from_assets(1_100_000, 1_000_000_000),
349            Ok(1_100_000)
350        );
351        assert_eq!(
352            share_price_from_assets(1_000_000, 0),
353            Err(RoshiError::InvalidVaultState)
354        );
355    }
356
357    #[test]
358    fn performance_fee_for_nav_accrues_on_high_watermark_gains() {
359        assert_eq!(
360            performance_fee_for_nav(1_100_000, 1_000_000_000, 1_000_000, 1_000),
361            Ok((10_000, 1_090_000, 1_090_000))
362        );
363    }
364
365    #[test]
366    fn performance_fee_for_nav_sets_initial_high_watermark_without_fee() {
367        assert_eq!(
368            performance_fee_for_nav(1_000_000, 1_000_000_000, 0, 1_000),
369            Ok((0, 1_000_000, 1_000_000))
370        );
371    }
372
373    #[test]
374    fn performance_fee_for_nav_keeps_high_watermark_on_drawdown() {
375        assert_eq!(
376            performance_fee_for_nav(900_000, 1_000_000_000, 1_000_000, 1_000),
377            Ok((0, 900_000, 1_000_000))
378        );
379    }
380
381    #[test]
382    fn performance_fee_for_nav_ceil_rounds_high_watermark_assets() {
383        assert_eq!(
384            performance_fee_for_nav(2, 3, 333_333_334, 10_000),
385            Ok((0, 2, 666_666_666))
386        );
387    }
388
389    #[test]
390    fn withdrawal_buffer_targets_can_round_up() {
391        assert_eq!(bps_ceil(1_001, 100), Ok(11));
392        assert_eq!(bps_floor(1_001, 100), Ok(10));
393    }
394
395    proptest! {
396        #![proptest_config(ProptestConfig::with_cases(256))]
397
398        #[test]
399        fn prop_floor_and_ceil_bound_exact_value(
400            lhs in any::<u64>(),
401            rhs in any::<u64>(),
402            denominator in 1u64..=u64::MAX,
403        ) {
404            let product = u128::from(lhs) * u128::from(rhs);
405            let denominator = u128::from(denominator);
406
407            let floor = mul_div_floor(u128::from(lhs), u128::from(rhs), denominator).unwrap();
408            let ceil = mul_div_ceil(u128::from(lhs), u128::from(rhs), denominator).unwrap();
409
410            prop_assert!(floor <= ceil);
411            prop_assert!(ceil <= floor + 1);
412            prop_assert!(floor * denominator <= product);
413            prop_assert!(product < (floor + 1) * denominator);
414            prop_assert!(ceil * denominator >= product);
415        }
416
417        #[test]
418        fn prop_bps_floor_and_ceil_are_ordered(
419            amount in any::<u64>(),
420            bps in 0u16..=BPS_DENOMINATOR,
421        ) {
422            let floor = bps_floor(amount, bps).unwrap();
423            let ceil = bps_ceil(amount, bps).unwrap();
424
425            prop_assert!(floor <= ceil);
426            prop_assert!(ceil <= floor + 1);
427            prop_assert!(ceil <= amount);
428        }
429
430        #[test]
431        fn prop_deposit_shares_are_monotonic(
432            total_assets in 1u64..=1_000_000_000,
433            total_shares in 1u64..=1_000_000_000,
434            base_atoms in 1u64..=1_000_000_000,
435            extra_atoms in 0u64..=1_000_000_000,
436        ) {
437            let larger_base_atoms = base_atoms.saturating_add(extra_atoms);
438            let smaller = shares_for_deposit(base_atoms, total_assets, total_shares);
439            let larger = shares_for_deposit(larger_base_atoms, total_assets, total_shares);
440
441            if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
442                prop_assert!(larger >= smaller);
443            }
444        }
445
446        #[test]
447        fn prop_redeem_assets_are_monotonic(
448            total_assets in 1u64..=1_000_000_000,
449            total_shares in 1u64..=1_000_000_000,
450            share_seed in any::<u64>(),
451            extra_seed in any::<u64>(),
452        ) {
453            let smaller_shares = 1 + (share_seed % total_shares);
454            let remaining = total_shares - smaller_shares;
455            let larger_shares = smaller_shares + (extra_seed % (remaining + 1));
456
457            let smaller = assets_for_redeem(smaller_shares, total_assets, total_shares);
458            let larger = assets_for_redeem(larger_shares, total_assets, total_shares);
459
460            if let (Ok(smaller), Ok(larger)) = (smaller, larger) {
461                prop_assert!(larger >= smaller);
462            }
463        }
464
465        #[test]
466        fn prop_deposit_then_redeem_never_overpays(
467            base_atoms in 1u64..=1_000_000_000,
468            total_assets in 1u64..=1_000_000_000,
469            total_shares in 1u64..=1_000_000_000,
470        ) {
471            if let Ok(shares) = shares_for_deposit(base_atoms, total_assets, total_shares) {
472                if let Ok(assets) = assets_for_redeem(shares, total_assets, total_shares) {
473                    prop_assert!(assets <= base_atoms);
474                }
475            }
476        }
477
478        #[test]
479        fn prop_full_redeem_returns_total_assets(
480            total_assets in 1u64..=u64::MAX,
481            total_shares in 1u64..=u64::MAX,
482        ) {
483            prop_assert_eq!(
484                assets_for_redeem(total_shares, total_assets, total_shares),
485                Ok(total_assets)
486            );
487        }
488
489    }
490}