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/// klend's flash-loan fee for borrowing `value` at a committed `num/den` rate:
56/// `round_half_up(value * num / den)`, floored at one atom. This reproduces
57/// klend's `calculate_flash_loan_fees` — `origination_fee = round(max(amount *
58/// rate, 1))` over a `fixed::U68F60` rate, with ties rounding away from zero —
59/// rather than merely bounding it. The `FlashApprove` delegate is bound to
60/// `F + fee` and a trailing `assert_delegate_cleared` requires the forced
61/// `flash_repay` to consume it to zero, so the fee must equal klend's bit-for-bit
62/// (a `ceil` over-charges and strands a residual delegate on every non-integer
63/// product — see #25). The rate stays opaque: the admin commits `num/den` to
64/// match the reserve's fee; Roshi never reads the reserve.
65///
66/// `num == 0` (or `value == 0`) is a zero fee without dividing, so a zero rate
67/// needs no valid denominator, reproducing #19's fee-free `delegated == F`. A
68/// non-zero `num` over `den == 0` is `DivisionByZero`. The min-1 floor applies
69/// only when a fee is actually charged (`num > 0 && value > 0`), as in klend.
70pub 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    // Round half away from zero (ties up), matching `fixed::U68F60::round`. The
86    // remainder is below the denominator (a `u64`), so doubling it stays in `u128`.
87    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
112/// Floor-rounded base atoms for `asset_atoms`, priced through two whole-token
113/// oracle legs sharing one quote currency:
114///
115/// - `asset_price`: quote units per whole asset token (e.g. an X/USD feed),
116/// - `base_price`: quote units per whole base token (e.g. a BASE/USD feed).
117///
118/// A direct asset/base feed is the degenerate case `base_price ==`
119/// [`OraclePrice::UNIT`] (the quote currency *is* the base, priced at exactly
120/// 1). Whether both legs really share a quote currency is a configuration
121/// fact the program cannot observe; it is the vault operator's contract.
122///
123/// With whole-token prices `p = value / 10^decimals`:
124///
125/// ```text
126/// base_atoms = floor(asset_atoms / 10^asset_decimals   // whole asset tokens
127///                  * (p_asset / p_base)                 // base per asset
128///                  * 10^base_decimals)                  // base atoms
129/// ```
130///
131/// which in integer form is one floor division after cancelling the shared
132/// powers of ten:
133///
134/// ```text
135/// asset_atoms * asset_value * 10^(base_decimals + base_price_decimals)
136/// --------------------------------------------------------------------
137///  base_value * 10^(asset_decimals + asset_price_decimals)
138/// ```
139pub 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
174/// The virtual share supply backing one virtual base atom:
175/// `10^(SHARE_DECIMALS - base_decimals)`, the empty-vault mint ratio.
176///
177/// Deposits and redeems price against `(total_shares + offset, total_assets + 1)`
178/// instead of the raw pair. This is the ERC-4626 virtual-offset defense against
179/// donation/first-deposit share-price inflation: the virtual position absorbs
180/// donated value, so inflating a later depositor's rounding loss costs the
181/// attacker ~`offset` times that loss. It also makes pricing continuous through
182/// the empty vault — the first deposit needs no special case.
183///
184/// Only virtual *shares* are scaled (virtual assets stay at 1): a virtual-asset
185/// offset above 1 would let a full redeem price above `total_assets`. That
186/// requires `base_decimals <= SHARE_DECIMALS`, enforced at vault initialization.
187pub 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
209/// Floor-rounded base value of `shares`. Zero is a valid result: a dust
210/// position can be worth less than one base atom, and withdrawal-ticket
211/// strikes must price it (to nothing) rather than wedge. Immediate redemption
212/// paths that must pay out should use [`assets_for_redeem`].
213pub 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    /// klend's flash-loan fee, recomputed independently through the *same*
339    /// `fixed::U68F60` arithmetic klend uses (`round(max(amount * rate, 1))`,
340    /// ties away from zero), as the cross-check oracle for [`round_with_min1`].
341    /// `None` mirrors klend's `BorrowTooSmall` (fee ≥ amount), where no flash
342    /// settles and so no fee is bound.
343    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        // Round-to-nearest, not ceil: a fractional part below 0.5 rounds down
363        // (1000/3 = 333.33 -> 333, where ceil would over-charge 334 and strand a
364        // residual delegate — the #25 bug).
365        assert_eq!(round_with_min1(1_000, 1, 3), Ok(333));
366        // At or above 0.5 it rounds up; an exact half ties away from zero.
367        assert_eq!(round_with_min1(2_000, 1, 3), Ok(667));
368        assert_eq!(round_with_min1(3, 1, 2), Ok(2));
369        // Min-1 floor: a sub-atom product still charges one atom (klend's minimum).
370        assert_eq!(round_with_min1(1, 1, 1_000), Ok(1));
371        // A zero numerator is a zero fee regardless of the denominator (incl. 0),
372        // reproducing #19's fee-free `delegated == F` — the floor does not apply.
373        assert_eq!(round_with_min1(1_000_000, 0, 0), Ok(0));
374        // Likewise a zero borrow.
375        assert_eq!(round_with_min1(0, 1, 10), Ok(0));
376        // A non-zero rate over a zero denominator is rejected, not silently zero.
377        assert_eq!(round_with_min1(1, 1, 0), Err(RoshiError::DivisionByZero));
378        // A result past u64 propagates rather than wrapping.
379        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        // Kamino main-market USDC: rate 0.00001 (sf 11529215046068 / 2^60). The
388        // aquila e2e's F = 5_000_000 lands on an integer product (49.9999.. -> 50)
389        // where ceil and round agree; nearby sizes do not.
390        let sf = 11_529_215_046_068u64;
391        let den = 1u64 << 60;
392        // A borrow of 1 is klend's `BorrowTooSmall` (the 1-atom fee equals it),
393        // so it never settles and binds no fee; compare only where klend charges.
394        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        // Same mint decimals: 2.5 base per asset token, atom-for-atom.
426        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        // 8-decimal asset on a 6-decimal base at 100_000 base per token:
437        // 1 whole asset (10^8 atoms) is 100_000 whole base (10^11 atoms).
438        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        // 6-decimal asset on a 9-decimal base: the scale flips sides.
443        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        // SOL (9 dec) deposited into a USDC-base vault (6 dec): SOL/USD at
452        // 150.0 (8 dec), USDC/USD at 1.0 (8 dec). 1 SOL -> 150 USDC.
453        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        // The legs need not share a scale: same ratio at different decimals.
464        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        // A cheapened base leg buys more base atoms: USDC marked at 0.50 USD
469        // doubles the credited base amount.
470        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        // One atom of a 9-dec asset worth 1.0 base token on a 6-dec base
511        // floors to zero base atoms.
512        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        // At par (supply/assets equals the virtual ratio) the offset cancels
562        // and pricing is exact.
563        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        // Off par the result floors (and the virtual position drags a
566        // dust-sized pot toward par; at realistic pots the pull vanishes).
567        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        // Classic ERC-4626 inflation attack at 6 base decimals (offset 1000):
577        // attacker seeds 1 atom, donates 10^9 atoms, and a NAV report folds the
578        // donation into total_assets before the victim deposits 10^6 atoms.
579        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        // The victim still mints (no zero-share grief)...
584        let victim_shares =
585            shares_for_deposit(1_000_000, donated_assets, attacker_shares, 6).unwrap();
586        assert_eq!(victim_shares, 1);
587
588        // ...and the virtual position absorbs the donation: the attacker's
589        // claim comes back ~offset times further short than the victim's loss.
590        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        // Same dust input that makes assets_for_redeem fail: 1 share of a
604        // 1000-share pot worth 100 atoms floors to zero.
605        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        // The guards stay identical otherwise.
611        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        // Par: exact inverse of the deposit example.
621        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        // At or below par price the virtual position rounds away and a full
635        // redeem drains the vault exactly.
636        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        // Far above par (only reachable at dust-sized supplies) the virtual
642        // position keeps its pro-rata slice as dust.
643        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        // Profit of 11 at 1_000 bps is 1.1 units of fee. The fee must *floor*
711        // (charge 1, in the depositor's favour), never ceil to 2. The divisible
712        // example above can't tell floor from ceil; this one pins the direction.
713        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        // Ground truth: bitwuzla-via-CBMC-6.8 reported `ceil <= amount` as a
722        // FAILURE, but its SMT2 backend crashes (smt2_conv invariant violation),
723        // so that verdict is a solver artifact. This native check is exhaustive
724        // over every valid bps at the boundary-relevant amounts.
725        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            // Sized so `amount * sf` stays well inside u128 and the U68F60 mul
777            // never overflows; spans fractional parts in (0, 0.5) and [0.5, 1)
778            // and tiny amounts that hit the 1-atom floor.
779            amount in 0u64..=1_000_000_000_000,
780            flash_loan_fee_sf in 0u64..=(1u64 << 50),
781        ) {
782            let den = 1u64 << 60;
783            // Compare only where klend actually charges a fee (not BorrowTooSmall);
784            // there Roshi must agree bit-for-bit, since the cleared-check demands it.
785            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                // Redeem against the post-deposit state the mint produced.
852                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            // At par the virtual offset cancels: deposits mint at exactly the
882            // empty-vault ratio and redeems pay exactly floor(shares / ratio).
883            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            // These inputs are all in-domain, so the accrual must not error;
921            // a swallowed `Err` would hide exactly the regression we want loud.
922            let (fee, net, new_hwm) =
923                performance_fee_for_nav(gross, total_shares, high_watermark, bps).unwrap();
924            // Conservation: the fee is carved out of gross, nothing created.
925            prop_assert!(fee <= gross);
926            prop_assert_eq!(net, gross - fee);
927            // The high-watermark only ever ratchets up.
928            prop_assert!(new_hwm >= high_watermark);
929            // No fee with no rate and no fee with no shares to charge against.
930            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            // Only assert when the UNIT-leg result is in u64 range; the
947            // out-of-range error paths are pinned by the example tests.
948            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                // A base leg of exactly 1.0 at any scale is the UNIT leg.
960                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            // A higher fee rate can never charge less on the same NAV report.
982            // Forces the fee-charged branch to be meaningful without the test
983            // re-deriving the fee formula (exact values live in the example tests).
984            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}