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    pub fn calculate_multiplier(lock_duration_days: u64) -> f64 {
60        if lock_duration_days == 0 {
61            return 1.0;
62        }
63
64        let lookup: [(u64, f64); 6] = [
65            (7, 1.18),
66            (30, 1.78),
67            (90, 3.35),
68            (180, 5.69),
69            (365, 10.5),
70            (730, 20.0),
71        ];
72
73        // Cap at 730 days
74        let days = lock_duration_days.min(730);
75
76        // Exact match
77        for &(d, m) in &lookup {
78            if days == d {
79                return m;
80            }
81        }
82
83        // Linear interpolation between lookup points
84        for i in 0..lookup.len() - 1 {
85            let (d1, m1) = lookup[i];
86            let (d2, m2) = lookup[i + 1];
87
88            if days >= d1 && days <= d2 {
89                let ratio = (days - d1) as f64 / (d2 - d1) as f64;
90                return m1 + (m2 - m1) * ratio;
91            }
92        }
93
94        // Extrapolate beyond 730 days (shouldn't happen, but cap at 20.0)
95        if days > 730 {
96            return 20.0;
97        }
98
99        // Below 7 days: linear from 1.0 to 1.18
100        if days < 7 {
101            return 1.0 + (days as f64 / 7.0) * 0.18;
102        }
103
104        1.0 // Fallback
105    }
106
107    pub fn score(&self) -> u64 {
108        let multiplier = Self::calculate_multiplier(self.lock_duration_days);
109        // Use fixed-point arithmetic: multiply by 1_000_000 for precision, then divide
110        ((self.balance as u128 * (multiplier * 1_000_000.0) as u128) / 1_000_000) as u64
111    }
112
113    pub fn is_locked(&self, clock: &Clock) -> bool {
114        self.lock_ends_at > 0 && (clock.unix_timestamp as u64) < self.lock_ends_at
115    }
116
117    pub fn remaining_lock_seconds(&self, clock: &Clock) -> u64 {
118        if self.lock_ends_at == 0 || (clock.unix_timestamp as u64) >= self.lock_ends_at {
119            return 0;
120        }
121        self.lock_ends_at - (clock.unix_timestamp as u64)
122    }
123
124    pub fn calculate_penalty_percent(lock_duration_days: u64) -> u64 {
125        match lock_duration_days {
126            0 => 0,                    // No lock = no penalty
127            1..=7 => 5,                // 1-7 days = 5%
128            8..=30 => 10,              // 8-30 days = 10%
129            31..=180 => 25,            // 31-180 days = 25%
130            181..=365 => 40,           // 181-365 days = 40%
131            366..=730 => 60,           // 366-730 days = 60% (capped)
132            _ => 60,                   // 730+ days = 60% (capped)
133        }
134    }
135
136    pub fn claim(&mut self, amount: u64, clock: &Clock, pool: &Pool) -> u64 {
137        self.update_rewards(pool);
138        let amount = self.rewards.min(amount);
139        self.rewards -= amount;
140        self.last_claim_at = clock.unix_timestamp;
141        amount
142    }
143
144    pub fn deposit(
145        &mut self,
146        amount: u64,
147        clock: &Clock,
148        pool: &mut Pool,
149        sender: &TokenAccount,
150    ) -> u64 {
151        self.update_rewards(pool);
152        
153        // Calculate old score before deposit
154        let old_score = self.score();
155        
156        let amount = sender.amount().min(amount);
157        self.balance += amount;
158        self.last_deposit_at = clock.unix_timestamp;
159        
160        // Calculate new score after deposit
161        let new_score = self.score();
162        
163        // Update pool totals
164        pool.total_staked += amount;
165        pool.total_staked_score = pool.total_staked_score.saturating_add(new_score).saturating_sub(old_score);
166        
167        amount
168    }
169
170    pub fn withdraw(&mut self, amount: u64, clock: &Clock, pool: &mut Pool) -> u64 {
171        self.update_rewards(pool);
172        
173        // Calculate old score before withdraw
174        let old_score = self.score();
175        
176        let amount = self.balance.min(amount);
177        self.balance -= amount;
178        self.last_withdraw_at = clock.unix_timestamp;
179        
180        // If balance reaches 0, reset the lock so user can set a new lock when depositing again
181        // This allows users to start fresh after withdrawing all funds (with or without penalty)
182        if self.balance == 0 {
183            self.lock_duration_days = 0;
184            self.lock_ends_at = 0;
185        }
186        
187        // Calculate new score after withdraw
188        let new_score = self.score();
189        
190        // Update pool totals
191        pool.total_staked -= amount;
192        pool.total_staked_score = pool.total_staked_score.saturating_add(new_score).saturating_sub(old_score);
193        
194        amount
195    }
196
197    pub fn update_rewards(&mut self, pool: &Pool) {
198        // Accumulate SOL rewards, weighted by stake score (balance * multiplier).
199        if pool.stake_rewards_factor > self.rewards_factor {
200            let accumulated_rewards = pool.stake_rewards_factor - self.rewards_factor;
201            if accumulated_rewards < Numeric::ZERO {
202                panic!("Accumulated rewards is negative");
203            }
204            // Use score instead of balance for lock-based weighted staking
205            let score = self.score();
206            let personal_rewards = accumulated_rewards * Numeric::from_u64(score);
207            self.rewards += personal_rewards.to_u64();
208            self.lifetime_rewards += personal_rewards.to_u64();
209        }
210
211        // Update this stake account's last seen rewards factor.
212        self.rewards_factor = pool.stake_rewards_factor;
213    }
214}
215
216account!(OilAccount, Stake);