From 7ee6fd25b8984df822c8e8c8393dcb39da73865f Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 10 Jan 2026 17:58:38 +0000 Subject: [PATCH 1/2] wip wip add mint support to sdk and macros sync tests, fix program-test compressioninfo parsing refactor macros update test flow wip wip - mix wip - force merge move to preinit wip wip: separate decomp stage separate ata and cmint handling in decompression wip - try atomic decomp wio wip create_pdas_and_mint_auto ref test_create_pdas_and_mint_auto: compress cmint feat(program-test): implement CMint auto-compression in warp_slot_forward - Add compress_cmint_forester() to handle CMint compression via mint_action - Track ACCOUNT_TYPE_MINT accounts in claim_and_compress - Key fix: pass mint: None to tell on-chain to read from CMint Solana account - Update test to rely on auto-compression instead of explicit compression Auto-compress coverage now includes: CToken, Program PDAs, and CMint wip - autocompress fix address derive path fix address derivation for cpdas cleanup basic_test.rs cleanup macro wip works cargo test-sbf attempt to clean up ov works: decompress_accounts_idempotent stage: before macro refactor specs for macro refactor 1 update refactor spec ph1 ph2 wip3 - before ctokenseedprovider decompress refactor macros owrks killed compressible macro cleanup rm non derived cleanup, ctoken cpi clean resolve mc fix mc wip fixing fix 2 resolve more mcs fix fix - fix fix fix fix rm docstring clean, fmt, lint fix build clean up and fix interface getters clean load --- Cargo.lock | 2 + .../src/v3/layout/layout-mint-action.ts | 12 +- .../docs/compressed_token/MINT_ACTION.md | 73 +- sdk-libs/compressible-client/Cargo.toml | 2 + .../src/account_interface.rs | 391 ++++++++++ .../src/decompress_mint.rs | 48 +- sdk-libs/compressible-client/src/lib.rs | 83 +- .../compressible-client/src/load_accounts.rs | 444 +++++++++++ sdk-libs/program-test/src/compressible.rs | 22 +- .../src/program_test/light_program_test.rs | 376 ++++----- .../token-sdk/src/token/decompress_mint.rs | 16 +- .../csdk-anchor-full-derived-test/src/lib.rs | 8 + .../src/state.rs | 27 +- .../tests/basic_test.rs | 243 ++---- sdk-tests/sdk-light-token-test/src/lib.rs | 7 +- .../tests/test_decompress_cmint.rs | 725 ++++++++++++++++++ 16 files changed, 1953 insertions(+), 526 deletions(-) create mode 100644 sdk-libs/compressible-client/src/account_interface.rs create mode 100644 sdk-libs/compressible-client/src/load_accounts.rs create mode 100644 sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs diff --git a/Cargo.lock b/Cargo.lock index e356f741f3..eecbd6bb2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3685,12 +3685,14 @@ version = "0.17.1" dependencies = [ "anchor-lang", "borsh 0.10.4", + "futures", "light-client", "light-compressed-account", "light-compressible", "light-sdk", "light-token-interface", "light-token-sdk", + "smallvec", "solana-account", "solana-instruction", "solana-program", diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index 6f4bd925c8..cc71c77608 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -146,7 +146,7 @@ export const CompressedMintMetadataLayout = struct([ u8('bump'), ]); -export const CompressedMintInstructionDataLayout = struct([ +export const MintInstructionDataLayout = struct([ u64('supply'), u8('decimals'), CompressedMintMetadataLayout.replicate('metadata'), @@ -164,7 +164,7 @@ export const MintActionCompressedInstructionDataLayout = struct([ vec(ActionLayout, 'actions'), option(CompressedProofLayout, 'proof'), option(CpiContextLayout, 'cpiContext'), - option(CompressedMintInstructionDataLayout, 'mint'), + option(MintInstructionDataLayout, 'mint'), ]); // TODO: Remove V1 layouts after devnet program update @@ -184,7 +184,7 @@ const CompressedMintMetadataLayoutV1 = struct([ publicKey('mint'), ]); // TODO: Remove V1 layouts after devnet program update -const CompressedMintInstructionDataLayoutV1 = struct([ +const MintInstructionDataLayoutV1 = struct([ u64('supply'), u8('decimals'), CompressedMintMetadataLayoutV1.replicate('metadata'), @@ -205,7 +205,7 @@ const MintActionCompressedInstructionDataLayoutV1 = struct([ vec(ActionLayoutV1, 'actions'), option(CompressedProofLayout, 'proof'), option(CpiContextLayout, 'cpiContext'), - CompressedMintInstructionDataLayoutV1.replicate('mint'), // V1: not optional + MintInstructionDataLayoutV1.replicate('mint'), // V1: not optional ]); export interface ValidityProof { @@ -314,7 +314,7 @@ export interface CompressedMintMetadata { bump: number; } -export interface CompressedMintInstructionData { +export interface MintInstructionData { supply: bigint; decimals: number; metadata: CompressedMintMetadata; @@ -332,7 +332,7 @@ export interface MintActionCompressedInstructionData { actions: Action[]; proof: ValidityProof | null; cpiContext: CpiContext | null; - mint: CompressedMintInstructionData | null; + mint: MintInstructionData | null; } /** diff --git a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index 2066a8d75c..57db5d945c 100644 --- a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -10,32 +10,26 @@ Batch instruction for managing compressed mint accounts (cmints) and performing This instruction supports 10 total actions - one creation action (controlled by `create_mint` flag) and 9 enum-based actions: **Compressed mint creation (executed first when `create_mint` is Some):** + 1. **Create Compressed Mint** - Create a new compressed mint account with initial authorities and optional TokenMetadata extension -**Core mint operations (Action enum variants):** -2. `MintToCompressed` - Mint new compressed tokens to one or more compressed token accounts -3. `MintToCToken` - Mint new tokens to decompressed ctoken accounts (not SPL tokens) +**Core mint operations (Action enum variants):** 2. `MintToCompressed` - Mint new compressed tokens to one or more compressed token accounts 3. `MintToCToken` - Mint new tokens to decompressed ctoken accounts (not SPL tokens) -**Authority updates (Action enum variants):** -4. `UpdateMintAuthority` - Update or remove the mint authority -5. `UpdateFreezeAuthority` - Update or remove the freeze authority +**Authority updates (Action enum variants):** 4. `UpdateMintAuthority` - Update or remove the mint authority 5. `UpdateFreezeAuthority` - Update or remove the freeze authority -**TokenMetadata extension operations (Action enum variants):** -6. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension -7. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension -8. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension +**TokenMetadata extension operations (Action enum variants):** 6. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension 7. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension 8. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension -**Decompress/Compress operations (Action enum variants):** -9. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. -10. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). +**Decompress/Compress operations (Action enum variants):** 9. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. 10. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). Key concepts integrated: + - **Compressed mint (cmint)**: Mint state stored in compressed account with deterministic address derived from a mint signer PDA - **Decompressed mint (CMint)**: When a compressed mint is decompressed, a CMint Solana account becomes the source of truth - **Authority validation**: All actions require appropriate authority (mint/freeze/metadata) to be transaction signer - **Batch processing**: Multiple actions execute sequentially with state updates persisted between actions **Instruction data:** + 1. instruction data is defined in path: program-libs/token-interface/src/instructions/mint_action/instruction_data.rs **Core fields:** @@ -47,7 +41,7 @@ Key concepts integrated: - `actions`: Vec - Ordered list of actions to execute - `proof`: Option - ZK proof for compressed account validation (required unless prove_by_index=true) - `cpi_context`: Option - For cross-program invocation support - - `mint`: Option - Full mint state including supply, decimals, metadata, authorities, and extensions (None when reading from decompressed CMint) + - `mint`: Option - Full mint state including supply, decimals, metadata, authorities, and extensions (None when reading from decompressed CMint) 2. Action types (path: program-libs/token-interface/src/instructions/mint_action/): - `MintToCompressed(MintToCompressedAction)` - Mint tokens to compressed accounts (mint_to_compressed.rs) @@ -65,6 +59,7 @@ Key concepts integrated: The account ordering differs based on whether writing to CPI context or executing. **Always present:** + 1. light_system_program - non-mutable - Light Protocol system program for CPI to create or update the compressed mint account. @@ -93,40 +88,47 @@ The account ordering differs based on whether writing to CPI context or executin - Rent sponsor PDA that pays for CMint account creation 7-12. Light system accounts (standard set): - - fee_payer (signer, mutable) - - cpi_authority_pda - - registered_program_pda - - account_compression_authority - - account_compression_program - - system_program - - sol_pool_pda (optional) - - sol_decompression_recipient (optional) - - cpi_context (optional) + +- fee_payer (signer, mutable) +- cpi_authority_pda +- registered_program_pda +- account_compression_authority +- account_compression_program +- system_program +- sol_pool_pda (optional) +- sol_decompression_recipient (optional) +- cpi_context (optional) 13. out_output_queue - - (mutable) - - Output queue for compressed mint account updates + +- (mutable) +- Output queue for compressed mint account updates 14. address_merkle_tree OR in_merkle_tree - - (mutable) - - If create_mint is Some: address_merkle_tree for new mint (must be CMINT_ADDRESS_TREE) - - If create_mint is None: in_merkle_tree for existing mint validation + +- (mutable) +- If create_mint is Some: address_merkle_tree for new mint (must be CMINT_ADDRESS_TREE) +- If create_mint is None: in_merkle_tree for existing mint validation 15. in_output_queue - - (mutable) - optional, required if create_mint is None - - Input queue for existing compressed mint + +- (mutable) - optional, required if create_mint is None +- Input queue for existing compressed mint 16. tokens_out_queue - - (mutable) - optional, required for MintToCompressed actions - - Output queue for newly minted compressed token accounts + +- (mutable) - optional, required for MintToCompressed actions +- Output queue for newly minted compressed token accounts **For CPI context write (when write_to_cpi_context=true):** 4-6. CPI context accounts: - - fee_payer (signer, mutable) - - cpi_authority_pda - - cpi_context + +- fee_payer (signer, mutable) +- cpi_authority_pda +- cpi_context **Packed accounts (remaining accounts):** + - Merkle tree and queue accounts for compressed storage - Recipient ctoken accounts for MintToCToken action @@ -241,5 +243,6 @@ The account ordering differs based on whether writing to CPI context or executin - `CTokenError::MaxTopUpExceeded` - Max top-up budget exceeded ### Spl mint migration + - cmint to spl mint migration is unimplemented and not planned. - A way to support it in the future would require a new instruction that creates an spl mint in the mint pda solana account and mints the supply to the spl interface. diff --git a/sdk-libs/compressible-client/Cargo.toml b/sdk-libs/compressible-client/Cargo.toml index fb1a3980d9..839c2fd03b 100644 --- a/sdk-libs/compressible-client/Cargo.toml +++ b/sdk-libs/compressible-client/Cargo.toml @@ -26,5 +26,7 @@ light-compressible = { workspace = true } anchor-lang = { workspace = true, features = ["idl-build"], optional = true } borsh = { workspace = true } +futures = { workspace = true } +smallvec = "1.15" thiserror = { workspace = true } diff --git a/sdk-libs/compressible-client/src/account_interface.rs b/sdk-libs/compressible-client/src/account_interface.rs new file mode 100644 index 0000000000..3209d0dbaa --- /dev/null +++ b/sdk-libs/compressible-client/src/account_interface.rs @@ -0,0 +1,391 @@ +//! Unified account interfaces for hot/cold account handling. +//! +//! Mirrors TypeScript SDK patterns: +//! - `AccountInfoInterface` - Generic compressible account (PDAs) +//! - `TokenAccountInterface` - Token accounts (SPL/T22/ctoken) +//! - `AtaInterface` - Associated token accounts +//! +//! All interfaces use standard Solana/SPL types: +//! - `solana_account::Account` for raw account data +//! - `spl_token_2022::state::Account` for parsed token data + +use light_client::indexer::{CompressedAccount, CompressedTokenAccount, TreeInfo}; +use light_token_interface::state::ExtensionStruct; +use solana_account::Account; +use solana_pubkey::Pubkey; +use spl_token_2022::state::Account as SplTokenAccount; +use thiserror::Error; + +/// Error type for account interface operations. +#[derive(Debug, Error)] +pub enum AccountInterfaceError { + #[error("Account not found")] + NotFound, + + #[error("Invalid account data")] + InvalidData, + + #[error("Parse error: {0}")] + ParseError(String), +} + +// ============================================================================ +// Decompression Contexts +// ============================================================================ + +/// Context for decompressing a cold PDA account. +#[derive(Debug, Clone)] +pub struct PdaLoadContext { + /// Full compressed account from indexer. + pub compressed: CompressedAccount, +} + +impl PdaLoadContext { + /// Get the compressed account hash (for validity proof). + #[inline] + pub fn hash(&self) -> [u8; 32] { + self.compressed.hash + } + + /// Get tree info (for proof and instruction building). + #[inline] + pub fn tree_info(&self) -> &TreeInfo { + &self.compressed.tree_info + } + + /// Get leaf index. + #[inline] + pub fn leaf_index(&self) -> u32 { + self.compressed.leaf_index + } +} + +/// Context for decompressing a cold token account (ATA or other). +#[derive(Debug, Clone)] +pub struct TokenLoadContext { + /// Full compressed token account from indexer. + pub compressed: CompressedTokenAccount, + /// Wallet owner (signer for decompression). + pub wallet_owner: Pubkey, + /// Token mint. + pub mint: Pubkey, + /// ATA derivation bump (if ATA). + pub bump: u8, +} + +impl TokenLoadContext { + /// Get the compressed account hash (for validity proof). + #[inline] + pub fn hash(&self) -> [u8; 32] { + self.compressed.account.hash + } + + /// Get tree info (for proof and instruction building). + #[inline] + pub fn tree_info(&self) -> &TreeInfo { + &self.compressed.account.tree_info + } + + /// Get leaf index. + #[inline] + pub fn leaf_index(&self) -> u32 { + self.compressed.account.leaf_index + } +} + +// ============================================================================ +// AccountInfoInterface - Generic compressible accounts (PDAs) +// ============================================================================ + +/// Generic account interface for compressible accounts (PDAs). +/// +/// Uses standard `solana_account::Account` for raw data. +/// For hot accounts: actual on-chain bytes. +/// For cold accounts: synthetic bytes from compressed data. +#[derive(Debug, Clone)] +pub struct AccountInfoInterface { + /// The account pubkey. + pub pubkey: Pubkey, + /// Raw Solana Account - always present. + pub account: Account, + /// Whether this account is compressed (needs decompression). + pub is_cold: bool, + /// Load context (only if cold). + pub load_context: Option, +} + +impl AccountInfoInterface { + /// Create a hot (on-chain) account interface. + pub fn hot(pubkey: Pubkey, account: Account) -> Self { + Self { + pubkey, + account, + is_cold: false, + load_context: None, + } + } + + /// Create a cold (compressed) account interface. + pub fn cold(pubkey: Pubkey, compressed: CompressedAccount, owner: Pubkey) -> Self { + // Synthesize Account from compressed data + let data = compressed + .data + .as_ref() + .map(|d| { + let mut buf = d.discriminator.to_vec(); + buf.extend_from_slice(&d.data); + buf + }) + .unwrap_or_default(); + + let account = Account { + lamports: compressed.lamports, + data, + owner, + executable: false, + rent_epoch: 0, + }; + + Self { + pubkey, + account, + is_cold: true, + load_context: Some(PdaLoadContext { compressed }), + } + } + + /// Get the compressed account hash if cold (for validity proof). + pub fn hash(&self) -> Option<[u8; 32]> { + self.load_context.as_ref().map(|ctx| ctx.hash()) + } + + /// Get the raw account data bytes. + #[inline] + pub fn data(&self) -> &[u8] { + &self.account.data + } +} + +// ============================================================================ +// TokenAccountInterface - Token accounts (SPL/T22/ctoken) +// ============================================================================ + +/// Token account interface with both raw and parsed data. +/// +/// Uses standard types: +/// - `solana_account::Account` for raw bytes +/// - `spl_token_2022::state::Account` for parsed token data +#[derive(Debug, Clone)] +pub struct TokenAccountInterface { + /// The token account pubkey. + pub pubkey: Pubkey, + /// Raw Solana Account - always present. + pub account: Account, + /// Parsed SPL Token Account - standard type. + pub parsed: SplTokenAccount, + /// Whether this account is compressed (needs decompression). + pub is_cold: bool, + /// Load context (only if cold). + pub load_context: Option, + /// Optional TLV extension data (compressed token extensions). + pub extensions: Option>, +} + +impl TokenAccountInterface { + /// Create a hot (on-chain) token account interface. + pub fn hot(pubkey: Pubkey, account: Account) -> Result { + use solana_program::program_pack::Pack; + + if account.data.len() < SplTokenAccount::LEN { + return Err(AccountInterfaceError::InvalidData); + } + + let parsed = SplTokenAccount::unpack(&account.data[..SplTokenAccount::LEN]) + .map_err(|e| AccountInterfaceError::ParseError(e.to_string()))?; + + Ok(Self { + pubkey, + account, + parsed, + is_cold: false, + load_context: None, + extensions: None, // Hot accounts don't have compressed extensions + }) + } + + /// Create a cold (compressed) token account interface. + pub fn cold( + pubkey: Pubkey, + compressed: CompressedTokenAccount, + wallet_owner: Pubkey, + mint: Pubkey, + bump: u8, + program_owner: Pubkey, + ) -> Self { + use light_token_sdk::compat::AccountState; + use solana_program::program_pack::Pack; + + let token = &compressed.token; + + // Create SPL Token Account from TokenData + let parsed = SplTokenAccount { + mint: token.mint, + owner: token.owner, + amount: token.amount, + delegate: token.delegate.into(), + state: match token.state { + AccountState::Frozen => spl_token_2022::state::AccountState::Frozen, + _ => spl_token_2022::state::AccountState::Initialized, + }, + is_native: solana_program::program_option::COption::None, + delegated_amount: 0, + close_authority: solana_program::program_option::COption::None, + }; + + // Pack into synthetic Account bytes (165 bytes SPL Token Account format) + let mut data = vec![0u8; SplTokenAccount::LEN]; + SplTokenAccount::pack(parsed, &mut data).expect("pack should never fail"); + + // Store extensions separately (not appended to data - they're compressed-specific) + let extensions = token.tlv.clone(); + + let account = Account { + lamports: compressed.account.lamports, + data, + owner: program_owner, + executable: false, + rent_epoch: 0, + }; + + Self { + pubkey, + account, + parsed, + is_cold: true, + load_context: Some(TokenLoadContext { + compressed, + wallet_owner, + mint, + bump, + }), + extensions, + } + } + + /// Convenience: get amount. + #[inline] + pub fn amount(&self) -> u64 { + self.parsed.amount + } + + /// Convenience: get delegate. + #[inline] + pub fn delegate(&self) -> Option { + self.parsed.delegate.into() + } + + /// Convenience: get mint. + #[inline] + pub fn mint(&self) -> Pubkey { + self.parsed.mint + } + + /// Convenience: get owner. + #[inline] + pub fn owner(&self) -> Pubkey { + self.parsed.owner + } + + /// Convenience: check if frozen. + #[inline] + pub fn is_frozen(&self) -> bool { + self.parsed.state == spl_token_2022::state::AccountState::Frozen + } + + /// Get the compressed account hash if cold (for validity proof). + pub fn hash(&self) -> Option<[u8; 32]> { + self.load_context.as_ref().map(|ctx| ctx.hash()) + } +} + +// ============================================================================ +// AtaInterface - Associated Token Accounts +// ============================================================================ + +/// Associated token account interface. +/// +/// Wraps `TokenAccountInterface` with ATA-specific marker. +/// The owner and mint are available via `parsed.owner` and `parsed.mint`. +#[derive(Debug, Clone)] +pub struct AtaInterface { + /// Inner token account interface. + pub inner: TokenAccountInterface, +} + +impl AtaInterface { + /// Create from TokenAccountInterface. + pub fn new(inner: TokenAccountInterface) -> Self { + Self { inner } + } + + /// The ATA pubkey. + #[inline] + pub fn pubkey(&self) -> Pubkey { + self.inner.pubkey + } + + /// Raw Solana Account. + #[inline] + pub fn account(&self) -> &Account { + &self.inner.account + } + + /// Parsed SPL Token Account. + #[inline] + pub fn parsed(&self) -> &SplTokenAccount { + &self.inner.parsed + } + + /// Whether compressed. + #[inline] + pub fn is_cold(&self) -> bool { + self.inner.is_cold + } + + /// Load context for decompression. + #[inline] + pub fn load_context(&self) -> Option<&TokenLoadContext> { + self.inner.load_context.as_ref() + } + + /// Amount. + #[inline] + pub fn amount(&self) -> u64 { + self.inner.amount() + } + + /// Mint. + #[inline] + pub fn mint(&self) -> Pubkey { + self.inner.mint() + } + + /// Owner (wallet that owns this ATA). + #[inline] + pub fn owner(&self) -> Pubkey { + self.inner.owner() + } + + /// Hash for validity proof. + pub fn hash(&self) -> Option<[u8; 32]> { + self.inner.hash() + } +} + +impl std::ops::Deref for AtaInterface { + type Target = TokenAccountInterface; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} diff --git a/sdk-libs/compressible-client/src/decompress_mint.rs b/sdk-libs/compressible-client/src/decompress_mint.rs index a843b82e29..6719fa72ca 100644 --- a/sdk-libs/compressible-client/src/decompress_mint.rs +++ b/sdk-libs/compressible-client/src/decompress_mint.rs @@ -1,41 +1,17 @@ -//! Decompress compressed CMint accounts. +//! Mint interface types for hot/cold CMint handling. //! -//! This module provides client-side functionality to decompress compressed -//! CMint accounts (mints created via `#[compressible]` macro that have been -//! auto-compressed by forester). -//! -//! DecompressMint is permissionless - any fee_payer can decompress any -//! compressed mint. The mint_seed_pubkey is required for PDA derivation. -//! -//! Three APIs are provided: -//! - `decompress_mint`: Simple async API (fetches state + proof internally) -//! - `build_decompress_mint`: Sync, caller provides pre-fetched state + proof -//! - `decompress_mint`: High-perf wrapper (takes MintInterface, fetches proof internally) - -use borsh::BorshDeserialize; -use light_client::indexer::{CompressedAccount, Indexer, IndexerError, ValidityProofWithContext}; -use light_compressed_account::instruction_data::compressed_proof::ValidityProof; -use light_token_interface::{ - instructions::mint_action::{MintInstructionData, MintWithContext}, - state::Mint, - CMINT_ADDRESS_TREE, -}; -use light_token_sdk::{ - compressed_token::create_compressed_mint::derive_mint_compressed_address, - token::{find_mint_address, DecompressMint}, -}; +//! Use `get_mint_interface()` from `LightProgramTest` to fetch, +//! then pass to `create_load_accounts_instructions()` for decompression. + +use light_client::indexer::CompressedAccount; +use light_token_interface::state::Mint; use solana_account::Account; -use solana_instruction::Instruction; -use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use thiserror::Error; /// Error type for decompress mint operations. #[derive(Debug, Error)] pub enum DecompressMintError { - #[error("Indexer error: {0}")] - Indexer(#[from] IndexerError), - #[error("Compressed mint not found for signer {signer:?}")] MintNotFound { signer: Pubkey }, @@ -43,10 +19,10 @@ pub enum DecompressMintError { MissingMintData, #[error("Program error: {0}")] - ProgramError(#[from] ProgramError), + ProgramError(#[from] solana_program_error::ProgramError), - #[error("Proof required for cold mint")] - ProofRequired, + #[error("Mint already decompressed")] + AlreadyDecompressed, } /// State of a CMint - either on-chain (hot), compressed (cold), or non-existent. @@ -66,10 +42,8 @@ pub enum MintState { /// Interface for a CMint that provides all info needed for decompression. /// -/// This is a superset of the solana Account type, containing: -/// - CMint pubkey (derived from signer) -/// - Signer pubkey (mint authority seed) -/// - State: Hot (on-chain), Cold (compressed), or None +/// Fetch via `rpc.get_mint_interface(&signer)`, then pass to +/// `create_load_accounts_instructions()` for decompression. #[derive(Debug, Clone)] pub struct MintInterface { /// The CMint PDA pubkey. diff --git a/sdk-libs/compressible-client/src/lib.rs b/sdk-libs/compressible-client/src/lib.rs index ebe3cb0f8f..e18907e773 100644 --- a/sdk-libs/compressible-client/src/lib.rs +++ b/sdk-libs/compressible-client/src/lib.rs @@ -1,10 +1,15 @@ +pub mod account_interface; pub mod create_accounts_proof; -pub mod decompress_atas; pub mod decompress_mint; pub mod get_compressible_account; pub mod initialize_config; +pub mod load_accounts; pub mod pack; +pub use account_interface::{ + AccountInfoInterface, AccountInterfaceError, AtaInterface, PdaLoadContext, + TokenAccountInterface, TokenLoadContext, +}; #[cfg(feature = "anchor")] use anchor_lang::{AnchorDeserialize, AnchorSerialize}; #[cfg(not(feature = "anchor"))] @@ -13,29 +18,8 @@ pub use create_accounts_proof::{ get_create_accounts_proof, CreateAccountsProofError, CreateAccountsProofInput, CreateAccountsProofResult, }; -// Re-export from light-compressible for convenience (SBF-compatible definition) -pub use decompress_atas::{ - // Legacy API (backward compatible) - build_decompress_atas, - // New API (recommended) - build_decompress_token_accounts, - decompress_atas, - decompress_atas_idempotent, - decompress_token_accounts, - pack_token_data_to_spl_bytes, - parse_token_account_interface, - AtaAccountInterface, - AtaDecompressionContext, - AtaInterface, - DecompressAtaError, - DecompressionContext, - TokenAccountInterface, -}; -// Re-export TokenData for convenience (standard SPL-compatible type) pub use decompress_mint::{ - build_decompress_mint, create_mint_interface, decompress_mint, decompress_mint_idempotent, - DecompressMintError, DecompressMintRequest, MintInterface, MintState, DEFAULT_RENT_PAYMENT, - DEFAULT_WRITE_TOP_UP, + DecompressMintError, MintInterface, MintState, DEFAULT_RENT_PAYMENT, DEFAULT_WRITE_TOP_UP, }; pub use initialize_config::InitializeRentFreeConfig; use light_client::indexer::{CompressedAccount, TreeInfo, ValidityProofWithContext}; @@ -52,6 +36,10 @@ pub use light_token_sdk::compat::TokenData; use light_token_sdk::token::{ COMPRESSIBLE_CONFIG_V1, LIGHT_TOKEN_CPI_AUTHORITY, LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR, }; +pub use load_accounts::{ + create_decompress_ata_instructions, create_decompress_idempotent_instructions, + create_decompress_mint_instructions, create_load_accounts_instructions, LoadAccountsError, +}; pub use pack::{pack_proof, PackError, PackedProofResult}; use solana_account::Account; use solana_instruction::{AccountMeta, Instruction}; @@ -144,6 +132,41 @@ impl AccountInterface { } } +impl From<&AccountInfoInterface> for AccountInterface { + fn from(info: &AccountInfoInterface) -> Self { + if info.is_cold { + Self::cold( + info.pubkey, + info.load_context + .as_ref() + .expect("cold account must have load_context") + .compressed + .clone(), + ) + } else { + Self::hot(info.pubkey) + } + } +} + +impl From<&TokenAccountInterface> for AccountInterface { + fn from(info: &TokenAccountInterface) -> Self { + if info.is_cold { + Self::cold( + info.pubkey, + info.load_context + .as_ref() + .expect("cold token account must have load_context") + .compressed + .account + .clone(), + ) + } else { + Self::hot(info.pubkey) + } + } +} + /// A rent-free decompression request combining account interface and variant. /// Generic over V (the CompressedAccountVariant type from the program). #[derive(Clone, Debug)] @@ -172,13 +195,6 @@ impl RentFreeDecompressAccount { /// * `interface` - The account interface (must be cold/compressed) /// * `seeds` - Seeds struct (e.g., `UserRecordSeeds`) that implements `IntoVariant` /// - /// # Example - /// ```ignore - /// RentFreeDecompressAccount::from_seeds( - /// AccountInterface::cold(user_record_pda, compressed_user), - /// UserRecordSeeds { authority, mint_authority, owner, category_id }, - /// )? - /// ``` #[cfg(feature = "anchor")] pub fn from_seeds( interface: AccountInterface, @@ -203,13 +219,6 @@ impl RentFreeDecompressAccount { /// * `interface` - The account interface (must be cold/compressed) /// * `ctoken_variant` - CToken variant (e.g., `TokenAccountVariant::Vault { cmint }`) /// - /// # Example - /// ```ignore - /// RentFreeDecompressAccount::from_ctoken( - /// AccountInterface::cold(vault_pda, compressed_vault.account), - /// TokenAccountVariant::Vault { cmint: cmint_pda }, - /// )? - /// ``` #[cfg(feature = "anchor")] pub fn from_ctoken( interface: AccountInterface, diff --git a/sdk-libs/compressible-client/src/load_accounts.rs b/sdk-libs/compressible-client/src/load_accounts.rs new file mode 100644 index 0000000000..bd38019901 --- /dev/null +++ b/sdk-libs/compressible-client/src/load_accounts.rs @@ -0,0 +1,444 @@ +//! Load (decompress) accounts API. +//! +//! Single entry point `create_load_accounts_instructions()` that: +//! - Filters cold accounts +//! - Fetches proofs concurrently +//! - Builds instructions via lean internal builders + +use crate::{ + account_interface::{TokenAccountInterface, TokenLoadContext}, + compressible_instruction::{self, DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR}, + decompress_mint::{ + DecompressMintError, MintInterface, DEFAULT_RENT_PAYMENT, DEFAULT_WRITE_TOP_UP, + }, + RentFreeDecompressAccount, +}; +use light_client::indexer::{Indexer, IndexerError, ValidityProofWithContext}; +use light_compressed_account::{ + compressed_account::PackedMerkleContext, instruction_data::compressed_proof::ValidityProof, +}; +use light_sdk::{compressible::Pack, instruction::PackedAccounts}; +use light_token_interface::{ + instructions::{ + extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, + mint_action::{MintInstructionData, MintWithContext}, + transfer2::MultiInputTokenDataWithContext, + }, + state::{ExtensionStruct, TokenDataVersion}, +}; +use light_token_sdk::{ + compat::AccountState, + compressed_token::{ + transfer2::{ + create_transfer2_instruction, Transfer2AccountsMetaConfig, Transfer2Config, + Transfer2Inputs, + }, + CTokenAccount2, + }, + token::{ + derive_token_ata, CreateAssociatedTokenAccount, DecompressMint, LIGHT_TOKEN_PROGRAM_ID, + }, +}; +use smallvec::SmallVec; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; +use thiserror::Error; + +/// Error type for load accounts operations. +#[derive(Debug, Error)] +pub enum LoadAccountsError { + #[error("Indexer error: {0}")] + Indexer(#[from] IndexerError), + + #[error("Build instruction failed: {0}")] + BuildInstruction(String), + + #[error("Token SDK error: {0}")] + TokenSdk(#[from] light_token_sdk::error::TokenSdkError), + + #[error("Mint error: {0}")] + Mint(#[from] DecompressMintError), +} + +/// Build load instructions for cold accounts. +/// Exists fast if all accounts are hot. +/// Else, fetches proofs, returns instructions. +pub async fn create_load_accounts_instructions( + program_owned_accounts: &[RentFreeDecompressAccount], + associated_token_accounts: &[TokenAccountInterface], + mint_accounts: &[MintInterface], + program_id: Pubkey, + fee_payer: Pubkey, + compression_config: Pubkey, + rent_sponsor: Pubkey, + indexer: &I, +) -> Result, LoadAccountsError> +where + V: Pack + Clone + std::fmt::Debug, + I: Indexer, +{ + // Fast exit if all hot. + let cold_pdas: SmallVec<[&RentFreeDecompressAccount; 8]> = program_owned_accounts + .iter() + .filter(|a| a.account_interface.is_cold) + .collect(); + let cold_atas: SmallVec<[&TokenAccountInterface; 8]> = associated_token_accounts + .iter() + .filter(|a| a.is_cold) + .collect(); + let cold_mints: SmallVec<[&MintInterface; 8]> = + mint_accounts.iter().filter(|m| m.is_cold()).collect(); + + if cold_pdas.is_empty() && cold_atas.is_empty() && cold_mints.is_empty() { + return Ok(vec![]); + } + + // get hashes + let pda_hashes: Vec<[u8; 32]> = cold_pdas + .iter() + .filter_map(|a| { + a.account_interface + .decompression_context + .as_ref() + .map(|c| c.compressed_account.hash) + }) + .collect(); + + let ata_hashes: Vec<[u8; 32]> = cold_atas.iter().filter_map(|a| a.hash()).collect(); + let mint_hashes: Vec<[u8; 32]> = cold_mints.iter().filter_map(|m| m.hash()).collect(); + + // Fetch proofs concurrently. + // TODO: single batched proof RPC endpoint. + let (pda_proof, ata_proof, mint_proofs) = futures::join!( + fetch_proof_if_needed(&pda_hashes, indexer), + fetch_proof_if_needed(&ata_hashes, indexer), + fetch_mint_proofs(&mint_hashes, indexer), + ); + + // cap + let cap = (!cold_pdas.is_empty()) as usize + + if !cold_atas.is_empty() { + cold_atas.len() + 1 + } else { + 0 + } + + cold_mints.len(); + let mut out = Vec::with_capacity(cap); + + // Build PDA + Token instructions + if !cold_pdas.is_empty() { + let proof = pda_proof? + .ok_or_else(|| LoadAccountsError::BuildInstruction("PDA proof fetch failed".into()))?; + let ix = create_decompress_idempotent_instructions( + &cold_pdas, + proof, + program_id, + fee_payer, + compression_config, + rent_sponsor, + )?; + out.push(ix); + } + + // Build associated token account instructions + if !cold_atas.is_empty() { + let proof = ata_proof? + .ok_or_else(|| LoadAccountsError::BuildInstruction("ATA proof fetch failed".into()))?; + let ixs = create_decompress_ata_instructions(&cold_atas, proof, fee_payer)?; + out.extend(ixs); + } + + // Build Mint instructions. One mint allowed per ixn. + let mint_proofs = mint_proofs?; + for (mint, proof) in cold_mints.iter().zip(mint_proofs.into_iter()) { + let ix = create_decompress_mint_instructions(mint, proof, fee_payer, None, None)?; + out.push(ix); + } + + Ok(out) +} + +// ============================================================================= +// Proof fetching helpers +// ============================================================================= + +async fn fetch_proof_if_needed( + hashes: &[[u8; 32]], + indexer: &I, +) -> Result, IndexerError> { + if hashes.is_empty() { + return Ok(None); + } + let result = indexer + .get_validity_proof(hashes.to_vec(), vec![], None) + .await?; + Ok(Some(result.value)) +} + +async fn fetch_mint_proofs( + hashes: &[[u8; 32]], + indexer: &I, +) -> Result, IndexerError> { + if hashes.is_empty() { + return Ok(vec![]); + } + + // Each mint needs its own proof + let mut proofs = Vec::with_capacity(hashes.len()); + for hash in hashes { + let result = indexer + .get_validity_proof(vec![*hash], vec![], None) + .await?; + proofs.push(result.value); + } + Ok(proofs) +} + +// ============================================================================= +// Lean internal builders (no filtering, proof required) +// ============================================================================= + +/// Build decompress instruction for PDA + Token accounts. +/// Assumes all inputs are cold (caller filtered). +pub fn create_decompress_idempotent_instructions( + accounts: &[&RentFreeDecompressAccount], + proof: ValidityProofWithContext, + program_id: Pubkey, + fee_payer: Pubkey, + compression_config: Pubkey, + rent_sponsor: Pubkey, +) -> Result +where + V: Pack + Clone + std::fmt::Debug, +{ + // Check for tokens by owner (LIGHT_TOKEN_PROGRAM_ID) + let has_tokens = accounts.iter().any(|a| { + a.account_interface + .decompression_context + .as_ref() + .map(|c| c.compressed_account.owner == LIGHT_TOKEN_PROGRAM_ID) + .unwrap_or(false) + }); + + let metas = if has_tokens { + compressible_instruction::decompress::accounts(fee_payer, compression_config, rent_sponsor) + } else { + compressible_instruction::decompress::accounts_pda_only( + fee_payer, + compression_config, + rent_sponsor, + ) + }; + + // Extract pubkeys and (CompressedAccount, variant) pairs + let decompressed_account_addresses: Vec = accounts + .iter() + .map(|a| a.account_interface.pubkey) + .collect(); + + let compressed_accounts: Vec<_> = accounts + .iter() + .map(|a| { + let compressed_account = a + .account_interface + .decompression_context + .as_ref() + .expect("Cold account must have decompression context") + .compressed_account + .clone(); + (compressed_account, a.variant.clone()) + }) + .collect(); + + compressible_instruction::build_decompress_idempotent_raw( + &program_id, + &DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + &decompressed_account_addresses, + &compressed_accounts, + &metas, + proof, + ) + .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string())) +} + +/// Build decompress instructions for ATA accounts. +/// Returns N create_ata + 1 decompress instruction. +/// Assumes all inputs are cold (caller filtered). +pub fn create_decompress_ata_instructions( + accounts: &[&TokenAccountInterface], + proof: ValidityProofWithContext, + fee_payer: Pubkey, +) -> Result, LoadAccountsError> { + let contexts: SmallVec<[&TokenLoadContext; 8]> = accounts + .iter() + .filter_map(|a| a.load_context.as_ref()) + .collect(); + + let mut out = Vec::with_capacity(contexts.len() + 1); + + // Build create_ata instructions (idempotent) + for ctx in &contexts { + let ix = CreateAssociatedTokenAccount::new(fee_payer, ctx.wallet_owner, ctx.mint) + .idempotent() + .instruction() + .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))?; + out.push(ix); + } + + // Build single Transfer2 decompress instruction + let decompress_ix = build_transfer2_decompress(&contexts, proof, fee_payer)?; + out.push(decompress_ix); + + Ok(out) +} + +/// Build Transfer2 decompress instruction from contexts. +fn build_transfer2_decompress( + contexts: &[&TokenLoadContext], + proof: ValidityProofWithContext, + fee_payer: Pubkey, +) -> Result { + let mut packed_accounts = PackedAccounts::default(); + + // Pack tree infos + let packed_tree_infos = proof.pack_tree_infos(&mut packed_accounts); + let tree_infos = packed_tree_infos + .state_trees + .as_ref() + .ok_or_else(|| LoadAccountsError::BuildInstruction("No state trees in proof".into()))?; + + let mut token_accounts = Vec::with_capacity(contexts.len()); + let mut in_tlv_data: Vec> = Vec::with_capacity(contexts.len()); + let mut has_any_tlv = false; + + for (i, ctx) in contexts.iter().enumerate() { + let token = &ctx.compressed.token; + let tree_info = &tree_infos.packed_tree_infos[i]; + + // Pack accounts + let owner_index = packed_accounts.insert_or_get_config(ctx.wallet_owner, true, false); + let ata_index = + packed_accounts.insert_or_get(derive_token_ata(&ctx.wallet_owner, &ctx.mint).0); + let mint_index = packed_accounts.insert_or_get(token.mint); + let delegate_index = token + .delegate + .map(|d| packed_accounts.insert_or_get(d)) + .unwrap_or(0); + + let source = MultiInputTokenDataWithContext { + owner: ata_index, + amount: token.amount, + has_delegate: token.delegate.is_some(), + delegate: delegate_index, + mint: mint_index, + version: TokenDataVersion::ShaFlat as u8, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_info.merkle_tree_pubkey_index, + queue_pubkey_index: tree_info.queue_pubkey_index, + prove_by_index: tree_info.prove_by_index, + leaf_index: tree_info.leaf_index, + }, + root_index: tree_info.root_index, + }; + + let mut ctoken = CTokenAccount2::new(vec![source]) + .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))?; + ctoken + .decompress(token.amount, ata_index) + .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))?; + token_accounts.push(ctoken); + + // Build TLV (CompressedOnly extension) + let is_frozen = token.state == AccountState::Frozen; + let tlv: Vec = token + .tlv + .as_ref() + .map(|exts| { + exts.iter() + .filter_map(|ext| match ext { + ExtensionStruct::CompressedOnly(co) => { + Some(ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: co.delegated_amount, + withheld_transfer_fee: co.withheld_transfer_fee, + is_frozen, + compression_index: 0, + is_ata: true, + bump: ctx.bump, + owner_index, + }, + )) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + + if !tlv.is_empty() { + has_any_tlv = true; + } + in_tlv_data.push(tlv); + } + + let (packed_metas, _, _) = packed_accounts.to_account_metas(); + + create_transfer2_instruction(Transfer2Inputs { + meta_config: Transfer2AccountsMetaConfig::new(fee_payer, packed_metas), + token_accounts, + transfer_config: Transfer2Config::default().filter_zero_amount_outputs(), + validity_proof: proof.proof, + in_tlv: if has_any_tlv { Some(in_tlv_data) } else { None }, + ..Default::default() + }) + .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string())) +} + +/// Build decompress instruction for a single mint. +pub fn create_decompress_mint_instructions( + mint: &MintInterface, + proof: ValidityProofWithContext, + fee_payer: Pubkey, + rent_payment: Option, + write_top_up: Option, +) -> Result { + // assume mint is cold + let (_, mint_data) = mint + .compressed() + .ok_or_else(|| LoadAccountsError::BuildInstruction("Expected cold mint".into()))?; + + // get tree info + let account_info = &proof.accounts[0]; + let state_tree = account_info.tree_info.tree; + let input_queue = account_info.tree_info.queue; + let output_queue = account_info + .tree_info + .next_tree_info + .as_ref() + .map(|n| n.queue) + .unwrap_or(input_queue); + + // ixdata + let mint_instruction_data = MintInstructionData::try_from(mint_data.clone()) + .map_err(|_| LoadAccountsError::BuildInstruction("Invalid mint data".into()))?; + + DecompressMint { + payer: fee_payer, + authority: fee_payer, + state_tree, + input_queue, + output_queue, + compressed_mint_with_context: MintWithContext { + leaf_index: account_info.leaf_index as u32, + prove_by_index: account_info.root_index.proof_by_index(), + root_index: account_info.root_index.root_index().unwrap_or_default(), + address: mint.compressed_address, + mint: Some(mint_instruction_data), + }, + proof: ValidityProof(proof.proof.into()), + rent_payment: rent_payment.unwrap_or(DEFAULT_RENT_PAYMENT), + write_top_up: write_top_up.unwrap_or(DEFAULT_WRITE_TOP_UP), + } + .instruction() + .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string())) +} diff --git a/sdk-libs/program-test/src/compressible.rs b/sdk-libs/program-test/src/compressible.rs index a51f16df00..8a0a0cf99d 100644 --- a/sdk-libs/program-test/src/compressible.rs +++ b/sdk-libs/program-test/src/compressible.rs @@ -75,9 +75,9 @@ fn extract_compression_info(data: &[u8]) -> Option<(CompressionInfo, u8, bool)> Some((compression_info, account_type, compression_only)) } ACCOUNT_TYPE_MINT => { - let mint = Mint::deserialize(&mut &data[..]).ok()?; - // Mint accounts don't have compression_only, default to false - Some((mint.compression, account_type, false)) + let cmint = Mint::deserialize(&mut &data[..]).ok()?; + // CMint accounts don't have compression_only, default to false + Some((cmint.compression, account_type, false)) } _ => None, } @@ -414,7 +414,7 @@ async fn compress_cmint_forester( use light_compressible::config::CompressibleConfig; use light_token_interface::{ instructions::mint_action::{ - CompressAndCloseMintAction, MintActionCompressedInstructionData, MintWithContext, + CompressAndCloseCMintAction, MintActionCompressedInstructionData, MintWithContext, }, LIGHT_TOKEN_PROGRAM_ID, }; @@ -426,12 +426,12 @@ async fn compress_cmint_forester( RpcError::CustomError(format!("CMint account {} not found", cmint_pubkey)) })?; - // Deserialize Mint to get compressed_address and rent_sponsor - let mint: Mint = BorshDeserialize::deserialize(&mut cmint_account.data.as_slice()) - .map_err(|e| RpcError::CustomError(format!("Failed to deserialize Mint: {:?}", e)))?; + // Deserialize CMint to get compressed_address and rent_sponsor + let cmint: Mint = BorshDeserialize::deserialize(&mut cmint_account.data.as_slice()) + .map_err(|e| RpcError::CustomError(format!("Failed to deserialize CMint: {:?}", e)))?; - let compressed_mint_address = mint.metadata.compressed_address(); - let rent_sponsor = Pubkey::from(mint.compression.rent_sponsor); + let compressed_mint_address = cmint.metadata.compressed_address(); + let rent_sponsor = Pubkey::from(cmint.compression.rent_sponsor); // Get the compressed mint account from indexer let compressed_mint_account = rpc @@ -464,12 +464,12 @@ async fn compress_cmint_forester( mint: None, // CMint is decompressed, data lives in CMint account }; - // Build instruction data with CompressAndCloseMint action + // Build instruction data with CompressAndCloseCMint action let instruction_data = MintActionCompressedInstructionData::new( compressed_mint_inputs, rpc_proof_result.proof.into(), ) - .with_compress_and_close_mint(CompressAndCloseMintAction { idempotent: 1 }); + .with_compress_and_close_mint(CompressAndCloseCMintAction { idempotent: 1 }); // Get state tree info let state_tree_info = rpc_proof_result.accounts[0].tree_info; diff --git a/sdk-libs/program-test/src/program_test/light_program_test.rs b/sdk-libs/program-test/src/program_test/light_program_test.rs index 47919e08d3..6b6ccd0930 100644 --- a/sdk-libs/program-test/src/program_test/light_program_test.rs +++ b/sdk-libs/program-test/src/program_test/light_program_test.rs @@ -437,194 +437,6 @@ impl LightProgramTest { /// Always returns `AtaInterface` with `data` populated so clients can /// access `amount`, `delegate`, etc. regardless of hot/cold state. /// - /// Fetches raw ATA account interface with Account bytes always present. - /// - /// For hot accounts: actual on-chain bytes. - /// For cold accounts: synthetic SPL Token Account format bytes. - /// - /// Use `parse_token_account_interface()` to extract typed `TokenData`. - /// - /// # Example - /// ```ignore - /// // 1. Fetch raw account interface (async) - /// let account = rpc.get_ata_account_interface(&mint, &owner).await?; - /// - /// // 2. Parse into token account interface (sync) - /// let parsed = parse_token_account_interface(&account)?; - /// - /// // 3. Check if cold and act accordingly - /// if parsed.is_cold { - /// let proof = rpc.get_validity_proof(vec![parsed.hash().unwrap()], vec![], None).await?; - /// let ixs = build_decompress_token_accounts(&[parsed], fee_payer, Some(proof.value))?; - /// } - /// ``` - pub async fn get_ata_account_interface( - &self, - mint: &solana_sdk::pubkey::Pubkey, - owner: &solana_sdk::pubkey::Pubkey, - ) -> Result { - use light_client::indexer::{GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer}; - use light_compressible_client::{ - pack_token_data_to_spl_bytes, AtaAccountInterface, AtaDecompressionContext, - }; - use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; - use light_token_sdk::token::derive_token_ata; - - let (ata, bump) = derive_token_ata(owner, mint); - - // Check on-chain first - if let Some(account) = self.context.get_account(&ata) { - return Ok(AtaAccountInterface { - pubkey: ata, - account, - is_cold: false, - decompression_context: None, - }); - } - - // Check compressed state - let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( - Some(*mint), - )); - let result = self - .get_compressed_token_accounts_by_owner(&ata, options, None) - .await?; - - if let Some(compressed) = result.value.items.into_iter().next() { - // Synthesize SPL Token Account bytes from TokenData - let token_data = &compressed.token; - let data = pack_token_data_to_spl_bytes(mint, &ata, token_data).to_vec(); - - // Create synthetic Account - let account = solana_sdk::account::Account { - lamports: 0, // Compressed accounts don't have lamports - data, - owner: LIGHT_TOKEN_PROGRAM_ID.into(), - executable: false, - rent_epoch: 0, - }; - - return Ok(AtaAccountInterface { - pubkey: ata, - account, - is_cold: true, - decompression_context: Some(AtaDecompressionContext { - compressed, - wallet_owner: *owner, - mint: *mint, - bump, - }), - }); - } - - // Doesn't exist - return empty synthetic account - let data = vec![0u8; 165]; - let account = solana_sdk::account::Account { - lamports: 0, - data, - owner: LIGHT_TOKEN_PROGRAM_ID.into(), - executable: false, - rent_epoch: 0, - }; - - Ok(AtaAccountInterface { - pubkey: ata, - account, - is_cold: false, - decompression_context: None, - }) - } - - /// Legacy: Fetches AtaInterface (unified ATA representation). - /// Prefer `get_ata_account_interface()` + `parse_token_account_interface()` for new code. - pub async fn get_ata_interface( - &self, - mint: &solana_sdk::pubkey::Pubkey, - owner: &solana_sdk::pubkey::Pubkey, - ) -> Result { - use light_client::indexer::{GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer}; - use light_compressible_client::{AtaInterface, DecompressionContext, TokenData}; - use light_token_sdk::{compat::AccountState, token::derive_token_ata}; - - let (ata, bump) = derive_token_ata(owner, mint); - - // Check on-chain first - if let Some(account) = self.context.get_account(&ata) { - use solana_sdk::program_pack::Pack; - let token_data = if account.data.len() >= 165 { - let spl_account = spl_token_2022::state::Account::unpack(&account.data[..165]) - .unwrap_or_default(); - TokenData { - mint: spl_account.mint, - owner: spl_account.owner, - amount: spl_account.amount, - delegate: spl_account.delegate.into(), - state: match spl_account.state { - spl_token_2022::state::AccountState::Frozen => AccountState::Frozen, - _ => AccountState::Initialized, - }, - tlv: None, - } - } else { - TokenData { - mint: *mint, - owner: ata, - ..Default::default() - } - }; - - return Ok(AtaInterface { - ata, - owner: *owner, - mint: *mint, - bump, - is_cold: false, - token_data, - raw_account: Some(account), - decompression: None, - }); - } - - // Check compressed state - let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( - Some(*mint), - )); - let result = self - .get_compressed_token_accounts_by_owner(&ata, options, None) - .await?; - - if let Some(compressed) = result.value.items.into_iter().next() { - let token_data = compressed.token.clone(); - - return Ok(AtaInterface { - ata, - owner: *owner, - mint: *mint, - bump, - is_cold: true, - token_data, - raw_account: None, - decompression: Some(DecompressionContext { compressed }), - }); - } - - // Doesn't exist - Ok(AtaInterface { - ata, - owner: *owner, - mint: *mint, - bump, - is_cold: false, - token_data: TokenData { - mint: *mint, - owner: ata, - ..Default::default() - }, - raw_account: None, - decompression: None, - }) - } - /// Fetches MintInterface for a mint signer pubkey. /// /// Checks on-chain first, then compressed state. @@ -703,6 +515,194 @@ impl LightProgramTest { state: MintState::None, }) } + + // ======================================================================== + // New unified interface functions (mirror TypeScript SDK) + // ======================================================================== + + /// Fetches AccountInfoInterface for a compressible PDA. + /// + /// Checks on-chain first, then compressed state. + /// Returns unified interface with: + /// - `account`: Always present (real or synthetic bytes) + /// - `is_cold`: True if needs decompression + /// - `load_context`: Decompression context if cold + /// + /// # Example + /// ```ignore + /// let account = rpc.get_account_info_interface(&pda, &program_id).await?; + /// if account.is_cold { + /// // Need to decompress before use + /// } + /// ``` + pub async fn get_account_info_interface( + &self, + address: &solana_sdk::pubkey::Pubkey, + program_id: &solana_sdk::pubkey::Pubkey, + ) -> Result { + use light_client::indexer::Indexer; + use light_client::rpc::Rpc as RpcTrait; + use light_compressed_account::address::derive_address; + use light_compressible_client::AccountInfoInterface; + + let address_tree = self.get_address_tree_v2().tree; + let compressed_address = derive_address( + &address.to_bytes(), + &address_tree.to_bytes(), + &program_id.to_bytes(), + ); + + // Check on-chain first + if let Some(account) = self.context.get_account(address) { + return Ok(AccountInfoInterface::hot(*address, account)); + } + + // Check compressed state + let result = self + .get_compressed_account(compressed_address, None) + .await?; + + if let Some(compressed) = result.value { + if compressed + .data + .as_ref() + .map_or(false, |d| !d.data.is_empty()) + { + return Ok(AccountInfoInterface::cold( + *address, + compressed, + *program_id, + )); + } + } + + // Doesn't exist - return empty synthetic account + let account = solana_sdk::account::Account { + lamports: 0, + data: vec![], + owner: *program_id, + executable: false, + rent_epoch: 0, + }; + + Ok(AccountInfoInterface::hot(*address, account)) + } + + /// Fetches TokenAccountInterface for a token account address. + /// + /// Checks on-chain first, then compressed state. + /// Uses standard SPL types: + /// - `account`: `solana_sdk::account::Account` + /// - `parsed`: `spl_token_2022::state::Account` + /// + /// # Example + /// ```ignore + /// let token = rpc.get_token_account_interface(&token_account).await?; + /// println!("Amount: {}", token.amount()); + /// if token.is_cold { + /// // Need to decompress + /// } + /// ``` + pub async fn get_token_account_interface( + &self, + address: &solana_sdk::pubkey::Pubkey, + ) -> Result { + use light_client::indexer::Indexer; + use light_compressible_client::account_interface::TokenAccountInterface; + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + + // Check on-chain first + if let Some(account) = self.context.get_account(address) { + return TokenAccountInterface::hot(*address, account).map_err(|e| { + RpcError::CustomError(format!("Failed to parse token account: {}", e)) + }); + } + + // Check compressed state by owner (address is the token account owner for ctoken) + let result = self + .get_compressed_token_accounts_by_owner(address, None, None) + .await?; + + if let Some(compressed) = result.value.items.into_iter().next() { + // Extract mint before moving compressed + let mint = compressed.token.mint; + // For token accounts fetched by address, we use the address as both + // the pubkey and owner (common pattern for PDA-owned token accounts) + return Ok(TokenAccountInterface::cold( + *address, + compressed, + *address, // wallet_owner = address for non-ATA token accounts + mint, + 0, // bump not applicable for non-ATA + LIGHT_TOKEN_PROGRAM_ID.into(), + )); + } + + Err(RpcError::CustomError(format!( + "Token account not found: {}", + address + ))) + } + + /// Fetches AtaInterface for an (owner, mint) pair. + /// + /// Uses standard SPL types and provides unified hot/cold interface. + /// The ATA address is derived from owner + mint. + /// + /// # Example + /// ```ignore + /// let ata = rpc.get_ata_interface(&owner, &mint).await?; + /// println!("Amount: {}", ata.amount()); + /// println!("Mint: {}", ata.mint()); + /// if ata.is_cold() { + /// // Need to decompress + /// } + /// ``` + pub async fn get_ata_interface( + &self, + owner: &solana_sdk::pubkey::Pubkey, + mint: &solana_sdk::pubkey::Pubkey, + ) -> Result { + use light_client::indexer::{GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer}; + use light_compressible_client::account_interface::{AtaInterface, TokenAccountInterface}; + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + use light_token_sdk::token::derive_token_ata; + + let (ata, bump) = derive_token_ata(owner, mint); + + // Check on-chain first + if let Some(account) = self.context.get_account(&ata) { + let inner = TokenAccountInterface::hot(ata, account) + .map_err(|e| RpcError::CustomError(format!("Failed to parse ATA: {}", e)))?; + return Ok(AtaInterface::new(inner)); + } + + // Check compressed state + let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( + Some(*mint), + )); + // Query by ATA address (token account owner for c-token ATAs) + let result = self + .get_compressed_token_accounts_by_owner(&ata, options, None) + .await?; + + if let Some(compressed) = result.value.items.into_iter().next() { + let inner = TokenAccountInterface::cold( + ata, + compressed, + *owner, + *mint, + bump, + LIGHT_TOKEN_PROGRAM_ID.into(), + ); + return Ok(AtaInterface::new(inner)); + } + + Err(RpcError::CustomError(format!( + "ATA not found for owner {} mint {}", + owner, mint + ))) + } } impl MerkleTreeExt for LightProgramTest {} diff --git a/sdk-libs/token-sdk/src/token/decompress_mint.rs b/sdk-libs/token-sdk/src/token/decompress_mint.rs index 0541358783..b698ef4c12 100644 --- a/sdk-libs/token-sdk/src/token/decompress_mint.rs +++ b/sdk-libs/token-sdk/src/token/decompress_mint.rs @@ -4,13 +4,10 @@ use light_compressed_account::instruction_data::{ use light_token_interface::instructions::mint_action::{ CpiContext, DecompressMintAction, MintActionCompressedInstructionData, MintWithContext, }; -use solana_account_info::AccountInfo; -use solana_cpi::{invoke, invoke_signed}; use solana_instruction::Instruction; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use super::{config_pda, rent_sponsor_pda, SystemAccountInfos}; use crate::compressed_token::mint_action::MintActionMetaConfig; /// Decompress a compressed mint to a Mint Solana account. @@ -28,8 +25,8 @@ use crate::compressed_token::mint_action::MintActionMetaConfig; /// output_queue, /// compressed_mint_with_context, /// proof, -/// rent_payment: 16, // epochs (~24 hours rent) -/// write_top_up: 766, // lamports (~3 hours rent per write) +/// rent_payment: 16, +/// write_top_up: 766, /// }.instruction()?; /// ``` #[derive(Debug, Clone)] @@ -101,6 +98,7 @@ impl DecompressMint { } } +<<<<<<< HEAD // ============================================================================ // CPI Struct: DecompressMintCpi // ============================================================================ @@ -228,6 +226,14 @@ impl<'info> TryFrom<&DecompressMintCpi<'info>> for DecompressMint { write_top_up: cpi.write_top_up, }) } +======= +fn config_pda() -> Pubkey { + super::config_pda() +} + +fn rent_sponsor_pda() -> Pubkey { + super::rent_sponsor_pda() +>>>>>>> 7d4ae004e (wip) } /// Decompress a compressed mint with CPI context support. diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index 8afc849d3f..bb5ecdfa1e 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -107,7 +107,11 @@ pub mod csdk_anchor_full_derived_test { if params.vault_mint_amount > 0 { CTokenMintToCpi { +<<<<<<< HEAD mint: ctx.accounts.cmint.to_account_info(), +======= + cmint: ctx.accounts.cmint.to_account_info(), +>>>>>>> 7d4ae004e (wip) destination: ctx.accounts.vault.to_account_info(), amount: params.vault_mint_amount, authority: ctx.accounts.mint_authority.to_account_info(), @@ -119,7 +123,11 @@ pub mod csdk_anchor_full_derived_test { if params.user_ata_mint_amount > 0 { CTokenMintToCpi { +<<<<<<< HEAD mint: ctx.accounts.cmint.to_account_info(), +======= + cmint: ctx.accounts.cmint.to_account_info(), +>>>>>>> 7d4ae004e (wip) destination: ctx.accounts.user_ata.to_account_info(), amount: params.user_ata_mint_amount, authority: ctx.accounts.mint_authority.to_account_info(), diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state.rs index cca63029fd..3fb266e965 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state.rs @@ -1,11 +1,6 @@ use anchor_lang::prelude::*; -use light_sdk::{ - compressible::CompressionInfo, - instruction::{PackedAddressTreeInfo, ValidityProof}, - LightDiscriminator, -}; +use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; use light_sdk_macros::RentFreeAccount; -use light_token_interface::instructions::mint_action::MintWithContext; #[derive(Default, Debug, InitSpace, RentFreeAccount)] #[account] @@ -43,26 +38,6 @@ pub struct PlaceholderRecord { pub counter: u32, } -#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] -pub struct AccountCreationData { - // Instruction data fields (accounts come from ctx.accounts.*) - pub owner: Pubkey, - pub category_id: u64, - pub user_name: String, - pub session_id: u64, - pub game_type: String, - pub placeholder_id: u64, - pub counter: u32, - pub mint_name: String, - pub mint_symbol: String, - pub mint_uri: String, - pub mint_decimals: u8, - pub mint_supply: u64, - pub mint_update_authority: Option, - pub mint_freeze_authority: Option, - pub additional_metadata: Option>, -} - #[derive(AnchorSerialize, AnchorDeserialize)] pub struct TokenAccountInfo { pub user: Pubkey, diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index f43bb8afd2..4ca033c29a 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -263,57 +263,36 @@ async fn test_create_pdas_and_mint_auto() { assert_compressed_token_exists(&mut rpc, &vault_pda, vault_mint_amount).await; assert_compressed_token_exists(&mut rpc, &user_ata_pda, user_ata_mint_amount).await; - // PHASE 3: Decompress PDAs + vault via build_decompress_idempotent + // PHASE 3: Decompress all accounts via create_load_accounts_instructions use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::{ GameSessionSeeds, TokenAccountVariant, UserRecordSeeds, }; use light_compressible_client::{ - compressible_instruction, AccountInterface, RentFreeDecompressAccount, + create_load_accounts_instructions, AccountInterface, RentFreeDecompressAccount, }; - // Fetch compressed PDA accounts - let compressed_user = rpc - .get_compressed_account(user_compressed_address, None) + // Fetch unified interfaces (hot/cold transparent) + let user_interface = rpc + .get_account_info_interface(&user_record_pda, &program_id) .await - .unwrap() - .value - .unwrap(); + .expect("failed to get user"); + assert!(user_interface.is_cold, "UserRecord should be cold"); - let compressed_game = rpc - .get_compressed_account(game_compressed_address, None) + let game_interface = rpc + .get_account_info_interface(&game_session_pda, &program_id) .await - .unwrap() - .value - .unwrap(); + .expect("failed to get game"); + assert!(game_interface.is_cold, "GameSession should be cold"); - // Fetch compressed vault token account - let compressed_vault_accounts = rpc - .get_compressed_token_accounts_by_owner(&vault_pda, None, None) - .await - .unwrap() - .value - .items; - let compressed_vault = &compressed_vault_accounts[0]; - - // Get validity proof for PDAs + vault - let rpc_result = rpc - .get_validity_proof( - vec![ - compressed_user.hash, - compressed_game.hash, - compressed_vault.account.hash, - ], - vec![], - None, - ) + let vault_interface = rpc + .get_token_account_interface(&vault_pda) .await - .unwrap() - .value; - - // Build RentFreeDecompressAccount using from_seeds and from_ctoken helpers - let decompress_accounts = vec![ + .expect("failed to get vault"); + assert!(vault_interface.is_cold, "Vault should be cold"); + assert_eq!(vault_interface.amount(), vault_mint_amount); // Build RentFreeDecompressAccount - From impls convert interfaces directly + let program_owned_accounts = vec![ RentFreeDecompressAccount::from_seeds( - AccountInterface::cold(user_record_pda, compressed_user.clone()), + AccountInterface::from(&user_interface), UserRecordSeeds { authority: authority.pubkey(), mint_authority: mint_authority.pubkey(), @@ -323,7 +302,7 @@ async fn test_create_pdas_and_mint_auto() { ) .expect("UserRecord seed verification failed"), RentFreeDecompressAccount::from_seeds( - AccountInterface::cold(game_session_pda, compressed_game.clone()), + AccountInterface::from(&game_interface), GameSessionSeeds { fee_payer: payer.pubkey(), authority: authority.pubkey(), @@ -332,37 +311,70 @@ async fn test_create_pdas_and_mint_auto() { ) .expect("GameSession seed verification failed"), RentFreeDecompressAccount::from_ctoken( - AccountInterface::cold(vault_pda, compressed_vault.account.clone()), + AccountInterface::from(&vault_interface), TokenAccountVariant::Vault { cmint: cmint_pda }, ) .expect("CToken variant construction failed"), ]; - // Build decompress instruction - // No SeedParams needed - data.* seeds from unpacked account, ctx.* from variant idx - let decompress_instruction = compressible_instruction::build_decompress_idempotent( - &program_id, - decompress_accounts, - compressible_instruction::decompress::accounts(payer.pubkey(), config_pda, payer.pubkey()), - rpc_result, + // get_ata_interface: fetches ATA with unified handling using standard SPL types + let ata_interface = rpc + .get_ata_interface(&payer.pubkey(), &cmint_pda) + .await + .expect("get_ata_interface should succeed"); + assert!(ata_interface.is_cold(), "ATA should be cold after warp"); + assert_eq!(ata_interface.amount(), user_ata_mint_amount); + assert_eq!(ata_interface.mint(), cmint_pda); + assert_eq!(ata_interface.owner(), ata_interface.pubkey()); // ctoken ATA owner = ATA address + + // Fetch mint interface + let mint_interface = rpc + .get_mint_interface(&mint_signer_pda) + .await + .expect("get_mint_interface should succeed"); + assert!(mint_interface.is_cold(), "Mint should be cold after warp"); + + // Load accounts if needed + let all_instructions = create_load_accounts_instructions( + &program_owned_accounts, + &[ata_interface.inner.clone()], + &[mint_interface.clone()], + program_id, + payer.pubkey(), + config_pda, + payer.pubkey(), // rent_sponsor + &rpc, ) - .unwrap() - .expect("Should have cold accounts to decompress"); + .await + .expect("create_load_accounts_instructions should succeed"); - rpc.create_and_send_transaction(&[decompress_instruction], &payer.pubkey(), &[&payer]) + // Expected: 1 PDA+Token ix + 2 ATA ixs (1 create_ata + 1 decompress) + 1 mint ix = 4 + assert_eq!( + all_instructions.len(), + 4, + "Should have 4 instructions: 1 PDA+Token, 1 create_ata, 1 decompress_ata, 1 mint" + ); + + // Execute all instructions + rpc.create_and_send_transaction(&all_instructions, &payer.pubkey(), &[&payer]) .await - .expect("PDA + vault decompression should succeed"); + .expect("Decompression should succeed"); - // Assert PDAs are back on-chain + // Assert all accounts are back on-chain assert_onchain_exists(&mut rpc, &user_record_pda).await; assert_onchain_exists(&mut rpc, &game_session_pda).await; - - // Assert vault is back on-chain with correct balance assert_onchain_exists(&mut rpc, &vault_pda).await; + assert_onchain_exists(&mut rpc, &user_ata_pda).await; + assert_onchain_exists(&mut rpc, &cmint_pda).await; + + // Verify balances let vault_after = parse_token(&rpc.get_account(vault_pda).await.unwrap().unwrap().data); assert_eq!(vault_after.amount, vault_mint_amount); - // Verify compressed vault token is consumed (no more compressed token accounts for vault) + let user_ata_after = parse_token(&rpc.get_account(user_ata_pda).await.unwrap().unwrap().data); + assert_eq!(user_ata_after.amount, user_ata_mint_amount); + + // Verify compressed vault token is consumed let remaining_vault = rpc .get_compressed_token_accounts_by_owner(&vault_pda, None, None) .await @@ -370,123 +382,4 @@ async fn test_create_pdas_and_mint_auto() { .value .items; assert!(remaining_vault.is_empty()); - - // PHASE 4: Decompress user ATA via new high-performance API pattern - use light_compressible_client::{ - build_decompress_token_accounts, decompress_mint, decompress_token_accounts, - parse_token_account_interface, - }; - - // Step 1: Fetch raw account interface (Account bytes always present) - let account_interface = rpc - .get_ata_account_interface(&cmint_pda, &payer.pubkey()) - .await - .expect("get_ata_account_interface should succeed"); - - // Verify raw bytes are present (even for cold accounts) - assert_eq!(account_interface.account.data.len(), 165); - - // Step 2: Parse into TokenAccountInterface (sync, no RPC) - let parsed = parse_token_account_interface(&account_interface) - .expect("parse_token_account_interface should succeed"); - - // Verify it's cold (compressed) - assert!(parsed.is_cold, "ATA should be cold after warp"); - assert!( - parsed.decompression_context.is_some(), - "Cold ATA should have decompression_context" - ); - - // Amount accessible via TokenData - assert_eq!(parsed.amount(), user_ata_mint_amount); - - // Step 3: Get proof and build instructions (sync after proof) - let cold_hash = parsed.hash().expect("Cold ATA should have hash"); - let proof = rpc - .get_validity_proof(vec![cold_hash], vec![], None) - .await - .expect("get_validity_proof should succeed") - .value; - - // Step 4: Build decompress instructions (sync) - let ata_instructions = build_decompress_token_accounts(&[parsed], payer.pubkey(), Some(proof)) - .expect("build_decompress_token_accounts should succeed"); - - assert!(!ata_instructions.is_empty(), "Should have instructions"); - - rpc.create_and_send_transaction(&ata_instructions, &payer.pubkey(), &[&payer]) - .await - .expect("ATA decompression should succeed"); - - // Assert user ATA is back on-chain with correct balance - assert_onchain_exists(&mut rpc, &user_ata_pda).await; - let user_ata_after = parse_token(&rpc.get_account(user_ata_pda).await.unwrap().unwrap().data); - assert_eq!(user_ata_after.amount, user_ata_mint_amount); - - // Verify idempotency: calling again should return empty vec - let account_interface_again = rpc - .get_ata_account_interface(&cmint_pda, &payer.pubkey()) - .await - .expect("get_ata_account_interface should succeed"); - - let parsed_again = parse_token_account_interface(&account_interface_again) - .expect("parse_token_account_interface should succeed"); - - assert!( - !parsed_again.is_cold, - "ATA should be hot after decompression" - ); - assert!( - parsed_again.decompression_context.is_none(), - "Hot ATA should not have decompression_context" - ); - - // Using async wrapper (alternative pattern) - let ata_instructions_again = decompress_token_accounts(&[parsed_again], payer.pubkey(), &rpc) - .await - .expect("decompress_token_accounts should succeed"); - assert!( - ata_instructions_again.is_empty(), - "Should return empty vec when already decompressed" - ); - - // PHASE 5: Decompress CMint via decompress_mint (lean wrapper) - let mint_interface = rpc - .get_mint_interface(&mint_signer_pda) - .await - .expect("get_mint_interface should succeed"); - - // Verify it's cold (compressed) - assert!(mint_interface.is_cold(), "Mint should be cold after warp"); - - // Decompress using lean wrapper (fetches proof internally) - let mint_instructions = decompress_mint(&mint_interface, payer.pubkey(), &rpc) - .await - .expect("decompress_mint should succeed"); - - if !mint_instructions.is_empty() { - rpc.create_and_send_transaction(&mint_instructions, &payer.pubkey(), &[&payer]) - .await - .expect("Mint decompression should succeed"); - } - - // Assert CMint is back on-chain - assert_onchain_exists(&mut rpc, &cmint_pda).await; - - // Verify calling again returns empty vec (idempotent) - let mint_interface_again = rpc - .get_mint_interface(&mint_signer_pda) - .await - .expect("get_mint_interface should succeed"); - assert!( - mint_interface_again.is_hot(), - "Mint should be hot after decompression" - ); - let mint_instructions_again = decompress_mint(&mint_interface_again, payer.pubkey(), &rpc) - .await - .expect("decompress_mint should succeed"); - assert!( - mint_instructions_again.is_empty(), - "Should return empty vec when mint already decompressed" - ); } diff --git a/sdk-tests/sdk-light-token-test/src/lib.rs b/sdk-tests/sdk-light-token-test/src/lib.rs index 21179b2e09..df5cd3559d 100644 --- a/sdk-tests/sdk-light-token-test/src/lib.rs +++ b/sdk-tests/sdk-light-token-test/src/lib.rs @@ -172,7 +172,6 @@ impl TryFrom for InstructionType { 30 => Ok(InstructionType::BurnInvokeSigned), 31 => Ok(InstructionType::CTokenMintToInvoke), 32 => Ok(InstructionType::CTokenMintToInvokeSigned), - 33 => Ok(InstructionType::DecompressCmintInvokeSigned), 34 => Ok(InstructionType::CTokenTransferCheckedInvoke), 35 => Ok(InstructionType::CTokenTransferCheckedInvokeSigned), _ => Err(ProgramError::InvalidInstructionData), @@ -368,7 +367,6 @@ mod tests { assert_eq!(InstructionType::BurnInvokeSigned as u8, 30); assert_eq!(InstructionType::CTokenMintToInvoke as u8, 31); assert_eq!(InstructionType::CTokenMintToInvokeSigned as u8, 32); - assert_eq!(InstructionType::DecompressCmintInvokeSigned as u8, 33); assert_eq!(InstructionType::CTokenTransferCheckedInvoke as u8, 34); assert_eq!(InstructionType::CTokenTransferCheckedInvokeSigned as u8, 35); } @@ -501,10 +499,7 @@ mod tests { InstructionType::try_from(32).unwrap(), InstructionType::CTokenMintToInvokeSigned ); - assert_eq!( - InstructionType::try_from(33).unwrap(), - InstructionType::DecompressCmintInvokeSigned - ); + assert!(InstructionType::try_from(33).is_err()); // Removed DecompressCmintInvokeSigned assert_eq!( InstructionType::try_from(34).unwrap(), InstructionType::CTokenTransferCheckedInvoke diff --git a/sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs b/sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs new file mode 100644 index 0000000000..92627834d1 --- /dev/null +++ b/sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs @@ -0,0 +1,725 @@ +// Tests for DecompressMint SDK instruction + +mod shared; + +use borsh::BorshDeserialize; +use light_client::{indexer::Indexer, rpc::Rpc}; +use light_compressible::compression_info::CompressionInfo; +use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_token_interface::{instructions::mint_action::MintWithContext, state::Mint}; +use light_token_sdk::token::{find_mint_address, DecompressMint}; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; + +/// Test decompressing a compressed mint to CMint account +#[tokio::test] +async fn test_decompress_mint() { + let config = ProgramTestConfig::new_v2(true, None); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let mint_authority = payer.pubkey(); + let decimals = 9u8; + + // Create a compressed mint (returns mint_seed keypair) + let (mint_pda, compression_address, _, _mint_seed) = + shared::setup_create_compressed_mint(&mut rpc, &payer, mint_authority, decimals, vec![]) + .await; + + // Verify CMint account does NOT exist on-chain yet + let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); + assert!( + cmint_account_before.is_none(), + "CMint should not exist before decompression" + ); + + // Verify compressed mint exists + let compressed_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value + .expect("Compressed mint should exist"); + + // Get validity proof for decompression + let rpc_result = rpc + .get_validity_proof(vec![compressed_account.hash], vec![], None) + .await + .unwrap() + .value; + + // Deserialize the compressed mint to build context + let compressed_mint = + Mint::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()).unwrap(); + + let compressed_mint_with_context = MintWithContext { + address: compression_address, + leaf_index: compressed_account.leaf_index, + prove_by_index: true, + root_index: rpc_result.accounts[0] + .root_index + .root_index() + .unwrap_or_default(), + mint: Some(compressed_mint.clone().try_into().unwrap()), + }; + + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Build and execute DecompressMint instruction + let decompress_ix = DecompressMint { + payer: payer.pubkey(), + authority: mint_authority, + state_tree: compressed_account.tree_info.tree, + input_queue: compressed_account.tree_info.queue, + output_queue, + compressed_mint_with_context, + proof: rpc_result.proof, + rent_payment: 16, + write_top_up: 766, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Verify CMint account now exists on-chain + let cmint_account_after = rpc.get_account(mint_pda).await.unwrap(); + assert!( + cmint_account_after.is_some(), + "CMint should exist after decompression" + ); + + // Verify CMint state with single assert_eq + let cmint_account = cmint_account_after.unwrap(); + let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); + + // Verify compression info is set (non-default) when decompressed + assert_ne!( + cmint.compression, + CompressionInfo::default(), + "CMint compression info should be set when decompressed" + ); + + // Build expected CMint from original compressed mint, updating fields changed by decompression + let mut expected_cmint = compressed_mint.clone(); + expected_cmint.metadata.cmint_decompressed = true; + expected_cmint.compression = cmint.compression; + + assert_eq!(cmint, expected_cmint, "CMint should match expected state"); +} + +/// Test decompressing a compressed mint with freeze_authority +#[tokio::test] +async fn test_decompress_mint_with_freeze_authority() { + let config = ProgramTestConfig::new_v2(true, None); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let mint_authority = payer.pubkey(); + let freeze_authority = Keypair::new(); + let decimals = 6u8; + + // Create a compressed mint with freeze_authority + let (mint_pda, compression_address, _mint_seed) = + setup_create_compressed_mint_with_freeze_authority_only( + &mut rpc, + &payer, + mint_authority, + Some(freeze_authority.pubkey()), + decimals, + ) + .await; + + // Verify CMint account does NOT exist on-chain yet + let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); + assert!( + cmint_account_before.is_none(), + "CMint should not exist before decompression" + ); + + // Get compressed mint account + let compressed_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value + .expect("Compressed mint should exist"); + + // Get validity proof for decompression + let rpc_result = rpc + .get_validity_proof(vec![compressed_account.hash], vec![], None) + .await + .unwrap() + .value; + + // Deserialize the compressed mint + let compressed_mint = + Mint::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()).unwrap(); + + let compressed_mint_with_context = MintWithContext { + address: compression_address, + leaf_index: compressed_account.leaf_index, + prove_by_index: true, + root_index: rpc_result.accounts[0] + .root_index + .root_index() + .unwrap_or_default(), + mint: Some(compressed_mint.clone().try_into().unwrap()), + }; + + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Build and execute DecompressMint instruction + let decompress_ix = DecompressMint { + payer: payer.pubkey(), + authority: mint_authority, + state_tree: compressed_account.tree_info.tree, + input_queue: compressed_account.tree_info.queue, + output_queue, + compressed_mint_with_context, + proof: rpc_result.proof, + rent_payment: 16, + write_top_up: 766, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Verify CMint state with single assert_eq + let cmint_account = rpc + .get_account(mint_pda) + .await + .unwrap() + .expect("CMint should exist after decompression"); + let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); + + // Verify compression info is set (non-default) when decompressed + assert_ne!( + cmint.compression, + CompressionInfo::default(), + "CMint compression info should be set when decompressed" + ); + + // Build expected CMint from original compressed mint, updating fields changed by decompression + let mut expected_cmint = compressed_mint.clone(); + expected_cmint.metadata.cmint_decompressed = true; + expected_cmint.compression = cmint.compression; + + assert_eq!(cmint, expected_cmint, "CMint should match expected state"); +} + +/// Helper function: Creates a compressed mint with optional freeze_authority +/// but does NOT decompress it (unlike setup_create_compressed_mint_with_freeze_authority) +/// Returns (mint_pda, compression_address, mint_seed_keypair) +async fn setup_create_compressed_mint_with_freeze_authority_only( + rpc: &mut (impl Rpc + Indexer), + payer: &Keypair, + mint_authority: Pubkey, + freeze_authority: Option, + decimals: u8, +) -> (Pubkey, [u8; 32], Keypair) { + use light_token_sdk::token::{CreateMint, CreateMintParams}; + + let mint_seed = Keypair::new(); + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Derive compression address using SDK helpers + let compression_address = light_token_sdk::token::derive_mint_compressed_address( + &mint_seed.pubkey(), + &address_tree.tree, + ); + + let (mint, bump) = find_mint_address(&mint_seed.pubkey()); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![light_client::indexer::AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .unwrap() + .value; + + // Build params for the SDK + let params = CreateMintParams { + decimals, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority, + proof: rpc_result.proof.0.unwrap(), + compression_address, + mint, + bump, + freeze_authority, + extensions: None, + }; + + // Create instruction directly using SDK + let create_cmint_builder = CreateMint::new( + params, + mint_seed.pubkey(), + payer.pubkey(), + address_tree.tree, + output_queue, + ); + let instruction = create_cmint_builder.instruction().unwrap(); + + // Send transaction + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, &mint_seed]) + .await + .unwrap(); + + // Verify the compressed mint was created + let compressed_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value; + + assert!( + compressed_account.is_some(), + "Compressed mint should exist after setup" + ); + + (mint, compression_address, mint_seed) +} + +/// Test decompressing a compressed mint with TokenMetadata extension +#[tokio::test] +async fn test_decompress_mint_with_token_metadata() { + use light_token_interface::instructions::extensions::{ + ExtensionInstructionData, TokenMetadataInstructionData, + }; + + let config = ProgramTestConfig::new_v2(true, None); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let mint_authority = payer.pubkey(); + let update_authority = Keypair::new(); + let decimals = 9u8; + + // Create TokenMetadata extension + let token_metadata = TokenMetadataInstructionData { + update_authority: Some(update_authority.pubkey().to_bytes().into()), + name: b"Test Token".to_vec(), + symbol: b"TEST".to_vec(), + uri: b"https://example.com/token.json".to_vec(), + additional_metadata: None, + }; + let extensions = vec![ExtensionInstructionData::TokenMetadata(token_metadata)]; + + // Create a compressed mint with TokenMetadata extension + let (mint_pda, compression_address, _mint_seed) = setup_create_compressed_mint_with_extensions( + &mut rpc, + &payer, + mint_authority, + None, + decimals, + extensions, + ) + .await; + + // Verify CMint account does NOT exist on-chain yet + let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); + assert!( + cmint_account_before.is_none(), + "CMint should not exist before decompression" + ); + + // Get compressed mint account + let compressed_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value + .expect("Compressed mint should exist"); + + // Get validity proof for decompression + let rpc_result = rpc + .get_validity_proof(vec![compressed_account.hash], vec![], None) + .await + .unwrap() + .value; + + // Deserialize the compressed mint + let compressed_mint = + Mint::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()).unwrap(); + + let compressed_mint_with_context = MintWithContext { + address: compression_address, + leaf_index: compressed_account.leaf_index, + prove_by_index: true, + root_index: rpc_result.accounts[0] + .root_index + .root_index() + .unwrap_or_default(), + mint: Some(compressed_mint.clone().try_into().unwrap()), + }; + + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Build and execute DecompressMint instruction + let decompress_ix = DecompressMint { + payer: payer.pubkey(), + authority: mint_authority, + state_tree: compressed_account.tree_info.tree, + input_queue: compressed_account.tree_info.queue, + output_queue, + compressed_mint_with_context, + proof: rpc_result.proof, + rent_payment: 16, + write_top_up: 766, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Verify CMint state with single assert_eq + let cmint_account = rpc + .get_account(mint_pda) + .await + .unwrap() + .expect("CMint should exist after decompression"); + let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); + + // Verify compression info is set (non-default) when decompressed + assert_ne!( + cmint.compression, + CompressionInfo::default(), + "CMint compression info should be set when decompressed" + ); + + // Verify TokenMetadata extension is preserved + assert!( + cmint.extensions.is_some(), + "CMint should have extensions with TokenMetadata" + ); + + // Build expected CMint from original compressed mint, updating fields changed by decompression + let mut expected_cmint = compressed_mint.clone(); + expected_cmint.metadata.cmint_decompressed = true; + expected_cmint.compression = cmint.compression; + // Extensions should preserve original TokenMetadata + + assert_eq!(cmint, expected_cmint, "CMint should match expected state"); +} + +/// Helper function: Creates a compressed mint with extensions +/// but does NOT decompress it +/// Returns (mint_pda, compression_address, mint_seed_keypair) +async fn setup_create_compressed_mint_with_extensions( + rpc: &mut (impl Rpc + Indexer), + payer: &Keypair, + mint_authority: Pubkey, + freeze_authority: Option, + decimals: u8, + extensions: Vec, +) -> (Pubkey, [u8; 32], Keypair) { + use light_token_sdk::token::{CreateMint, CreateMintParams}; + + let mint_seed = Keypair::new(); + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Derive compression address using SDK helpers + let compression_address = light_token_sdk::token::derive_mint_compressed_address( + &mint_seed.pubkey(), + &address_tree.tree, + ); + + let (mint, bump) = find_mint_address(&mint_seed.pubkey()); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![light_client::indexer::AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .unwrap() + .value; + + // Build params for the SDK + let params = CreateMintParams { + decimals, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority, + proof: rpc_result.proof.0.unwrap(), + compression_address, + mint, + bump, + freeze_authority, + extensions: Some(extensions), + }; + + // Create instruction directly using SDK + let create_cmint_builder = CreateMint::new( + params, + mint_seed.pubkey(), + payer.pubkey(), + address_tree.tree, + output_queue, + ); + let instruction = create_cmint_builder.instruction().unwrap(); + + // Send transaction + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, &mint_seed]) + .await + .unwrap(); + + // Verify the compressed mint was created + let compressed_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value; + + assert!( + compressed_account.is_some(), + "Compressed mint should exist after setup" + ); + + (mint, compression_address, mint_seed) +} + +/// Test decompressing a compressed mint via CPI with PDA authority using invoke_signed +#[tokio::test] +async fn test_decompress_mint_cpi_invoke_signed() { + use borsh::BorshSerialize; + use native_ctoken_examples::{ + CreateCmintData, DecompressCmintData, InstructionType, ID, MINT_AUTHORITY_SEED, + MINT_SIGNER_SEED, + }; + use solana_sdk::instruction::{AccountMeta, Instruction}; + + let config = ProgramTestConfig::new_v2(true, Some(vec![("native_ctoken_examples", ID)])); + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Derive the PDAs from our wrapper program + let (mint_signer_pda, _) = Pubkey::find_program_address(&[MINT_SIGNER_SEED], &ID); + let (pda_mint_authority, _) = Pubkey::find_program_address(&[MINT_AUTHORITY_SEED], &ID); + + let decimals = 9u8; + let address_tree = rpc.get_address_tree_v2(); + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Derive compression address using the PDA mint_signer + let compression_address = light_token_sdk::token::derive_mint_compressed_address( + &mint_signer_pda, + &address_tree.tree, + ); + + let (mint_pda, mint_bump) = find_mint_address(&mint_signer_pda); + + // Step 1: Create compressed mint with PDA authority using wrapper program (discriminator 14) + { + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![light_client::indexer::AddressWithTree { + address: compression_address, + tree: address_tree.tree, + }], + None, + ) + .await + .unwrap() + .value; + + let compressed_token_program_id = + Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID); + let default_pubkeys = light_token_sdk::utils::TokenDefaultAccounts::default(); + + let create_cmint_data = CreateCmintData { + decimals, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority: pda_mint_authority, + proof: rpc_result.proof.0.unwrap(), + compression_address, + mint: mint_pda, + bump: mint_bump, + freeze_authority: None, + extensions: None, + rent_payment: 16, + write_top_up: 766, + }; + // Discriminator 14 = CreateCmintWithPdaAuthority + let wrapper_instruction_data = + [vec![14u8], create_cmint_data.try_to_vec().unwrap()].concat(); + + let wrapper_accounts = vec![ + AccountMeta::new_readonly(compressed_token_program_id, false), + AccountMeta::new_readonly(default_pubkeys.light_system_program, false), + AccountMeta::new_readonly(mint_signer_pda, false), + AccountMeta::new(pda_mint_authority, false), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new_readonly(default_pubkeys.cpi_authority_pda, false), + AccountMeta::new_readonly(default_pubkeys.registered_program_pda, false), + AccountMeta::new_readonly(default_pubkeys.account_compression_authority, false), + AccountMeta::new_readonly(default_pubkeys.account_compression_program, false), + AccountMeta::new_readonly(default_pubkeys.system_program, false), + AccountMeta::new(output_queue, false), + AccountMeta::new(address_tree.tree, false), + ]; + + let create_mint_ix = Instruction { + program_id: ID, + accounts: wrapper_accounts, + data: wrapper_instruction_data, + }; + + rpc.create_and_send_transaction(&[create_mint_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + } + + // Verify CMint account does NOT exist on-chain yet + let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); + assert!( + cmint_account_before.is_none(), + "CMint should not exist before decompression" + ); + + // Step 2: Decompress the mint via wrapper program (PDA authority requires CPI) + let compressed_mint = { + let compressed_mint_account = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value + .expect("Compressed mint should exist"); + + let compressed_mint = Mint::deserialize( + &mut compressed_mint_account + .data + .as_ref() + .unwrap() + .data + .as_slice(), + ) + .unwrap(); + + let rpc_result = rpc + .get_validity_proof(vec![compressed_mint_account.hash], vec![], None) + .await + .unwrap() + .value; + + let compressed_mint_with_context = MintWithContext { + address: compression_address, + leaf_index: compressed_mint_account.leaf_index, + prove_by_index: true, + root_index: rpc_result.accounts[0] + .root_index + .root_index() + .unwrap_or_default(), + mint: Some(compressed_mint.clone().try_into().unwrap()), + }; + + let default_pubkeys = light_token_sdk::utils::TokenDefaultAccounts::default(); + let compressible_config = light_token_sdk::token::config_pda(); + let rent_sponsor = light_token_sdk::token::rent_sponsor_pda(); + + let decompress_data = DecompressCmintData { + compressed_mint_with_context, + proof: rpc_result.proof, + rent_payment: 16, + write_top_up: 766, + }; + + // Discriminator 33 = DecompressCmintInvokeSigned + let wrapper_instruction_data = [ + vec![InstructionType::DecompressCmintInvokeSigned as u8], + decompress_data.try_to_vec().unwrap(), + ] + .concat(); + + // Account order matches process_decompress_mint_invoke_signed: + // 0: authority (PDA, readonly - program signs) + // 1: payer (signer, writable) + // 2: cmint (writable) + // 3: compressible_config (readonly) + // 4: rent_sponsor (writable) + // 5: state_tree (writable) + // 6: input_queue (writable) + // 7: output_queue (writable) + // 8: light_system_program (readonly) + // 9: cpi_authority_pda (readonly) + // 10: registered_program_pda (readonly) + // 11: account_compression_authority (readonly) + // 12: account_compression_program (readonly) + // 13: system_program (readonly) + // 14: light_token_program (readonly) - required for CPI + let light_token_program_id = + Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID); + let wrapper_accounts = vec![ + AccountMeta::new_readonly(pda_mint_authority, false), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(mint_pda, false), + AccountMeta::new_readonly(compressible_config, false), + AccountMeta::new(rent_sponsor, false), + AccountMeta::new(compressed_mint_account.tree_info.tree, false), + AccountMeta::new(compressed_mint_account.tree_info.queue, false), + AccountMeta::new(output_queue, false), + AccountMeta::new_readonly(default_pubkeys.light_system_program, false), + AccountMeta::new_readonly(default_pubkeys.cpi_authority_pda, false), + AccountMeta::new_readonly(default_pubkeys.registered_program_pda, false), + AccountMeta::new_readonly(default_pubkeys.account_compression_authority, false), + AccountMeta::new_readonly(default_pubkeys.account_compression_program, false), + AccountMeta::new_readonly(default_pubkeys.system_program, false), + AccountMeta::new_readonly(light_token_program_id, false), + ]; + + let decompress_ix = Instruction { + program_id: ID, + accounts: wrapper_accounts, + data: wrapper_instruction_data, + }; + + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + compressed_mint + }; + + // Verify CMint state with single assert_eq + let cmint_account = rpc + .get_account(mint_pda) + .await + .unwrap() + .expect("CMint should exist after decompression"); + let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); + + // Verify compression info is set (non-default) when decompressed + assert_ne!( + cmint.compression, + CompressionInfo::default(), + "CMint compression info should be set when decompressed" + ); + + // Build expected CMint from original compressed mint, updating fields changed by decompression + let mut expected_cmint = compressed_mint.clone(); + expected_cmint.metadata.cmint_decompressed = true; + expected_cmint.compression = cmint.compression; + + assert_eq!(cmint, expected_cmint, "CMint should match expected state"); +} From ee4baf87e8f86182f20173f71848c58bbbe94de4 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sat, 17 Jan 2026 01:07:53 +0000 Subject: [PATCH 2/2] lint wip clean add split_by_tx_size fix js wip resize fix ixn lint fix fix lint fix mint_action.md fix smallvec dep, cleanup fix lint again --- .cargo/config.toml | 3 - Cargo.lock | 1 + Cargo.toml | 1 + .../src/v3/layout/layout-mint-action.ts | 4 +- .../docs/compressed_token/MINT_ACTION.md | 28 +- sdk-libs/compressible-client/Cargo.toml | 3 +- .../src/account_interface_ext.rs | 226 ++++++ .../src/decompress_mint.rs | 20 +- sdk-libs/compressible-client/src/lib.rs | 4 + .../compressible-client/src/load_accounts.rs | 77 +- sdk-libs/compressible-client/src/tx_size.rs | 250 ++++++ .../src/rentfree/traits/seed_extraction.rs | 4 +- sdk-libs/program-test/src/compressible.rs | 65 +- .../src/program_test/light_program_test.rs | 273 ------- sdk-libs/sdk/src/compressible/close.rs | 6 +- sdk-libs/token-sdk/src/token/create_ata.rs | 28 +- .../token-sdk/src/token/decompress_mint.rs | 20 +- sdk-libs/token-sdk/src/token/mod.rs | 8 +- .../token-sdk/src/token/transfer_interface.rs | 24 +- .../src/instruction_accounts.rs | 2 +- .../csdk-anchor-full-derived-test/src/lib.rs | 12 +- .../src/state.rs | 6 +- .../tests/basic_test.rs | 7 +- .../sdk-light-token-test/src/create_ata.rs | 6 +- sdk-tests/sdk-light-token-test/src/lib.rs | 7 +- .../tests/test_decompress_cmint.rs | 725 ------------------ 26 files changed, 664 insertions(+), 1146 deletions(-) create mode 100644 sdk-libs/compressible-client/src/account_interface_ext.rs create mode 100644 sdk-libs/compressible-client/src/tx_size.rs delete mode 100644 sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 018fc87b4b..c00d2a43f4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,9 +1,6 @@ [alias] xtask = "run --package xtask --" -[resolver] -incompatible-rust-versions = "fallback" - # On Windows # ``` # cargo install -f cargo-binutils diff --git a/Cargo.lock b/Cargo.lock index eecbd6bb2a..85eafabf0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3684,6 +3684,7 @@ name = "light-compressible-client" version = "0.17.1" dependencies = [ "anchor-lang", + "async-trait", "borsh 0.10.4", "futures", "light-client", diff --git a/Cargo.toml b/Cargo.toml index f1c4aae7f6..fcd384ed77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -230,6 +230,7 @@ create-address-program-test = { path = "program-tests/create-address-test-progra groth16-solana = { version = "0.2.0" } bytemuck = { version = "1.19.0" } arrayvec = "0.7" +smallvec = "1.15" tinyvec = "1.10.0" pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" } # Math and crypto diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index cc71c77608..8799a7068d 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -314,7 +314,7 @@ export interface CompressedMintMetadata { bump: number; } -export interface MintInstructionData { +export interface MintLayoutData { supply: bigint; decimals: number; metadata: CompressedMintMetadata; @@ -332,7 +332,7 @@ export interface MintActionCompressedInstructionData { actions: Action[]; proof: ValidityProof | null; cpiContext: CpiContext | null; - mint: MintInstructionData | null; + mint: MintLayoutData | null; } /** diff --git a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md index 57db5d945c..cac5b0a3c0 100644 --- a/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md +++ b/programs/compressed-token/program/docs/compressed_token/MINT_ACTION.md @@ -13,13 +13,13 @@ This instruction supports 10 total actions - one creation action (controlled by 1. **Create Compressed Mint** - Create a new compressed mint account with initial authorities and optional TokenMetadata extension -**Core mint operations (Action enum variants):** 2. `MintToCompressed` - Mint new compressed tokens to one or more compressed token accounts 3. `MintToCToken` - Mint new tokens to decompressed ctoken accounts (not SPL tokens) +**Core mint operations (Action enum variants):** 2. `MintToCompressed` - Mint new compressed tokens to one or more compressed token accounts 3. `MintTo` - Mint new tokens to decompressed ctoken accounts (not SPL tokens) **Authority updates (Action enum variants):** 4. `UpdateMintAuthority` - Update or remove the mint authority 5. `UpdateFreezeAuthority` - Update or remove the freeze authority **TokenMetadata extension operations (Action enum variants):** 6. `UpdateMetadataField` - Update name, symbol, uri, or additional_metadata fields in the TokenMetadata extension 7. `UpdateMetadataAuthority` - Update the metadata update authority in the TokenMetadata extension 8. `RemoveMetadataKey` - Remove a key-value pair from additional_metadata in the TokenMetadata extension -**Decompress/Compress operations (Action enum variants):** 9. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. 10. `CompressAndCloseCMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). +**Decompress/Compress operations (Action enum variants):** 9. `DecompressMint` - Decompress a compressed mint to a CMint Solana account. Creates a CMint PDA that becomes the source of truth. 10. `CompressAndCloseMint` - Compress and close a CMint Solana account. Permissionless - anyone can call if is_compressible() returns true (rent expired). Key concepts integrated: @@ -47,12 +47,12 @@ Key concepts integrated: - `MintToCompressed(MintToCompressedAction)` - Mint tokens to compressed accounts (mint_to_compressed.rs) - `UpdateMintAuthority(UpdateAuthority)` - Update mint authority (update_mint.rs) - `UpdateFreezeAuthority(UpdateAuthority)` - Update freeze authority (update_mint.rs) - - `MintToCToken(MintToCTokenAction)` - Mint to ctoken accounts (mint_to_ctoken.rs) + - `MintTo(MintToAction)` - Mint to ctoken accounts (mint_to.rs) - `UpdateMetadataField(UpdateMetadataFieldAction)` - Update metadata field (update_metadata.rs) - `UpdateMetadataAuthority(UpdateMetadataAuthorityAction)` - Update metadata authority (update_metadata.rs) - `RemoveMetadataKey(RemoveMetadataKeyAction)` - Remove metadata key (update_metadata.rs) - `DecompressMint(DecompressMintAction)` - Decompress compressed mint to CMint Solana account (decompress_mint.rs) - - `CompressAndCloseCMint(CompressAndCloseCMintAction)` - Compress and close CMint Solana account (compress_and_close_cmint.rs) + - `CompressAndCloseMint(CompressAndCloseMintAction)` - Compress and close CMint Solana account (compress_and_close_cmint.rs) **Accounts:** @@ -76,15 +76,15 @@ The account ordering differs based on whether writing to CPI context or executin **For execution (when not writing to CPI context):** 4. compressible_config (optional) - - Required when DecompressMint or CompressAndCloseCMint action is present + - Required when DecompressMint or CompressAndCloseMint action is present - CompressibleConfig account - parsed and validated for active state 5. cmint (optional) - (mutable) - CMint Solana account (decompressed compressed mint) - - Required when cmint_decompressed=true OR DecompressMint OR CompressAndCloseCMint action present + - Required when cmint_decompressed=true OR DecompressMint OR CompressAndCloseMint action present 6. rent_sponsor (optional) - - (mutable) - Required when DecompressMint or CompressAndCloseCMint action is present + - (mutable) - Required when DecompressMint or CompressAndCloseMint action is present - Rent sponsor PDA that pays for CMint account creation 7-12. Light system accounts (standard set): @@ -130,7 +130,7 @@ The account ordering differs based on whether writing to CPI context or executin **Packed accounts (remaining accounts):** - Merkle tree and queue accounts for compressed storage -- Recipient ctoken accounts for MintToCToken action +- Recipient ctoken accounts for MintTo action **Instruction Logic and Checks:** @@ -143,7 +143,7 @@ The account ordering differs based on whether writing to CPI context or executin - Check authority is signer - Validate CMint account matches expected mint pubkey (when cmint_pubkey provided) - For create_mint: validate address_merkle_tree is CMINT_ADDRESS_TREE - - Parse compressible config when DecompressMint or CompressAndCloseCMint action present + - Parse compressible config when DecompressMint or CompressAndCloseMint action present - Extract packed accounts for dynamic operations 3. **Process mint creation or input:** @@ -169,7 +169,7 @@ The account ordering differs based on whether writing to CPI context or executin - Validate: current authority matches signer - Update: set new authority (can be None to disable) - **MintToCToken:** + **MintTo:** - Validate: mint authority matches signer - Calculate: sum recipient amount - Update: mint supply += amount @@ -194,7 +194,7 @@ The account ordering differs based on whether writing to CPI context or executin - Create CMint PDA that becomes the source of truth - Update cmint_decompressed flag in compressed mint metadata - **CompressAndCloseCMint:** + **CompressAndCloseMint:** - Compress and close a CMint Solana account - Permissionless - anyone can call if is_compressible() returns true (rent expired) - Compressed mint state is preserved @@ -232,13 +232,13 @@ The account ordering differs based on whether writing to CPI context or executin - `ErrorCode::MintActionInvalidCompressionState` (error code: 6072) - New mint must start as compressed - `ErrorCode::MintActionUnsupportedOperation` (error code: 6073) - Unsupported operation - `ErrorCode::CpiContextExpected` (error code: 6085) - CPI context required but not provided -- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Account index out of bounds for MintToCToken +- `ErrorCode::TooManyCompressionTransfers` (error code: 6095) - Account index out of bounds for MintTo - `ErrorCode::MintActionInvalidCpiContextForCreateMint` (error code: 6104) - Invalid CPI context for create mint operation - `ErrorCode::MintActionInvalidCpiContextAddressTreePubkey` (error code: 6105) - Invalid address tree pubkey in CPI context - `ErrorCode::MintActionInvalidCompressedMintAddress` (error code: 6103) - Invalid compressed mint address derivation - `ErrorCode::MintDataRequired` (error code: 6125) - Mint data required in instruction when not decompressed -- `ErrorCode::CannotDecompressAndCloseInSameInstruction` (error code: 6123) - Cannot combine DecompressMint and CompressAndCloseCMint in same instruction -- `ErrorCode::CompressAndCloseCMintMustBeOnlyAction` (error code: 6169) - CompressAndCloseCMint must be the only action in the instruction +- `ErrorCode::CannotDecompressAndCloseInSameInstruction` (error code: 6123) - Cannot combine DecompressMint and CompressAndCloseMint in same instruction +- `ErrorCode::CompressAndCloseCMintMustBeOnlyAction` (error code: 6169) - CompressAndCloseMint must be the only action in the instruction - `ErrorCode::CpiContextSetNotUsable` (error code: 6035) - Mint to ctokens or decompress mint not allowed when writing to CPI context - `CTokenError::MaxTopUpExceeded` - Max top-up budget exceeded diff --git a/sdk-libs/compressible-client/Cargo.toml b/sdk-libs/compressible-client/Cargo.toml index 839c2fd03b..0c21f770db 100644 --- a/sdk-libs/compressible-client/Cargo.toml +++ b/sdk-libs/compressible-client/Cargo.toml @@ -25,8 +25,9 @@ light-compressed-account = { workspace = true } light-compressible = { workspace = true } anchor-lang = { workspace = true, features = ["idl-build"], optional = true } +async-trait = { workspace = true } borsh = { workspace = true } futures = { workspace = true } -smallvec = "1.15" +smallvec = { workspace = true } thiserror = { workspace = true } diff --git a/sdk-libs/compressible-client/src/account_interface_ext.rs b/sdk-libs/compressible-client/src/account_interface_ext.rs new file mode 100644 index 0000000000..105a7cfdf9 --- /dev/null +++ b/sdk-libs/compressible-client/src/account_interface_ext.rs @@ -0,0 +1,226 @@ +//! Extension trait for unified hot/cold account interfaces. +//! +//! Blanket-implemented for `Rpc + Indexer`. + +use async_trait::async_trait; +use borsh::BorshDeserialize as _; +use light_client::{ + indexer::{GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer}, + rpc::{Rpc, RpcError}, +}; +use light_compressed_account::address::derive_address; +use light_token_interface::{state::Mint, CMINT_ADDRESS_TREE}; +use light_token_sdk::token::{derive_mint_compressed_address, derive_token_ata, find_mint_address}; +use solana_pubkey::Pubkey; + +use crate::{AccountInfoInterface, AtaInterface, MintInterface, MintState, TokenAccountInterface}; + +fn indexer_err(e: impl std::fmt::Display) -> RpcError { + RpcError::CustomError(format!("IndexerError: {}", e)) +} + +/// Extension trait for fetching unified hot/cold account interfaces. +/// +/// Blanket-implemented for all `Rpc + Indexer` types. +/// TODO: move to server endpoint. +#[async_trait] +pub trait AccountInterfaceExt: Rpc + Indexer { + /// Fetch MintInterface for a mint signer. + async fn get_mint_interface(&self, signer: &Pubkey) -> Result; + + /// Fetch AccountInfoInterface for a rent-free PDA. + async fn get_account_info_interface( + &self, + address: &Pubkey, + program_id: &Pubkey, + ) -> Result; + + /// Fetch TokenAccountInterface for a token account address. + async fn get_token_account_interface( + &self, + address: &Pubkey, + ) -> Result; + + /// Fetch AtaInterface for an (owner, mint) pair. + async fn get_ata_interface( + &self, + owner: &Pubkey, + mint: &Pubkey, + ) -> Result; +} + +#[async_trait] +impl AccountInterfaceExt for T { + async fn get_mint_interface(&self, signer: &Pubkey) -> Result { + let (cmint, _) = find_mint_address(signer); + let address_tree = Pubkey::new_from_array(CMINT_ADDRESS_TREE); + let compressed_address = derive_mint_compressed_address(signer, &address_tree); + + // On-chain first + if let Some(account) = self.get_account(cmint).await? { + return Ok(MintInterface { + cmint, + signer: *signer, + address_tree, + compressed_address, + state: MintState::Hot { account }, + }); + } + + // Compressed state + let result = self + .get_compressed_account(compressed_address, None) + .await + .map_err(indexer_err)?; + + if let Some(compressed) = result.value { + if let Some(data) = compressed.data.as_ref() { + if !data.data.is_empty() { + if let Ok(mint_data) = Mint::try_from_slice(&data.data) { + return Ok(MintInterface { + cmint, + signer: *signer, + address_tree, + compressed_address, + state: MintState::Cold { + compressed, + mint_data, + }, + }); + } + } + } + } + + Ok(MintInterface { + cmint, + signer: *signer, + address_tree, + compressed_address, + state: MintState::None, + }) + } + + async fn get_account_info_interface( + &self, + address: &Pubkey, + program_id: &Pubkey, + ) -> Result { + let address_tree = self.get_address_tree_v2().tree; + let compressed_address = derive_address( + &address.to_bytes(), + &address_tree.to_bytes(), + &program_id.to_bytes(), + ); + + // On-chain first + if let Some(account) = self.get_account(*address).await? { + return Ok(AccountInfoInterface::hot(*address, account)); + } + + // Compressed state + let result = self + .get_compressed_account(compressed_address, None) + .await + .map_err(indexer_err)?; + + if let Some(compressed) = result.value { + if compressed.data.as_ref().is_some_and(|d| !d.data.is_empty()) { + return Ok(AccountInfoInterface::cold( + *address, + compressed, + *program_id, + )); + } + } + + // Doesn't exist + let account = solana_account::Account { + lamports: 0, + data: vec![], + owner: *program_id, + executable: false, + rent_epoch: 0, + }; + Ok(AccountInfoInterface::hot(*address, account)) + } + + async fn get_token_account_interface( + &self, + address: &Pubkey, + ) -> Result { + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + + // On-chain first + if let Some(account) = self.get_account(*address).await? { + return TokenAccountInterface::hot(*address, account) + .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))); + } + + // Compressed state + let result = self + .get_compressed_token_accounts_by_owner(address, None, None) + .await + .map_err(indexer_err)?; + + if let Some(compressed) = result.value.items.into_iter().next() { + let mint = compressed.token.mint; + return Ok(TokenAccountInterface::cold( + *address, + compressed, + *address, + mint, + 0, + LIGHT_TOKEN_PROGRAM_ID.into(), + )); + } + + Err(RpcError::CustomError(format!( + "token account not found: {}", + address + ))) + } + + async fn get_ata_interface( + &self, + owner: &Pubkey, + mint: &Pubkey, + ) -> Result { + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + + let (ata, bump) = derive_token_ata(owner, mint); + + // On-chain first + if let Some(account) = self.get_account(ata).await? { + let inner = TokenAccountInterface::hot(ata, account) + .map_err(|e| RpcError::CustomError(format!("parse error: {}", e)))?; + return Ok(AtaInterface::new(inner)); + } + + // Compressed state + let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( + Some(*mint), + )); + let result = self + .get_compressed_token_accounts_by_owner(&ata, options, None) + .await + .map_err(indexer_err)?; + + if let Some(compressed) = result.value.items.into_iter().next() { + let inner = TokenAccountInterface::cold( + ata, + compressed, + *owner, + *mint, + bump, + LIGHT_TOKEN_PROGRAM_ID.into(), + ); + return Ok(AtaInterface::new(inner)); + } + + Err(RpcError::CustomError(format!( + "ATA not found: owner={} mint={}", + owner, mint + ))) + } +} diff --git a/sdk-libs/compressible-client/src/decompress_mint.rs b/sdk-libs/compressible-client/src/decompress_mint.rs index 6719fa72ca..873660ecee 100644 --- a/sdk-libs/compressible-client/src/decompress_mint.rs +++ b/sdk-libs/compressible-client/src/decompress_mint.rs @@ -1,11 +1,19 @@ //! Mint interface types for hot/cold CMint handling. //! -//! Use `get_mint_interface()` from `LightProgramTest` to fetch, +//! Use `AccountInterfaceExt::get_mint_interface()` to fetch, //! then pass to `create_load_accounts_instructions()` for decompression. -use light_client::indexer::CompressedAccount; -use light_token_interface::state::Mint; +use borsh::BorshDeserialize; +use light_client::indexer::{CompressedAccount, Indexer, ValidityProofWithContext}; +use light_compressed_account::instruction_data::compressed_proof::ValidityProof; +use light_token_interface::{ + instructions::mint_action::{MintInstructionData, MintWithContext}, + state::Mint, + CMINT_ADDRESS_TREE, +}; +use light_token_sdk::token::{derive_mint_compressed_address, find_mint_address, DecompressMint}; use solana_account::Account; +use solana_instruction::Instruction; use solana_pubkey::Pubkey; use thiserror::Error; @@ -23,6 +31,12 @@ pub enum DecompressMintError { #[error("Mint already decompressed")] AlreadyDecompressed, + + #[error("Validity proof required for cold mint")] + ProofRequired, + + #[error("Indexer error: {0}")] + IndexerError(#[from] light_client::indexer::IndexerError), } /// State of a CMint - either on-chain (hot), compressed (cold), or non-existent. diff --git a/sdk-libs/compressible-client/src/lib.rs b/sdk-libs/compressible-client/src/lib.rs index e18907e773..9e09986cc3 100644 --- a/sdk-libs/compressible-client/src/lib.rs +++ b/sdk-libs/compressible-client/src/lib.rs @@ -1,15 +1,18 @@ pub mod account_interface; +pub mod account_interface_ext; pub mod create_accounts_proof; pub mod decompress_mint; pub mod get_compressible_account; pub mod initialize_config; pub mod load_accounts; pub mod pack; +pub mod tx_size; pub use account_interface::{ AccountInfoInterface, AccountInterfaceError, AtaInterface, PdaLoadContext, TokenAccountInterface, TokenLoadContext, }; +pub use account_interface_ext::AccountInterfaceExt; #[cfg(feature = "anchor")] use anchor_lang::{AnchorDeserialize, AnchorSerialize}; #[cfg(not(feature = "anchor"))] @@ -44,6 +47,7 @@ pub use pack::{pack_proof, PackError, PackedProofResult}; use solana_account::Account; use solana_instruction::{AccountMeta, Instruction}; use solana_pubkey::Pubkey; +pub use tx_size::{split_by_tx_size, InstructionTooLargeError, PACKET_DATA_SIZE}; /// Helper function to get the output queue from tree info. /// Prefers next_tree_info.queue if available, otherwise uses current queue. diff --git a/sdk-libs/compressible-client/src/load_accounts.rs b/sdk-libs/compressible-client/src/load_accounts.rs index bd38019901..8eb540f3b9 100644 --- a/sdk-libs/compressible-client/src/load_accounts.rs +++ b/sdk-libs/compressible-client/src/load_accounts.rs @@ -1,18 +1,4 @@ //! Load (decompress) accounts API. -//! -//! Single entry point `create_load_accounts_instructions()` that: -//! - Filters cold accounts -//! - Fetches proofs concurrently -//! - Builds instructions via lean internal builders - -use crate::{ - account_interface::{TokenAccountInterface, TokenLoadContext}, - compressible_instruction::{self, DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR}, - decompress_mint::{ - DecompressMintError, MintInterface, DEFAULT_RENT_PAYMENT, DEFAULT_WRITE_TOP_UP, - }, - RentFreeDecompressAccount, -}; use light_client::indexer::{Indexer, IndexerError, ValidityProofWithContext}; use light_compressed_account::{ compressed_account::PackedMerkleContext, instruction_data::compressed_proof::ValidityProof, @@ -44,6 +30,15 @@ use solana_instruction::Instruction; use solana_pubkey::Pubkey; use thiserror::Error; +use crate::{ + account_interface::{TokenAccountInterface, TokenLoadContext}, + compressible_instruction::{self, DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR}, + decompress_mint::{ + DecompressMintError, MintInterface, DEFAULT_RENT_PAYMENT, DEFAULT_WRITE_TOP_UP, + }, + RentFreeDecompressAccount, +}; + /// Error type for load accounts operations. #[derive(Debug, Error)] pub enum LoadAccountsError { @@ -58,11 +53,21 @@ pub enum LoadAccountsError { #[error("Mint error: {0}")] Mint(#[from] DecompressMintError), + + #[error("Cold PDA at index {index} (pubkey {pubkey}) is missing decompression_context")] + MissingPdaDecompressionContext { index: usize, pubkey: Pubkey }, + + #[error("Cold ATA at index {index} (pubkey {pubkey}) is missing load_context")] + MissingAtaLoadContext { index: usize, pubkey: Pubkey }, + + #[error("Cold mint at index {index} (cmint {cmint}) is missing compressed hash")] + MissingMintHash { index: usize, cmint: Pubkey }, } /// Build load instructions for cold accounts. /// Exists fast if all accounts are hot. /// Else, fetches proofs, returns instructions. +#[allow(clippy::too_many_arguments)] pub async fn create_load_accounts_instructions( program_owned_accounts: &[RentFreeDecompressAccount], associated_token_accounts: &[TokenAccountInterface], @@ -93,19 +98,43 @@ where return Ok(vec![]); } - // get hashes + // get hashes - fail fast if any cold account is missing required context let pda_hashes: Vec<[u8; 32]> = cold_pdas .iter() - .filter_map(|a| { + .enumerate() + .map(|(i, a)| { a.account_interface .decompression_context .as_ref() .map(|c| c.compressed_account.hash) + .ok_or(LoadAccountsError::MissingPdaDecompressionContext { + index: i, + pubkey: a.account_interface.pubkey, + }) }) - .collect(); + .collect::, _>>()?; - let ata_hashes: Vec<[u8; 32]> = cold_atas.iter().filter_map(|a| a.hash()).collect(); - let mint_hashes: Vec<[u8; 32]> = cold_mints.iter().filter_map(|m| m.hash()).collect(); + let ata_hashes: Vec<[u8; 32]> = cold_atas + .iter() + .enumerate() + .map(|(i, a)| { + a.hash().ok_or(LoadAccountsError::MissingAtaLoadContext { + index: i, + pubkey: a.pubkey, + }) + }) + .collect::, _>>()?; + + let mint_hashes: Vec<[u8; 32]> = cold_mints + .iter() + .enumerate() + .map(|(i, m)| { + m.hash().ok_or(LoadAccountsError::MissingMintHash { + index: i, + cmint: m.cmint, + }) + }) + .collect::, _>>()?; // Fetch proofs concurrently. // TODO: single batched proof RPC endpoint. @@ -158,10 +187,6 @@ where Ok(out) } -// ============================================================================= -// Proof fetching helpers -// ============================================================================= - async fn fetch_proof_if_needed( hashes: &[[u8; 32]], indexer: &I, @@ -194,10 +219,6 @@ async fn fetch_mint_proofs( Ok(proofs) } -// ============================================================================= -// Lean internal builders (no filtering, proof required) -// ============================================================================= - /// Build decompress instruction for PDA + Token accounts. /// Assumes all inputs are cold (caller filtered). pub fn create_decompress_idempotent_instructions( @@ -211,7 +232,7 @@ pub fn create_decompress_idempotent_instructions( where V: Pack + Clone + std::fmt::Debug, { - // Check for tokens by owner (LIGHT_TOKEN_PROGRAM_ID) + // Check for tokens by program id let has_tokens = accounts.iter().any(|a| { a.account_interface .decompression_context diff --git a/sdk-libs/compressible-client/src/tx_size.rs b/sdk-libs/compressible-client/src/tx_size.rs new file mode 100644 index 0000000000..9ca277780f --- /dev/null +++ b/sdk-libs/compressible-client/src/tx_size.rs @@ -0,0 +1,250 @@ +//! Transaction size estimation and instruction batching. + +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; + +/// Maximum transaction size in bytes (1280 MTU - 40 IPv6 header - 8 fragment header). +pub const PACKET_DATA_SIZE: usize = 1232; + +/// Error when a single instruction exceeds the maximum transaction size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstructionTooLargeError { + /// Index of the oversized instruction in the input vector. + pub instruction_index: usize, + /// Estimated size of a transaction containing only this instruction. + pub estimated_size: usize, + /// Maximum allowed transaction size. + pub max_size: usize, +} + +impl std::fmt::Display for InstructionTooLargeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "instruction at index {} exceeds max transaction size: {} > {}", + self.instruction_index, self.estimated_size, self.max_size + ) + } +} + +impl std::error::Error for InstructionTooLargeError {} + +/// Split instructions into groups that fit within transaction size limits. +/// +/// Signer count is derived from instruction AccountMeta.is_signer flags plus the payer. +/// +/// # Arguments +/// * `instructions` - Instructions to split +/// * `payer` - Fee payer pubkey (always counted as a signer) +/// * `max_size` - Max tx size (defaults to PACKET_DATA_SIZE) +/// +/// # Errors +/// Returns `InstructionTooLargeError` if any single instruction alone exceeds `max_size`. +pub fn split_by_tx_size( + instructions: Vec, + payer: &Pubkey, + max_size: Option, +) -> Result>, InstructionTooLargeError> { + let max_size = max_size.unwrap_or(PACKET_DATA_SIZE); + + if instructions.is_empty() { + return Ok(vec![]); + } + + let mut batches = Vec::new(); + let mut current_batch = Vec::new(); + + for (idx, ix) in instructions.into_iter().enumerate() { + let mut trial = current_batch.clone(); + trial.push(ix.clone()); + + if estimate_tx_size(&trial, payer) > max_size { + // Check if this single instruction alone exceeds max_size + let single_ix_size = estimate_tx_size(std::slice::from_ref(&ix), payer); + if single_ix_size > max_size { + return Err(InstructionTooLargeError { + instruction_index: idx, + estimated_size: single_ix_size, + max_size, + }); + } + + if !current_batch.is_empty() { + batches.push(current_batch); + } + current_batch = vec![ix]; + } else { + current_batch.push(ix); + } + } + + if !current_batch.is_empty() { + batches.push(current_batch); + } + + Ok(batches) +} + +/// Count unique signers from instructions plus the payer. +fn count_signers(instructions: &[Instruction], payer: &Pubkey) -> usize { + let mut signers = vec![*payer]; + for ix in instructions { + for meta in &ix.accounts { + if meta.is_signer && !signers.contains(&meta.pubkey) { + signers.push(meta.pubkey); + } + } + } + signers.len() +} + +/// Estimate transaction size including signatures. +/// +/// Signer count is derived from instruction AccountMeta.is_signer flags plus the payer. +fn estimate_tx_size(instructions: &[Instruction], payer: &Pubkey) -> usize { + let num_signers = count_signers(instructions, payer); + + // Collect unique accounts + let mut accounts = vec![*payer]; + for ix in instructions { + if !accounts.contains(&ix.program_id) { + accounts.push(ix.program_id); + } + for meta in &ix.accounts { + if !accounts.contains(&meta.pubkey) { + accounts.push(meta.pubkey); + } + } + } + + // Header: 3 bytes + let mut size = 3; + // Account keys: compact-u16 len + 32 bytes each + size += compact_len(accounts.len()) + accounts.len() * 32; + // Blockhash: 32 bytes + size += 32; + // Instructions + size += compact_len(instructions.len()); + for ix in instructions { + size += 1; // program_id index + size += compact_len(ix.accounts.len()) + ix.accounts.len(); + size += compact_len(ix.data.len()) + ix.data.len(); + } + // Signatures + size += compact_len(num_signers) + num_signers * 64; + + size +} + +#[inline] +fn compact_len(val: usize) -> usize { + if val < 0x80 { + 1 + } else if val < 0x4000 { + 2 + } else { + 3 + } +} + +#[cfg(test)] +mod tests { + use solana_instruction::AccountMeta; + + use super::*; + + #[test] + fn test_split_by_tx_size() { + let payer = Pubkey::new_unique(); + let instructions: Vec = (0..10) + .map(|_| Instruction { + program_id: Pubkey::new_unique(), + accounts: (0..10) + .map(|_| AccountMeta::new(Pubkey::new_unique(), false)) + .collect(), + data: vec![0u8; 200], + }) + .collect(); + + let batches = split_by_tx_size(instructions, &payer, None).unwrap(); + assert!(batches.len() > 1); + + for batch in &batches { + assert!(estimate_tx_size(batch, &payer) <= PACKET_DATA_SIZE); + } + } + + #[test] + fn test_split_by_tx_size_oversized_instruction() { + let payer = Pubkey::new_unique(); + + // Create an instruction that exceeds PACKET_DATA_SIZE on its own + let oversized_ix = Instruction { + program_id: Pubkey::new_unique(), + accounts: (0..5) + .map(|_| AccountMeta::new(Pubkey::new_unique(), false)) + .collect(), + data: vec![0u8; 2000], // Large data payload + }; + + let small_ix = Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![AccountMeta::new(Pubkey::new_unique(), false)], + data: vec![0u8; 10], + }; + + // Oversized instruction at index 1 + let instructions = vec![small_ix.clone(), oversized_ix, small_ix]; + + let result = split_by_tx_size(instructions, &payer, None); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert_eq!(err.instruction_index, 1); + assert!(err.estimated_size > err.max_size); + assert_eq!(err.max_size, PACKET_DATA_SIZE); + } + + #[test] + fn test_signer_count_derived_from_metadata() { + let payer = Pubkey::new_unique(); + let extra_signer = Pubkey::new_unique(); + + // Instruction with an additional signer + let ix_with_signer = Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![ + AccountMeta::new(Pubkey::new_unique(), false), + AccountMeta::new(extra_signer, true), // is_signer = true + ], + data: vec![0u8; 10], + }; + + // Instruction without additional signers + let ix_no_signer = Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![AccountMeta::new(Pubkey::new_unique(), false)], + data: vec![0u8; 10], + }; + + // Payer only + assert_eq!( + count_signers(std::slice::from_ref(&ix_no_signer), &payer), + 1 + ); + + // Payer + extra signer + assert_eq!( + count_signers(std::slice::from_ref(&ix_with_signer), &payer), + 2 + ); + + // Payer duplicated in instruction should still be 1 + let ix_payer_signer = Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![AccountMeta::new(payer, true)], + data: vec![0u8; 10], + }; + assert_eq!(count_signers(&[ix_payer_signer], &payer), 1); + } +} diff --git a/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs b/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs index f24be467bf..143c7958e7 100644 --- a/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs +++ b/sdk-libs/macros/src/rentfree/traits/seed_extraction.rs @@ -525,8 +525,8 @@ pub fn classify_seed_expr(expr: &Expr) -> syn::Result { /// Classify a method call expression like account.key().as_ref() fn classify_method_call(mc: &syn::ExprMethodCall) -> syn::Result { - // Unwrap .as_ref() at the end - if mc.method == "as_ref" { + // Unwrap .as_ref() or .as_bytes() at the end + if mc.method == "as_ref" || mc.method == "as_bytes" { return classify_seed_expr(&mc.receiver); } diff --git a/sdk-libs/program-test/src/compressible.rs b/sdk-libs/program-test/src/compressible.rs index 8a0a0cf99d..b46f70b668 100644 --- a/sdk-libs/program-test/src/compressible.rs +++ b/sdk-libs/program-test/src/compressible.rs @@ -75,9 +75,9 @@ fn extract_compression_info(data: &[u8]) -> Option<(CompressionInfo, u8, bool)> Some((compression_info, account_type, compression_only)) } ACCOUNT_TYPE_MINT => { - let cmint = Mint::deserialize(&mut &data[..]).ok()?; - // CMint accounts don't have compression_only, default to false - Some((cmint.compression, account_type, false)) + let mint = Mint::deserialize(&mut &data[..]).ok()?; + // Mint accounts don't have compression_only, default to false + Some((mint.compression, account_type, false)) } _ => None, } @@ -194,7 +194,7 @@ pub async fn claim_and_compress( // Separate accounts by type and compression_only setting let mut compress_accounts_compression_only = Vec::new(); let mut compress_accounts_normal = Vec::new(); - let mut compress_cmint_accounts = Vec::new(); + let mut compress_mint_accounts = Vec::new(); let mut claim_accounts = Vec::new(); // For each stored account, determine action using AccountRentState @@ -228,8 +228,8 @@ pub async fn claim_and_compress( compress_accounts_normal.push(*pubkey); } } else if stored_account.account_type == ACCOUNT_TYPE_MINT { - // CMint accounts - use mint_action flow - compress_cmint_accounts.push(*pubkey); + // Mint accounts - use mint_action flow + compress_mint_accounts.push(*pubkey); } } Some(claimable_amount) if claimable_amount > 0 => { @@ -269,10 +269,10 @@ pub async fn claim_and_compress( } } - // Process CMint accounts via mint_action - for cmint_pubkey in compress_cmint_accounts { - compress_cmint_forester(rpc, cmint_pubkey, &payer).await?; - stored_compressible_accounts.remove(&cmint_pubkey); + // Process Mint accounts via mint_action + for mint_pubkey in compress_mint_accounts { + compress_mint_forester(rpc, mint_pubkey, &payer).await?; + stored_compressible_accounts.remove(&mint_pubkey); } Ok(()) @@ -401,12 +401,12 @@ async fn try_compress_chunk( } } -/// Compress and close a CMint account via mint_action instruction. -/// CMint uses MintAction::CompressAndCloseCMint flow instead of registry compress_and_close. +/// Compress and close a Mint account via mint_action instruction. +/// Mint uses MintAction::CompressAndCloseMint flow instead of registry compress_and_close. #[cfg(feature = "devenv")] -async fn compress_cmint_forester( +async fn compress_mint_forester( rpc: &mut LightProgramTest, - cmint_pubkey: Pubkey, + mint_pubkey: Pubkey, payer: &solana_sdk::signature::Keypair, ) -> Result<(), RpcError> { use light_client::indexer::Indexer; @@ -414,24 +414,25 @@ async fn compress_cmint_forester( use light_compressible::config::CompressibleConfig; use light_token_interface::{ instructions::mint_action::{ - CompressAndCloseCMintAction, MintActionCompressedInstructionData, MintWithContext, + CompressAndCloseMintAction, MintActionCompressedInstructionData, MintWithContext, }, LIGHT_TOKEN_PROGRAM_ID, }; use light_token_sdk::compressed_token::mint_action::MintActionMetaConfig; use solana_sdk::signature::Signer; - // Get CMint account data - let cmint_account = rpc.get_account(cmint_pubkey).await?.ok_or_else(|| { - RpcError::CustomError(format!("CMint account {} not found", cmint_pubkey)) - })?; + // Get Mint account data + let mint_account = rpc + .get_account(mint_pubkey) + .await? + .ok_or_else(|| RpcError::CustomError(format!("Mint account {} not found", mint_pubkey)))?; - // Deserialize CMint to get compressed_address and rent_sponsor - let cmint: Mint = BorshDeserialize::deserialize(&mut cmint_account.data.as_slice()) - .map_err(|e| RpcError::CustomError(format!("Failed to deserialize CMint: {:?}", e)))?; + // Deserialize Mint to get compressed_address and rent_sponsor + let mint: Mint = BorshDeserialize::deserialize(&mut mint_account.data.as_slice()) + .map_err(|e| RpcError::CustomError(format!("Failed to deserialize Mint: {:?}", e)))?; - let compressed_mint_address = cmint.metadata.compressed_address(); - let rent_sponsor = Pubkey::from(cmint.compression.rent_sponsor); + let compressed_mint_address = mint.metadata.compressed_address(); + let rent_sponsor = Pubkey::from(mint.compression.rent_sponsor); // Get the compressed mint account from indexer let compressed_mint_account = rpc @@ -450,8 +451,8 @@ async fn compress_cmint_forester( .value; // Build compressed mint inputs - // IMPORTANT: Set mint to None when CMint is decompressed - // This tells on-chain code to read mint data from CMint Solana account + // IMPORTANT: Set mint to None when Mint is decompressed + // This tells on-chain code to read mint data from Mint Solana account // (not from instruction data which would have stale compression_info) let compressed_mint_inputs = MintWithContext { prove_by_index: rpc_proof_result.accounts[0].root_index.proof_by_index(), @@ -461,29 +462,29 @@ async fn compress_cmint_forester( .root_index() .unwrap_or_default(), address: compressed_mint_address, - mint: None, // CMint is decompressed, data lives in CMint account + mint: None, // Mint is decompressed, data lives in Mint account }; - // Build instruction data with CompressAndCloseCMint action + // Build instruction data with CompressAndCloseMint action let instruction_data = MintActionCompressedInstructionData::new( compressed_mint_inputs, rpc_proof_result.proof.into(), ) - .with_compress_and_close_mint(CompressAndCloseCMintAction { idempotent: 1 }); + .with_compress_and_close_mint(CompressAndCloseMintAction { idempotent: 1 }); // Get state tree info let state_tree_info = rpc_proof_result.accounts[0].tree_info; - // Build account metas - authority can be anyone for permissionless CompressAndCloseCMint + // Build account metas - authority can be anyone for permissionless CompressAndCloseMint let config_address = CompressibleConfig::light_token_v1_config_pda(); let meta_config = MintActionMetaConfig::new( payer.pubkey(), - payer.pubkey(), // authority doesn't matter for CompressAndCloseCMint + payer.pubkey(), // authority doesn't matter for CompressAndCloseMint state_tree_info.tree, state_tree_info.queue, state_tree_info.queue, ) - .with_compressible_mint(cmint_pubkey, config_address, rent_sponsor); + .with_compressible_mint(mint_pubkey, config_address, rent_sponsor); let account_metas = meta_config.to_account_metas(); diff --git a/sdk-libs/program-test/src/program_test/light_program_test.rs b/sdk-libs/program-test/src/program_test/light_program_test.rs index 6b6ccd0930..08b4680f26 100644 --- a/sdk-libs/program-test/src/program_test/light_program_test.rs +++ b/sdk-libs/program-test/src/program_test/light_program_test.rs @@ -430,279 +430,6 @@ impl LightProgramTest { self.auto_mine_cold_state_programs .retain(|&pid| pid != program_id); } - - /// Fetches ATA interface for a (mint, owner) pair. - /// - /// Checks on-chain first, then compressed state. - /// Always returns `AtaInterface` with `data` populated so clients can - /// access `amount`, `delegate`, etc. regardless of hot/cold state. - /// - /// Fetches MintInterface for a mint signer pubkey. - /// - /// Checks on-chain first, then compressed state. - /// Returns `MintInterface` with state: - /// - `Hot` if CMint exists on-chain - /// - `Cold` if CMint is compressed (needs decompression) - /// - `None` if CMint doesn't exist - /// - /// # Example - /// ```ignore - /// let mint = rpc.get_mint_interface(&signer).await?; - /// if mint.is_cold() { - /// // Need to decompress - /// } - /// ``` - pub async fn get_mint_interface( - &self, - signer: &solana_sdk::pubkey::Pubkey, - ) -> Result { - use borsh::BorshDeserialize; - use light_client::indexer::Indexer; - use light_compressible_client::{MintInterface, MintState}; - use light_token_interface::{state::Mint, CMINT_ADDRESS_TREE}; - use light_token_sdk::{ - compressed_token::create_compressed_mint::derive_mint_compressed_address, - token::find_mint_address, - }; - - let (cmint, _) = find_mint_address(signer); - let address_tree = solana_sdk::pubkey::Pubkey::new_from_array(CMINT_ADDRESS_TREE); - let compressed_address = derive_mint_compressed_address(signer, &address_tree); - - // Check on-chain first - if let Some(account) = self.context.get_account(&cmint) { - return Ok(MintInterface { - cmint, - signer: *signer, - address_tree, - compressed_address, - state: MintState::Hot { account }, - }); - } - - // Check compressed state - let result = self - .get_compressed_account(compressed_address, None) - .await?; - - if let Some(compressed) = result.value { - // Parse mint data if available - if let Some(data) = compressed.data.as_ref() { - if !data.data.is_empty() { - if let Ok(mint_data) = Mint::try_from_slice(&data.data) { - return Ok(MintInterface { - cmint, - signer: *signer, - address_tree, - compressed_address, - state: MintState::Cold { - compressed, - mint_data, - }, - }); - } - } - } - // Empty data = already decompressed (return None, not Cold) - } - - // Doesn't exist - Ok(MintInterface { - cmint, - signer: *signer, - address_tree, - compressed_address, - state: MintState::None, - }) - } - - // ======================================================================== - // New unified interface functions (mirror TypeScript SDK) - // ======================================================================== - - /// Fetches AccountInfoInterface for a compressible PDA. - /// - /// Checks on-chain first, then compressed state. - /// Returns unified interface with: - /// - `account`: Always present (real or synthetic bytes) - /// - `is_cold`: True if needs decompression - /// - `load_context`: Decompression context if cold - /// - /// # Example - /// ```ignore - /// let account = rpc.get_account_info_interface(&pda, &program_id).await?; - /// if account.is_cold { - /// // Need to decompress before use - /// } - /// ``` - pub async fn get_account_info_interface( - &self, - address: &solana_sdk::pubkey::Pubkey, - program_id: &solana_sdk::pubkey::Pubkey, - ) -> Result { - use light_client::indexer::Indexer; - use light_client::rpc::Rpc as RpcTrait; - use light_compressed_account::address::derive_address; - use light_compressible_client::AccountInfoInterface; - - let address_tree = self.get_address_tree_v2().tree; - let compressed_address = derive_address( - &address.to_bytes(), - &address_tree.to_bytes(), - &program_id.to_bytes(), - ); - - // Check on-chain first - if let Some(account) = self.context.get_account(address) { - return Ok(AccountInfoInterface::hot(*address, account)); - } - - // Check compressed state - let result = self - .get_compressed_account(compressed_address, None) - .await?; - - if let Some(compressed) = result.value { - if compressed - .data - .as_ref() - .map_or(false, |d| !d.data.is_empty()) - { - return Ok(AccountInfoInterface::cold( - *address, - compressed, - *program_id, - )); - } - } - - // Doesn't exist - return empty synthetic account - let account = solana_sdk::account::Account { - lamports: 0, - data: vec![], - owner: *program_id, - executable: false, - rent_epoch: 0, - }; - - Ok(AccountInfoInterface::hot(*address, account)) - } - - /// Fetches TokenAccountInterface for a token account address. - /// - /// Checks on-chain first, then compressed state. - /// Uses standard SPL types: - /// - `account`: `solana_sdk::account::Account` - /// - `parsed`: `spl_token_2022::state::Account` - /// - /// # Example - /// ```ignore - /// let token = rpc.get_token_account_interface(&token_account).await?; - /// println!("Amount: {}", token.amount()); - /// if token.is_cold { - /// // Need to decompress - /// } - /// ``` - pub async fn get_token_account_interface( - &self, - address: &solana_sdk::pubkey::Pubkey, - ) -> Result { - use light_client::indexer::Indexer; - use light_compressible_client::account_interface::TokenAccountInterface; - use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; - - // Check on-chain first - if let Some(account) = self.context.get_account(address) { - return TokenAccountInterface::hot(*address, account).map_err(|e| { - RpcError::CustomError(format!("Failed to parse token account: {}", e)) - }); - } - - // Check compressed state by owner (address is the token account owner for ctoken) - let result = self - .get_compressed_token_accounts_by_owner(address, None, None) - .await?; - - if let Some(compressed) = result.value.items.into_iter().next() { - // Extract mint before moving compressed - let mint = compressed.token.mint; - // For token accounts fetched by address, we use the address as both - // the pubkey and owner (common pattern for PDA-owned token accounts) - return Ok(TokenAccountInterface::cold( - *address, - compressed, - *address, // wallet_owner = address for non-ATA token accounts - mint, - 0, // bump not applicable for non-ATA - LIGHT_TOKEN_PROGRAM_ID.into(), - )); - } - - Err(RpcError::CustomError(format!( - "Token account not found: {}", - address - ))) - } - - /// Fetches AtaInterface for an (owner, mint) pair. - /// - /// Uses standard SPL types and provides unified hot/cold interface. - /// The ATA address is derived from owner + mint. - /// - /// # Example - /// ```ignore - /// let ata = rpc.get_ata_interface(&owner, &mint).await?; - /// println!("Amount: {}", ata.amount()); - /// println!("Mint: {}", ata.mint()); - /// if ata.is_cold() { - /// // Need to decompress - /// } - /// ``` - pub async fn get_ata_interface( - &self, - owner: &solana_sdk::pubkey::Pubkey, - mint: &solana_sdk::pubkey::Pubkey, - ) -> Result { - use light_client::indexer::{GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer}; - use light_compressible_client::account_interface::{AtaInterface, TokenAccountInterface}; - use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; - use light_token_sdk::token::derive_token_ata; - - let (ata, bump) = derive_token_ata(owner, mint); - - // Check on-chain first - if let Some(account) = self.context.get_account(&ata) { - let inner = TokenAccountInterface::hot(ata, account) - .map_err(|e| RpcError::CustomError(format!("Failed to parse ATA: {}", e)))?; - return Ok(AtaInterface::new(inner)); - } - - // Check compressed state - let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( - Some(*mint), - )); - // Query by ATA address (token account owner for c-token ATAs) - let result = self - .get_compressed_token_accounts_by_owner(&ata, options, None) - .await?; - - if let Some(compressed) = result.value.items.into_iter().next() { - let inner = TokenAccountInterface::cold( - ata, - compressed, - *owner, - *mint, - bump, - LIGHT_TOKEN_PROGRAM_ID.into(), - ); - return Ok(AtaInterface::new(inner)); - } - - Err(RpcError::CustomError(format!( - "ATA not found for owner {} mint {}", - owner, mint - ))) - } } impl MerkleTreeExt for LightProgramTest {} diff --git a/sdk-libs/sdk/src/compressible/close.rs b/sdk-libs/sdk/src/compressible/close.rs index 0608569fbc..a240d3aae3 100644 --- a/sdk-libs/sdk/src/compressible/close.rs +++ b/sdk-libs/sdk/src/compressible/close.rs @@ -11,7 +11,8 @@ pub fn close<'info>( if info.key == sol_destination.key { info.assign(&system_program_id); - info.resize(0)?; + info.resize(0) + .map_err(|_| LightSdkError::ConstraintViolation)?; return Ok(()); } @@ -37,7 +38,8 @@ pub fn close<'info>( } info.assign(&system_program_id); - info.resize(0)?; + info.resize(0) + .map_err(|_| LightSdkError::ConstraintViolation)?; Ok(()) } diff --git a/sdk-libs/token-sdk/src/token/create_ata.rs b/sdk-libs/token-sdk/src/token/create_ata.rs index b2b983f87f..f3334e82ee 100644 --- a/sdk-libs/token-sdk/src/token/create_ata.rs +++ b/sdk-libs/token-sdk/src/token/create_ata.rs @@ -136,7 +136,7 @@ impl CreateAssociatedTokenAccount { /// /// # Example - Rent-free ATA (idempotent) /// ```rust,ignore -/// CreateCTokenAtaCpi { +/// CreateTokenAtaCpi { /// payer: ctx.accounts.payer.to_account_info(), /// owner: ctx.accounts.owner.to_account_info(), /// mint: ctx.accounts.mint.to_account_info(), @@ -151,7 +151,7 @@ impl CreateAssociatedTokenAccount { /// ) /// .invoke()?; /// ``` -pub struct CreateCTokenAtaCpi<'info> { +pub struct CreateTokenAtaCpi<'info> { pub payer: AccountInfo<'info>, pub owner: AccountInfo<'info>, pub mint: AccountInfo<'info>, @@ -159,10 +159,10 @@ pub struct CreateCTokenAtaCpi<'info> { pub bump: u8, } -impl<'info> CreateCTokenAtaCpi<'info> { +impl<'info> CreateTokenAtaCpi<'info> { /// Make this an idempotent create (won't fail if ATA already exists). - pub fn idempotent(self) -> CreateCTokenAtaCpiIdempotent<'info> { - CreateCTokenAtaCpiIdempotent { base: self } + pub fn idempotent(self) -> CreateTokenAtaCpiIdempotent<'info> { + CreateTokenAtaCpiIdempotent { base: self } } /// Enable rent-free mode with compressible config. @@ -171,8 +171,8 @@ impl<'info> CreateCTokenAtaCpi<'info> { config: AccountInfo<'info>, sponsor: AccountInfo<'info>, system_program: AccountInfo<'info>, - ) -> CreateCTokenAtaRentFreeCpi<'info> { - CreateCTokenAtaRentFreeCpi { + ) -> CreateTokenAtaRentFreeCpi<'info> { + CreateTokenAtaRentFreeCpi { payer: self.payer, owner: self.owner, mint: self.mint, @@ -206,19 +206,19 @@ impl<'info> CreateCTokenAtaCpi<'info> { } /// Idempotent ATA creation (intermediate type). -pub struct CreateCTokenAtaCpiIdempotent<'info> { - base: CreateCTokenAtaCpi<'info>, +pub struct CreateTokenAtaCpiIdempotent<'info> { + base: CreateTokenAtaCpi<'info>, } -impl<'info> CreateCTokenAtaCpiIdempotent<'info> { +impl<'info> CreateTokenAtaCpiIdempotent<'info> { /// Enable rent-free mode with compressible config. pub fn rent_free( self, config: AccountInfo<'info>, sponsor: AccountInfo<'info>, system_program: AccountInfo<'info>, - ) -> CreateCTokenAtaRentFreeCpi<'info> { - CreateCTokenAtaRentFreeCpi { + ) -> CreateTokenAtaRentFreeCpi<'info> { + CreateTokenAtaRentFreeCpi { payer: self.base.payer, owner: self.base.owner, mint: self.base.mint, @@ -252,7 +252,7 @@ impl<'info> CreateCTokenAtaCpiIdempotent<'info> { } /// Rent-free enabled CToken ATA creation CPI. -pub struct CreateCTokenAtaRentFreeCpi<'info> { +pub struct CreateTokenAtaRentFreeCpi<'info> { payer: AccountInfo<'info>, owner: AccountInfo<'info>, mint: AccountInfo<'info>, @@ -264,7 +264,7 @@ pub struct CreateCTokenAtaRentFreeCpi<'info> { system_program: AccountInfo<'info>, } -impl<'info> CreateCTokenAtaRentFreeCpi<'info> { +impl<'info> CreateTokenAtaRentFreeCpi<'info> { /// Invoke CPI. pub fn invoke(self) -> Result<(), ProgramError> { InternalCreateAtaCpi { diff --git a/sdk-libs/token-sdk/src/token/decompress_mint.rs b/sdk-libs/token-sdk/src/token/decompress_mint.rs index b698ef4c12..fb9e688d64 100644 --- a/sdk-libs/token-sdk/src/token/decompress_mint.rs +++ b/sdk-libs/token-sdk/src/token/decompress_mint.rs @@ -4,11 +4,16 @@ use light_compressed_account::instruction_data::{ use light_token_interface::instructions::mint_action::{ CpiContext, DecompressMintAction, MintActionCompressedInstructionData, MintWithContext, }; +use solana_account_info::AccountInfo; +use solana_cpi::{invoke, invoke_signed}; use solana_instruction::Instruction; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::compressed_token::mint_action::MintActionMetaConfig; +use crate::{ + compressed_token::mint_action::MintActionMetaConfig, + token::{config_pda, rent_sponsor_pda, SystemAccountInfos}, +}; /// Decompress a compressed mint to a Mint Solana account. /// @@ -25,8 +30,8 @@ use crate::compressed_token::mint_action::MintActionMetaConfig; /// output_queue, /// compressed_mint_with_context, /// proof, -/// rent_payment: 16, -/// write_top_up: 766, +/// rent_payment: 16, // epochs (~24 hours rent) +/// write_top_up: 766, // lamports (~3 hours rent per write) /// }.instruction()?; /// ``` #[derive(Debug, Clone)] @@ -98,7 +103,6 @@ impl DecompressMint { } } -<<<<<<< HEAD // ============================================================================ // CPI Struct: DecompressMintCpi // ============================================================================ @@ -226,14 +230,6 @@ impl<'info> TryFrom<&DecompressMintCpi<'info>> for DecompressMint { write_top_up: cpi.write_top_up, }) } -======= -fn config_pda() -> Pubkey { - super::config_pda() -} - -fn rent_sponsor_pda() -> Pubkey { - super::rent_sponsor_pda() ->>>>>>> 7d4ae004e (wip) } /// Decompress a compressed mint with CPI context support. diff --git a/sdk-libs/token-sdk/src/token/mod.rs b/sdk-libs/token-sdk/src/token/mod.rs index 46360c12d7..999d68cc85 100644 --- a/sdk-libs/token-sdk/src/token/mod.rs +++ b/sdk-libs/token-sdk/src/token/mod.rs @@ -4,7 +4,7 @@ //! ## Account Creation //! //! - [`CreateAssociatedCTokenAccount`] - Create associated ctoken account (ATA) instruction -//! - [`CreateCTokenAtaCpi`] - Create associated ctoken account (ATA) via CPI +//! - [`CreateTokenAtaCpi`] - Create associated ctoken account (ATA) via CPI //! - [`CreateCTokenAccount`] - Create ctoken account instruction //! - [`CreateTokenAccountCpi`] - Create ctoken account via CPI //! @@ -52,9 +52,9 @@ //! # Example: Create rent-free ATA via CPI //! //! ```rust,ignore -//! use light_token_sdk::token::CreateCTokenAtaCpi; +//! use light_token_sdk::token::CreateTokenAtaCpi; //! -//! CreateCTokenAtaCpi { +//! CreateTokenAtaCpi { //! payer: ctx.accounts.payer.to_account_info(), //! owner: ctx.accounts.owner.to_account_info(), //! mint: ctx.accounts.mint.to_account_info(), @@ -122,7 +122,7 @@ pub use compressible::{CompressibleParams, CompressibleParamsCpi}; pub use create::*; pub use create_ata::{ derive_token_ata, CreateAssociatedTokenAccount, - CreateCTokenAtaCpi as CreateAssociatedAccountCpi, CreateCTokenAtaCpi, + CreateTokenAtaCpi as CreateAssociatedAccountCpi, CreateTokenAtaCpi, }; pub use create_mint::*; pub use decompress::Decompress; diff --git a/sdk-libs/token-sdk/src/token/transfer_interface.rs b/sdk-libs/token-sdk/src/token/transfer_interface.rs index 3bc732dcea..8d5a5ddfbb 100644 --- a/sdk-libs/token-sdk/src/token/transfer_interface.rs +++ b/sdk-libs/token-sdk/src/token/transfer_interface.rs @@ -11,11 +11,11 @@ use crate::error::TokenSdkError; /// Internal enum to classify transfer types based on account owners. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TransferType { - /// ctoken -> ctoken + /// light -> light LightToLight, - /// ctoken -> SPL (decompress) + /// light -> SPL (decompress) LightToSpl, - /// SPL -> ctoken (compress) + /// SPL -> light (compress) SplToLight, /// SPL -> SPL (pass-through to SPL token program) SplToSpl, @@ -54,7 +54,7 @@ fn determine_transfer_type( } } -/// Required accounts to interface between ctoken and SPL token accounts (Pubkey-based). +/// Required accounts to interface between light and SPL token accounts (Pubkey-based). /// /// Use this struct when building instructions outside of CPI context. #[derive(Debug, Clone, Copy)] @@ -76,7 +76,7 @@ impl<'info> From<&SplInterfaceCpi<'info>> for SplInterface { } } -/// Required accounts to interface between ctoken and SPL token accounts (AccountInfo-based). +/// Required accounts to interface between light and SPL token accounts (AccountInfo-based). /// /// Use this struct when building CPIs. pub struct SplInterfaceCpi<'info> { @@ -94,7 +94,7 @@ pub struct SplInterfaceCpi<'info> { /// # let destination = Pubkey::new_unique(); /// # let authority = Pubkey::new_unique(); /// # let payer = Pubkey::new_unique(); -/// // For ctoken -> ctoken transfer (source_owner and destination_owner are LIGHT_TOKEN_PROGRAM_ID) +/// // For light -> light transfer (source_owner and destination_owner are LIGHT_TOKEN_PROGRAM_ID) /// let instruction = TransferInterface { /// source, /// destination, @@ -117,7 +117,7 @@ pub struct TransferInterface { pub authority: Pubkey, pub payer: Pubkey, pub spl_interface: Option, - /// Maximum lamports for rent and top-up combined (for ctoken->ctoken transfers) + /// Maximum lamports for rent and top-up combined (for light->light transfers) pub max_top_up: Option, /// Owner of the source account (used to determine transfer type) pub source_owner: Pubkey, @@ -259,8 +259,8 @@ impl<'info> TransferInterfaceCpi<'info> { /// # Arguments /// * `amount` - Amount to transfer /// * `decimals` - Token decimals (required for SPL transfers) - /// * `source_account` - Source token account (can be ctoken or SPL) - /// * `destination_account` - Destination token account (can be ctoken or SPL) + /// * `source_account` - Source token account (can be light or SPL) + /// * `destination_account` - Destination token account (can be light or SPL) /// * `authority` - Authority for the transfer (must be signer) /// * `payer` - Payer for the transaction /// * `compressed_token_program_authority` - Compressed token program authority @@ -290,9 +290,9 @@ impl<'info> TransferInterfaceCpi<'info> { } /// # Arguments - /// * `mint` - Optional mint account (required for SPL<->ctoken transfers) - /// * `spl_token_program` - Optional SPL token program (required for SPL<->ctoken transfers) - /// * `spl_interface_pda` - Optional SPL interface PDA (required for SPL<->ctoken transfers) + /// * `mint` - Optional mint account (required for SPL<->light transfers) + /// * `spl_token_program` - Optional SPL token program (required for SPL<->light transfers) + /// * `spl_interface_pda` - Optional SPL interface PDA (required for SPL<->light transfers) /// * `spl_interface_pda_bump` - Optional bump seed for SPL interface PDA pub fn with_spl_interface( mut self, diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs index 2b806739de..dc76c417d0 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs @@ -4,7 +4,7 @@ use light_sdk_macros::RentFree; use crate::state::*; -#[derive(AnchorSerialize, AnchorDeserialize)] +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct FullAutoWithMintParams { pub create_accounts_proof: CreateAccountsProof, pub owner: Pubkey, diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index bb5ecdfa1e..4477e078a5 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -54,7 +54,7 @@ pub mod csdk_anchor_full_derived_test { ) -> Result<()> { use anchor_lang::solana_program::sysvar::clock::Clock; use light_token_sdk::token::{ - CreateCTokenAtaCpi, CreateTokenAccountCpi, MintToCpi as CTokenMintToCpi, + CreateTokenAccountCpi, CreateTokenAtaCpi, MintToCpi as CTokenMintToCpi, }; let user_record = &mut ctx.accounts.user_record; @@ -90,7 +90,7 @@ pub mod csdk_anchor_full_derived_test { &[params.vault_bump], ])?; - CreateCTokenAtaCpi { + CreateTokenAtaCpi { payer: ctx.accounts.fee_payer.to_account_info(), owner: ctx.accounts.fee_payer.to_account_info(), mint: ctx.accounts.cmint.to_account_info(), @@ -107,11 +107,7 @@ pub mod csdk_anchor_full_derived_test { if params.vault_mint_amount > 0 { CTokenMintToCpi { -<<<<<<< HEAD mint: ctx.accounts.cmint.to_account_info(), -======= - cmint: ctx.accounts.cmint.to_account_info(), ->>>>>>> 7d4ae004e (wip) destination: ctx.accounts.vault.to_account_info(), amount: params.vault_mint_amount, authority: ctx.accounts.mint_authority.to_account_info(), @@ -123,11 +119,7 @@ pub mod csdk_anchor_full_derived_test { if params.user_ata_mint_amount > 0 { CTokenMintToCpi { -<<<<<<< HEAD mint: ctx.accounts.cmint.to_account_info(), -======= - cmint: ctx.accounts.cmint.to_account_info(), ->>>>>>> 7d4ae004e (wip) destination: ctx.accounts.user_ata.to_account_info(), amount: params.user_ata_mint_amount, authority: ctx.accounts.mint_authority.to_account_info(), diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs b/sdk-tests/csdk-anchor-full-derived-test/src/state.rs index 3fb266e965..2a8884fb96 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/state.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/state.rs @@ -1,6 +1,10 @@ use anchor_lang::prelude::*; -use light_sdk::{compressible::CompressionInfo, LightDiscriminator}; +use light_sdk::{ + compressible::CompressionInfo, instruction::PackedAddressTreeInfo, LightDiscriminator, +}; use light_sdk_macros::RentFreeAccount; +use light_token_interface::instructions::mint_action::MintWithContext; +use light_token_sdk::ValidityProof; #[derive(Default, Debug, InitSpace, RentFreeAccount)] #[account] diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index 4ca033c29a..3f26e3e3ad 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -1,7 +1,8 @@ use anchor_lang::{InstructionData, ToAccountMetas}; use light_compressible::rent::SLOTS_PER_EPOCH; use light_compressible_client::{ - get_create_accounts_proof, CreateAccountsProofInput, InitializeRentFreeConfig, + get_create_accounts_proof, AccountInterfaceExt, CreateAccountsProofInput, + InitializeRentFreeConfig, }; use light_macros::pubkey; use light_program_test::{ @@ -337,8 +338,8 @@ async fn test_create_pdas_and_mint_auto() { // Load accounts if needed let all_instructions = create_load_accounts_instructions( &program_owned_accounts, - &[ata_interface.inner.clone()], - &[mint_interface.clone()], + std::slice::from_ref(&ata_interface.inner), + std::slice::from_ref(&mint_interface), program_id, payer.pubkey(), config_pda, diff --git a/sdk-tests/sdk-light-token-test/src/create_ata.rs b/sdk-tests/sdk-light-token-test/src/create_ata.rs index 05fec5045e..a376725b6f 100644 --- a/sdk-tests/sdk-light-token-test/src/create_ata.rs +++ b/sdk-tests/sdk-light-token-test/src/create_ata.rs @@ -1,5 +1,5 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use light_token_sdk::token::CreateCTokenAtaCpi; +use light_token_sdk::token::CreateTokenAtaCpi; use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; use crate::{ATA_SEED, ID}; @@ -30,7 +30,7 @@ pub fn process_create_ata_invoke( return Err(ProgramError::NotEnoughAccountKeys); } - CreateCTokenAtaCpi { + CreateTokenAtaCpi { payer: accounts[2].clone(), owner: accounts[0].clone(), mint: accounts[1].clone(), @@ -75,7 +75,7 @@ pub fn process_create_ata_invoke_signed( let signer_seeds: &[&[u8]] = &[ATA_SEED, &[bump]]; - CreateCTokenAtaCpi { + CreateTokenAtaCpi { payer: accounts[2].clone(), owner: accounts[0].clone(), mint: accounts[1].clone(), diff --git a/sdk-tests/sdk-light-token-test/src/lib.rs b/sdk-tests/sdk-light-token-test/src/lib.rs index df5cd3559d..21179b2e09 100644 --- a/sdk-tests/sdk-light-token-test/src/lib.rs +++ b/sdk-tests/sdk-light-token-test/src/lib.rs @@ -172,6 +172,7 @@ impl TryFrom for InstructionType { 30 => Ok(InstructionType::BurnInvokeSigned), 31 => Ok(InstructionType::CTokenMintToInvoke), 32 => Ok(InstructionType::CTokenMintToInvokeSigned), + 33 => Ok(InstructionType::DecompressCmintInvokeSigned), 34 => Ok(InstructionType::CTokenTransferCheckedInvoke), 35 => Ok(InstructionType::CTokenTransferCheckedInvokeSigned), _ => Err(ProgramError::InvalidInstructionData), @@ -367,6 +368,7 @@ mod tests { assert_eq!(InstructionType::BurnInvokeSigned as u8, 30); assert_eq!(InstructionType::CTokenMintToInvoke as u8, 31); assert_eq!(InstructionType::CTokenMintToInvokeSigned as u8, 32); + assert_eq!(InstructionType::DecompressCmintInvokeSigned as u8, 33); assert_eq!(InstructionType::CTokenTransferCheckedInvoke as u8, 34); assert_eq!(InstructionType::CTokenTransferCheckedInvokeSigned as u8, 35); } @@ -499,7 +501,10 @@ mod tests { InstructionType::try_from(32).unwrap(), InstructionType::CTokenMintToInvokeSigned ); - assert!(InstructionType::try_from(33).is_err()); // Removed DecompressCmintInvokeSigned + assert_eq!( + InstructionType::try_from(33).unwrap(), + InstructionType::DecompressCmintInvokeSigned + ); assert_eq!( InstructionType::try_from(34).unwrap(), InstructionType::CTokenTransferCheckedInvoke diff --git a/sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs b/sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs deleted file mode 100644 index 92627834d1..0000000000 --- a/sdk-tests/sdk-light-token-test/tests/test_decompress_cmint.rs +++ /dev/null @@ -1,725 +0,0 @@ -// Tests for DecompressMint SDK instruction - -mod shared; - -use borsh::BorshDeserialize; -use light_client::{indexer::Indexer, rpc::Rpc}; -use light_compressible::compression_info::CompressionInfo; -use light_program_test::{LightProgramTest, ProgramTestConfig}; -use light_token_interface::{instructions::mint_action::MintWithContext, state::Mint}; -use light_token_sdk::token::{find_mint_address, DecompressMint}; -use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; - -/// Test decompressing a compressed mint to CMint account -#[tokio::test] -async fn test_decompress_mint() { - let config = ProgramTestConfig::new_v2(true, None); - let mut rpc = LightProgramTest::new(config).await.unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let mint_authority = payer.pubkey(); - let decimals = 9u8; - - // Create a compressed mint (returns mint_seed keypair) - let (mint_pda, compression_address, _, _mint_seed) = - shared::setup_create_compressed_mint(&mut rpc, &payer, mint_authority, decimals, vec![]) - .await; - - // Verify CMint account does NOT exist on-chain yet - let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); - assert!( - cmint_account_before.is_none(), - "CMint should not exist before decompression" - ); - - // Verify compressed mint exists - let compressed_account = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value - .expect("Compressed mint should exist"); - - // Get validity proof for decompression - let rpc_result = rpc - .get_validity_proof(vec![compressed_account.hash], vec![], None) - .await - .unwrap() - .value; - - // Deserialize the compressed mint to build context - let compressed_mint = - Mint::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()).unwrap(); - - let compressed_mint_with_context = MintWithContext { - address: compression_address, - leaf_index: compressed_account.leaf_index, - prove_by_index: true, - root_index: rpc_result.accounts[0] - .root_index - .root_index() - .unwrap_or_default(), - mint: Some(compressed_mint.clone().try_into().unwrap()), - }; - - let output_queue = rpc.get_random_state_tree_info().unwrap().queue; - - // Build and execute DecompressMint instruction - let decompress_ix = DecompressMint { - payer: payer.pubkey(), - authority: mint_authority, - state_tree: compressed_account.tree_info.tree, - input_queue: compressed_account.tree_info.queue, - output_queue, - compressed_mint_with_context, - proof: rpc_result.proof, - rent_payment: 16, - write_top_up: 766, - } - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - // Verify CMint account now exists on-chain - let cmint_account_after = rpc.get_account(mint_pda).await.unwrap(); - assert!( - cmint_account_after.is_some(), - "CMint should exist after decompression" - ); - - // Verify CMint state with single assert_eq - let cmint_account = cmint_account_after.unwrap(); - let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); - - // Verify compression info is set (non-default) when decompressed - assert_ne!( - cmint.compression, - CompressionInfo::default(), - "CMint compression info should be set when decompressed" - ); - - // Build expected CMint from original compressed mint, updating fields changed by decompression - let mut expected_cmint = compressed_mint.clone(); - expected_cmint.metadata.cmint_decompressed = true; - expected_cmint.compression = cmint.compression; - - assert_eq!(cmint, expected_cmint, "CMint should match expected state"); -} - -/// Test decompressing a compressed mint with freeze_authority -#[tokio::test] -async fn test_decompress_mint_with_freeze_authority() { - let config = ProgramTestConfig::new_v2(true, None); - let mut rpc = LightProgramTest::new(config).await.unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let mint_authority = payer.pubkey(); - let freeze_authority = Keypair::new(); - let decimals = 6u8; - - // Create a compressed mint with freeze_authority - let (mint_pda, compression_address, _mint_seed) = - setup_create_compressed_mint_with_freeze_authority_only( - &mut rpc, - &payer, - mint_authority, - Some(freeze_authority.pubkey()), - decimals, - ) - .await; - - // Verify CMint account does NOT exist on-chain yet - let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); - assert!( - cmint_account_before.is_none(), - "CMint should not exist before decompression" - ); - - // Get compressed mint account - let compressed_account = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value - .expect("Compressed mint should exist"); - - // Get validity proof for decompression - let rpc_result = rpc - .get_validity_proof(vec![compressed_account.hash], vec![], None) - .await - .unwrap() - .value; - - // Deserialize the compressed mint - let compressed_mint = - Mint::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()).unwrap(); - - let compressed_mint_with_context = MintWithContext { - address: compression_address, - leaf_index: compressed_account.leaf_index, - prove_by_index: true, - root_index: rpc_result.accounts[0] - .root_index - .root_index() - .unwrap_or_default(), - mint: Some(compressed_mint.clone().try_into().unwrap()), - }; - - let output_queue = rpc.get_random_state_tree_info().unwrap().queue; - - // Build and execute DecompressMint instruction - let decompress_ix = DecompressMint { - payer: payer.pubkey(), - authority: mint_authority, - state_tree: compressed_account.tree_info.tree, - input_queue: compressed_account.tree_info.queue, - output_queue, - compressed_mint_with_context, - proof: rpc_result.proof, - rent_payment: 16, - write_top_up: 766, - } - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - // Verify CMint state with single assert_eq - let cmint_account = rpc - .get_account(mint_pda) - .await - .unwrap() - .expect("CMint should exist after decompression"); - let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); - - // Verify compression info is set (non-default) when decompressed - assert_ne!( - cmint.compression, - CompressionInfo::default(), - "CMint compression info should be set when decompressed" - ); - - // Build expected CMint from original compressed mint, updating fields changed by decompression - let mut expected_cmint = compressed_mint.clone(); - expected_cmint.metadata.cmint_decompressed = true; - expected_cmint.compression = cmint.compression; - - assert_eq!(cmint, expected_cmint, "CMint should match expected state"); -} - -/// Helper function: Creates a compressed mint with optional freeze_authority -/// but does NOT decompress it (unlike setup_create_compressed_mint_with_freeze_authority) -/// Returns (mint_pda, compression_address, mint_seed_keypair) -async fn setup_create_compressed_mint_with_freeze_authority_only( - rpc: &mut (impl Rpc + Indexer), - payer: &Keypair, - mint_authority: Pubkey, - freeze_authority: Option, - decimals: u8, -) -> (Pubkey, [u8; 32], Keypair) { - use light_token_sdk::token::{CreateMint, CreateMintParams}; - - let mint_seed = Keypair::new(); - let address_tree = rpc.get_address_tree_v2(); - let output_queue = rpc.get_random_state_tree_info().unwrap().queue; - - // Derive compression address using SDK helpers - let compression_address = light_token_sdk::token::derive_mint_compressed_address( - &mint_seed.pubkey(), - &address_tree.tree, - ); - - let (mint, bump) = find_mint_address(&mint_seed.pubkey()); - - // Get validity proof for the address - let rpc_result = rpc - .get_validity_proof( - vec![], - vec![light_client::indexer::AddressWithTree { - address: compression_address, - tree: address_tree.tree, - }], - None, - ) - .await - .unwrap() - .value; - - // Build params for the SDK - let params = CreateMintParams { - decimals, - address_merkle_tree_root_index: rpc_result.addresses[0].root_index, - mint_authority, - proof: rpc_result.proof.0.unwrap(), - compression_address, - mint, - bump, - freeze_authority, - extensions: None, - }; - - // Create instruction directly using SDK - let create_cmint_builder = CreateMint::new( - params, - mint_seed.pubkey(), - payer.pubkey(), - address_tree.tree, - output_queue, - ); - let instruction = create_cmint_builder.instruction().unwrap(); - - // Send transaction - rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, &mint_seed]) - .await - .unwrap(); - - // Verify the compressed mint was created - let compressed_account = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value; - - assert!( - compressed_account.is_some(), - "Compressed mint should exist after setup" - ); - - (mint, compression_address, mint_seed) -} - -/// Test decompressing a compressed mint with TokenMetadata extension -#[tokio::test] -async fn test_decompress_mint_with_token_metadata() { - use light_token_interface::instructions::extensions::{ - ExtensionInstructionData, TokenMetadataInstructionData, - }; - - let config = ProgramTestConfig::new_v2(true, None); - let mut rpc = LightProgramTest::new(config).await.unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let mint_authority = payer.pubkey(); - let update_authority = Keypair::new(); - let decimals = 9u8; - - // Create TokenMetadata extension - let token_metadata = TokenMetadataInstructionData { - update_authority: Some(update_authority.pubkey().to_bytes().into()), - name: b"Test Token".to_vec(), - symbol: b"TEST".to_vec(), - uri: b"https://example.com/token.json".to_vec(), - additional_metadata: None, - }; - let extensions = vec![ExtensionInstructionData::TokenMetadata(token_metadata)]; - - // Create a compressed mint with TokenMetadata extension - let (mint_pda, compression_address, _mint_seed) = setup_create_compressed_mint_with_extensions( - &mut rpc, - &payer, - mint_authority, - None, - decimals, - extensions, - ) - .await; - - // Verify CMint account does NOT exist on-chain yet - let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); - assert!( - cmint_account_before.is_none(), - "CMint should not exist before decompression" - ); - - // Get compressed mint account - let compressed_account = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value - .expect("Compressed mint should exist"); - - // Get validity proof for decompression - let rpc_result = rpc - .get_validity_proof(vec![compressed_account.hash], vec![], None) - .await - .unwrap() - .value; - - // Deserialize the compressed mint - let compressed_mint = - Mint::deserialize(&mut compressed_account.data.as_ref().unwrap().data.as_slice()).unwrap(); - - let compressed_mint_with_context = MintWithContext { - address: compression_address, - leaf_index: compressed_account.leaf_index, - prove_by_index: true, - root_index: rpc_result.accounts[0] - .root_index - .root_index() - .unwrap_or_default(), - mint: Some(compressed_mint.clone().try_into().unwrap()), - }; - - let output_queue = rpc.get_random_state_tree_info().unwrap().queue; - - // Build and execute DecompressMint instruction - let decompress_ix = DecompressMint { - payer: payer.pubkey(), - authority: mint_authority, - state_tree: compressed_account.tree_info.tree, - input_queue: compressed_account.tree_info.queue, - output_queue, - compressed_mint_with_context, - proof: rpc_result.proof, - rent_payment: 16, - write_top_up: 766, - } - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - // Verify CMint state with single assert_eq - let cmint_account = rpc - .get_account(mint_pda) - .await - .unwrap() - .expect("CMint should exist after decompression"); - let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); - - // Verify compression info is set (non-default) when decompressed - assert_ne!( - cmint.compression, - CompressionInfo::default(), - "CMint compression info should be set when decompressed" - ); - - // Verify TokenMetadata extension is preserved - assert!( - cmint.extensions.is_some(), - "CMint should have extensions with TokenMetadata" - ); - - // Build expected CMint from original compressed mint, updating fields changed by decompression - let mut expected_cmint = compressed_mint.clone(); - expected_cmint.metadata.cmint_decompressed = true; - expected_cmint.compression = cmint.compression; - // Extensions should preserve original TokenMetadata - - assert_eq!(cmint, expected_cmint, "CMint should match expected state"); -} - -/// Helper function: Creates a compressed mint with extensions -/// but does NOT decompress it -/// Returns (mint_pda, compression_address, mint_seed_keypair) -async fn setup_create_compressed_mint_with_extensions( - rpc: &mut (impl Rpc + Indexer), - payer: &Keypair, - mint_authority: Pubkey, - freeze_authority: Option, - decimals: u8, - extensions: Vec, -) -> (Pubkey, [u8; 32], Keypair) { - use light_token_sdk::token::{CreateMint, CreateMintParams}; - - let mint_seed = Keypair::new(); - let address_tree = rpc.get_address_tree_v2(); - let output_queue = rpc.get_random_state_tree_info().unwrap().queue; - - // Derive compression address using SDK helpers - let compression_address = light_token_sdk::token::derive_mint_compressed_address( - &mint_seed.pubkey(), - &address_tree.tree, - ); - - let (mint, bump) = find_mint_address(&mint_seed.pubkey()); - - // Get validity proof for the address - let rpc_result = rpc - .get_validity_proof( - vec![], - vec![light_client::indexer::AddressWithTree { - address: compression_address, - tree: address_tree.tree, - }], - None, - ) - .await - .unwrap() - .value; - - // Build params for the SDK - let params = CreateMintParams { - decimals, - address_merkle_tree_root_index: rpc_result.addresses[0].root_index, - mint_authority, - proof: rpc_result.proof.0.unwrap(), - compression_address, - mint, - bump, - freeze_authority, - extensions: Some(extensions), - }; - - // Create instruction directly using SDK - let create_cmint_builder = CreateMint::new( - params, - mint_seed.pubkey(), - payer.pubkey(), - address_tree.tree, - output_queue, - ); - let instruction = create_cmint_builder.instruction().unwrap(); - - // Send transaction - rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[payer, &mint_seed]) - .await - .unwrap(); - - // Verify the compressed mint was created - let compressed_account = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value; - - assert!( - compressed_account.is_some(), - "Compressed mint should exist after setup" - ); - - (mint, compression_address, mint_seed) -} - -/// Test decompressing a compressed mint via CPI with PDA authority using invoke_signed -#[tokio::test] -async fn test_decompress_mint_cpi_invoke_signed() { - use borsh::BorshSerialize; - use native_ctoken_examples::{ - CreateCmintData, DecompressCmintData, InstructionType, ID, MINT_AUTHORITY_SEED, - MINT_SIGNER_SEED, - }; - use solana_sdk::instruction::{AccountMeta, Instruction}; - - let config = ProgramTestConfig::new_v2(true, Some(vec![("native_ctoken_examples", ID)])); - let mut rpc = LightProgramTest::new(config).await.unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - // Derive the PDAs from our wrapper program - let (mint_signer_pda, _) = Pubkey::find_program_address(&[MINT_SIGNER_SEED], &ID); - let (pda_mint_authority, _) = Pubkey::find_program_address(&[MINT_AUTHORITY_SEED], &ID); - - let decimals = 9u8; - let address_tree = rpc.get_address_tree_v2(); - let output_queue = rpc.get_random_state_tree_info().unwrap().queue; - - // Derive compression address using the PDA mint_signer - let compression_address = light_token_sdk::token::derive_mint_compressed_address( - &mint_signer_pda, - &address_tree.tree, - ); - - let (mint_pda, mint_bump) = find_mint_address(&mint_signer_pda); - - // Step 1: Create compressed mint with PDA authority using wrapper program (discriminator 14) - { - let rpc_result = rpc - .get_validity_proof( - vec![], - vec![light_client::indexer::AddressWithTree { - address: compression_address, - tree: address_tree.tree, - }], - None, - ) - .await - .unwrap() - .value; - - let compressed_token_program_id = - Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID); - let default_pubkeys = light_token_sdk::utils::TokenDefaultAccounts::default(); - - let create_cmint_data = CreateCmintData { - decimals, - address_merkle_tree_root_index: rpc_result.addresses[0].root_index, - mint_authority: pda_mint_authority, - proof: rpc_result.proof.0.unwrap(), - compression_address, - mint: mint_pda, - bump: mint_bump, - freeze_authority: None, - extensions: None, - rent_payment: 16, - write_top_up: 766, - }; - // Discriminator 14 = CreateCmintWithPdaAuthority - let wrapper_instruction_data = - [vec![14u8], create_cmint_data.try_to_vec().unwrap()].concat(); - - let wrapper_accounts = vec![ - AccountMeta::new_readonly(compressed_token_program_id, false), - AccountMeta::new_readonly(default_pubkeys.light_system_program, false), - AccountMeta::new_readonly(mint_signer_pda, false), - AccountMeta::new(pda_mint_authority, false), - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new_readonly(default_pubkeys.cpi_authority_pda, false), - AccountMeta::new_readonly(default_pubkeys.registered_program_pda, false), - AccountMeta::new_readonly(default_pubkeys.account_compression_authority, false), - AccountMeta::new_readonly(default_pubkeys.account_compression_program, false), - AccountMeta::new_readonly(default_pubkeys.system_program, false), - AccountMeta::new(output_queue, false), - AccountMeta::new(address_tree.tree, false), - ]; - - let create_mint_ix = Instruction { - program_id: ID, - accounts: wrapper_accounts, - data: wrapper_instruction_data, - }; - - rpc.create_and_send_transaction(&[create_mint_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - } - - // Verify CMint account does NOT exist on-chain yet - let cmint_account_before = rpc.get_account(mint_pda).await.unwrap(); - assert!( - cmint_account_before.is_none(), - "CMint should not exist before decompression" - ); - - // Step 2: Decompress the mint via wrapper program (PDA authority requires CPI) - let compressed_mint = { - let compressed_mint_account = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value - .expect("Compressed mint should exist"); - - let compressed_mint = Mint::deserialize( - &mut compressed_mint_account - .data - .as_ref() - .unwrap() - .data - .as_slice(), - ) - .unwrap(); - - let rpc_result = rpc - .get_validity_proof(vec![compressed_mint_account.hash], vec![], None) - .await - .unwrap() - .value; - - let compressed_mint_with_context = MintWithContext { - address: compression_address, - leaf_index: compressed_mint_account.leaf_index, - prove_by_index: true, - root_index: rpc_result.accounts[0] - .root_index - .root_index() - .unwrap_or_default(), - mint: Some(compressed_mint.clone().try_into().unwrap()), - }; - - let default_pubkeys = light_token_sdk::utils::TokenDefaultAccounts::default(); - let compressible_config = light_token_sdk::token::config_pda(); - let rent_sponsor = light_token_sdk::token::rent_sponsor_pda(); - - let decompress_data = DecompressCmintData { - compressed_mint_with_context, - proof: rpc_result.proof, - rent_payment: 16, - write_top_up: 766, - }; - - // Discriminator 33 = DecompressCmintInvokeSigned - let wrapper_instruction_data = [ - vec![InstructionType::DecompressCmintInvokeSigned as u8], - decompress_data.try_to_vec().unwrap(), - ] - .concat(); - - // Account order matches process_decompress_mint_invoke_signed: - // 0: authority (PDA, readonly - program signs) - // 1: payer (signer, writable) - // 2: cmint (writable) - // 3: compressible_config (readonly) - // 4: rent_sponsor (writable) - // 5: state_tree (writable) - // 6: input_queue (writable) - // 7: output_queue (writable) - // 8: light_system_program (readonly) - // 9: cpi_authority_pda (readonly) - // 10: registered_program_pda (readonly) - // 11: account_compression_authority (readonly) - // 12: account_compression_program (readonly) - // 13: system_program (readonly) - // 14: light_token_program (readonly) - required for CPI - let light_token_program_id = - Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID); - let wrapper_accounts = vec![ - AccountMeta::new_readonly(pda_mint_authority, false), - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new(mint_pda, false), - AccountMeta::new_readonly(compressible_config, false), - AccountMeta::new(rent_sponsor, false), - AccountMeta::new(compressed_mint_account.tree_info.tree, false), - AccountMeta::new(compressed_mint_account.tree_info.queue, false), - AccountMeta::new(output_queue, false), - AccountMeta::new_readonly(default_pubkeys.light_system_program, false), - AccountMeta::new_readonly(default_pubkeys.cpi_authority_pda, false), - AccountMeta::new_readonly(default_pubkeys.registered_program_pda, false), - AccountMeta::new_readonly(default_pubkeys.account_compression_authority, false), - AccountMeta::new_readonly(default_pubkeys.account_compression_program, false), - AccountMeta::new_readonly(default_pubkeys.system_program, false), - AccountMeta::new_readonly(light_token_program_id, false), - ]; - - let decompress_ix = Instruction { - program_id: ID, - accounts: wrapper_accounts, - data: wrapper_instruction_data, - }; - - rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - compressed_mint - }; - - // Verify CMint state with single assert_eq - let cmint_account = rpc - .get_account(mint_pda) - .await - .unwrap() - .expect("CMint should exist after decompression"); - let cmint = Mint::deserialize(&mut &cmint_account.data[..]).unwrap(); - - // Verify compression info is set (non-default) when decompressed - assert_ne!( - cmint.compression, - CompressionInfo::default(), - "CMint compression info should be set when decompressed" - ); - - // Build expected CMint from original compressed mint, updating fields changed by decompression - let mut expected_cmint = compressed_mint.clone(); - expected_cmint.metadata.cmint_decompressed = true; - expected_cmint.compression = cmint.compression; - - assert_eq!(cmint, expected_cmint, "CMint should match expected state"); -}