Skip to main content

light_program_test/
compressible.rs

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