light_program_test/
compressible.rs

1use std::collections::HashMap;
2
3use borsh::BorshDeserialize;
4use light_client::rpc::{Rpc, RpcError};
5use light_compressible::{
6    compression_info::CompressionInfo,
7    config::CompressibleConfig as CtokenCompressibleConfig,
8    rent::{RentConfig, SLOTS_PER_EPOCH},
9};
10use light_sdk::interface::LightConfig;
11use light_token_interface::{
12    state::{Mint, Token, ACCOUNT_TYPE_MINT, ACCOUNT_TYPE_TOKEN_ACCOUNT},
13    LIGHT_TOKEN_PROGRAM_ID,
14};
15use solana_pubkey::Pubkey;
16
17use crate::{
18    litesvm_extensions::LiteSvmExtensions, registry_sdk::REGISTRY_PROGRAM_ID, LightProgramTest,
19};
20
21/// Determines account type from account data.
22/// - If account is exactly 165 bytes: Token (legacy size without extensions)
23/// - If account is > 165 bytes: read byte 165 for discriminator
24/// - If account is < 165 bytes: invalid (returns None)
25fn determine_account_type(data: &[u8]) -> Option<u8> {
26    const ACCOUNT_TYPE_OFFSET: usize = 165;
27
28    match data.len().cmp(&ACCOUNT_TYPE_OFFSET) {
29        std::cmp::Ordering::Less => None,
30        std::cmp::Ordering::Equal => Some(ACCOUNT_TYPE_TOKEN_ACCOUNT), // 165 bytes = Token
31        std::cmp::Ordering::Greater => Some(data[ACCOUNT_TYPE_OFFSET]),
32    }
33}
34
35/// Extracts CompressionInfo, account type, and compression_only from account data.
36/// Returns (CompressionInfo, account_type, compression_only) or None if parsing fails.
37fn extract_compression_info(data: &[u8]) -> Option<(CompressionInfo, u8, bool)> {
38    use light_zero_copy::traits::ZeroCopyAt;
39
40    let account_type = determine_account_type(data)?;
41
42    match account_type {
43        ACCOUNT_TYPE_TOKEN_ACCOUNT => {
44            let (ctoken, _) = Token::zero_copy_at(data).ok()?;
45            let ext = ctoken.get_compressible_extension()?;
46
47            let compression_info = CompressionInfo {
48                config_account_version: ext.info.config_account_version.into(),
49                compress_to_pubkey: ext.info.compress_to_pubkey,
50                account_version: ext.info.account_version,
51                lamports_per_write: ext.info.lamports_per_write.into(),
52                compression_authority: ext.info.compression_authority,
53                rent_sponsor: ext.info.rent_sponsor,
54                last_claimed_slot: ext.info.last_claimed_slot.into(),
55                rent_exemption_paid: ext.info.rent_exemption_paid.into(),
56                _reserved: ext.info._reserved.into(),
57                rent_config: RentConfig {
58                    base_rent: ext.info.rent_config.base_rent.into(),
59                    compression_cost: ext.info.rent_config.compression_cost.into(),
60                    lamports_per_byte_per_epoch: ext.info.rent_config.lamports_per_byte_per_epoch,
61                    max_funded_epochs: ext.info.rent_config.max_funded_epochs,
62                    max_top_up: ext.info.rent_config.max_top_up.into(),
63                },
64            };
65            let compression_only = ext.compression_only != 0;
66            Some((compression_info, account_type, compression_only))
67        }
68        ACCOUNT_TYPE_MINT => {
69            let mint = Mint::deserialize(&mut &data[..]).ok()?;
70            // Mint accounts don't have compression_only, default to false
71            Some((mint.compression, account_type, false))
72        }
73        _ => None,
74    }
75}
76
77pub type CompressibleAccountStore = HashMap<Pubkey, StoredCompressibleAccount>;
78
79#[derive(Eq, Hash, PartialEq)]
80pub struct StoredCompressibleAccount {
81    pub pubkey: Pubkey,
82    pub last_paid_slot: u64,
83    pub compression: CompressionInfo,
84    /// Account type: ACCOUNT_TYPE_TOKEN_ACCOUNT (2) or ACCOUNT_TYPE_MINT (1)
85    pub account_type: u8,
86    /// Whether this is a compression-only account (affects batching)
87    pub compression_only: bool,
88}
89
90#[derive(Debug, PartialEq, Copy, Clone)]
91pub struct FundingPoolConfig {
92    pub compressible_config_pda: Pubkey,
93    pub compression_authority_pda: Pubkey,
94    pub compression_authority_pda_bump: u8,
95    /// rent_sponsor == pool pda
96    pub rent_sponsor_pda: Pubkey,
97    pub rent_sponsor_pda_bump: u8,
98}
99
100impl FundingPoolConfig {
101    pub fn new(version: u16) -> Self {
102        let config = CtokenCompressibleConfig::new_light_token(
103            version,
104            true,
105            Pubkey::default(),
106            Pubkey::default(),
107            RentConfig::default(),
108        );
109        let compressible_config =
110            CtokenCompressibleConfig::derive_pda(&REGISTRY_PROGRAM_ID, version).0;
111        Self {
112            compressible_config_pda: compressible_config,
113            rent_sponsor_pda: config.rent_sponsor,
114            rent_sponsor_pda_bump: config.rent_sponsor_bump,
115            compression_authority_pda: config.compression_authority,
116            compression_authority_pda_bump: config.compression_authority_bump,
117        }
118    }
119
120    pub fn get_v1() -> Self {
121        Self::new(1)
122    }
123}
124
125pub async fn claim_and_compress(
126    rpc: &mut LightProgramTest,
127    stored_compressible_accounts: &mut CompressibleAccountStore,
128) -> Result<(), RpcError> {
129    use crate::forester::{claim_forester, compress_and_close_forester};
130
131    let forester_keypair = rpc.test_accounts.protocol.forester.insecure_clone();
132    let payer = rpc.get_payer().insecure_clone();
133
134    // Get all compressible token/mint accounts (both Token and Mint)
135    let compressible_ctoken_accounts = rpc
136        .context
137        .get_program_accounts(&Pubkey::from(LIGHT_TOKEN_PROGRAM_ID));
138
139    // CToken base accounts are 165 bytes, filter above that to exclude empty/minimal accounts
140    for account in compressible_ctoken_accounts
141        .iter()
142        .filter(|e| e.1.data.len() >= 165 && e.1.lamports > 0)
143    {
144        // Extract compression info, account type, and compression_only
145        let Some((compression, account_type, compression_only)) =
146            extract_compression_info(&account.1.data)
147        else {
148            continue;
149        };
150
151        let base_lamports = rpc
152            .get_minimum_balance_for_rent_exemption(account.1.data.len())
153            .await
154            .unwrap();
155        let last_funded_epoch = compression
156            .get_last_funded_epoch(
157                account.1.data.len() as u64,
158                account.1.lamports,
159                base_lamports,
160            )
161            .unwrap();
162        let last_funded_slot = last_funded_epoch * SLOTS_PER_EPOCH;
163        stored_compressible_accounts.insert(
164            account.0,
165            StoredCompressibleAccount {
166                pubkey: account.0,
167                last_paid_slot: last_funded_slot,
168                compression,
169                account_type,
170                compression_only,
171            },
172        );
173    }
174
175    let current_slot = rpc.get_slot().await?;
176
177    // Separate accounts by type and compression_only setting
178    let mut compress_accounts_compression_only = Vec::new();
179    let mut compress_accounts_normal = Vec::new();
180    let mut compress_mint_accounts = Vec::new();
181    let mut claim_accounts = Vec::new();
182
183    // For each stored account, determine action using AccountRentState
184    for (pubkey, stored_account) in stored_compressible_accounts.iter() {
185        let account = rpc.get_account(*pubkey).await?.unwrap();
186        let rent_exemption = rpc
187            .get_minimum_balance_for_rent_exemption(account.data.len())
188            .await?;
189
190        use light_compressible::rent::AccountRentState;
191
192        let compression = &stored_account.compression;
193
194        // Create state for rent calculation
195        let state = AccountRentState {
196            num_bytes: account.data.len() as u64,
197            current_slot,
198            current_lamports: account.lamports,
199            last_claimed_slot: compression.last_claimed_slot,
200        };
201
202        // Check what action is needed
203        match state.calculate_claimable_rent(&compression.rent_config, rent_exemption) {
204            None => {
205                // Account is compressible (has rent deficit)
206                if stored_account.account_type == ACCOUNT_TYPE_TOKEN_ACCOUNT {
207                    // CToken accounts - separate by compression_only
208                    if stored_account.compression_only {
209                        compress_accounts_compression_only.push(*pubkey);
210                    } else {
211                        compress_accounts_normal.push(*pubkey);
212                    }
213                } else if stored_account.account_type == ACCOUNT_TYPE_MINT {
214                    // Mint accounts - use mint_action flow
215                    compress_mint_accounts.push(*pubkey);
216                }
217            }
218            Some(claimable_amount) if claimable_amount > 0 => {
219                // Has rent to claim from completed epochs
220                // Both Token and Mint can be claimed
221                claim_accounts.push(*pubkey);
222            }
223            Some(_) => {
224                // Well-funded, nothing to claim (0 completed epochs)
225                // Do nothing - skip this account
226            }
227        }
228    }
229
230    // Process claimable accounts in batches
231    for token_accounts in claim_accounts.as_slice().chunks(20) {
232        claim_forester(rpc, token_accounts, &forester_keypair, &payer).await?;
233    }
234
235    // Process compressible accounts in batches, separated by compression_only setting
236    // This prevents TlvExtensionLengthMismatch errors when batching accounts together
237    const BATCH_SIZE: usize = 10;
238
239    // Process compression_only=true CToken accounts
240    for chunk in compress_accounts_compression_only.chunks(BATCH_SIZE) {
241        compress_and_close_forester(rpc, chunk, &forester_keypair, &payer, None).await?;
242        for account_pubkey in chunk {
243            stored_compressible_accounts.remove(account_pubkey);
244        }
245    }
246
247    // Process compression_only=false CToken accounts
248    for chunk in compress_accounts_normal.chunks(BATCH_SIZE) {
249        compress_and_close_forester(rpc, chunk, &forester_keypair, &payer, None).await?;
250        for account_pubkey in chunk {
251            stored_compressible_accounts.remove(account_pubkey);
252        }
253    }
254
255    // Process Mint accounts via mint_action
256    for mint_pubkey in compress_mint_accounts {
257        compress_mint_forester(rpc, mint_pubkey, &payer).await?;
258        stored_compressible_accounts.remove(&mint_pubkey);
259    }
260
261    Ok(())
262}
263
264pub async fn auto_compress_program_pdas(
265    rpc: &mut LightProgramTest,
266    program_id: Pubkey,
267) -> Result<(), RpcError> {
268    use solana_instruction::AccountMeta;
269    use solana_sdk::signature::Signer;
270
271    let payer = rpc.get_payer().insecure_clone();
272
273    let config_pda = LightConfig::derive_pda(&program_id, 0).0;
274
275    let cfg_acc_opt = rpc.get_account(config_pda).await?;
276    let Some(cfg_acc) = cfg_acc_opt else {
277        return Ok(());
278    };
279    let cfg = LightConfig::try_from_slice(&cfg_acc.data)
280        .map_err(|e| RpcError::CustomError(format!("config deserialize: {e:?}")))?;
281    let rent_sponsor = cfg.rent_sponsor;
282    // compression_authority is the payer by default for auto-compress
283    let compression_authority = payer.pubkey();
284    let address_tree = cfg.address_space[0];
285
286    let program_accounts = rpc.context.get_program_accounts(&program_id);
287
288    if program_accounts.is_empty() {
289        return Ok(());
290    }
291
292    // CompressAccountsIdempotent struct expects 4 accounts:
293    // 1. fee_payer (signer, writable)
294    // 2. config (read-only)
295    // 3. rent_sponsor (writable)
296    // 4. compression_authority (writable - per generated struct)
297    let program_metas = vec![
298        AccountMeta::new(payer.pubkey(), true),
299        AccountMeta::new_readonly(config_pda, false),
300        AccountMeta::new(rent_sponsor, false),
301        AccountMeta::new(compression_authority, false),
302    ];
303
304    const BATCH_SIZE: usize = 5;
305    let mut chunk = Vec::with_capacity(BATCH_SIZE);
306    for (pubkey, account) in program_accounts
307        .into_iter()
308        .filter(|(_, acc)| acc.lamports > 0 && !acc.data.is_empty())
309    {
310        chunk.push((pubkey, account));
311        if chunk.len() == BATCH_SIZE {
312            try_compress_chunk(rpc, &program_id, &chunk, &program_metas, &address_tree).await;
313            chunk.clear();
314        }
315    }
316
317    if !chunk.is_empty() {
318        try_compress_chunk(rpc, &program_id, &chunk, &program_metas, &address_tree).await;
319    }
320
321    Ok(())
322}
323
324async fn try_compress_chunk(
325    rpc: &mut LightProgramTest,
326    program_id: &Pubkey,
327    chunk: &[(Pubkey, solana_sdk::account::Account)],
328    program_metas: &[solana_instruction::AccountMeta],
329    address_tree: &Pubkey,
330) {
331    use light_client::{indexer::Indexer, interface::instructions};
332    use light_compressed_account::address::derive_address;
333    use solana_sdk::signature::Signer;
334
335    // Attempt compression per-account idempotently.
336    for (pda, _acc) in chunk.iter() {
337        // v2 address derive using PDA as seed
338        let addr = derive_address(
339            &pda.to_bytes(),
340            &address_tree.to_bytes(),
341            &program_id.to_bytes(),
342        );
343
344        // Only proceed if a compressed account exists
345        let Ok(resp) = rpc.get_compressed_account(addr, None).await else {
346            continue;
347        };
348        let Some(cacc) = resp.value else {
349            continue;
350        };
351
352        // Fetch proof for this single account hash
353        let Ok(proof_with_context) = rpc
354            .get_validity_proof(vec![cacc.hash], vec![], None)
355            .await
356            .map(|r| r.value)
357        else {
358            continue;
359        };
360
361        // Build compress instruction
362        let Ok(ix) = instructions::build_compress_accounts_idempotent(
363            program_id,
364            &instructions::COMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR,
365            &[*pda],
366            program_metas,
367            proof_with_context,
368        )
369        .map_err(|e| e.to_string()) else {
370            continue;
371        };
372
373        let payer = rpc.get_payer().insecure_clone();
374        let payer_pubkey = payer.pubkey();
375
376        // Ignore errors to continue compressing other PDAs
377        let _ = rpc
378            .create_and_send_transaction(std::slice::from_ref(&ix), &payer_pubkey, &[&payer])
379            .await;
380    }
381}
382
383/// Compress and close a Mint account via mint_action instruction.
384/// Mint uses MintAction::CompressAndCloseMint flow instead of registry compress_and_close.
385async fn compress_mint_forester(
386    rpc: &mut LightProgramTest,
387    mint_pubkey: Pubkey,
388    payer: &solana_sdk::signature::Keypair,
389) -> Result<(), RpcError> {
390    use light_client::indexer::Indexer;
391    use light_compressed_account::instruction_data::traits::LightInstructionData;
392    use light_compressible::config::CompressibleConfig;
393    use light_token::compressed_token::mint_action::MintActionMetaConfig;
394    use light_token_interface::instructions::mint_action::{
395        CompressAndCloseMintAction, MintActionCompressedInstructionData, MintWithContext,
396    };
397    use solana_sdk::signature::Signer;
398
399    // Get Mint account data
400    let mint_account = rpc
401        .get_account(mint_pubkey)
402        .await?
403        .ok_or_else(|| RpcError::CustomError(format!("Mint account {} not found", mint_pubkey)))?;
404
405    // Deserialize Mint to get compressed_address and rent_sponsor
406    let mint: Mint = BorshDeserialize::deserialize(&mut mint_account.data.as_slice())
407        .map_err(|e| RpcError::CustomError(format!("Failed to deserialize Mint: {:?}", e)))?;
408
409    let compressed_mint_address = mint.metadata.compressed_address();
410    let rent_sponsor = Pubkey::from(mint.compression.rent_sponsor);
411
412    // Get the compressed mint account from indexer
413    let compressed_mint_account = rpc
414        .get_compressed_account(compressed_mint_address, None)
415        .await?
416        .value
417        .ok_or(RpcError::AccountDoesNotExist(format!(
418            "Compressed mint {:?}",
419            compressed_mint_address
420        )))?;
421
422    // Get validity proof
423    let rpc_proof_result = rpc
424        .get_validity_proof(vec![compressed_mint_account.hash], vec![], None)
425        .await?
426        .value;
427
428    // Build compressed mint inputs
429    // IMPORTANT: Set mint to None when Mint is decompressed
430    // This tells on-chain code to read mint data from Mint Solana account
431    // (not from instruction data which would have stale compression_info)
432    let compressed_mint_inputs = MintWithContext {
433        prove_by_index: rpc_proof_result.accounts[0].root_index.proof_by_index(),
434        leaf_index: compressed_mint_account.leaf_index,
435        root_index: rpc_proof_result.accounts[0]
436            .root_index
437            .root_index()
438            .unwrap_or_default(),
439        address: compressed_mint_address,
440        mint: None, // Mint is decompressed, data lives in Mint account
441    };
442
443    // Build instruction data with CompressAndCloseMint action
444    let instruction_data = MintActionCompressedInstructionData::new(
445        compressed_mint_inputs,
446        rpc_proof_result.proof.into(),
447    )
448    .with_compress_and_close_mint(CompressAndCloseMintAction { idempotent: 1 });
449
450    // Get state tree info
451    let state_tree_info = rpc_proof_result.accounts[0].tree_info;
452
453    // Build account metas - authority can be anyone for permissionless CompressAndCloseMint
454    let config_address = CompressibleConfig::light_token_v1_config_pda();
455    let meta_config = MintActionMetaConfig::new(
456        payer.pubkey(),
457        payer.pubkey(), // authority doesn't matter for CompressAndCloseMint
458        state_tree_info.tree,
459        state_tree_info.queue,
460        state_tree_info.queue,
461    )
462    .with_compressible_mint(mint_pubkey, config_address, rent_sponsor);
463
464    let account_metas = meta_config.to_account_metas();
465
466    // Serialize instruction data
467    let data = instruction_data
468        .data()
469        .map_err(|e| RpcError::CustomError(format!("Failed to serialize instruction: {:?}", e)))?;
470
471    // Build instruction
472    let instruction = solana_instruction::Instruction {
473        program_id: Pubkey::from(LIGHT_TOKEN_PROGRAM_ID),
474        accounts: account_metas,
475        data,
476    };
477
478    // Send transaction
479    rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer])
480        .await?;
481
482    Ok(())
483}