light_sdk/compressible/
compression_info.rs

1use std::borrow::Cow;
2
3use light_compressible::rent::RentConfig;
4use light_sdk_types::instruction::account_meta::CompressedAccountMetaNoLamportsNoAddress;
5use solana_account_info::AccountInfo;
6use solana_clock::Clock;
7use solana_pubkey::Pubkey;
8use solana_sysvar::Sysvar;
9
10use crate::{instruction::PackedAccounts, AnchorDeserialize, AnchorSerialize, ProgramError};
11
12/// Replace 32-byte Pubkeys with 1-byte indices to save space.
13/// If your type has no Pubkeys, just return self.
14pub trait Pack {
15    type Packed: AnchorSerialize + Clone + std::fmt::Debug;
16
17    fn pack(&self, remaining_accounts: &mut PackedAccounts) -> Self::Packed;
18}
19
20pub trait Unpack {
21    type Unpacked;
22
23    fn unpack(
24        &self,
25        remaining_accounts: &[AccountInfo],
26    ) -> Result<Self::Unpacked, crate::ProgramError>;
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)]
30#[repr(u8)]
31pub enum AccountState {
32    Initialized,
33    Frozen,
34}
35
36pub trait HasCompressionInfo {
37    fn compression_info(&self) -> &CompressionInfo;
38    fn compression_info_mut(&mut self) -> &mut CompressionInfo;
39    fn compression_info_mut_opt(&mut self) -> &mut Option<CompressionInfo>;
40    fn set_compression_info_none(&mut self);
41}
42
43/// Account space when compressed.
44pub trait CompressedInitSpace {
45    const COMPRESSED_INIT_SPACE: usize;
46}
47
48/// Override what gets stored when compressing. Return Self or a different type.
49pub trait CompressAs {
50    type Output: crate::AnchorSerialize
51        + crate::AnchorDeserialize
52        + crate::LightDiscriminator
53        + crate::account::Size
54        + HasCompressionInfo
55        + Default
56        + Clone;
57
58    fn compress_as(&self) -> Cow<'_, Self::Output>;
59}
60
61#[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize)]
62pub struct CompressionInfo {
63    /// Version of the compressible config used to initialize this account.
64    pub config_version: u16,
65    /// Lamports to top up on each write (from config, stored per-account to avoid passing config on every write)
66    pub lamports_per_write: u32,
67    /// Slot when rent was last claimed (epoch boundary accounting).
68    pub last_claimed_slot: u64,
69    /// Rent function parameters for determining compressibility/claims.
70    pub rent_config: RentConfig,
71    /// Account compression state.
72    pub state: CompressionState,
73}
74
75#[derive(Debug, Clone, Default, AnchorSerialize, AnchorDeserialize, PartialEq)]
76pub enum CompressionState {
77    #[default]
78    Uninitialized,
79    Decompressed,
80    Compressed,
81}
82
83impl CompressionInfo {
84    /// Create a new CompressionInfo initialized from a compressible config.
85    ///
86    /// Rent sponsor is always the config's rent_sponsor (not stored per-account).
87    /// This means rent always flows to the protocol's rent pool upon compression,
88    /// regardless of who paid for account creation.
89    pub fn new_from_config(
90        cfg: &crate::compressible::CompressibleConfig,
91        current_slot: u64,
92    ) -> Self {
93        Self {
94            config_version: cfg.version as u16,
95            lamports_per_write: cfg.write_top_up,
96            last_claimed_slot: current_slot,
97            rent_config: cfg.rent_config,
98            state: CompressionState::Decompressed,
99        }
100    }
101
102    /// Backward-compat constructor used by older call sites; initializes minimal fields.
103    /// Rent will flow to config's rent_sponsor upon compression.
104    pub fn new_decompressed() -> Result<Self, crate::ProgramError> {
105        Ok(Self {
106            config_version: 0,
107            lamports_per_write: 0,
108            last_claimed_slot: Clock::get()?.slot,
109            rent_config: RentConfig::default(),
110            state: CompressionState::Decompressed,
111        })
112    }
113
114    /// Update last_claimed_slot to the current slot.
115    pub fn bump_last_claimed_slot(&mut self) -> Result<(), crate::ProgramError> {
116        self.last_claimed_slot = Clock::get()?.slot;
117        Ok(())
118    }
119
120    /// Explicitly set last_claimed_slot.
121    pub fn set_last_claimed_slot(&mut self, slot: u64) {
122        self.last_claimed_slot = slot;
123    }
124
125    /// Get last_claimed_slot.
126    pub fn last_claimed_slot(&self) -> u64 {
127        self.last_claimed_slot
128    }
129
130    pub fn set_compressed(&mut self) {
131        self.state = CompressionState::Compressed;
132    }
133
134    pub fn is_compressed(&self) -> bool {
135        self.state == CompressionState::Compressed
136    }
137}
138
139impl CompressionInfo {
140    /// Calculate top-up lamports required for a write.
141    ///
142    /// Logic (same as CTokens):
143    /// - If account is compressible (can't pay current + next epoch): return lamports_per_write + deficit
144    /// - If account has >= max_funded_epochs: return 0 (no top-up needed)
145    /// - Otherwise: return lamports_per_write (maintenance mode)
146    pub fn calculate_top_up_lamports(
147        &self,
148        num_bytes: u64,
149        current_slot: u64,
150        current_lamports: u64,
151        rent_exemption_lamports: u64,
152    ) -> u64 {
153        use light_compressible::rent::AccountRentState;
154
155        let state = AccountRentState {
156            num_bytes,
157            current_slot,
158            current_lamports,
159            last_claimed_slot: self.last_claimed_slot(),
160        };
161
162        // If compressible (emergency mode), return lamports_per_write + deficit
163        if let Some(rent_deficit) =
164            state.is_compressible(&self.rent_config, rent_exemption_lamports)
165        {
166            return self.lamports_per_write as u64 + rent_deficit;
167        }
168
169        let epochs_funded_ahead =
170            state.epochs_funded_ahead(&self.rent_config, rent_exemption_lamports);
171
172        // If already at or above target, no top-up needed (cruise control)
173        if epochs_funded_ahead >= self.rent_config.max_funded_epochs as u64 {
174            return 0;
175        }
176
177        // Maintenance mode - add lamports_per_write each time
178        self.lamports_per_write as u64
179    }
180
181    /// Top up rent on write if needed and transfer lamports from payer to account.
182    /// This is the standard pattern for all write operations on compressible PDAs.
183    ///
184    /// # Arguments
185    /// * `account_info` - The PDA account to top up
186    /// * `payer_info` - The payer account (will be debited)
187    /// * `system_program_info` - The System Program account for CPI
188    ///
189    /// # Returns
190    /// * `Ok(())` if top-up succeeded or was not needed
191    /// * `Err(ProgramError)` if transfer failed
192    pub fn top_up_rent<'a>(
193        &self,
194        account_info: &AccountInfo<'a>,
195        payer_info: &AccountInfo<'a>,
196        system_program_info: &AccountInfo<'a>,
197    ) -> Result<(), crate::ProgramError> {
198        use solana_clock::Clock;
199        use solana_sysvar::{rent::Rent, Sysvar};
200
201        let bytes = account_info.data_len() as u64;
202        let current_lamports = account_info.lamports();
203        let current_slot = Clock::get()?.slot;
204        let rent_exemption_lamports = Rent::get()?.minimum_balance(bytes as usize);
205
206        let top_up = self.calculate_top_up_lamports(
207            bytes,
208            current_slot,
209            current_lamports,
210            rent_exemption_lamports,
211        );
212
213        if top_up > 0 {
214            // Use System Program CPI to transfer lamports
215            // This is required because the payer account is owned by the System Program,
216            // not by the calling program
217            transfer_lamports_cpi(payer_info, account_info, system_program_info, top_up)?;
218        }
219
220        Ok(())
221    }
222}
223
224pub trait Space {
225    const INIT_SPACE: usize;
226}
227
228impl Space for CompressionInfo {
229    // 2 (u16 config_version) + 4 (u32 lamports_per_write) + 8 (u64 last_claimed_slot) + size_of::<RentConfig>() + 1 (CompressionState)
230    const INIT_SPACE: usize = 2 + 4 + 8 + core::mem::size_of::<RentConfig>() + 1;
231}
232
233#[cfg(feature = "anchor")]
234impl anchor_lang::Space for CompressionInfo {
235    const INIT_SPACE: usize = <Self as Space>::INIT_SPACE;
236}
237
238/// Space required for Option<CompressionInfo> when Some (1 byte discriminator + INIT_SPACE).
239/// Use this constant in account space calculations.
240pub const OPTION_COMPRESSION_INFO_SPACE: usize = 1 + CompressionInfo::INIT_SPACE;
241
242/// Compressed account data used when decompressing.
243#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
244pub struct CompressedAccountData<T> {
245    pub meta: CompressedAccountMetaNoLamportsNoAddress,
246    pub data: T,
247}
248
249/// Claim completed-epoch rent to the provided rent sponsor and update last_claimed_slot.
250/// Returns Some(claimed) if any lamports were claimed; None if account is compressible or nothing to claim.
251pub fn claim_completed_epoch_rent<'info, A>(
252    account_info: &AccountInfo<'info>,
253    account_data: &mut A,
254    rent_sponsor: &AccountInfo<'info>,
255) -> Result<Option<u64>, ProgramError>
256where
257    A: HasCompressionInfo,
258{
259    use light_compressible::rent::{AccountRentState, SLOTS_PER_EPOCH};
260    use solana_sysvar::rent::Rent;
261
262    let current_slot = Clock::get()?.slot;
263    let bytes = account_info.data_len() as u64;
264    let current_lamports = account_info.lamports();
265    let rent_exemption_lamports = Rent::get()
266        .map_err(|_| ProgramError::Custom(0))?
267        .minimum_balance(bytes as usize);
268
269    let ci = account_data.compression_info_mut();
270    let state = AccountRentState {
271        num_bytes: bytes,
272        current_slot,
273        current_lamports,
274        last_claimed_slot: ci.last_claimed_slot(),
275    };
276
277    // If compressible (insufficient for current+next epoch), do not claim
278    if state
279        .is_compressible(&ci.rent_config, rent_exemption_lamports)
280        .is_some()
281    {
282        return Ok(None);
283    }
284
285    // Claim only completed epochs
286    let claimable = state.calculate_claimable_rent(&ci.rent_config, rent_exemption_lamports);
287    if let Some(amount) = claimable {
288        if amount > 0 {
289            // Advance last_claimed_slot by completed epochs
290            let completed_epochs = state.get_completed_epochs();
291            ci.set_last_claimed_slot(
292                ci.last_claimed_slot()
293                    .saturating_add(completed_epochs * SLOTS_PER_EPOCH),
294            );
295
296            // Transfer lamports to rent sponsor
297            {
298                let mut src = account_info
299                    .try_borrow_mut_lamports()
300                    .map_err(|_| ProgramError::Custom(0))?;
301                let mut dst = rent_sponsor
302                    .try_borrow_mut_lamports()
303                    .map_err(|_| ProgramError::Custom(0))?;
304                let new_src = src
305                    .checked_sub(amount)
306                    .ok_or(ProgramError::InsufficientFunds)?;
307                let new_dst = dst.checked_add(amount).ok_or(ProgramError::Custom(0))?;
308                **src = new_src;
309                **dst = new_dst;
310            }
311            return Ok(Some(amount));
312        }
313    }
314    Ok(Some(0))
315}
316
317/// Transfer lamports from one account to another using System Program CPI.
318/// This is required when transferring from accounts owned by the System Program.
319///
320/// # Arguments
321/// * `from` - Source account (owned by System Program)
322/// * `to` - Destination account
323/// * `system_program` - System Program account
324/// * `lamports` - Amount of lamports to transfer
325fn transfer_lamports_cpi<'a>(
326    from: &AccountInfo<'a>,
327    to: &AccountInfo<'a>,
328    system_program: &AccountInfo<'a>,
329    lamports: u64,
330) -> Result<(), ProgramError> {
331    use solana_cpi::invoke;
332    use solana_instruction::{AccountMeta, Instruction};
333
334    // System Program ID
335    const SYSTEM_PROGRAM_ID: [u8; 32] = [
336        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
337        0, 0,
338    ];
339
340    // System Program Transfer instruction discriminator: 2 (u32 little-endian)
341    let mut instruction_data = vec![2, 0, 0, 0];
342    instruction_data.extend_from_slice(&lamports.to_le_bytes());
343
344    let transfer_instruction = Instruction {
345        program_id: Pubkey::from(SYSTEM_PROGRAM_ID),
346        accounts: vec![
347            AccountMeta::new(*from.key, true),
348            AccountMeta::new(*to.key, false),
349        ],
350        data: instruction_data,
351    };
352
353    invoke(
354        &transfer_instruction,
355        &[from.clone(), to.clone(), system_program.clone()],
356    )
357}