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 driller 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    /// Returns multiplier (1.0 = no lock, up to 20.0 for 730 days)
61    pub fn calculate_multiplier(lock_duration_days: u64) -> f64 {
62        if lock_duration_days == 0 {
63            return 1.0;
64        }
65
66        // Magic Eden lookup table (exact values from GODL)
67        let lookup: [(u64, f64); 6] = [
68            (7, 1.18),
69            (30, 1.78),
70            (90, 3.35),
71            (180, 5.69),
72            (365, 10.5),
73            (730, 20.0),
74        ];
75
76        // Cap at 730 days
77        let days = lock_duration_days.min(730);
78
79        // Exact match
80        for &(d, m) in &lookup {
81            if days == d {
82                return m;
83            }
84        }
85
86        // Linear interpolation between lookup points
87        for i in 0..lookup.len() - 1 {
88            let (d1, m1) = lookup[i];
89            let (d2, m2) = lookup[i + 1];
90
91            if days >= d1 && days <= d2 {
92                let ratio = (days - d1) as f64 / (d2 - d1) as f64;
93                return m1 + (m2 - m1) * ratio;
94            }
95        }
96
97        // Extrapolate beyond 730 days (shouldn't happen, but cap at 20.0)
98        if days > 730 {
99            return 20.0;
100        }
101
102        // Below 7 days: linear from 1.0 to 1.18
103        if days < 7 {
104            return 1.0 + (days as f64 / 7.0) * 0.18;
105        }
106
107        1.0 // Fallback
108    }
109
110    /// Calculate score (balance * multiplier)
111    pub fn score(&self) -> u64 {
112        let multiplier = Self::calculate_multiplier(self.lock_duration_days);
113        // Use fixed-point arithmetic: multiply by 1_000_000 for precision, then divide
114        // Result fits in u64: max balance (2.1M OIL) * max multiplier (20.0) = 42M << u64::MAX
115        ((self.balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64
116    }
117
118    /// Check if stake is currently locked
119    pub fn is_locked(&self, clock: &Clock) -> bool {
120        self.lock_ends_at > 0 && (clock.unix_timestamp as u64) < self.lock_ends_at
121    }
122
123    /// Get remaining lock time in seconds (0 if unlocked)
124    pub fn remaining_lock_seconds(&self, clock: &Clock) -> u64 {
125        if self.lock_ends_at == 0 || (clock.unix_timestamp as u64) >= self.lock_ends_at {
126            return 0;
127        }
128        self.lock_ends_at - (clock.unix_timestamp as u64)
129    }
130
131    pub fn claim(&mut self, amount: u64, clock: &Clock, pool: &Pool) -> u64 {
132        self.update_rewards(pool);
133        let amount = self.rewards.min(amount);
134        self.rewards -= amount;
135        self.last_claim_at = clock.unix_timestamp;
136        amount
137    }
138
139    pub fn deposit(
140        &mut self,
141        amount: u64,
142        clock: &Clock,
143        pool: &mut Pool,
144        sender: &TokenAccount,
145    ) -> u64 {
146        self.update_rewards(pool);
147        
148        // Calculate old score before deposit
149        let old_score = self.score();
150        
151        let amount = sender.amount().min(amount);
152        self.balance += amount;
153        self.last_deposit_at = clock.unix_timestamp;
154        
155        // Calculate new score after deposit
156        let new_score = self.score();
157        
158        // Update pool totals
159        pool.total_staked += amount;
160        pool.total_staked_score = pool.total_staked_score.saturating_add(new_score).saturating_sub(old_score);
161        
162        amount
163    }
164
165    pub fn withdraw(&mut self, amount: u64, clock: &Clock, pool: &mut Pool) -> u64 {
166        self.update_rewards(pool);
167        
168        // Calculate old score before withdraw
169        let old_score = self.score();
170        
171        let amount = self.balance.min(amount);
172        self.balance -= amount;
173        self.last_withdraw_at = clock.unix_timestamp;
174        
175        // Calculate new score after withdraw
176        let new_score = self.score();
177        
178        // Update pool totals
179        pool.total_staked -= amount;
180        pool.total_staked_score = pool.total_staked_score.saturating_add(new_score).saturating_sub(old_score);
181        
182        amount
183    }
184
185    pub fn update_rewards(&mut self, pool: &Pool) {
186        // Accumulate SOL rewards, weighted by stake score (balance * multiplier).
187        if pool.stake_rewards_factor > self.rewards_factor {
188            let accumulated_rewards = pool.stake_rewards_factor - self.rewards_factor;
189            if accumulated_rewards < Numeric::ZERO {
190                panic!("Accumulated rewards is negative");
191            }
192            // Use score instead of balance for lock-based weighted staking
193            let score = self.score();
194            let personal_rewards = accumulated_rewards * Numeric::from_u64(score);
195            self.rewards += personal_rewards.to_u64();
196            self.lifetime_rewards += personal_rewards.to_u64();
197        }
198
199        // Update this stake account's last seen rewards factor.
200        self.rewards_factor = pool.stake_rewards_factor;
201    }
202}
203
204account!(OilAccount, Stake);
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_multiplier_exact_lookup_values() {
212        // Test exact lookup table values
213        assert_eq!(Stake::calculate_multiplier(7), 1.18);
214        assert_eq!(Stake::calculate_multiplier(30), 1.78);
215        assert_eq!(Stake::calculate_multiplier(90), 3.35);
216        assert_eq!(Stake::calculate_multiplier(180), 5.69);
217        assert_eq!(Stake::calculate_multiplier(365), 10.5);
218        assert_eq!(Stake::calculate_multiplier(730), 20.0);
219    }
220
221    #[test]
222    fn test_multiplier_no_lock() {
223        // No lock should return 1.0
224        assert_eq!(Stake::calculate_multiplier(0), 1.0);
225    }
226
227    #[test]
228    fn test_multiplier_below_7_days() {
229        // Linear interpolation from 1.0 to 1.18 for days < 7
230        assert_eq!(Stake::calculate_multiplier(1), 1.0 + (1.0 / 7.0) * 0.18);
231        assert_eq!(Stake::calculate_multiplier(3), 1.0 + (3.0 / 7.0) * 0.18);
232        assert_eq!(Stake::calculate_multiplier(6), 1.0 + (6.0 / 7.0) * 0.18);
233        
234        // Verify it's less than 1.18
235        assert!(Stake::calculate_multiplier(6) < 1.18);
236    }
237
238    #[test]
239    fn test_multiplier_interpolation_between_lookup_points() {
240        // Test interpolation between 7 and 30 days
241        let multiplier_15 = Stake::calculate_multiplier(15);
242        assert!(multiplier_15 > 1.18);
243        assert!(multiplier_15 < 1.78);
244        
245        // Test interpolation between 30 and 90 days
246        let multiplier_60 = Stake::calculate_multiplier(60);
247        assert!(multiplier_60 > 1.78);
248        assert!(multiplier_60 < 3.35);
249        
250        // Test interpolation between 90 and 180 days
251        let multiplier_135 = Stake::calculate_multiplier(135);
252        assert!(multiplier_135 > 3.35);
253        assert!(multiplier_135 < 5.69);
254        
255        // Test interpolation between 180 and 365 days
256        let multiplier_272 = Stake::calculate_multiplier(272);
257        assert!(multiplier_272 > 5.69);
258        assert!(multiplier_272 < 10.5);
259        
260        // Test interpolation between 365 and 730 days
261        let multiplier_547 = Stake::calculate_multiplier(547);
262        assert!(multiplier_547 > 10.5);
263        assert!(multiplier_547 < 20.0);
264    }
265
266    #[test]
267    fn test_multiplier_above_730_days() {
268        // Should cap at 20.0 for days > 730
269        assert_eq!(Stake::calculate_multiplier(730), 20.0);
270        assert_eq!(Stake::calculate_multiplier(1000), 20.0);
271        assert_eq!(Stake::calculate_multiplier(u64::MAX), 20.0);
272    }
273
274    #[test]
275    fn test_score_calculation() {
276        // Create a mock stake for testing
277        // Note: We can't easily create a full Stake struct without all dependencies,
278        // so we'll test the score calculation logic directly
279        
280        // Test with no lock (multiplier = 1.0)
281        let balance = 1000u64;
282        let multiplier = Stake::calculate_multiplier(0);
283        let expected_score = ((balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
284        assert_eq!(expected_score, 1000);
285        
286        // Test with 7-day lock (multiplier = 1.18)
287        let multiplier = Stake::calculate_multiplier(7);
288        let expected_score = ((balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
289        assert_eq!(expected_score, 1180);
290        
291        // Test with 730-day lock (multiplier = 20.0)
292        let multiplier = Stake::calculate_multiplier(730);
293        let expected_score = ((balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
294        assert_eq!(expected_score, 20000);
295    }
296
297    #[test]
298    fn test_score_with_large_balance() {
299        // Test with maximum realistic balance (2.1M OIL)
300        let max_balance = 2_100_000u64;
301        let multiplier = Stake::calculate_multiplier(730); // 20.0x
302        let score = ((max_balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64;
303        
304        // Max score = 2.1M * 20 = 42M, which fits in u64
305        assert_eq!(score, 42_000_000);
306        assert!(score < u64::MAX);
307    }
308
309    #[test]
310    fn test_multiplier_linear_interpolation_accuracy() {
311        // Test that interpolation is linear between points
312        // Between 7 and 30 days: 15 days should be halfway
313        let m7 = Stake::calculate_multiplier(7);
314        let m30 = Stake::calculate_multiplier(30);
315        let m15 = Stake::calculate_multiplier(15);
316        
317        // 15 is (15-7)/(30-7) = 8/23 of the way from 7 to 30
318        let ratio = (15.0 - 7.0) / (30.0 - 7.0);
319        let expected = m7 + ratio * (m30 - m7);
320        
321        // Allow small floating point error
322        assert!((m15 - expected).abs() < 0.0001);
323    }
324
325    #[test]
326    fn test_multiplier_monotonic_increase() {
327        // Verify multipliers increase monotonically with lock duration
328        let mut prev_multiplier = 0.0;
329        for days in [0, 1, 7, 15, 30, 60, 90, 135, 180, 272, 365, 547, 730, 1000] {
330            let multiplier = Stake::calculate_multiplier(days);
331            assert!(multiplier >= prev_multiplier, 
332                "Multiplier should not decrease: {} days = {}, previous = {}", 
333                days, multiplier, prev_multiplier);
334            prev_multiplier = multiplier;
335        }
336    }
337
338    #[test]
339    fn test_multiplier_boundaries() {
340        // Test boundary conditions
341        assert_eq!(Stake::calculate_multiplier(0), 1.0);
342        assert!(Stake::calculate_multiplier(1) > 1.0);
343        assert!(Stake::calculate_multiplier(1) < 1.18);
344        assert_eq!(Stake::calculate_multiplier(7), 1.18);
345        assert_eq!(Stake::calculate_multiplier(730), 20.0);
346        assert_eq!(Stake::calculate_multiplier(731), 20.0);
347    }
348}