Skip to main content

oil_api/state/
stake.rs

1use serde::{Deserialize, Serialize};
2use steel::*;
3
4use crate::state::{stake_pda, Pool};
5
6use super::OilAccount;
7
8#[repr(C)]
9#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable, Serialize, Deserialize)]
10pub struct Stake {
11    /// The authority of this miner account.
12    pub authority: Pubkey,
13
14    /// The balance of this stake account.
15    pub balance: u64,
16
17    /// Lock duration in days (0 = no lock, 1-730 days)
18    pub lock_duration_days: u64,
19
20    /// Unix timestamp when lock expires (0 = no lock)
21    pub lock_ends_at: u64,
22
23    /// Buffer c (placeholder)
24    pub buffer_c: u64,
25
26    /// Buffer d (placeholder)
27    pub buffer_d: u64,
28
29    /// Buffer e (placeholder)
30    pub buffer_e: u64,
31
32    /// The timestamp of last claim.
33    pub last_claim_at: i64,
34
35    /// The timestamp the last time this staker deposited.
36    pub last_deposit_at: i64,
37
38    /// The timestamp the last time this staker withdrew.
39    pub last_withdraw_at: i64,
40
41    /// The rewards factor last time rewards were updated on this stake account.
42    pub rewards_factor: Numeric,
43
44    /// The amount of SOL this staker can claim.
45    pub rewards: u64,
46
47    /// The total amount of SOL this staker has earned over its lifetime.
48    pub lifetime_rewards: u64,
49
50    /// Buffer f (placeholder)
51    pub buffer_f: u64,
52}
53
54impl Stake {
55    pub fn pda(&self) -> (Pubkey, u8) {
56        stake_pda(self.authority)
57    }
58
59    /// Calculate lock multiplier based on duration (Magic Eden formula)
60    pub fn calculate_multiplier(lock_duration_days: u64) -> f64 {
61        if lock_duration_days == 0 {
62            return 1.0;
63        }
64
65        // Magic Eden lookup table (exact values from GODL)
66        let lookup: [(u64, f64); 6] = [
67            (7, 1.18),
68            (30, 1.78),
69            (90, 3.35),
70            (180, 5.69),
71            (365, 10.5),
72            (730, 20.0),
73        ];
74
75        // Cap at 730 days
76        let days = lock_duration_days.min(730);
77
78        // Exact match
79        for &(d, m) in &lookup {
80            if days == d {
81                return m;
82            }
83        }
84
85        // Linear interpolation between lookup points
86        for i in 0..lookup.len() - 1 {
87            let (d1, m1) = lookup[i];
88            let (d2, m2) = lookup[i + 1];
89
90            if days >= d1 && days <= d2 {
91                let ratio = (days - d1) as f64 / (d2 - d1) as f64;
92                return m1 + (m2 - m1) * ratio;
93            }
94        }
95
96        // Extrapolate beyond 730 days (shouldn't happen, but cap at 20.0)
97        if days > 730 {
98            return 20.0;
99        }
100
101        // Below 7 days: linear from 1.0 to 1.18
102        if days < 7 {
103            return 1.0 + (days as f64 / 7.0) * 0.18;
104        }
105
106        1.0 // Fallback
107    }
108
109    /// Calculate score (balance * multiplier)
110    pub fn score(&self) -> u64 {
111        let multiplier = Self::calculate_multiplier(self.lock_duration_days);
112        // Use fixed-point arithmetic: multiply by 1_000_000 for precision, then divide
113        ((self.balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64
114    }
115
116    /// Check if stake is currently locked
117    pub fn is_locked(&self, clock: &Clock) -> bool {
118        self.lock_ends_at > 0 && (clock.unix_timestamp as u64) < self.lock_ends_at
119    }
120
121    /// Get remaining lock time in seconds (0 if unlocked)
122    pub fn remaining_lock_seconds(&self, clock: &Clock) -> u64 {
123        if self.lock_ends_at == 0 || (clock.unix_timestamp as u64) >= self.lock_ends_at {
124            return 0;
125        }
126        self.lock_ends_at - (clock.unix_timestamp as u64)
127    }
128
129    /// Calculate early withdrawal penalty percentage based on lock duration
130    /// Uses next higher tier (no interpolation)
131    /// Penalty tiers: 0% (0 days), 5% (1-7), 10% (8-30), 25% (31-180), 40% (181-365), 60% (366-730)
132    pub fn calculate_penalty_percent(lock_duration_days: u64) -> u64 {
133        match lock_duration_days {
134            0 => 0,                    // No lock = no penalty
135            1..=7 => 5,                // 1-7 days = 5%
136            8..=30 => 10,              // 8-30 days = 10%
137            31..=180 => 25,            // 31-180 days = 25%
138            181..=365 => 40,           // 181-365 days = 40%
139            366..=730 => 60,           // 366-730 days = 60% (capped)
140            _ => 60,                   // 730+ days = 60% (capped)
141        }
142    }
143
144    pub fn claim(&mut self, amount: u64, clock: &Clock, pool: &Pool) -> u64 {
145        self.update_rewards(pool);
146        let amount = self.rewards.min(amount);
147        self.rewards -= amount;
148        self.last_claim_at = clock.unix_timestamp;
149        amount
150    }
151
152    pub fn deposit(
153        &mut self,
154        amount: u64,
155        clock: &Clock,
156        pool: &mut Pool,
157        sender: &TokenAccount,
158    ) -> u64 {
159        self.update_rewards(pool);
160        
161        // Calculate old score before deposit
162        let old_score = self.score();
163        
164        let amount = sender.amount().min(amount);
165        self.balance += amount;
166        self.last_deposit_at = clock.unix_timestamp;
167        
168        // Calculate new score after deposit
169        let new_score = self.score();
170        
171        // Update pool totals
172        pool.total_staked += amount;
173        pool.total_staked_score = pool.total_staked_score.saturating_add(new_score).saturating_sub(old_score);
174        
175        amount
176    }
177
178    pub fn withdraw(&mut self, amount: u64, clock: &Clock, pool: &mut Pool) -> u64 {
179        self.update_rewards(pool);
180        
181        // Calculate old score before withdraw
182        let old_score = self.score();
183        
184        let amount = self.balance.min(amount);
185        self.balance -= amount;
186        self.last_withdraw_at = clock.unix_timestamp;
187        
188        // Calculate new score after withdraw
189        let new_score = self.score();
190        
191        // Update pool totals
192        pool.total_staked -= amount;
193        pool.total_staked_score = pool.total_staked_score.saturating_add(new_score).saturating_sub(old_score);
194        
195        amount
196    }
197
198    pub fn update_rewards(&mut self, pool: &Pool) {
199        // Accumulate SOL rewards, weighted by stake score (balance * multiplier).
200        if pool.stake_rewards_factor > self.rewards_factor {
201            let accumulated_rewards = pool.stake_rewards_factor - self.rewards_factor;
202            if accumulated_rewards < Numeric::ZERO {
203                panic!("Accumulated rewards is negative");
204            }
205            // Use score instead of balance for lock-based weighted staking
206            let score = self.score();
207            let personal_rewards = accumulated_rewards * Numeric::from_u64(score);
208            self.rewards += personal_rewards.to_u64();
209            self.lifetime_rewards += personal_rewards.to_u64();
210        }
211
212        // Update this stake account's last seen rewards factor.
213        self.rewards_factor = pool.stake_rewards_factor;
214    }
215}
216
217account!(OilAccount, Stake);
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_multiplier_exact_lookup_values() {
225        // Test exact lookup table values
226        assert_eq!(Stake::calculate_multiplier(7), 1.18);
227        assert_eq!(Stake::calculate_multiplier(30), 1.78);
228        assert_eq!(Stake::calculate_multiplier(90), 3.35);
229        assert_eq!(Stake::calculate_multiplier(180), 5.69);
230        assert_eq!(Stake::calculate_multiplier(365), 10.5);
231        assert_eq!(Stake::calculate_multiplier(730), 20.0);
232    }
233
234    #[test]
235    fn test_multiplier_no_lock() {
236        // No lock should return 1.0
237        assert_eq!(Stake::calculate_multiplier(0), 1.0);
238    }
239
240    #[test]
241    fn test_multiplier_below_7_days() {
242        // Linear interpolation from 1.0 to 1.18 for days < 7
243        assert_eq!(Stake::calculate_multiplier(1), 1.0 + (1.0 / 7.0) * 0.18);
244        assert_eq!(Stake::calculate_multiplier(3), 1.0 + (3.0 / 7.0) * 0.18);
245        assert_eq!(Stake::calculate_multiplier(6), 1.0 + (6.0 / 7.0) * 0.18);
246        
247        // Verify it's less than 1.18
248        assert!(Stake::calculate_multiplier(6) < 1.18);
249    }
250
251    #[test]
252    fn test_multiplier_interpolation_between_lookup_points() {
253        // Test interpolation between 7 and 30 days
254        let multiplier_15 = Stake::calculate_multiplier(15);
255        assert!(multiplier_15 > 1.18);
256        assert!(multiplier_15 < 1.78);
257        
258        // Test interpolation between 30 and 90 days
259        let multiplier_60 = Stake::calculate_multiplier(60);
260        assert!(multiplier_60 > 1.78);
261        assert!(multiplier_60 < 3.35);
262        
263        // Test interpolation between 90 and 180 days
264        let multiplier_135 = Stake::calculate_multiplier(135);
265        assert!(multiplier_135 > 3.35);
266        assert!(multiplier_135 < 5.69);
267        
268        // Test interpolation between 180 and 365 days
269        let multiplier_272 = Stake::calculate_multiplier(272);
270        assert!(multiplier_272 > 5.69);
271        assert!(multiplier_272 < 10.5);
272        
273        // Test interpolation between 365 and 730 days
274        let multiplier_547 = Stake::calculate_multiplier(547);
275        assert!(multiplier_547 > 10.5);
276        assert!(multiplier_547 < 20.0);
277    }
278
279    #[test]
280    fn test_multiplier_above_730_days() {
281        // Should cap at 20.0 for days > 730
282        assert_eq!(Stake::calculate_multiplier(730), 20.0);
283        assert_eq!(Stake::calculate_multiplier(1000), 20.0);
284        assert_eq!(Stake::calculate_multiplier(u64::MAX), 20.0);
285    }
286
287    #[test]
288    fn test_score_calculation() {
289        // Test with no lock (multiplier = 1.0)
290        let balance = 1000u64;
291        let multiplier = Stake::calculate_multiplier(0);
292        let expected_score = ((balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
293        assert_eq!(expected_score, 1000);
294        
295        // Test with 7-day lock (multiplier = 1.18)
296        let multiplier = Stake::calculate_multiplier(7);
297        let expected_score = ((balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
298        assert_eq!(expected_score, 1180);
299        
300        // Test with 730-day lock (multiplier = 20.0)
301        let multiplier = Stake::calculate_multiplier(730);
302        let expected_score = ((balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
303        assert_eq!(expected_score, 20000);
304    }
305
306    #[test]
307    fn test_score_with_large_balance() {
308        // Test with maximum realistic balance (2.1M OIL)
309        let max_balance = 2_100_000u64;
310        let multiplier = Stake::calculate_multiplier(730); // 20.0x
311        let score = ((max_balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
312        
313        // Max score = 2.1M * 20 = 42M, which fits in u64
314        assert_eq!(score, 42_000_000);
315        assert!(score < u64::MAX);
316    }
317
318    #[test]
319    fn test_multiplier_linear_interpolation_accuracy() {
320        // Test that interpolation is linear between points
321        let m7 = Stake::calculate_multiplier(7);
322        let m30 = Stake::calculate_multiplier(30);
323        let m15 = Stake::calculate_multiplier(15);
324        
325        // 15 is (15-7)/(30-7) = 8/23 of the way from 7 to 30
326        let ratio = (15.0 - 7.0) / (30.0 - 7.0);
327        let expected = m7 + ratio * (m30 - m7);
328        
329        // Allow small floating point error
330        assert!((m15 - expected).abs() < 0.0001);
331    }
332
333    #[test]
334    fn test_multiplier_monotonic_increase() {
335        // Verify multipliers increase monotonically with lock duration
336        let mut prev_multiplier = 0.0;
337        for days in [0, 1, 7, 15, 30, 60, 90, 135, 180, 272, 365, 547, 730, 1000] {
338            let multiplier = Stake::calculate_multiplier(days);
339            assert!(multiplier >= prev_multiplier, 
340                "Multiplier should not decrease: {} days = {}, previous = {}", 
341                days, multiplier, prev_multiplier);
342            prev_multiplier = multiplier;
343        }
344    }
345
346    #[test]
347    fn test_multiplier_boundaries() {
348        // Test boundary conditions
349        assert_eq!(Stake::calculate_multiplier(0), 1.0);
350        assert!(Stake::calculate_multiplier(1) > 1.0);
351        assert!(Stake::calculate_multiplier(1) < 1.18);
352        assert_eq!(Stake::calculate_multiplier(7), 1.18);
353        assert_eq!(Stake::calculate_multiplier(730), 20.0);
354        assert_eq!(Stake::calculate_multiplier(731), 20.0);
355    }
356}