light_sdk/interface/
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) -> Result<Self::Packed, ProgramError>;
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) -> Result<&CompressionInfo, ProgramError>;
38    fn compression_info_mut(&mut self) -> Result<&mut CompressionInfo, ProgramError>;
39    fn compression_info_mut_opt(&mut self) -> &mut Option<CompressionInfo>;
40    fn set_compression_info_none(&mut self) -> Result<(), ProgramError>;
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, PartialEq, 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(cfg: &crate::interface::LightConfig, current_slot: u64) -> Self {
90        Self {
91            config_version: cfg.version as u16,
92            lamports_per_write: cfg.write_top_up,
93            last_claimed_slot: current_slot,
94            rent_config: cfg.rent_config,
95            state: CompressionState::Decompressed,
96        }
97    }
98
99    /// Backward-compat constructor used by older call sites; initializes minimal fields.
100    /// Rent will flow to config's rent_sponsor upon compression.
101    pub fn new_decompressed() -> Result<Self, crate::ProgramError> {
102        Ok(Self {
103            config_version: 0,
104            lamports_per_write: 0,
105            last_claimed_slot: Clock::get()?.slot,
106            rent_config: RentConfig::default(),
107            state: CompressionState::Decompressed,
108        })
109    }
110
111    /// Update last_claimed_slot to the current slot.
112    pub fn bump_last_claimed_slot(&mut self) -> Result<(), crate::ProgramError> {
113        self.last_claimed_slot = Clock::get()?.slot;
114        Ok(())
115    }
116
117    /// Explicitly set last_claimed_slot.
118    pub fn set_last_claimed_slot(&mut self, slot: u64) {
119        self.last_claimed_slot = slot;
120    }
121
122    /// Get last_claimed_slot.
123    pub fn last_claimed_slot(&self) -> u64 {
124        self.last_claimed_slot
125    }
126
127    pub fn set_compressed(&mut self) {
128        self.state = CompressionState::Compressed;
129    }
130
131    pub fn is_compressed(&self) -> bool {
132        self.state == CompressionState::Compressed
133    }
134}
135
136impl CompressionInfo {
137    /// Calculate top-up lamports required for a write.
138    ///
139    /// Logic (same as CTokens):
140    /// - If account is compressible (can't pay current + next epoch): return lamports_per_write + deficit
141    /// - If account has >= max_funded_epochs: return 0 (no top-up needed)
142    /// - Otherwise: return lamports_per_write (maintenance mode)
143    pub fn calculate_top_up_lamports(
144        &self,
145        num_bytes: u64,
146        current_slot: u64,
147        current_lamports: u64,
148        rent_exemption_lamports: u64,
149    ) -> u64 {
150        use light_compressible::rent::AccountRentState;
151
152        let state = AccountRentState {
153            num_bytes,
154            current_slot,
155            current_lamports,
156            last_claimed_slot: self.last_claimed_slot(),
157        };
158
159        // If compressible (emergency mode), return lamports_per_write + deficit
160        if let Some(rent_deficit) =
161            state.is_compressible(&self.rent_config, rent_exemption_lamports)
162        {
163            return self.lamports_per_write as u64 + rent_deficit;
164        }
165
166        let epochs_funded_ahead =
167            state.epochs_funded_ahead(&self.rent_config, rent_exemption_lamports);
168
169        // If already at or above target, no top-up needed (cruise control)
170        if epochs_funded_ahead >= self.rent_config.max_funded_epochs as u64 {
171            return 0;
172        }
173
174        // Maintenance mode - add lamports_per_write each time
175        self.lamports_per_write as u64
176    }
177
178    /// Top up rent on write if needed and transfer lamports from payer to account.
179    /// This is the standard pattern for all write operations on compressible PDAs.
180    ///
181    /// # Arguments
182    /// * `account_info` - The PDA account to top up
183    /// * `payer_info` - The payer account (will be debited)
184    /// * `system_program_info` - The System Program account for CPI
185    ///
186    /// # Returns
187    /// * `Ok(())` if top-up succeeded or was not needed
188    /// * `Err(ProgramError)` if transfer failed
189    pub fn top_up_rent<'a>(
190        &self,
191        account_info: &AccountInfo<'a>,
192        payer_info: &AccountInfo<'a>,
193        system_program_info: &AccountInfo<'a>,
194    ) -> Result<(), crate::ProgramError> {
195        use solana_clock::Clock;
196        use solana_sysvar::{rent::Rent, Sysvar};
197
198        let bytes = account_info.data_len() as u64;
199        let current_lamports = account_info.lamports();
200        let current_slot = Clock::get()?.slot;
201        let rent_exemption_lamports = Rent::get()?.minimum_balance(bytes as usize);
202
203        let top_up = self.calculate_top_up_lamports(
204            bytes,
205            current_slot,
206            current_lamports,
207            rent_exemption_lamports,
208        );
209
210        if top_up > 0 {
211            // Use System Program CPI to transfer lamports
212            // This is required because the payer account is owned by the System Program,
213            // not by the calling program
214            transfer_lamports_cpi(payer_info, account_info, system_program_info, top_up)?;
215        }
216
217        Ok(())
218    }
219}
220
221pub trait Space {
222    const INIT_SPACE: usize;
223}
224
225impl Space for CompressionInfo {
226    // 2 (u16 config_version) + 4 (u32 lamports_per_write) + 8 (u64 last_claimed_slot) + size_of::<RentConfig>() + 1 (CompressionState)
227    const INIT_SPACE: usize = 2 + 4 + 8 + core::mem::size_of::<RentConfig>() + 1;
228}
229
230#[cfg(feature = "anchor")]
231impl anchor_lang::Space for CompressionInfo {
232    const INIT_SPACE: usize = <Self as Space>::INIT_SPACE;
233}
234
235/// Space required for Option<CompressionInfo> when Some (1 byte discriminator + INIT_SPACE).
236/// Use this constant in account space calculations.
237pub const OPTION_COMPRESSION_INFO_SPACE: usize = 1 + CompressionInfo::INIT_SPACE;
238
239/// Compressed account data used when decompressing.
240#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
241pub struct CompressedAccountData<T> {
242    pub meta: CompressedAccountMetaNoLamportsNoAddress,
243    pub data: T,
244}
245
246/// Claim completed-epoch rent to the provided rent sponsor and update last_claimed_slot.
247/// Returns Some(claimed) if any lamports were claimed; None if account is compressible or nothing to claim.
248pub fn claim_completed_epoch_rent<'info, A>(
249    account_info: &AccountInfo<'info>,
250    account_data: &mut A,
251    rent_sponsor: &AccountInfo<'info>,
252) -> Result<Option<u64>, ProgramError>
253where
254    A: HasCompressionInfo,
255{
256    use light_compressible::rent::{AccountRentState, SLOTS_PER_EPOCH};
257    use solana_sysvar::rent::Rent;
258
259    let current_slot = Clock::get()?.slot;
260    let bytes = account_info.data_len() as u64;
261    let current_lamports = account_info.lamports();
262    let rent_exemption_lamports = Rent::get()
263        .map_err(|_| ProgramError::Custom(0))?
264        .minimum_balance(bytes as usize);
265
266    let ci = account_data.compression_info_mut()?;
267    let state = AccountRentState {
268        num_bytes: bytes,
269        current_slot,
270        current_lamports,
271        last_claimed_slot: ci.last_claimed_slot(),
272    };
273
274    // If compressible (insufficient for current+next epoch), do not claim
275    if state
276        .is_compressible(&ci.rent_config, rent_exemption_lamports)
277        .is_some()
278    {
279        return Ok(None);
280    }
281
282    // Claim only completed epochs
283    let claimable = state.calculate_claimable_rent(&ci.rent_config, rent_exemption_lamports);
284    if let Some(amount) = claimable {
285        if amount > 0 {
286            // Advance last_claimed_slot by completed epochs
287            let completed_epochs = state.get_completed_epochs();
288            ci.set_last_claimed_slot(
289                ci.last_claimed_slot()
290                    .saturating_add(completed_epochs * SLOTS_PER_EPOCH),
291            );
292
293            // Transfer lamports to rent sponsor
294            {
295                let mut src = account_info
296                    .try_borrow_mut_lamports()
297                    .map_err(|_| ProgramError::Custom(0))?;
298                let mut dst = rent_sponsor
299                    .try_borrow_mut_lamports()
300                    .map_err(|_| ProgramError::Custom(0))?;
301                let new_src = src
302                    .checked_sub(amount)
303                    .ok_or(ProgramError::InsufficientFunds)?;
304                let new_dst = dst.checked_add(amount).ok_or(ProgramError::Custom(0))?;
305                **src = new_src;
306                **dst = new_dst;
307            }
308            return Ok(Some(amount));
309        }
310    }
311    Ok(Some(0))
312}
313
314/// Transfer lamports from one account to another using System Program CPI.
315/// This is required when transferring from accounts owned by the System Program.
316///
317/// # Arguments
318/// * `from` - Source account (owned by System Program)
319/// * `to` - Destination account
320/// * `system_program` - System Program account
321/// * `lamports` - Amount of lamports to transfer
322fn transfer_lamports_cpi<'a>(
323    from: &AccountInfo<'a>,
324    to: &AccountInfo<'a>,
325    system_program: &AccountInfo<'a>,
326    lamports: u64,
327) -> Result<(), ProgramError> {
328    use solana_cpi::invoke;
329    use solana_instruction::{AccountMeta, Instruction};
330
331    // System Program ID
332    const SYSTEM_PROGRAM_ID: [u8; 32] = [
333        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,
334        0, 0,
335    ];
336
337    // System Program Transfer instruction discriminator: 2 (u32 little-endian)
338    let mut instruction_data = vec![2, 0, 0, 0];
339    instruction_data.extend_from_slice(&lamports.to_le_bytes());
340
341    let transfer_instruction = Instruction {
342        program_id: Pubkey::from(SYSTEM_PROGRAM_ID),
343        accounts: vec![
344            AccountMeta::new(*from.key, true),
345            AccountMeta::new(*to.key, false),
346        ],
347        data: instruction_data,
348    };
349
350    invoke(
351        &transfer_instruction,
352        &[from.clone(), to.clone(), system_program.clone()],
353    )
354}