Skip to main content

hylo_core/exchange_context/
mod.rs

1//! Exchange context trait and implementations.
2//!
3//! [`ExchangeContext`] abstracts over the collateral source, with default
4//! NAVs, rebalance modes, swap fees, and validations.
5
6mod exo;
7mod lst;
8#[cfg(feature = "offchain")]
9pub mod marginal;
10
11use fix::prelude::*;
12
13pub use self::exo::ExoExchangeContext;
14pub use self::lst::LstExchangeContext;
15use crate::collateral_ratio::{CollateralRatio, CR};
16use crate::conversion::SwapConversion;
17use crate::error::CoreError;
18#[cfg(feature = "offchain")]
19use crate::error::CoreError::CollateralRatioOverflow;
20use crate::error::CoreError::{
21  DestinationStablecoin, LevercoinMarketCapArithmetic, LevercoinNav,
22  MaxMintable, MaxSwappable, RebalanceBuySideTarget,
23  RebalanceSellSideLiquidity, RequestedStablecoinOverMaxMintable,
24  VirtualStablecoinOverhang, VirtualStablecoinSurplus,
25};
26use crate::exchange_math::{
27  depeg_stablecoin_nav, max_mintable_stablecoin, max_redeemable_stablecoin,
28  max_swappable_stablecoin, next_levercoin_mint_nav, next_levercoin_redeem_nav,
29  total_value_locked,
30};
31use crate::fees::controller::{FeeExtract, LevercoinFees};
32use crate::fees::curve_controller::cr_from_curve;
33use crate::fees::curves::REDEEM_MAX_CR;
34use crate::pyth::{OraclePrice, PriceRange};
35use crate::rebalance::math::{
36  max_buyable_collateral, max_sellable_collateral, midpoint,
37};
38use crate::rebalance::mode::RebalanceMode;
39use crate::rebalance::pricing::{
40  BuyPriceCurve, RebalanceCurveConfig, RebalancePriceController, SellPriceCurve,
41};
42#[cfg(feature = "offchain")]
43use crate::util::max_scaled_input;
44
45/// Post-trade totals and collateral ratio from a fee projection.
46/// Totals feed the offchain marginal rate math.
47pub struct ProjectedState {
48  pub total_collateral: UFix64<N9>,
49  pub stablecoin_supply: UFix64<N6>,
50  pub collateral_ratio: CR,
51}
52
53/// Shared interface for exchange context implementations.
54pub trait ExchangeContext {
55  /// Total collateral in N9 precision.
56  fn total_collateral(&self) -> UFix64<N9>;
57
58  /// Collateral/USD oracle price range.
59  fn collateral_usd_price(&self) -> PriceRange<N9>;
60
61  /// Raw oracle spot + confidence.
62  fn collateral_oracle_price(&self) -> OraclePrice;
63
64  /// Sell-side rebalance curve configuration.
65  fn sell_curve_config(&self) -> &RebalanceCurveConfig;
66
67  /// Buy-side rebalance curve configuration.
68  fn buy_curve_config(&self) -> &RebalanceCurveConfig;
69
70  /// Collateral ratio defining target leverage and stablecoin mint ability.
71  fn stablecoin_mint_threshold(&self) -> UFix64<N9>;
72
73  /// Confirm stablecoin mint capability based on configured normal mode CR.
74  fn stablecoin_mint_enabled(&self) -> bool {
75    self
76      .collateral_ratio()
77      .at_least(self.stablecoin_mint_threshold())
78  }
79
80  /// Confirm levercoin mint capability; disabled only during Depeg.
81  fn levercoin_mint_enabled(&self) -> bool {
82    self.rebalance_mode() != RebalanceMode::Depeg
83  }
84
85  /// Sell-side rebalance price curve from oracle spot.
86  ///
87  /// # Errors
88  /// * Curve construction failure
89  fn rebalance_sell_curve(&self) -> Result<SellPriceCurve, CoreError> {
90    SellPriceCurve::new(
91      self.collateral_oracle_price(),
92      self.sell_curve_config(),
93    )
94  }
95
96  /// Buy-side rebalance price curve from oracle spot.
97  ///
98  /// # Errors
99  /// * Curve construction failure
100  fn rebalance_buy_curve(&self) -> Result<BuyPriceCurve, CoreError> {
101    BuyPriceCurve::new(self.collateral_oracle_price(), self.buy_curve_config())
102  }
103
104  /// Returns true if sell-side rebalancing is active at the current CR.
105  fn rebalance_sell_active(&self) -> bool {
106    self
107      .rebalance_sell_curve()
108      .is_ok_and(|c| c.is_active(self.collateral_ratio()))
109  }
110
111  /// Returns true if buy-side rebalancing is active at the current CR.
112  fn rebalance_buy_active(&self) -> bool {
113    self
114      .rebalance_buy_curve()
115      .is_ok_and(|c| c.is_active(self.collateral_ratio()))
116  }
117
118  /// Available collateral liquidity to sell off for CR rebalancing.
119  ///
120  /// # Errors
121  /// * Arithmetic or invalid stablecoin supply
122  fn rebalance_sell_liquidity(&self) -> Result<UFix64<N9>, CoreError> {
123    let target_cr = RebalanceMode::Neutral.active_range().start()?;
124    let virtual_stablecoin = self.virtual_stablecoin_supply()?;
125    let collateral_usd_price = self.collateral_oracle_price().spot;
126    let total_collateral = self.total_collateral();
127    max_sellable_collateral(
128      target_cr,
129      virtual_stablecoin,
130      collateral_usd_price,
131      total_collateral,
132    )
133    .ok_or(RebalanceSellSideLiquidity)
134  }
135
136  /// Collateral the protocol is willing to buy for CR rebalancing.
137  ///
138  /// # Errors
139  /// * Arithmetic or invalid stablecoin supply
140  fn rebalance_buy_target(&self) -> Result<UFix64<N9>, CoreError> {
141    let target_cr = RebalanceMode::BuyZone1.active_range().start()?;
142    let virtual_stablecoin = self.virtual_stablecoin_supply()?;
143    let collateral_usd_price = self.collateral_oracle_price().spot;
144    let total_collateral = self.total_collateral();
145    max_buyable_collateral(
146      target_cr,
147      virtual_stablecoin,
148      collateral_usd_price,
149      total_collateral,
150    )
151    .ok_or(RebalanceBuySideTarget)
152  }
153
154  /// Target collateral ratio for a buy-side rebalance.
155  ///
156  /// # Errors
157  /// * Mode is not a buy zone
158  /// * Curve or arithmetic failure
159  fn rebalance_buy_target_cr(&self) -> Result<UFix64<N9>, CoreError> {
160    match self.rebalance_mode() {
161      RebalanceMode::BuyZone2 => RebalanceMode::BuyZone1.active_range().end(),
162      RebalanceMode::BuyZone1 => {
163        let spot = self.collateral_oracle_price().spot;
164        let cr_spot = self.rebalance_buy_curve()?.cr_at_price(spot)?;
165        self
166          .collateral_ratio()
167          .as_finite()
168          .and_then(|cr| midpoint(cr, cr_spot))
169          .ok_or(RebalanceBuySideTarget)
170      }
171      _ => Err(RebalanceBuySideTarget),
172    }
173  }
174
175  /// Collateral to buy at a premium to spot.
176  ///
177  /// # Errors
178  /// * Mode is not a buy zone
179  /// * Curve or arithmetic failure
180  fn rebalance_buy_amount_at_premium(&self) -> Result<UFix64<N9>, CoreError> {
181    max_buyable_collateral(
182      self.rebalance_buy_target_cr()?,
183      self.virtual_stablecoin_supply()?,
184      self.collateral_oracle_price().spot,
185      self.total_collateral(),
186    )
187    .ok_or(RebalanceBuySideTarget)
188  }
189
190  /// Target collateral ratio for a sell-side rebalance.
191  ///
192  /// # Errors
193  /// * Mode is not a sell zone
194  /// * Curve or arithmetic failure
195  fn rebalance_sell_target_cr(&self) -> Result<UFix64<N9>, CoreError> {
196    match self.rebalance_mode() {
197      RebalanceMode::SellZone2 => {
198        RebalanceMode::SellZone1.active_range().start()
199      }
200      RebalanceMode::SellZone1 => {
201        let spot = self.collateral_oracle_price().spot;
202        let cr_spot = self.rebalance_sell_curve()?.cr_at_price(spot)?;
203        self
204          .collateral_ratio()
205          .as_finite()
206          .and_then(|cr| midpoint(cr, cr_spot))
207          .ok_or(RebalanceSellSideLiquidity)
208      }
209      _ => Err(RebalanceSellSideLiquidity),
210    }
211  }
212
213  /// Collateral to sell at a discount to spot.
214  ///
215  /// # Errors
216  /// * Mode is not a sell zone
217  /// * Curve or arithmetic failure
218  fn rebalance_sell_amount_at_discount(&self) -> Result<UFix64<N9>, CoreError> {
219    max_sellable_collateral(
220      self.rebalance_sell_target_cr()?,
221      self.virtual_stablecoin_supply()?,
222      self.collateral_oracle_price().spot,
223      self.total_collateral(),
224    )
225    .ok_or(RebalanceSellSideLiquidity)
226  }
227
228  /// Virtual stablecoin supply.
229  fn virtual_stablecoin_supply(&self) -> Result<UFix64<N6>, CoreError>;
230
231  /// Current levercoin supply.
232  fn levercoin_supply(&self) -> Result<UFix64<N6>, CoreError>;
233
234  /// Current rebalance mode, computed at construction.
235  fn rebalance_mode(&self) -> RebalanceMode;
236
237  /// Cached collateral ratio, computed at construction.
238  fn collateral_ratio(&self) -> CollateralRatio;
239
240  /// Levercoin fee configuration.
241  fn levercoin_fees(&self) -> &LevercoinFees;
242
243  /// TVL in USD.
244  ///
245  /// # Errors
246  /// * Arithmetic overflow
247  fn total_value_locked(&self) -> Result<UFix64<N9>, CoreError> {
248    total_value_locked(
249      self.total_collateral(),
250      self.collateral_usd_price().lower,
251    )
252  }
253
254  /// Current levercoin market cap in USD: collateral value above the
255  /// stablecoin claim, zero when the levercoin is underwater or unissued.
256  ///
257  /// # Errors
258  /// * Missing supply or arithmetic overflow
259  fn levercoin_market_cap(&self) -> Result<UFix64<N9>, CoreError> {
260    if self.levercoin_supply()? == UFix64::zero() {
261      Ok(UFix64::zero())
262    } else {
263      let collateral_value = self
264        .total_collateral()
265        .mul_ceil(self.collateral_usd_price().upper)
266        .ok_or(LevercoinMarketCapArithmetic)?;
267      let stablecoin_nav = self.stablecoin_nav()?;
268      let stablecoin_value = self
269        .virtual_stablecoin_supply()?
270        .checked_convert::<N9>()
271        .and_then(|supply| supply.mul_floor(stablecoin_nav))
272        .ok_or(LevercoinMarketCapArithmetic)?;
273      Ok(collateral_value.saturating_sub(&stablecoin_value))
274    }
275  }
276
277  /// Stablecoin NAV — $1 in all modes except Depeg.
278  ///
279  /// # Errors
280  /// * Arithmetic failure in depeg path
281  fn stablecoin_nav(&self) -> Result<UFix64<N9>, CoreError> {
282    match self.rebalance_mode() {
283      RebalanceMode::Depeg => depeg_stablecoin_nav(
284        self.total_collateral(),
285        self.collateral_usd_price().lower,
286        self.virtual_stablecoin_supply()?,
287      ),
288      _ => Ok(UFix64::one()),
289    }
290  }
291
292  /// Upper-bound levercoin NAV for minting.
293  ///
294  /// # Errors
295  /// * Missing supply or arithmetic failure
296  fn levercoin_mint_nav(&self) -> Result<UFix64<N9>, CoreError> {
297    next_levercoin_mint_nav(
298      self.total_collateral(),
299      self.collateral_usd_price(),
300      self.virtual_stablecoin_supply()?,
301      self.stablecoin_nav()?,
302      self.levercoin_supply()?,
303    )
304    .ok_or(LevercoinNav)
305  }
306
307  /// Lower-bound levercoin NAV for redemption.
308  ///
309  /// # Errors
310  /// * Missing supply or arithmetic failure
311  fn levercoin_redeem_nav(&self) -> Result<UFix64<N9>, CoreError> {
312    next_levercoin_redeem_nav(
313      self.total_collateral(),
314      self.collateral_usd_price(),
315      self.virtual_stablecoin_supply()?,
316      self.stablecoin_nav()?,
317      self.levercoin_supply()?,
318    )
319    .ok_or(LevercoinNav)
320  }
321
322  /// Delta of current virtual stablecoin supply and TVL.
323  ///
324  /// # Errors
325  /// * Virtual stablecoin not depegged
326  /// * Underflow
327  fn virtual_stablecoin_overhang(&self) -> Result<UFix64<N6>, CoreError> {
328    let tvl = self.total_value_locked()?;
329    let virtual_stablecoin = self.virtual_stablecoin_supply()?;
330    tvl
331      .checked_convert::<N6>()
332      .and_then(|tvl| virtual_stablecoin.checked_sub(&tvl))
333      .ok_or(VirtualStablecoinOverhang)
334  }
335
336  /// Delta of TVL and current virtual stablecoin supply.
337  ///
338  /// # Errors
339  /// * Virtual stablecoin in depeg
340  /// * Underflow
341  fn virtual_stablecoin_surplus(&self) -> Result<UFix64<N6>, CoreError> {
342    let tvl = self.total_value_locked()?;
343    let virtual_stablecoin = self.virtual_stablecoin_supply()?;
344    tvl
345      .checked_convert::<N6>()
346      .and_then(|tvl| tvl.checked_sub(&virtual_stablecoin))
347      .ok_or(VirtualStablecoinSurplus)
348  }
349
350  /// Projects rebalance mode after changing collateral and stablecoin
351  /// totals.
352  ///
353  /// # Errors
354  /// * Collateral ratio computation failure
355  fn projected_rebalance_mode(
356    &self,
357    new_total: UFix64<N9>,
358    new_stablecoin: UFix64<N6>,
359  ) -> Result<RebalanceMode, CoreError> {
360    let projected_cr = CollateralRatio::new(
361      new_total,
362      self.collateral_usd_price().lower,
363      new_stablecoin,
364    )?;
365    Ok(RebalanceMode::from_cr(projected_cr))
366  }
367
368  /// Worse of the current and projected rebalance modes, so a transaction
369  /// that worsens CR pays fees at the projected mode.
370  fn select_rebalance_mode_for_fees(
371    &self,
372    projected: RebalanceMode,
373  ) -> RebalanceMode {
374    projected.min(self.rebalance_mode())
375  }
376
377  /// Swap conversion between stablecoin and levercoin NAVs.
378  ///
379  /// # Errors
380  /// * NAV computation failure
381  fn swap_conversion(&self) -> Result<SwapConversion, CoreError> {
382    let levercoin_nav =
383      PriceRange::new(self.levercoin_redeem_nav()?, self.levercoin_mint_nav()?);
384    Ok(SwapConversion::new(self.stablecoin_nav()?, levercoin_nav))
385  }
386
387  /// Maximum mintable stablecoin before hitting the configured
388  /// stablecoin mint threshold.
389  ///
390  /// # Errors
391  /// * Arithmetic overflow
392  fn max_mintable_stablecoin(&self) -> Result<UFix64<N6>, CoreError> {
393    let target = self
394      .stablecoin_mint_threshold()
395      .checked_convert()
396      .ok_or(MaxMintable)?;
397    max_mintable_stablecoin(
398      target,
399      self.total_collateral(),
400      self.collateral_usd_price().lower,
401      self.virtual_stablecoin_supply()?,
402    )
403  }
404
405  /// Maximum redeemable stablecoin before the projected collateral ratio
406  /// leaves the redeem fee curve domain. Zero once already above it.
407  ///
408  /// # Errors
409  /// * Arithmetic overflow
410  fn max_redeemable_stablecoin(&self) -> Result<UFix64<N6>, CoreError> {
411    max_redeemable_stablecoin(
412      cr_from_curve(REDEEM_MAX_CR)?,
413      self.total_value_locked()?,
414      self.virtual_stablecoin_supply()?,
415      self.stablecoin_nav()?,
416    )
417  }
418
419  /// Maximum stablecoin swappable from levercoin before hitting the
420  /// lowest CR threshold.
421  ///
422  /// # Errors
423  /// * TVL computation or arithmetic failure
424  fn max_swappable_stablecoin(&self) -> Result<UFix64<N6>, CoreError> {
425    let target = RebalanceMode::SellZone1
426      .active_range()
427      .end()?
428      .checked_convert()
429      .ok_or(MaxSwappable)?;
430    max_swappable_stablecoin(
431      target,
432      self.total_value_locked()?,
433      self.virtual_stablecoin_supply()?,
434    )
435  }
436
437  /// Collateral removable before the projected rebalance mode reaches
438  /// [`RebalanceMode::Depeg`].
439  ///
440  /// # Errors
441  /// * Arithmetic overflow
442  /// * Current state already below the Depeg exit
443  #[cfg(feature = "offchain")]
444  fn max_collateral_removal(&self) -> Result<UFix64<N9>, CoreError> {
445    let supply = self.virtual_stablecoin_supply()?;
446    if supply == UFix64::zero() {
447      Ok(self.total_collateral())
448    } else {
449      let atom = UFix64::new(1);
450      let last_depeg_cr = RebalanceMode::SellZone2
451        .active_range()
452        .start()?
453        .checked_sub(&atom)
454        .ok_or(CollateralRatioOverflow)?;
455      let min_collateral = supply
456        .checked_convert::<N9>()
457        .and_then(|supply| {
458          max_scaled_input(
459            last_depeg_cr,
460            self.collateral_usd_price().lower,
461            supply,
462          )
463        })
464        .and_then(|last_depeg_collateral| {
465          last_depeg_collateral.checked_add(&atom)
466        })
467        .ok_or(CollateralRatioOverflow)?;
468      self
469        .total_collateral()
470        .checked_sub(&min_collateral)
471        .ok_or(CoreError::DestinationCollateral)
472    }
473  }
474
475  /// Stablecoin removable before the projected collateral ratio
476  /// overflows its representation.
477  ///
478  /// # Errors
479  /// * Arithmetic overflow
480  #[cfg(feature = "offchain")]
481  fn max_stablecoin_removal(&self) -> Result<UFix64<N6>, CoreError> {
482    let min_supply = self
483      .total_collateral()
484      .mul_div_ceil(
485        self.collateral_usd_price().lower,
486        UFix64::<N9>::new(u64::MAX),
487      )
488      .and_then(UFix64::checked_convert_ceil::<N6>)
489      .ok_or(CollateralRatioOverflow)?;
490    Ok(
491      self
492        .virtual_stablecoin_supply()?
493        .checked_sub(&min_supply)
494        .unwrap_or_default(),
495    )
496  }
497
498  /// Validates a stablecoin mint amount against the protocol max.
499  ///
500  /// # Errors
501  /// * Amount exceeds max mintable
502  fn validate_stablecoin_amount(
503    &self,
504    requested: UFix64<N6>,
505  ) -> Result<UFix64<N6>, CoreError> {
506    let max = self.max_mintable_stablecoin()?;
507    if requested <= max {
508      Ok(requested)
509    } else {
510      Err(RequestedStablecoinOverMaxMintable)
511    }
512  }
513
514  /// Validates `PnL` stablecoin profit against `SellZone2` threshold.
515  ///
516  /// # Errors
517  /// * Arithmetic overflow
518  fn validate_stablecoin_pnl_profit(
519    &self,
520    requested: UFix64<N6>,
521  ) -> Result<UFix64<N6>, CoreError> {
522    let target = RebalanceMode::SellZone2
523      .active_range()
524      .end()?
525      .checked_convert()
526      .ok_or(MaxMintable)?;
527    let max = max_swappable_stablecoin(
528      target,
529      self.total_value_locked()?,
530      self.virtual_stablecoin_supply()?,
531    )
532    .unwrap_or_default();
533    Ok(requested.min(max))
534  }
535
536  /// Validates a stablecoin swap amount against the protocol max.
537  ///
538  /// # Errors
539  /// * Amount exceeds max swappable
540  fn validate_stablecoin_swap_amount(
541    &self,
542    requested: UFix64<N6>,
543  ) -> Result<UFix64<N6>, CoreError> {
544    let max = self.max_swappable_stablecoin()?;
545    if requested <= max {
546      Ok(requested)
547    } else {
548      Err(RequestedStablecoinOverMaxMintable)
549    }
550  }
551
552  /// Swap fee for levercoin-to-stablecoin direction.
553  ///
554  /// # Errors
555  /// * Projection overflow or mode-based fee lookup
556  fn levercoin_to_stablecoin_fee(
557    &self,
558    amount_stablecoin_out: UFix64<N6>,
559  ) -> Result<FeeExtract<N6>, CoreError> {
560    let rate = self.levercoin_to_stablecoin_fee_rate(amount_stablecoin_out)?;
561    FeeExtract::new(rate, amount_stablecoin_out)
562  }
563
564  /// Mode-based fee rate for the levercoin-to-stablecoin direction.
565  ///
566  /// # Errors
567  /// * Projection overflow or mode-based fee lookup
568  fn levercoin_to_stablecoin_fee_rate(
569    &self,
570    amount_stablecoin_out: UFix64<N6>,
571  ) -> Result<UFix64<N4>, CoreError> {
572    let new_stablecoin = self
573      .virtual_stablecoin_supply()?
574      .checked_add(&amount_stablecoin_out)
575      .ok_or(DestinationStablecoin)?;
576    let projected =
577      self.projected_rebalance_mode(self.total_collateral(), new_stablecoin)?;
578    let mode = self.select_rebalance_mode_for_fees(projected);
579    self.levercoin_fees().convert_to_stablecoin_fee(mode)
580  }
581
582  /// Swap fee for stablecoin-to-levercoin direction.
583  ///
584  /// # Errors
585  /// * Projection underflow or mode-based fee lookup
586  fn stablecoin_to_levercoin_fee(
587    &self,
588    amount_stablecoin_in: UFix64<N6>,
589  ) -> Result<FeeExtract<N6>, CoreError> {
590    let rate = self.stablecoin_to_levercoin_fee_rate(amount_stablecoin_in)?;
591    FeeExtract::new(rate, amount_stablecoin_in)
592  }
593
594  /// Mode-based fee rate for the stablecoin-to-levercoin direction.
595  ///
596  /// # Errors
597  /// * Projection underflow or mode-based fee lookup
598  fn stablecoin_to_levercoin_fee_rate(
599    &self,
600    amount_stablecoin_in: UFix64<N6>,
601  ) -> Result<UFix64<N4>, CoreError> {
602    let new_stablecoin = self
603      .virtual_stablecoin_supply()?
604      .checked_sub(&amount_stablecoin_in)
605      .ok_or(DestinationStablecoin)?;
606    let projected =
607      self.projected_rebalance_mode(self.total_collateral(), new_stablecoin)?;
608    let mode = self.select_rebalance_mode_for_fees(projected);
609    self.levercoin_fees().convert_from_stablecoin_fee(mode)
610  }
611}