Skip to main content

hylo_core/fees/
controller.rs

1use anchor_lang::prelude::{
2  borsh, AnchorDeserialize, AnchorSerialize, InitSpace,
3};
4use fix::prelude::*;
5#[cfg(feature = "offchain")]
6use fix::typenum::Integer;
7use serde::{Deserialize, Serialize};
8
9use crate::error::CoreError;
10use crate::error::CoreError::{
11  FeeExtraction, InvalidFees, NoValidLevercoinMintFee,
12  NoValidLevercoinRedeemFee, NoValidSwapFee,
13};
14use crate::rebalance::mode::RebalanceMode::{
15  self, BuyZone1, BuyZone2, Depeg, Neutral, SellZone1, SellZone2,
16};
17#[cfg(feature = "offchain")]
18use crate::util::max_scaled_input;
19
20const MAX_FEE: UFix64<N4> = UFix64::constant(1000);
21
22/// Represents the spread of fees between mint and redeem for protocol tokens.
23/// All fees must be in basis points to represent a fractional percentage
24/// directly applicable to a token amount e.g. `0.XXXX` or `bips x 10^-4`.
25#[derive(
26  Clone,
27  Copy,
28  PartialEq,
29  InitSpace,
30  AnchorSerialize,
31  AnchorDeserialize,
32  Serialize,
33  Deserialize,
34)]
35pub struct FeePair {
36  pub mint: UFixValue64,
37  pub redeem: UFixValue64,
38}
39
40impl FeePair {
41  #[must_use]
42  pub fn new(mint: UFixValue64, redeem: UFixValue64) -> FeePair {
43    FeePair { mint, redeem }
44  }
45
46  pub fn mint(&self) -> Result<UFix64<N4>, CoreError> {
47    Ok(self.mint.try_into()?)
48  }
49
50  pub fn redeem(&self) -> Result<UFix64<N4>, CoreError> {
51    Ok(self.redeem.try_into()?)
52  }
53
54  pub fn validate(&self) -> Result<(), CoreError> {
55    (self.mint()? <= MAX_FEE && self.redeem()? <= MAX_FEE)
56      .then_some(())
57      .ok_or(InvalidFees)
58  }
59}
60
61/// Fee configuration table reacts to different rebalance modes.
62pub trait FeeController: Sized {
63  fn mint_fee(&self, mode: RebalanceMode) -> Result<UFix64<N4>, CoreError>;
64  fn redeem_fee(&self, mode: RebalanceMode) -> Result<UFix64<N4>, CoreError>;
65  fn validate(self) -> Result<Self, CoreError>;
66}
67
68/// Combines fee multiplication for a token amount with the remaining token
69/// amount by subtraction.
70pub struct FeeExtract<Exp> {
71  pub fees_extracted: UFix64<Exp>,
72  pub amount_remaining: UFix64<Exp>,
73}
74
75impl<Exp> FeeExtract<Exp> {
76  pub fn new<FeeExp>(
77    fee: UFix64<FeeExp>,
78    amount_in: UFix64<Exp>,
79  ) -> Result<FeeExtract<Exp>, CoreError>
80  where
81    UFix64<FeeExp>: FixExt,
82  {
83    FeeExtract::split(fee, amount_in).ok_or(FeeExtraction)
84  }
85
86  fn split<FeeExp>(
87    fee: UFix64<FeeExp>,
88    amount_in: UFix64<Exp>,
89  ) -> Option<FeeExtract<Exp>>
90  where
91    UFix64<FeeExp>: FixExt,
92  {
93    let fees_extracted =
94      amount_in.mul_div_ceil(fee, UFix64::<FeeExp>::one())?;
95    let amount_remaining = amount_in.checked_sub(&fees_extracted)?;
96    Some(FeeExtract {
97      fees_extracted,
98      amount_remaining,
99    })
100  }
101
102  /// Inverse of fee extraction under a remainder cap.
103  ///
104  /// ```txt
105  /// x - ceil(x * fee) = floor(x * (1 - fee)) <= cap
106  /// ```
107  ///
108  /// # Errors
109  /// * Fee at or above 100%
110  #[cfg(feature = "offchain")]
111  pub fn max_input<FeeExp>(
112    fee: UFix64<FeeExp>,
113    cap: UFix64<Exp>,
114  ) -> Result<UFix64<Exp>, CoreError>
115  where
116    UFix64<FeeExp>: FixExt,
117    Exp: Integer,
118    FeeExp: Integer,
119  {
120    let kept = UFix64::one()
121      .checked_sub(&fee)
122      .filter(|kept| *kept != UFix64::zero())
123      .ok_or(FeeExtraction)?;
124    max_scaled_input(cap, kept, UFix64::one()).ok_or(FeeExtraction)
125  }
126}
127
128/// **Deprecated** — retained only for `Hylo` account deserialization.
129#[derive(
130  Clone,
131  Copy,
132  InitSpace,
133  AnchorSerialize,
134  AnchorDeserialize,
135  Serialize,
136  Deserialize,
137)]
138pub struct StablecoinFees {
139  pub normal: FeePair,
140  pub mode_1: FeePair,
141}
142
143impl StablecoinFees {
144  #[must_use]
145  pub fn new(normal: FeePair, mode_1: FeePair) -> StablecoinFees {
146    StablecoinFees { normal, mode_1 }
147  }
148}
149
150#[derive(
151  Clone,
152  Copy,
153  PartialEq,
154  InitSpace,
155  AnchorDeserialize,
156  AnchorSerialize,
157  Serialize,
158  Deserialize,
159)]
160pub struct LevercoinFees {
161  pub normal: FeePair,
162  pub sell_zone_1: FeePair,
163  pub sell_zone_2: FeePair,
164}
165
166impl FeeController for LevercoinFees {
167  /// Determines minting fee based on
168  fn mint_fee(&self, mode: RebalanceMode) -> Result<UFix64<N4>, CoreError> {
169    match mode {
170      Neutral | BuyZone1 | BuyZone2 => self.normal.mint(),
171      SellZone1 => self.sell_zone_1.mint(),
172      SellZone2 => self.sell_zone_2.mint(),
173      Depeg => Err(NoValidLevercoinMintFee),
174    }
175  }
176
177  /// Determines fee to charge when redeeming `xSOL`.
178  fn redeem_fee(&self, mode: RebalanceMode) -> Result<UFix64<N4>, CoreError> {
179    match mode {
180      Neutral | BuyZone1 | BuyZone2 => self.normal.redeem(),
181      SellZone1 => self.sell_zone_1.redeem(),
182      SellZone2 => self.sell_zone_2.redeem(),
183      Depeg => Err(NoValidLevercoinRedeemFee),
184    }
185  }
186
187  /// Run validations
188  fn validate(self) -> Result<LevercoinFees, CoreError> {
189    self.normal.validate()?;
190    self.sell_zone_1.validate()?;
191    self.sell_zone_2.validate()?;
192    Ok(self)
193  }
194}
195
196impl LevercoinFees {
197  #[must_use]
198  pub fn new(
199    normal: FeePair,
200    sell_zone_1: FeePair,
201    sell_zone_2: FeePair,
202  ) -> LevercoinFees {
203    LevercoinFees {
204      normal,
205      sell_zone_1,
206      sell_zone_2,
207    }
208  }
209
210  /// Fees to charge in the levercoin to stablecoin conversion.
211  pub fn convert_to_stablecoin_fee(
212    &self,
213    mode: RebalanceMode,
214  ) -> Result<UFix64<N4>, CoreError> {
215    match mode {
216      Neutral | BuyZone1 | BuyZone2 => self.normal.redeem(),
217      SellZone1 => self.sell_zone_1.redeem(),
218      SellZone2 | Depeg => Err(NoValidSwapFee),
219    }
220  }
221
222  /// Fees to charge in the stablecoin to levercoin conversion.
223  pub fn convert_from_stablecoin_fee(
224    &self,
225    mode: RebalanceMode,
226  ) -> Result<UFix64<N4>, CoreError> {
227    match mode {
228      Neutral | BuyZone1 | BuyZone2 => self.normal.mint(),
229      SellZone1 => self.sell_zone_1.mint(),
230      SellZone2 => self.sell_zone_2.mint(),
231      Depeg => Err(NoValidSwapFee),
232    }
233  }
234}
235
236#[cfg(test)]
237mod tests {
238  use super::*;
239
240  #[test]
241  fn fee_extraction() -> Result<(), CoreError> {
242    let fee = UFix64::<N4>::new(50);
243    let amount = UFix64::<N9>::new(69_618_816_010);
244    let out = FeeExtract::new(fee, amount)?;
245    assert_eq!(out.fees_extracted, UFix64::new(348_094_081));
246    assert_eq!(out.amount_remaining, UFix64::new(69_270_721_929));
247    Ok(())
248  }
249
250  #[test]
251  fn fee_extraction_underflow() {
252    let fee = UFix64::<N4>::new(10001);
253    let amount = UFix64::<N9>::new(69_618_816_010);
254    let out = FeeExtract::new(fee, amount);
255    assert_eq!(out.err(), Some(FeeExtraction));
256  }
257}
258
259#[cfg(kani)]
260mod proofs {
261  use fix::prelude::*;
262
263  use crate::fees::controller::FeeExtract;
264  use crate::kani_generators::{narrow_ufix64, tolerance};
265
266  /// `fees_extracted + amount_remaining == amount_in` (fee in [0, 1.0]).
267  #[kani::proof]
268  fn fee_extract_conservation() {
269    let fee = tolerance();
270    let amount_in: UFix64<N6> = narrow_ufix64();
271    let extract = FeeExtract::split(fee, amount_in);
272    assert!(extract.is_none_or(|e| {
273      e.fees_extracted.checked_add(&e.amount_remaining) == Some(amount_in)
274    }));
275  }
276}