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
11const USDC_BASE_UNITS_PER_CENT: u64 = 10_000;
14
15const MARGIN_PRECISION: i128 = 10_000;
16
17pub 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
26pub 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#[derive(Debug, Clone, PartialEq)]
48pub struct PositionInfo {
49 pub market_index: u16,
50 pub balance: u64,
52 pub position_type: SpotBalanceType,
53 pub price_usdc_base_units: u64,
54 pub weight_bps: u32,
56}
57
58#[derive(Debug, Clone)]
60pub struct CapacityResult {
61 pub total_spendable_cents: u64,
64 pub available_credit_cents: u64,
66 pub usdc_balance_cents: u64,
68 pub weighted_collateral_usdc_base_units: u64,
70 pub weighted_liabilities_usdc_base_units: u64,
72 pub position_infos: Vec<PositionInfo>,
74}
75
76pub 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 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 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 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 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 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 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 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
209fn 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 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 #[test]
324 fn cents_basic() {
325 assert_eq!(usdc_base_units_to_cents(1_000_000).unwrap(), 100); }
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); assert_eq!(usdc_base_units_to_cents(10_000).unwrap(), 1); }
338
339 const NOW: u64 = 1_700_000_000;
342
343 #[test]
344 fn spend_limit_basic() {
345 let vault = make_vault(
347 10_000_000, 50_000_000, 30_000_000, NOW + 3600, 86_400,
352 );
353 let limit = calculate_spend_limit_cents(&vault, 10_000, NOW).unwrap();
354 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, 50_000_000, 10_000_000, NOW - 100, 86_400,
366 );
367 let limit = calculate_spend_limit_cents(&vault, 100_000, NOW).unwrap();
368 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, 1_000_000_000, 1_000_000_000, 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 assert_eq!(limit, 0);
391 }
392
393 #[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)]; 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); 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), make_position(0, 50_000_000, false), ];
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); assert_eq!(result.total_spendable_cents, 5_000); assert_eq!(result.available_credit_cents, 5_000);
445 }
446
447 #[test]
448 fn unliquidatable_excluded_from_spendable() {
449 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), make_position(4, 1_000_000_000, true), ];
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); let unliquidatable = vec![4u16];
466 let result =
467 calculate_capacity(&positions, &markets, &prices, &unliquidatable, 0).unwrap();
468
469 assert_eq!(result.total_spendable_cents, 1_000);
471 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)]; 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 let result = calculate_capacity(&positions, &markets, &prices, &[], 1_000).unwrap();
488
489 assert_eq!(result.total_spendable_cents, 9_000);
491 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); 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), make_position(4, 1_000_000_000, true), make_position(5, 500_000_000, true), make_position(6, 20_000_000, false), ];
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 assert_eq!(result.total_spendable_cents, 7_500);
555
556 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 #[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 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 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}