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 e356f741f3..85eafabf0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3684,13 +3684,16 @@ name = "light-compressible-client" version = "0.17.1" dependencies = [ "anchor-lang", + "async-trait", "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/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 6f4bd925c8..8799a7068d 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 MintLayoutData { 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: 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 2066a8d75c..cac5b0a3c0 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. `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 +**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. `CompressAndCloseMint` - 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,24 +41,25 @@ 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) - `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:** 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. @@ -81,54 +76,61 @@ 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): - - 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 +- Recipient ctoken accounts for MintTo action **Instruction Logic and Checks:** @@ -141,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:** @@ -167,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 @@ -192,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 @@ -230,16 +232,17 @@ 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 ### 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..0c21f770db 100644 --- a/sdk-libs/compressible-client/Cargo.toml +++ b/sdk-libs/compressible-client/Cargo.toml @@ -25,6 +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 = { workspace = true } 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/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 a843b82e29..873660ecee 100644 --- a/sdk-libs/compressible-client/src/decompress_mint.rs +++ b/sdk-libs/compressible-client/src/decompress_mint.rs @@ -1,41 +1,25 @@ -//! 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 `AccountInterfaceExt::get_mint_interface()` to fetch, +//! then pass to `create_load_accounts_instructions()` for decompression. use borsh::BorshDeserialize; -use light_client::indexer::{CompressedAccount, Indexer, IndexerError, ValidityProofWithContext}; +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::{ - compressed_token::create_compressed_mint::derive_mint_compressed_address, - token::{find_mint_address, DecompressMint}, -}; +use light_token_sdk::token::{derive_mint_compressed_address, find_mint_address, DecompressMint}; 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 +27,16 @@ pub enum DecompressMintError { MissingMintData, #[error("Program error: {0}")] - ProgramError(#[from] ProgramError), + ProgramError(#[from] solana_program_error::ProgramError), + + #[error("Mint already decompressed")] + AlreadyDecompressed, - #[error("Proof required for cold mint")] + #[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. @@ -66,10 +56,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..9e09986cc3 100644 --- a/sdk-libs/compressible-client/src/lib.rs +++ b/sdk-libs/compressible-client/src/lib.rs @@ -1,10 +1,18 @@ +pub mod account_interface; +pub mod account_interface_ext; 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 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"))] @@ -13,29 +21,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,10 +39,15 @@ 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}; 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. @@ -144,6 +136,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 +199,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 +223,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..8eb540f3b9 --- /dev/null +++ b/sdk-libs/compressible-client/src/load_accounts.rs @@ -0,0 +1,465 @@ +//! Load (decompress) accounts API. +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; + +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 { + #[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), + + #[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], + 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 - fail fast if any cold account is missing required context + let pda_hashes: Vec<[u8; 32]> = cold_pdas + .iter() + .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::, _>>()?; + + 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. + 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) +} + +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) +} + +/// 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 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/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 a51f16df00..b46f70b668 100644 --- a/sdk-libs/program-test/src/compressible.rs +++ b/sdk-libs/program-test/src/compressible.rs @@ -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; @@ -421,13 +421,14 @@ async fn compress_cmint_forester( 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 Mint to get compressed_address and rent_sponsor - let mint: Mint = BorshDeserialize::deserialize(&mut cmint_account.data.as_slice()) + 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 = mint.metadata.compressed_address(); @@ -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,7 +462,7 @@ 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 CompressAndCloseMint action @@ -474,16 +475,16 @@ async fn compress_cmint_forester( // 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 47919e08d3..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 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. - /// 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, - }) - } } 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 0541358783..fb9e688d64 100644 --- a/sdk-libs/token-sdk/src/token/decompress_mint.rs +++ b/sdk-libs/token-sdk/src/token/decompress_mint.rs @@ -10,8 +10,10 @@ 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; +use crate::{ + compressed_token::mint_action::MintActionMetaConfig, + token::{config_pda, rent_sponsor_pda, SystemAccountInfos}, +}; /// Decompress a compressed mint to a Mint Solana account. /// 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 8afc849d3f..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(), 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..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,11 +1,10 @@ use anchor_lang::prelude::*; use light_sdk::{ - compressible::CompressionInfo, - instruction::{PackedAddressTreeInfo, ValidityProof}, - LightDiscriminator, + 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] @@ -43,26 +42,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..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::{ @@ -263,57 +264,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 +303,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 +312,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, + std::slice::from_ref(&ata_interface.inner), + std::slice::from_ref(&mint_interface), + 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 +383,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/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(),