yi/
state.rs

1//! Struct definitions for accounts that hold state.
2#![deny(missing_docs)]
3#![deny(clippy::integer_arithmetic)]
4
5use anchor_lang::solana_program::pubkey::PUBKEY_BYTES;
6use num_traits::ToPrimitive;
7
8use crate::*;
9
10/// A YiToken is an SPL Token which auto-compounds an underlying token.
11///
12/// It is a simplified version of the core Crate Protocol, optimized to minimize compute units.
13#[account(zero_copy)]
14#[derive(Debug, Default)]
15pub struct YiToken {
16    /// Mint of the [YiToken].
17    pub mint: Pubkey,
18    /// Bump seed.
19    pub bump: u8,
20    /// Padding.
21    pub _padding: [u8; 7],
22
23    /// The [anchor_spl::token::Mint] backing the [YiToken].
24    pub underlying_token_mint: Pubkey,
25    /// The [anchor_spl::token::TokenAccount] containing the staked tokens.
26    pub underlying_tokens: Pubkey,
27
28    /// The staking fee in thousands of bps.
29    pub stake_fee_millibps: u32,
30    /// The unstaking fee in thousands of bps.
31    pub unstake_fee_millibps: u32,
32}
33
34impl YiToken {
35    /// Number of bytes in a [YiToken].
36    pub const SIZE: usize = PUBKEY_BYTES + 1 + 7 + PUBKEY_BYTES * 2 + 4 + 4;
37
38    /// Calculates the number of [YiToken::underlying_token_mint] tokens to mint for the given amount of [YiToken]s.
39    pub fn calculate_underlying_for_yitokens(
40        &self,
41        yitoken_amount: u64,
42        total_underlying_tokens: u64,
43        total_supply: u64,
44    ) -> Option<u64> {
45        if yitoken_amount == 0 {
46            return Some(0);
47        }
48        // impossible to have more yitokens than the total supply.
49        if yitoken_amount > total_supply {
50            return None;
51        }
52        // if withdrawing all tokens, give the entire supply
53        if yitoken_amount == total_supply {
54            return Some(total_underlying_tokens);
55        }
56        let amt_no_fee = (yitoken_amount as u128)
57            .checked_mul(total_underlying_tokens.into())?
58            .checked_div(total_supply.into())?
59            .to_u64()?;
60        if self.unstake_fee_millibps == 0 {
61            Some(amt_no_fee)
62        } else {
63            (amt_no_fee as u128)
64                .checked_mul(self.unstake_fee_millibps.into())?
65                .checked_div(MILLIBPS_PER_WHOLE.into())?
66                .to_u64()
67        }
68    }
69
70    /// Calculates the number of [YiToken]s to mint for the given amount of underlying tokens.
71    pub fn calculate_yitokens_for_underlying(
72        &self,
73        underlying_amount: u64,
74        total_underlying_tokens: u64,
75        total_supply: u64,
76    ) -> Option<u64> {
77        if underlying_amount == 0 {
78            return Some(0);
79        }
80        // if there are no tokens in the contract, it's 1:1
81        if total_underlying_tokens == 0 {
82            return Some(underlying_amount);
83        }
84        let amt_no_fee = (underlying_amount as u128)
85            .checked_mul(total_supply.into())?
86            .checked_div(total_underlying_tokens.into())?
87            .to_u64()?;
88        if self.stake_fee_millibps == 0 {
89            Some(amt_no_fee)
90        } else {
91            (amt_no_fee as u128)
92                .checked_mul(self.stake_fee_millibps.into())?
93                .checked_div(MILLIBPS_PER_WHOLE.into())?
94                .to_u64()
95        }
96    }
97}
98
99#[cfg(test)]
100#[allow(clippy::unwrap_used, clippy::integer_arithmetic)]
101mod tests {
102    use std::mem::size_of;
103
104    use super::*;
105    use proptest::prelude::*;
106
107    #[test]
108    fn test_yitoken_size() {
109        assert_eq!(YiToken::SIZE, size_of::<YiToken>());
110    }
111
112    #[test]
113    fn test_calculate_yitokens_for_underlying_init() {
114        let yi_token: YiToken = YiToken::default();
115
116        let amount = yi_token
117            .calculate_yitokens_for_underlying(700_000, 0, 0)
118            .unwrap();
119        assert_eq!(amount, 700_000);
120    }
121
122    #[test]
123    fn test_calculate_yitokens_for_underlying_no_fees() {
124        let yi_token: YiToken = YiToken::default();
125        let amount = yi_token
126            .calculate_yitokens_for_underlying(100_000, 700_000, 700_000)
127            .unwrap();
128        assert_eq!(amount, 100_000);
129    }
130
131    #[test]
132    fn test_calculate_underlying_for_yitokens_init() {
133        let yi_token: YiToken = YiToken::default();
134        let amount = yi_token.calculate_underlying_for_yitokens(700_000, 0, 0);
135        assert_eq!(amount, None);
136    }
137
138    #[test]
139    fn test_calculate_underlying_for_yitokens_no_fees() {
140        let yi_token: YiToken = YiToken::default();
141        let amount = yi_token
142            .calculate_underlying_for_yitokens(100_000, 700_000, 700_000)
143            .unwrap();
144        assert_eq!(amount, 100_000);
145    }
146
147    fn perform_test_cannot_increase_no_fees(
148        initial_underlying_tokens: u64,
149        initial_total_underlying_tokens: u64,
150        initial_total_supply: u64,
151        ratios: [f64; 32],
152    ) {
153        let yi_token: YiToken = YiToken::default();
154
155        let mut my_yitokens = 0;
156        let mut my_underlying_tokens = initial_underlying_tokens;
157
158        let mut total_underlying_tokens: u64 = initial_total_underlying_tokens;
159        let mut total_supply: u64 = initial_total_supply;
160
161        for ratio in ratios {
162            // unstake
163            match ratio.partial_cmp(&0f64) {
164                Some(std::cmp::Ordering::Less) => {
165                    let underlying_stake_amount =
166                        ((my_underlying_tokens as f64) * -ratio).to_u64().unwrap();
167                    let mint_yitokens = yi_token
168                        .calculate_yitokens_for_underlying(
169                            underlying_stake_amount,
170                            total_underlying_tokens,
171                            total_supply,
172                        )
173                        .unwrap();
174
175                    my_yitokens += mint_yitokens;
176                    my_underlying_tokens -= underlying_stake_amount;
177
178                    total_underlying_tokens += underlying_stake_amount;
179                    total_supply += mint_yitokens;
180                }
181                Some(std::cmp::Ordering::Greater) => {
182                    let yitoken_amount = ((my_yitokens as f64) * ratio).to_u64().unwrap();
183                    let withdraw_underlying_tokens = yi_token
184                        .calculate_underlying_for_yitokens(
185                            yitoken_amount,
186                            total_underlying_tokens,
187                            total_supply,
188                        )
189                        .unwrap();
190
191                    my_yitokens -= yitoken_amount;
192                    my_underlying_tokens += withdraw_underlying_tokens;
193
194                    total_underlying_tokens -= withdraw_underlying_tokens;
195                    total_supply -= yitoken_amount;
196                }
197                _ => {
198                    // do nothing
199                }
200            }
201        }
202
203        assert!(my_underlying_tokens <= initial_underlying_tokens);
204
205        {
206            // do a full unstake
207            let final_yitokens = my_yitokens;
208            let withdraw_underlying_tokens = yi_token
209                .calculate_underlying_for_yitokens(
210                    final_yitokens,
211                    total_underlying_tokens,
212                    total_supply,
213                )
214                .unwrap();
215
216            my_yitokens -= final_yitokens;
217            my_underlying_tokens += withdraw_underlying_tokens;
218
219            total_underlying_tokens -= withdraw_underlying_tokens;
220            total_supply -= my_yitokens;
221        }
222
223        assert_eq!(my_yitokens, 0);
224        // user may have lost tokens due to rounding
225        assert!(my_underlying_tokens <= initial_underlying_tokens);
226
227        // pool may have gained tokens due to rounding
228        assert!(total_underlying_tokens >= initial_total_underlying_tokens);
229        assert!(total_supply >= initial_total_supply);
230    }
231
232    proptest! {
233        #[test]
234        fn cannot_increase_no_fees(
235            initial_underlying_tokens in 0..=u32::MAX,
236            initial_total_underlying_tokens in 0..=u32::MAX,
237            initial_total_supply in 0..=u32::MAX,
238            amounts in prop::array::uniform32(-1.0..=1.0)
239        ) {
240            perform_test_cannot_increase_no_fees(
241                initial_underlying_tokens.into(),
242                initial_total_underlying_tokens.into(),
243                initial_total_supply.into(),
244                amounts
245            )
246        }
247    }
248}