Skip to main content

pyra_margin/
capacity.rs

1use std::cmp;
2use std::collections::HashMap;
3
4use pyra_types::{SpotBalanceType, SpotMarket, SpotPosition};
5
6use crate::balance::{calculate_value_usdc_base_units, get_token_balance};
7use crate::error::{MathError, MathResult};
8use crate::spend_limits::get_remaining_timeframe_limit;
9use crate::weights::{calculate_asset_weight, calculate_liability_weight, get_strict_price};
10
11/// USDC has 6 decimals: 1 USDC = 1_000_000 base units = 100 cents.
12/// So 1 cent = 10_000 base units.
13const USDC_BASE_UNITS_PER_CENT: u64 = 10_000;
14
15const MARGIN_PRECISION: i128 = 10_000;
16
17/// Convert USDC base units to cents.
18///
19/// 1 USDC = 1_000_000 base units = 100 cents, so divide by 10_000.
20pub fn usdc_base_units_to_cents(base_units: u64) -> MathResult<u64> {
21    base_units
22        .checked_div(USDC_BASE_UNITS_PER_CENT)
23        .ok_or(MathError::Overflow)
24}
25
26/// Calculate spend limit in cents from vault state.
27///
28/// Returns `min(transaction_limit, timeframe_remaining, max_cap)` in cents.
29///
30/// `now_unix` is the current Unix timestamp in seconds (e.g. `Utc::now().timestamp() as u64`).
31pub fn calculate_spend_limit_cents(
32    vault: &pyra_types::Vault,
33    max_transaction_limit_cents: u64,
34    now_unix: u64,
35) -> MathResult<u64> {
36    let timeframe_base_units = get_remaining_timeframe_limit(vault, now_unix);
37    let transaction_limit_cents = usdc_base_units_to_cents(vault.spend_limit_per_transaction)?;
38    let timeframe_remaining_cents = usdc_base_units_to_cents(timeframe_base_units)?;
39
40    let spend_limit_no_cap = cmp::min(transaction_limit_cents, timeframe_remaining_cents);
41    Ok(cmp::min(spend_limit_no_cap, max_transaction_limit_cents))
42}
43
44/// Per-position info emitted during capacity calculation.
45///
46/// Used downstream by liquidating spend jobs to know position sizes and weights.
47#[derive(Debug, Clone, PartialEq)]
48pub struct PositionInfo {
49    pub market_index: u16,
50    /// Unsigned token balance in base units.
51    pub balance: u64,
52    pub position_type: SpotBalanceType,
53    pub price_usdc_base_units: u64,
54    /// Initial asset weight from the spot market (basis points).
55    pub weight_bps: u32,
56}
57
58/// Result of spending capacity calculation.
59#[derive(Debug, Clone)]
60pub struct CapacityResult {
61    /// Max spendable via liquidating spend (unweighted collateral - slippage - liabilities), in cents.
62    /// Excludes unliquidatable assets from collateral.
63    pub total_spendable_cents: u64,
64    /// Available credit line (weighted collateral - weighted liabilities), in cents.
65    pub available_credit_cents: u64,
66    /// USDC balance (market index 0) in cents.
67    pub usdc_balance_cents: u64,
68    /// Total weighted collateral in USDC base units.
69    pub weighted_collateral_usdc_base_units: u64,
70    /// Total weighted liabilities in USDC base units.
71    pub weighted_liabilities_usdc_base_units: u64,
72    /// Per-position breakdown for downstream use.
73    pub position_infos: Vec<PositionInfo>,
74}
75
76/// Calculate spending capacity from Drift spot positions.
77///
78/// This is the core calculation used for card transaction authorization.
79/// It computes:
80/// - **total_spendable**: max amount for liquidating spends (collateral minus slippage minus liabilities,
81///   excluding unliquidatable assets)
82/// - **available_credit**: credit line from weighted margin (weighted collateral minus weighted liabilities)
83/// - **usdc_balance**: direct USDC holdings
84///
85/// Positions in `unliquidatable_market_indices` are excluded from unweighted collateral
86/// (affecting `total_spendable`) but still included in weighted calculations
87/// (affecting `available_credit`).
88///
89/// Positions whose market index is missing from `spot_market_map` or `price_map` are skipped.
90pub fn calculate_capacity(
91    spot_positions: &[SpotPosition],
92    spot_market_map: &HashMap<u16, SpotMarket>,
93    price_map: &HashMap<u16, u64>,
94    unliquidatable_market_indices: &[u16],
95    max_slippage_bps: u64,
96) -> MathResult<CapacityResult> {
97    let mut total_collateral_usdc_base_units: u64 = 0;
98    let mut total_liabilities_usdc_base_units: u64 = 0;
99
100    let mut total_weighted_collateral_usdc_base_units: u64 = 0;
101    let mut total_weighted_liabilities_usdc_base_units: u64 = 0;
102
103    let mut usdc_balance_base_units: u64 = 0;
104
105    let mut position_infos: Vec<PositionInfo> = Vec::new();
106
107    for position in spot_positions {
108        let market_index = position.market_index;
109
110        let Some(spot_market) = spot_market_map.get(&market_index) else {
111            continue;
112        };
113        let Some(price_usdc_base_units) = price_map.get(&market_index).copied() else {
114            continue;
115        };
116
117        // Step 1: Calculate token balance and USDC value
118        let token_balance_base_units = get_token_balance(position, spot_market)?;
119
120        let is_asset = token_balance_base_units >= 0;
121        let twap5min = spot_market
122            .historical_oracle_data
123            .last_oracle_price_twap5min;
124        let strict_price = get_strict_price(price_usdc_base_units, twap5min, is_asset);
125
126        let value_usdc_base_units = calculate_value_usdc_base_units(
127            token_balance_base_units,
128            strict_price,
129            spot_market.decimals,
130        )?;
131
132        // Accumulate unweighted totals (excluding unliquidatable collateral)
133        let is_unliquidatable_collateral =
134            unliquidatable_market_indices.contains(&market_index) && value_usdc_base_units > 0;
135        if !is_unliquidatable_collateral {
136            update_running_totals(
137                &mut total_collateral_usdc_base_units,
138                &mut total_liabilities_usdc_base_units,
139                value_usdc_base_units,
140            )?;
141        }
142
143        // Step 2: Apply IMF-adjusted weights
144        let token_amount_unsigned = token_balance_base_units.unsigned_abs();
145        let weight_bps = if is_asset {
146            calculate_asset_weight(token_amount_unsigned, price_usdc_base_units, spot_market)?
147                as i128
148        } else {
149            calculate_liability_weight(token_amount_unsigned, spot_market)? as i128
150        };
151        let weighted_value_usdc_base_units = value_usdc_base_units
152            .checked_mul(weight_bps)
153            .ok_or(MathError::Overflow)?
154            .checked_div(MARGIN_PRECISION)
155            .ok_or(MathError::Overflow)?;
156
157        update_running_totals(
158            &mut total_weighted_collateral_usdc_base_units,
159            &mut total_weighted_liabilities_usdc_base_units,
160            weighted_value_usdc_base_units,
161        )?;
162
163        // Step 3: Track USDC balance (market index 0)
164        if market_index == 0 && usdc_balance_base_units == 0 && token_balance_base_units > 0 {
165            usdc_balance_base_units =
166                u64::try_from(token_balance_base_units).map_err(|_| MathError::Overflow)?;
167        }
168
169        // Step 4: Store position info
170        let token_balance_unsigned =
171            u64::try_from(token_balance_base_units.unsigned_abs()).map_err(|_| MathError::Overflow)?;
172        position_infos.push(PositionInfo {
173            market_index,
174            balance: token_balance_unsigned,
175            position_type: position.balance_type.clone(),
176            price_usdc_base_units,
177            weight_bps: spot_market.initial_asset_weight,
178        });
179    }
180
181    // Step 5: Available credit = weighted collateral - weighted liabilities
182    let available_credit_base_units = total_weighted_collateral_usdc_base_units
183        .saturating_sub(total_weighted_liabilities_usdc_base_units);
184    let available_credit_cents = usdc_base_units_to_cents(available_credit_base_units)?;
185
186    // Step 6: Total spendable = collateral - slippage - liabilities (for liquidating spends)
187    let max_slippage_usdc_base_units = total_collateral_usdc_base_units
188        .checked_mul(max_slippage_bps)
189        .ok_or(MathError::Overflow)?
190        .checked_div(10_000)
191        .ok_or(MathError::Overflow)?;
192    let total_spendable_base_units = total_collateral_usdc_base_units
193        .saturating_sub(max_slippage_usdc_base_units)
194        .saturating_sub(total_liabilities_usdc_base_units);
195    let total_spendable_cents = usdc_base_units_to_cents(total_spendable_base_units)?;
196
197    let usdc_balance_cents = usdc_base_units_to_cents(usdc_balance_base_units)?;
198
199    Ok(CapacityResult {
200        total_spendable_cents,
201        available_credit_cents,
202        usdc_balance_cents,
203        weighted_collateral_usdc_base_units: total_weighted_collateral_usdc_base_units,
204        weighted_liabilities_usdc_base_units: total_weighted_liabilities_usdc_base_units,
205        position_infos,
206    })
207}
208
209/// Accumulate a signed value into positive/negative running totals.
210fn update_running_totals(
211    total_positive: &mut u64,
212    total_negative: &mut u64,
213    value: i128,
214) -> MathResult<()> {
215    let value_unsigned = u64::try_from(value.unsigned_abs()).map_err(|_| MathError::Overflow)?;
216
217    if value >= 0 {
218        *total_positive = total_positive
219            .checked_add(value_unsigned)
220            .ok_or(MathError::Overflow)?;
221    } else {
222        *total_negative = total_negative
223            .checked_add(value_unsigned)
224            .ok_or(MathError::Overflow)?;
225    }
226
227    Ok(())
228}
229
230#[cfg(test)]
231#[allow(
232    clippy::unwrap_used,
233    clippy::expect_used,
234    clippy::panic,
235    clippy::arithmetic_side_effects
236)]
237mod tests {
238    use super::*;
239    use pyra_types::{HistoricalOracleData, InsuranceFund, Vault};
240
241    fn make_spot_market_with_twap(
242        market_index: u16,
243        decimals: u32,
244        initial_asset_weight: u32,
245        initial_liability_weight: u32,
246        twap5min: i64,
247    ) -> SpotMarket {
248        let precision_decrease = 10u128.pow(19u32.saturating_sub(decimals));
249        SpotMarket {
250            pubkey: vec![],
251            market_index,
252            initial_asset_weight,
253            initial_liability_weight,
254            imf_factor: 0,
255            scale_initial_asset_weight_start: 0,
256            decimals,
257            cumulative_deposit_interest: precision_decrease,
258            cumulative_borrow_interest: precision_decrease,
259            deposit_balance: 0,
260            borrow_balance: 0,
261            optimal_utilization: 0,
262            optimal_borrow_rate: 0,
263            max_borrow_rate: 0,
264            min_borrow_rate: 0,
265            insurance_fund: InsuranceFund::default(),
266            historical_oracle_data: HistoricalOracleData {
267                last_oracle_price_twap5min: twap5min,
268            },
269            oracle: None,
270        }
271    }
272
273    /// Convenience: creates a spot market where TWAP matches the oracle price.
274    fn make_spot_market(
275        market_index: u16,
276        decimals: u32,
277        initial_asset_weight: u32,
278        initial_liability_weight: u32,
279        oracle_price: u64,
280    ) -> SpotMarket {
281        make_spot_market_with_twap(
282            market_index,
283            decimals,
284            initial_asset_weight,
285            initial_liability_weight,
286            oracle_price as i64,
287        )
288    }
289
290    fn make_position(market_index: u16, scaled_balance: u64, is_deposit: bool) -> SpotPosition {
291        SpotPosition {
292            market_index,
293            scaled_balance,
294            balance_type: if is_deposit {
295                SpotBalanceType::Deposit
296            } else {
297                SpotBalanceType::Borrow
298            },
299            ..Default::default()
300        }
301    }
302
303    fn make_vault(
304        spend_limit_per_transaction: u64,
305        spend_limit_per_timeframe: u64,
306        remaining_spend_limit_per_timeframe: u64,
307        next_timeframe_reset_timestamp: u64,
308        timeframe_in_seconds: u64,
309    ) -> Vault {
310        Vault {
311            owner: vec![0; 32],
312            bump: 0,
313            spend_limit_per_transaction,
314            spend_limit_per_timeframe,
315            remaining_spend_limit_per_timeframe,
316            next_timeframe_reset_timestamp,
317            timeframe_in_seconds,
318        }
319    }
320
321    // --- usdc_base_units_to_cents ---
322
323    #[test]
324    fn cents_basic() {
325        assert_eq!(usdc_base_units_to_cents(1_000_000).unwrap(), 100); // 1 USDC = 100 cents
326    }
327
328    #[test]
329    fn cents_zero() {
330        assert_eq!(usdc_base_units_to_cents(0).unwrap(), 0);
331    }
332
333    #[test]
334    fn cents_sub_cent_truncates() {
335        assert_eq!(usdc_base_units_to_cents(9_999).unwrap(), 0); // < 1 cent
336        assert_eq!(usdc_base_units_to_cents(10_000).unwrap(), 1); // exactly 1 cent
337    }
338
339    // --- calculate_spend_limit_cents ---
340
341    const NOW: u64 = 1_700_000_000;
342
343    #[test]
344    fn spend_limit_basic() {
345        // 10 USDC per tx, 50 USDC per timeframe, 30 USDC remaining, max cap 1000 cents
346        let vault = make_vault(
347            10_000_000,  // 10 USDC per tx
348            50_000_000,  // 50 USDC per timeframe
349            30_000_000,  // 30 USDC remaining
350            NOW + 3600,  // active timeframe
351            86_400,
352        );
353        let limit = calculate_spend_limit_cents(&vault, 10_000, NOW).unwrap();
354        // min(1000 cents tx, 3000 cents remaining, 10000 cents cap) = 1000
355        assert_eq!(limit, 1000);
356    }
357
358    #[test]
359    fn spend_limit_expired_timeframe_uses_full() {
360        let vault = make_vault(
361            100_000_000, // 100 USDC per tx
362            50_000_000,  // 50 USDC per timeframe
363            10_000_000,  // 10 USDC remaining (ignored because expired)
364            NOW - 100,   // expired
365            86_400,
366        );
367        let limit = calculate_spend_limit_cents(&vault, 100_000, NOW).unwrap();
368        // min(10000 cents tx, 5000 cents full timeframe, 100000 cap) = 5000
369        assert_eq!(limit, 5000);
370    }
371
372    #[test]
373    fn spend_limit_capped_by_max() {
374        let vault = make_vault(
375            1_000_000_000, // 1000 USDC per tx
376            1_000_000_000, // 1000 USDC per timeframe
377            1_000_000_000, // 1000 USDC remaining
378            NOW + 3600,
379            86_400,
380        );
381        let limit = calculate_spend_limit_cents(&vault, 500, NOW).unwrap();
382        assert_eq!(limit, 500);
383    }
384
385    #[test]
386    fn spend_limit_zero_timeframe() {
387        let vault = make_vault(10_000_000, 50_000_000, 30_000_000, NOW + 3600, 0);
388        let limit = calculate_spend_limit_cents(&vault, 10_000, NOW).unwrap();
389        // timeframe_in_seconds == 0 -> remaining = 0 -> limit = 0
390        assert_eq!(limit, 0);
391    }
392
393    // --- calculate_capacity ---
394
395    #[test]
396    fn empty_positions() {
397        let result = calculate_capacity(&[], &HashMap::new(), &HashMap::new(), &[], 0).unwrap();
398        assert_eq!(result.total_spendable_cents, 0);
399        assert_eq!(result.available_credit_cents, 0);
400        assert_eq!(result.usdc_balance_cents, 0);
401        assert_eq!(result.weighted_collateral_usdc_base_units, 0);
402        assert_eq!(result.weighted_liabilities_usdc_base_units, 0);
403        assert!(result.position_infos.is_empty());
404    }
405
406    #[test]
407    fn single_usdc_deposit() {
408        let usdc = make_spot_market(0, 6, 10_000, 10_000, 1_000_000);
409        let positions = vec![make_position(0, 100_000_000, true)]; // 100 USDC
410
411        let mut markets = HashMap::new();
412        markets.insert(0, usdc);
413        let mut prices = HashMap::new();
414        prices.insert(0, 1_000_000u64);
415
416        let result = calculate_capacity(&positions, &markets, &prices, &[], 0).unwrap();
417
418        assert_eq!(result.usdc_balance_cents, 10_000); // $100
419        assert_eq!(result.total_spendable_cents, 10_000);
420        assert_eq!(result.available_credit_cents, 10_000);
421        assert_eq!(result.weighted_collateral_usdc_base_units, 100_000_000);
422        assert_eq!(result.weighted_liabilities_usdc_base_units, 0);
423        assert_eq!(result.position_infos.len(), 1);
424        assert_eq!(result.position_infos[0].market_index, 0);
425    }
426
427    #[test]
428    fn deposit_and_borrow() {
429        let usdc = make_spot_market(0, 6, 10_000, 10_000, 1_000_000);
430        let positions = vec![
431            make_position(0, 100_000_000, true), // 100 USDC deposit
432            make_position(0, 50_000_000, false),  // 50 USDC borrow
433        ];
434
435        let mut markets = HashMap::new();
436        markets.insert(0, usdc);
437        let mut prices = HashMap::new();
438        prices.insert(0, 1_000_000u64);
439
440        let result = calculate_capacity(&positions, &markets, &prices, &[], 0).unwrap();
441
442        assert_eq!(result.usdc_balance_cents, 10_000); // 100 USDC deposit
443        assert_eq!(result.total_spendable_cents, 5_000); // 100 - 50 = 50 USDC
444        assert_eq!(result.available_credit_cents, 5_000);
445    }
446
447    #[test]
448    fn unliquidatable_excluded_from_spendable() {
449        // Market 4 is unliquidatable
450        let usdc = make_spot_market(0, 6, 10_000, 10_000, 1_000_000);
451        let weth = make_spot_market(4, 9, 8_000, 12_000, 100_000_000);
452
453        let positions = vec![
454            make_position(0, 10_000_000, true),    // 10 USDC
455            make_position(4, 1_000_000_000, true),  // 1 wETH at $100
456        ];
457
458        let mut markets = HashMap::new();
459        markets.insert(0, usdc);
460        markets.insert(4, weth);
461        let mut prices = HashMap::new();
462        prices.insert(0, 1_000_000u64);
463        prices.insert(4, 100_000_000u64); // $100
464
465        let unliquidatable = vec![4u16];
466        let result =
467            calculate_capacity(&positions, &markets, &prices, &unliquidatable, 0).unwrap();
468
469        // total_spendable: only USDC (10M base = 1000 cents), wETH excluded
470        assert_eq!(result.total_spendable_cents, 1_000);
471        // available_credit: includes wETH weighted (100M * 80% = 80M) + USDC (10M * 100% = 10M) = 90M = 9000 cents
472        assert_eq!(result.available_credit_cents, 9_000);
473        assert_eq!(result.position_infos.len(), 2);
474    }
475
476    #[test]
477    fn slippage_reduces_spendable() {
478        let usdc = make_spot_market(0, 6, 10_000, 10_000, 1_000_000);
479        let positions = vec![make_position(0, 100_000_000, true)]; // 100 USDC
480
481        let mut markets = HashMap::new();
482        markets.insert(0, usdc);
483        let mut prices = HashMap::new();
484        prices.insert(0, 1_000_000u64);
485
486        // 10% slippage = 1000 bps
487        let result = calculate_capacity(&positions, &markets, &prices, &[], 1_000).unwrap();
488
489        // 100 USDC collateral, 10% slippage = 10 USDC, spendable = 90 USDC = 9000 cents
490        assert_eq!(result.total_spendable_cents, 9_000);
491        // available_credit not affected by slippage
492        assert_eq!(result.available_credit_cents, 10_000);
493    }
494
495    #[test]
496    fn missing_market_skipped() {
497        let positions = vec![make_position(5, 1_000_000, true)];
498
499        let result =
500            calculate_capacity(&positions, &HashMap::new(), &HashMap::new(), &[], 0).unwrap();
501
502        assert_eq!(result.total_spendable_cents, 0);
503        assert!(result.position_infos.is_empty());
504    }
505
506    #[test]
507    fn missing_price_skipped() {
508        let usdc = make_spot_market(0, 6, 10_000, 10_000, 1_000_000);
509        let positions = vec![make_position(0, 1_000_000, true)];
510
511        let mut markets = HashMap::new();
512        markets.insert(0, usdc);
513
514        let result =
515            calculate_capacity(&positions, &markets, &HashMap::new(), &[], 0).unwrap();
516
517        assert_eq!(result.total_spendable_cents, 0);
518        assert!(result.position_infos.is_empty());
519    }
520
521    #[test]
522    fn multi_position_with_unliquidatable_and_slippage() {
523        let usdc = make_spot_market(0, 6, 10_000, 10_000, 1_000_000);
524        let m4 = make_spot_market(4, 9, 8_000, 12_000, 200_000_000); // unliquidatable
525        let m5 = make_spot_market(5, 9, 8_000, 12_000, 100_000_000);
526        let m6 = make_spot_market(6, 6, 10_000, 10_000, 1_000_000);
527
528        let positions = vec![
529            make_position(0, 50_000_000, true),     // 50 USDC
530            make_position(4, 1_000_000_000, true),   // 1 token @ $200 (unliquidatable)
531            make_position(5, 500_000_000, true),     // 0.5 token @ $100
532            make_position(6, 20_000_000, false),     // 20 USDC-like borrow
533        ];
534
535        let mut markets = HashMap::new();
536        markets.insert(0, usdc);
537        markets.insert(4, m4);
538        markets.insert(5, m5);
539        markets.insert(6, m6);
540        let mut prices = HashMap::new();
541        prices.insert(0, 1_000_000u64);
542        prices.insert(4, 200_000_000u64);
543        prices.insert(5, 100_000_000u64);
544        prices.insert(6, 1_000_000u64);
545
546        let unliquidatable = vec![4u16, 32u16];
547        let result =
548            calculate_capacity(&positions, &markets, &prices, &unliquidatable, 500).unwrap();
549
550        // Unweighted collateral (excluding market 4): 50M (USDC) + 50M (m5: 0.5 * $100) = 100M
551        // Unweighted liabilities: 20M (m6 borrow)
552        // Slippage: 100M * 500/10000 = 5M
553        // total_spendable = 100M - 5M - 20M = 75M base = 7500 cents
554        assert_eq!(result.total_spendable_cents, 7_500);
555
556        // Weighted collateral: 50M*100% + 200M*80% + 50M*80% = 50M + 160M + 40M = 250M
557        // Weighted liabilities: 20M*100% = 20M
558        // available_credit = 250M - 20M = 230M base = 23000 cents
559        assert_eq!(result.available_credit_cents, 23_000);
560        assert_eq!(result.usdc_balance_cents, 5_000);
561        assert_eq!(result.position_infos.len(), 4);
562    }
563
564    // --- update_running_totals ---
565
566    #[test]
567    fn running_totals_positive() {
568        let mut pos = 0u64;
569        let mut neg = 0u64;
570        update_running_totals(&mut pos, &mut neg, 100).unwrap();
571        assert_eq!(pos, 100);
572        assert_eq!(neg, 0);
573    }
574
575    #[test]
576    fn running_totals_negative() {
577        let mut pos = 0u64;
578        let mut neg = 0u64;
579        update_running_totals(&mut pos, &mut neg, -50).unwrap();
580        assert_eq!(pos, 0);
581        assert_eq!(neg, 50);
582    }
583
584    #[test]
585    fn running_totals_accumulate() {
586        let mut pos = 10u64;
587        let mut neg = 5u64;
588        update_running_totals(&mut pos, &mut neg, 20).unwrap();
589        update_running_totals(&mut pos, &mut neg, -15).unwrap();
590        assert_eq!(pos, 30);
591        assert_eq!(neg, 20);
592    }
593}
594
595#[cfg(test)]
596#[allow(
597    clippy::unwrap_used,
598    clippy::expect_used,
599    clippy::panic,
600    clippy::arithmetic_side_effects
601)]
602mod proptests {
603    use super::*;
604    use proptest::prelude::*;
605
606    proptest! {
607        #[test]
608        fn usdc_cents_never_exceeds_base_units(base_units in 0u64..=u64::MAX) {
609            let cents = usdc_base_units_to_cents(base_units).unwrap();
610            prop_assert!(cents <= base_units, "cents {} > base_units {}", cents, base_units);
611        }
612
613        #[test]
614        fn spendable_le_collateral_minus_liabilities(
615            collateral_base in 0u64..=1_000_000_000_000u64,
616            liabilities_base in 0u64..=500_000_000_000u64,
617        ) {
618            // Spendable should never exceed collateral - liabilities (without slippage)
619            let collateral_cents = usdc_base_units_to_cents(collateral_base).unwrap();
620            let liabilities_cents = usdc_base_units_to_cents(liabilities_base).unwrap();
621            let max_possible = collateral_cents.saturating_sub(liabilities_cents);
622
623            // Since we're testing the formula directly, we can verify the invariant
624            let spendable_base = collateral_base.saturating_sub(liabilities_base);
625            let spendable_cents = usdc_base_units_to_cents(spendable_base).unwrap();
626            prop_assert!(spendable_cents <= max_possible + 1, "rounding violation");
627        }
628    }
629}