diff --git a/CLAUDE.md b/CLAUDE.md index b64c170c7a..c869521b52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,3 +197,57 @@ Format and clippy checks across the entire codebase. - **`program-tests/`**: Integration tests requiring Solana runtime, depend on `light-test-utils` - **`sdk-tests/`**: SDK-specific integration tests - **Special case**: `zero-copy-derive-test` in `program-tests/` only to break cyclic dependencies + +### Test Assertion Pattern + +When testing account state, use borsh deserialization with a single `assert_eq` against an expected reference account: + +```rust +use borsh::BorshDeserialize; +use light_ctoken_types::state::{ + AccountState, CToken, ExtensionStruct, PausableAccountExtension, + PermanentDelegateAccountExtension, +}; + +// Deserialize the account +let ctoken = CToken::deserialize(&mut &account.data[..]) + .expect("Failed to deserialize CToken account"); + +// Extract runtime-specific values from deserialized account +let compression_info = ctoken + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(info.clone()), + _ => None, + }) + }) + .expect("Should have Compressible extension"); + +// Build expected account for comparison +let expected_ctoken = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: payer.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Frozen, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ]), +}; + +// Single assert comparing full account state +assert_eq!(ctoken, expected_ctoken, "CToken account should match expected"); +``` + +**Benefits:** +- Type-safe assertions using actual struct fields instead of magic byte offsets +- Maintainable - if account layout changes, deserialization handles it +- Readable - clear field names vs `account.data[108]` +- Single assertion point for the entire account state diff --git a/Cargo.lock b/Cargo.lock index 42a98c0e90..f5ed876f5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,7 +307,7 @@ dependencies = [ "solana-sdk", "solana-security-txt", "spl-token 7.0.0", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "zerocopy", ] @@ -1422,6 +1422,7 @@ dependencies = [ "account-compression", "anchor-lang", "anchor-spl 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", + "borsh 0.10.4", "forester-utils", "light-batched-merkle-tree", "light-client", @@ -1445,7 +1446,7 @@ dependencies = [ "solana-system-interface 1.0.0", "spl-pod", "spl-token 7.0.0", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "tokio", ] @@ -3594,6 +3595,7 @@ dependencies = [ "light-compressed-account", "light-compressed-token-sdk", "light-concurrent-merkle-tree", + "light-ctoken-types", "light-event", "light-hasher", "light-indexed-merkle-tree", @@ -3688,8 +3690,7 @@ dependencies = [ "solana-security-txt", "spl-pod", "spl-token 7.0.0", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "spl-token-2022 7.0.0 (git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf)", + "spl-token-2022 7.0.0", "tinyvec", "zerocopy", ] @@ -3720,7 +3721,7 @@ dependencies = [ "solana-program-error 2.2.2", "solana-pubkey 2.4.0", "spl-pod", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "thiserror 2.0.17", ] @@ -3826,7 +3827,7 @@ dependencies = [ "solana-pubkey 2.4.0", "solana-sysvar", "spl-pod", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "spl-token-metadata-interface 0.6.0", "thiserror 2.0.17", "tinyvec", @@ -4048,7 +4049,7 @@ dependencies = [ "solana-transaction", "solana-transaction-status", "solana-transaction-status-client-types", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "tabled", "tokio", ] @@ -4095,6 +4096,7 @@ dependencies = [ "light-merkle-tree-metadata", "light-program-profiler", "light-system-program-anchor", + "light-zero-copy", "solana-account-info", "solana-instruction", "solana-pubkey 2.4.0", @@ -4277,8 +4279,10 @@ dependencies = [ "reqwest 0.12.24", "solana-banks-client", "solana-sdk", + "solana-system-interface 1.0.0", + "spl-pod", "spl-token 7.0.0", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "thiserror 2.0.17", ] @@ -4300,8 +4304,9 @@ dependencies = [ "solana-pubkey 2.4.0", "solana-signature", "solana-signer", + "solana-system-interface 1.0.0", "spl-pod", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", ] [[package]] @@ -5091,7 +5096,7 @@ dependencies = [ [[package]] name = "pinocchio-token-interface" version = "0.0.0" -source = "git+https://github.com/Lightprotocol/token?rev=38d8634353e5eeb8c015d364df0eaa39f5c48b05#38d8634353e5eeb8c015d364df0eaa39f5c48b05" +source = "git+https://github.com/Lightprotocol/token?rev=5d2768b98075ba03dfc5d6e6dd8567ba065c84ba#5d2768b98075ba03dfc5d6e6dd8567ba065c84ba" dependencies = [ "pinocchio", "pinocchio-pubkey", @@ -5100,7 +5105,7 @@ dependencies = [ [[package]] name = "pinocchio-token-program" version = "0.1.0" -source = "git+https://github.com/Lightprotocol/token?rev=38d8634353e5eeb8c015d364df0eaa39f5c48b05#38d8634353e5eeb8c015d364df0eaa39f5c48b05" +source = "git+https://github.com/Lightprotocol/token?rev=5d2768b98075ba03dfc5d6e6dd8567ba065c84ba#5d2768b98075ba03dfc5d6e6dd8567ba065c84ba" dependencies = [ "pinocchio", "pinocchio-log", @@ -6090,7 +6095,7 @@ dependencies = [ "solana-program", "solana-sdk", "spl-pod", - "spl-token-2022 7.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-2022 7.0.0", "tokio", ] @@ -9465,19 +9470,7 @@ dependencies = [ "solana-program", "solana-zk-sdk", "spl-pod", - "spl-token-confidential-transfer-proof-extraction 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", -] - -[[package]] -name = "spl-elgamal-registry" -version = "0.1.1" -source = "git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf#06d12f50a06db25d73857d253b9a82857d6f4cdf" -dependencies = [ - "bytemuck", - "solana-program", - "solana-zk-sdk", - "spl-pod", - "spl-token-confidential-transfer-proof-extraction 0.2.1 (git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf)", + "spl-token-confidential-transfer-proof-extraction 0.2.1", ] [[package]] @@ -9700,12 +9693,12 @@ dependencies = [ "solana-program", "solana-security-txt", "solana-zk-sdk", - "spl-elgamal-registry 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-elgamal-registry 0.1.1", "spl-memo", "spl-pod", "spl-token 7.0.0", - "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "spl-token-confidential-transfer-proof-extraction 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1", + "spl-token-confidential-transfer-proof-extraction 0.2.1", "spl-token-confidential-transfer-proof-generation 0.2.0", "spl-token-group-interface 0.5.0", "spl-token-metadata-interface 0.6.0", @@ -9728,40 +9721,13 @@ dependencies = [ "solana-program", "solana-security-txt", "solana-zk-sdk", - "spl-elgamal-registry 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "spl-elgamal-registry 0.1.1", "spl-memo", "spl-pod", "spl-token 7.0.0", - "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "spl-token-confidential-transfer-proof-extraction 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "spl-token-confidential-transfer-proof-generation 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", - "spl-token-group-interface 0.5.0", - "spl-token-metadata-interface 0.6.0", - "spl-transfer-hook-interface 0.9.0", - "spl-type-length-value 0.7.0", - "thiserror 2.0.17", -] - -[[package]] -name = "spl-token-2022" -version = "7.0.0" -source = "git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf#06d12f50a06db25d73857d253b9a82857d6f4cdf" -dependencies = [ - "arrayref", - "bytemuck", - "num-derive 0.4.2", - "num-traits", - "num_enum", - "solana-program", - "solana-security-txt", - "solana-zk-sdk", - "spl-elgamal-registry 0.1.1 (git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf)", - "spl-memo", - "spl-pod", - "spl-token 7.0.0", - "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1 (git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf)", - "spl-token-confidential-transfer-proof-extraction 0.2.1 (git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf)", - "spl-token-confidential-transfer-proof-generation 0.3.0 (git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf)", + "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1", + "spl-token-confidential-transfer-proof-extraction 0.2.1", + "spl-token-confidential-transfer-proof-generation 0.3.0", "spl-token-group-interface 0.5.0", "spl-token-metadata-interface 0.6.0", "spl-transfer-hook-interface 0.9.0", @@ -9825,17 +9791,6 @@ dependencies = [ "solana-zk-sdk", ] -[[package]] -name = "spl-token-confidential-transfer-ciphertext-arithmetic" -version = "0.2.1" -source = "git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf#06d12f50a06db25d73857d253b9a82857d6f4cdf" -dependencies = [ - "base64 0.22.1", - "bytemuck", - "solana-curve25519", - "solana-zk-sdk", -] - [[package]] name = "spl-token-confidential-transfer-ciphertext-arithmetic" version = "0.3.1" @@ -9862,19 +9817,6 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "spl-token-confidential-transfer-proof-extraction" -version = "0.2.1" -source = "git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf#06d12f50a06db25d73857d253b9a82857d6f4cdf" -dependencies = [ - "bytemuck", - "solana-curve25519", - "solana-program", - "solana-zk-sdk", - "spl-pod", - "thiserror 2.0.17", -] - [[package]] name = "spl-token-confidential-transfer-proof-extraction" version = "0.3.0" @@ -9917,16 +9859,6 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "spl-token-confidential-transfer-proof-generation" -version = "0.3.0" -source = "git+https://github.com/Lightprotocol/token-2022?rev=06d12f50a06db25d73857d253b9a82857d6f4cdf#06d12f50a06db25d73857d253b9a82857d6f4cdf" -dependencies = [ - "curve25519-dalek 4.1.3", - "solana-zk-sdk", - "thiserror 2.0.17", -] - [[package]] name = "spl-token-confidential-transfer-proof-generation" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 4051b7e9fb..bae018a0dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -229,7 +229,7 @@ groth16-solana = { version = "0.2.0" } bytemuck = { version = "1.19.0" } arrayvec = "0.7" tinyvec = "1.10.0" -pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="38d8634353e5eeb8c015d364df0eaa39f5c48b05" } +pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="5d2768b98075ba03dfc5d6e6dd8567ba065c84ba" } # Math and crypto num-bigint = "0.4.6" tabled = "0.20" diff --git a/forester/src/compressible/compressor.rs b/forester/src/compressible/compressor.rs index ccff682bed..ed008a431b 100644 --- a/forester/src/compressible/compressor.rs +++ b/forester/src/compressible/compressor.rs @@ -127,7 +127,7 @@ impl Compressor { .ok_or_else(|| anyhow::anyhow!("Account missing compressible extension"))?; // Determine owner based on compress_to_pubkey flag - let compressed_token_owner = if compressible_ext.compress_to_pubkey != 0 { + let compressed_token_owner = if compressible_ext.info.compress_to_pubkey != 0 { account_state.pubkey // Use account pubkey for PDAs } else { Pubkey::new_from_array(account_state.account.owner.to_bytes()) // Use original owner @@ -136,14 +136,25 @@ impl Compressor { let owner_index = packed_accounts.insert_or_get(compressed_token_owner); // Extract rent_sponsor from extension - let rent_sponsor = Pubkey::new_from_array(compressible_ext.rent_sponsor); + let rent_sponsor = Pubkey::new_from_array(compressible_ext.info.rent_sponsor); let rent_sponsor_index = packed_accounts.insert_or_get(rent_sponsor); + // Handle delegate if present + let delegate_index = account_state + .account + .delegate + .map(|delegate| { + let delegate_pubkey = Pubkey::new_from_array(delegate.to_bytes()); + packed_accounts.insert_or_get(delegate_pubkey) + }) + .unwrap_or(0); + indices_vec.push(CompressAndCloseIndices { source_index, mint_index, owner_index, rent_sponsor_index, + delegate_index, }); } diff --git a/forester/src/compressible/state.rs b/forester/src/compressible/state.rs index a795dd30fd..417710707d 100644 --- a/forester/src/compressible/state.rs +++ b/forester/src/compressible/state.rs @@ -2,11 +2,8 @@ use std::sync::Arc; use borsh::BorshDeserialize; use dashmap::DashMap; -use light_ctoken_types::{ - state::{extensions::ExtensionStruct, CToken}, - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, COMPRESSIBLE_TOKEN_RENT_EXEMPTION, -}; -use solana_sdk::pubkey::Pubkey; +use light_ctoken_types::state::{extensions::ExtensionStruct, CToken}; +use solana_sdk::{pubkey::Pubkey, rent::Rent}; use tracing::{debug, warn}; use super::types::CompressibleAccountState; @@ -14,7 +11,11 @@ use crate::Result; /// Calculate the slot at which an account becomes compressible /// Returns the last funded slot; accounts are compressible when current_slot > this value -fn calculate_compressible_slot(account: &CToken, lamports: u64) -> Result { +fn calculate_compressible_slot( + account: &CToken, + lamports: u64, + account_size: usize, +) -> Result { use light_compressible::rent::SLOTS_PER_EPOCH; // Find the Compressible extension @@ -29,13 +30,13 @@ fn calculate_compressible_slot(account: &CToken, lamports: u64) -> Result { }) .ok_or_else(|| anyhow::anyhow!("Account missing Compressible extension"))?; + // Calculate rent exemption dynamically + let rent_exemption = Rent::default().minimum_balance(account_size); + // Calculate last funded epoch let last_funded_epoch = compressible_ext - .get_last_funded_epoch( - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, - lamports, - COMPRESSIBLE_TOKEN_RENT_EXEMPTION, - ) + .info + .get_last_funded_epoch(account_size as u64, lamports, rent_exemption) .map_err(|e| { anyhow::anyhow!( "Failed to calculate last funded epoch for account with {} lamports: {:?}", @@ -123,16 +124,17 @@ impl CompressibleAccountTracker { .map_err(|e| anyhow::anyhow!("Failed to deserialize CToken with borsh: {:?}", e))?; // Calculate compressible slot - let compressible_slot = match calculate_compressible_slot(&ctoken, lamports) { - Ok(slot) => slot, - Err(e) => { - warn!( + let compressible_slot = + match calculate_compressible_slot(&ctoken, lamports, account_data.len()) { + Ok(slot) => slot, + Err(e) => { + warn!( "Failed to calculate compressible slot for account {}: {}. Skipping account.", pubkey, e ); - return Ok(()); - } - }; + return Ok(()); + } + }; // Create state with full CToken account let state = CompressibleAccountState { @@ -206,7 +208,11 @@ impl CompressibleAccountTracker { // Account is valid - update state if let Some(mut state) = self.accounts.get_mut(pubkey) { - match calculate_compressible_slot(&ctoken, account.lamports) { + match calculate_compressible_slot( + &ctoken, + account.lamports, + account.data.len(), + ) { Ok(compressible_slot) => { state.account = ctoken; state.lamports = account.lamports; diff --git a/program-libs/array-map/src/lib.rs b/program-libs/array-map/src/lib.rs index 560d6ed45b..184381f011 100644 --- a/program-libs/array-map/src/lib.rs +++ b/program-libs/array-map/src/lib.rs @@ -39,20 +39,20 @@ where self.last_updated_index } - pub fn get(&self, index: usize) -> Option<&(K, V)> { + pub fn get_by_index(&self, index: usize) -> Option<&(K, V)> { self.entries.get(index) } - pub fn get_mut(&mut self, index: usize) -> Option<&mut (K, V)> { + pub fn get_by_index_mut(&mut self, index: usize) -> Option<&mut (K, V)> { self.entries.get_mut(index) } - pub fn get_u8(&self, index: u8) -> Option<&(K, V)> { - self.get(index as usize) + pub fn get_by_index_u8(&self, index: u8) -> Option<&(K, V)> { + self.get_by_index(index as usize) } - pub fn get_mut_u8(&mut self, index: u8) -> Option<&mut (K, V)> { - self.get_mut(index as usize) + pub fn get_by_index_mut_u8(&mut self, index: u8) -> Option<&mut (K, V)> { + self.get_by_index_mut(index as usize) } pub fn get_by_key(&self, key: &K) -> Option<&V> { diff --git a/program-libs/array-map/tests/array_map_tests.rs b/program-libs/array-map/tests/array_map_tests.rs index 765c9bf595..73f40cb2d6 100644 --- a/program-libs/array-map/tests/array_map_tests.rs +++ b/program-libs/array-map/tests/array_map_tests.rs @@ -30,7 +30,7 @@ fn test_insert() { assert_eq!(idx, 0); assert_eq!(map.len(), 1); assert_eq!(map.last_updated_index(), Some(0)); - assert_eq!(map.get(0).unwrap().1, "one"); + assert_eq!(map.get_by_index(0).unwrap().1, "one"); } #[test] @@ -118,11 +118,11 @@ fn test_get_mut_direct() { map.insert(1, 100, TestError::Custom).unwrap(); - if let Some(entry) = map.get_mut(0) { + if let Some(entry) = map.get_by_index_mut(0) { entry.1 += 50; } - assert_eq!(map.get(0).unwrap().1, 150); + assert_eq!(map.get_by_index(0).unwrap().1, 150); } #[test] @@ -165,13 +165,13 @@ fn test_get_u8() { .unwrap(); // Test valid indices - assert_eq!(map.get_u8(0).unwrap().1, "one"); - assert_eq!(map.get_u8(1).unwrap().1, "two"); - assert_eq!(map.get_u8(2).unwrap().1, "three"); + assert_eq!(map.get_by_index_u8(0).unwrap().1, "one"); + assert_eq!(map.get_by_index_u8(1).unwrap().1, "two"); + assert_eq!(map.get_by_index_u8(2).unwrap().1, "three"); // Test out of bounds - assert!(map.get_u8(3).is_none()); - assert!(map.get_u8(255).is_none()); + assert!(map.get_by_index_u8(3).is_none()); + assert!(map.get_by_index_u8(255).is_none()); } #[test] @@ -182,17 +182,17 @@ fn test_get_mut_u8() { map.insert(2, 200, TestError::Custom).unwrap(); map.insert(3, 300, TestError::Custom).unwrap(); - // Modify via get_mut_u8 - if let Some(entry) = map.get_mut_u8(1) { + // Modify via get_by_index_mut_u8 + if let Some(entry) = map.get_by_index_mut_u8(1) { entry.1 += 50; } // Verify modification - assert_eq!(map.get_u8(1).unwrap().1, 250); - assert_eq!(map.get_u8(0).unwrap().1, 100); - assert_eq!(map.get_u8(2).unwrap().1, 300); + assert_eq!(map.get_by_index_u8(1).unwrap().1, 250); + assert_eq!(map.get_by_index_u8(0).unwrap().1, 100); + assert_eq!(map.get_by_index_u8(2).unwrap().1, 300); // Test out of bounds - assert!(map.get_mut_u8(3).is_none()); - assert!(map.get_mut_u8(255).is_none()); + assert!(map.get_by_index_mut_u8(3).is_none()); + assert!(map.get_by_index_mut_u8(255).is_none()); } diff --git a/program-libs/ctoken-types/src/constants.rs b/program-libs/ctoken-types/src/constants.rs index aa882f7121..4f8ac3b8bf 100644 --- a/program-libs/ctoken-types/src/constants.rs +++ b/program-libs/ctoken-types/src/constants.rs @@ -13,13 +13,21 @@ pub const BASE_TOKEN_ACCOUNT_SIZE: u64 = 165; /// Extension metadata overhead: AccountType (1) + Option discriminator (1) + Vec length (4) + Extension enum variant (1) pub const EXTENSION_METADATA: u64 = 7; -/// Size of a token account with compressible extension 260 bytes. +/// Size of a token account with compressible extension 261 bytes. +/// CompressibleExtension: 1 byte compression_only + 88 bytes CompressionInfo pub const COMPRESSIBLE_TOKEN_ACCOUNT_SIZE: u64 = - BASE_TOKEN_ACCOUNT_SIZE + CompressionInfo::LEN as u64 + EXTENSION_METADATA; + BASE_TOKEN_ACCOUNT_SIZE + 1 + CompressionInfo::LEN as u64 + EXTENSION_METADATA; -/// Rent exemption threshold for compressible token accounts (in lamports) -/// This value determines when an account has sufficient rent to be considered not compressible -pub const COMPRESSIBLE_TOKEN_RENT_EXEMPTION: u64 = 2700480; +/// Size of a token account with compressible + pausable extensions (262 bytes). +/// Adds 1 byte for PausableAccount discriminator (marker extension with 0 data bytes). +pub const COMPRESSIBLE_PAUSABLE_TOKEN_ACCOUNT_SIZE: u64 = COMPRESSIBLE_TOKEN_ACCOUNT_SIZE + 1; + +// /// Rent exemption threshold for compressible token accounts (in lamports) +// /// This value determines when an account has sufficient rent to be considered not compressible +// pub const COMPRESSIBLE_TOKEN_RENT_EXEMPTION: u64 = 2700480; + +/// Size of CompressedOnly extension (8 bytes for u64 delegated_amount) +pub const COMPRESSED_ONLY_EXTENSION_SIZE: u64 = 8; /// Size of a Token-2022 mint account pub const MINT_ACCOUNT_SIZE: u64 = 82; @@ -28,3 +36,9 @@ pub const NATIVE_MINT: [u8; 32] = pubkey_array!("So11111111111111111111111111111 pub const CMINT_ADDRESS_TREE: [u8; 32] = pubkey_array!("amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx"); + +/// Size of TransferFeeAccountExtension: 1 discriminant + 8 withheld_amount +pub const TRANSFER_FEE_ACCOUNT_EXTENSION_LEN: u64 = 9; + +/// Size of TransferHookAccountExtension: 1 discriminant + 1 transferring +pub const TRANSFER_HOOK_ACCOUNT_EXTENSION_LEN: u64 = 2; diff --git a/program-libs/ctoken-types/src/error.rs b/program-libs/ctoken-types/src/error.rs index 1e88755f2d..3bfc487787 100644 --- a/program-libs/ctoken-types/src/error.rs +++ b/program-libs/ctoken-types/src/error.rs @@ -135,6 +135,12 @@ pub enum CTokenError { #[error("Calculated top-up exceeds sender's max_top_up limit")] MaxTopUpExceeded, + + #[error("CompressedOnly tokens cannot have compressed outputs - must decompress only")] + CompressedOnlyBlocksTransfer, + + #[error("out_tlv output count must match compressions count")] + OutTlvOutputCountMismatch, } impl From for u32 { @@ -183,6 +189,8 @@ impl From for u32 { CTokenError::TooManySeeds(_) => 18041, CTokenError::WriteTopUpExceedsMaximum => 18042, CTokenError::MaxTopUpExceeded => 18043, + CTokenError::CompressedOnlyBlocksTransfer => 18044, + CTokenError::OutTlvOutputCountMismatch => 18045, CTokenError::HasherError(e) => u32::from(e), CTokenError::ZeroCopyError(e) => u32::from(e), CTokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/ctoken-types/src/instructions/extensions/compressed_only.rs b/program-libs/ctoken-types/src/instructions/extensions/compressed_only.rs new file mode 100644 index 0000000000..9a5733d41e --- /dev/null +++ b/program-libs/ctoken-types/src/instructions/extensions/compressed_only.rs @@ -0,0 +1,17 @@ +use light_zero_copy::ZeroCopy; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// CompressedOnly extension instruction data for compressed token accounts. +/// This extension marks a compressed account as decompress-only (cannot be transferred). +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, AnchorSerialize, AnchorDeserialize, ZeroCopy)] +#[repr(C)] +pub struct CompressedOnlyExtensionInstructionData { + /// The delegated amount from the source CToken account's delegate field. + /// When decompressing, the decompression amount must match this value. + pub delegated_amount: u64, + /// Withheld transfer fee amount + pub withheld_transfer_fee: u64, + /// Whether the source CToken account was frozen when compressed. + pub is_frozen: bool, +} diff --git a/program-libs/ctoken-types/src/instructions/extensions/compressible.rs b/program-libs/ctoken-types/src/instructions/extensions/compressible.rs index 5f5a51171b..979b6e7e15 100644 --- a/program-libs/ctoken-types/src/instructions/extensions/compressible.rs +++ b/program-libs/ctoken-types/src/instructions/extensions/compressible.rs @@ -19,8 +19,8 @@ pub struct CompressibleExtensionInstructionData { /// Paid once at initialization. pub rent_payment: u8, pub has_top_up: u8, - /// Placeholder for future use. If true, the compressed token account cannot be transferred, - /// only decompressed. Currently unused - always set to 0. + /// If true, the compressed token account cannot be transferred, + /// only decompressed. Used for delegated compress operations. pub compression_only: u8, pub write_top_up: u32, pub compress_to_account_pubkey: Option, diff --git a/program-libs/ctoken-types/src/instructions/extensions/mod.rs b/program-libs/ctoken-types/src/instructions/extensions/mod.rs index 2740b942a1..5e357ed768 100644 --- a/program-libs/ctoken-types/src/instructions/extensions/mod.rs +++ b/program-libs/ctoken-types/src/instructions/extensions/mod.rs @@ -1,6 +1,13 @@ +pub mod compressed_only; pub mod compressible; +pub mod pausable; +pub mod permanent_delegate; pub mod token_metadata; + +pub use compressed_only::CompressedOnlyExtensionInstructionData; use light_zero_copy::ZeroCopy; +pub use pausable::PausableExtensionInstructionData; +pub use permanent_delegate::PermanentDelegateExtensionInstructionData; pub use token_metadata::{TokenMetadataInstructionData, ZTokenMetadataInstructionData}; use crate::{AnchorDeserialize, AnchorSerialize}; @@ -35,12 +42,10 @@ pub enum ExtensionInstructionData { Placeholder24, Placeholder25, Placeholder26, - /// Reserved for PausableAccount extension - Placeholder27, - /// Reserved for PermanentDelegateAccount extension - Placeholder28, + PausableAccount(PausableExtensionInstructionData), + PermanentDelegateAccount(PermanentDelegateExtensionInstructionData), Placeholder29, Placeholder30, - /// Reserved for CompressedOnly extension - Placeholder31, + /// CompressedOnly extension for compressed token accounts + CompressedOnly(CompressedOnlyExtensionInstructionData), } diff --git a/program-libs/ctoken-types/src/instructions/extensions/pausable.rs b/program-libs/ctoken-types/src/instructions/extensions/pausable.rs new file mode 100644 index 0000000000..46ca814e90 --- /dev/null +++ b/program-libs/ctoken-types/src/instructions/extensions/pausable.rs @@ -0,0 +1,11 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Instruction data for PausableAccount extension. +/// PausableAccount is a marker extension with no persisted data. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, AnchorSerialize, AnchorDeserialize, ZeroCopy, ZeroCopyMut, +)] +#[repr(C)] +pub struct PausableExtensionInstructionData; diff --git a/program-libs/ctoken-types/src/instructions/extensions/permanent_delegate.rs b/program-libs/ctoken-types/src/instructions/extensions/permanent_delegate.rs new file mode 100644 index 0000000000..7e9261f98c --- /dev/null +++ b/program-libs/ctoken-types/src/instructions/extensions/permanent_delegate.rs @@ -0,0 +1,12 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Instruction data for PermanentDelegateAccount extension. +/// This is a marker extension - no instruction data needed since +/// the permanent delegate is looked up from the mint at runtime. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, AnchorSerialize, AnchorDeserialize, ZeroCopy, ZeroCopyMut, +)] +#[repr(C)] +pub struct PermanentDelegateExtensionInstructionData; diff --git a/program-libs/ctoken-types/src/instructions/transfer2/compression.rs b/program-libs/ctoken-types/src/instructions/transfer2/compression.rs index 1be28e39bb..3e71ced46c 100644 --- a/program-libs/ctoken-types/src/instructions/transfer2/compression.rs +++ b/program-libs/ctoken-types/src/instructions/transfer2/compression.rs @@ -68,8 +68,8 @@ pub struct Compression { /// compressed account index for CompressAndClose pub pool_index: u8, // This account is not necessary to decompress ctokens because there are no token pools pub bump: u8, // This account is not necessary to decompress ctokens because there are no token pools - /// Placeholder for future use (decimals for spl token operations, or flags). - /// Currently unused - always set to 0. + /// decimals for spl token Compression/Decompression (used in transfer_checked) + /// rent_sponsor_is_signer flag for CompressAndClose (non-zero = true) pub decimals: u8, } @@ -92,9 +92,14 @@ impl ZCompression<'_> { _ => Err(CTokenError::InvalidCompressionMode), } } + /// For CompressAndClose: returns true if rent sponsor is the signer (skip mint checks) + pub fn rent_sponsor_is_signer(&self) -> bool { + self.mode == ZCompressionMode::CompressAndClose && self.decimals != 0 + } } impl Compression { + #[allow(clippy::too_many_arguments)] pub fn compress_and_close_ctoken( amount: u64, mint: u8, @@ -103,6 +108,7 @@ impl Compression { rent_sponsor_index: u8, compressed_account_index: u8, destination_index: u8, + rent_sponsor_is_signer: bool, ) -> Self { Compression { amount, // the full balance of the ctoken account to be compressed @@ -113,10 +119,11 @@ impl Compression { pool_account_index: rent_sponsor_index, pool_index: compressed_account_index, bump: destination_index, - decimals: 0, + decimals: rent_sponsor_is_signer as u8, } } + #[allow(clippy::too_many_arguments)] pub fn compress_spl( amount: u64, mint: u8, @@ -125,6 +132,7 @@ impl Compression { pool_account_index: u8, pool_index: u8, bump: u8, + decimals: u8, ) -> Self { Compression { amount, @@ -135,7 +143,7 @@ impl Compression { pool_account_index, pool_index, bump, - decimals: 0, + decimals, } } pub fn compress_ctoken(amount: u64, mint: u8, source: u8, authority: u8) -> Self { @@ -159,6 +167,7 @@ impl Compression { pool_account_index: u8, pool_index: u8, bump: u8, + decimals: u8, ) -> Self { Compression { amount, @@ -169,7 +178,7 @@ impl Compression { pool_account_index, pool_index, bump, - decimals: 0, + decimals, } } diff --git a/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs b/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs index 342ce06be0..e120d610f8 100644 --- a/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs +++ b/program-libs/ctoken-types/src/instructions/transfer2/instruction_data.rs @@ -4,10 +4,13 @@ use light_compressed_account::{ use light_zero_copy::{ZeroCopy, ZeroCopyMut}; use super::compression::Compression; -use crate::{instructions::transfer2::CompressedCpiContext, AnchorDeserialize, AnchorSerialize}; +use crate::{ + instructions::{extensions::ExtensionInstructionData, transfer2::CompressedCpiContext}, + AnchorDeserialize, AnchorSerialize, +}; #[repr(C)] -#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, ZeroCopy, ZeroCopyMut)] +#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, ZeroCopy)] pub struct CompressedTokenInstructionDataTransfer2 { pub with_transaction_hash: bool, /// Placeholder currently unimplemented. @@ -28,10 +31,10 @@ pub struct CompressedTokenInstructionDataTransfer2 { pub in_lamports: Option>, /// Placeholder currently unimplemented. pub out_lamports: Option>, - /// Placeholder currently unimplemented. - pub in_tlv: Option>>, - /// Placeholder currently unimplemented. - pub out_tlv: Option>>, + /// Extensions for input compressed token accounts (one Vec per input account) + pub in_tlv: Option>>, + /// Extensions for output compressed token accounts (one Vec per output account) + pub out_tlv: Option>>, } #[repr(C)] diff --git a/program-libs/ctoken-types/src/state/compressed_token/token_data.rs b/program-libs/ctoken-types/src/state/compressed_token/token_data.rs index 05d365b35f..17e15f2d53 100644 --- a/program-libs/ctoken-types/src/state/compressed_token/token_data.rs +++ b/program-libs/ctoken-types/src/state/compressed_token/token_data.rs @@ -2,7 +2,11 @@ use light_compressed_account::Pubkey; use light_program_profiler::profile; use light_zero_copy::{num_trait::ZeroCopyNumTrait, ZeroCopy, ZeroCopyMut}; -use crate::{AnchorDeserialize, AnchorSerialize, CTokenError}; +use crate::{ + instructions::extensions::ZExtensionInstructionData, + state::extensions::{ExtensionStruct, ZExtensionStructMut}, + AnchorDeserialize, AnchorSerialize, CTokenError, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)] #[repr(u8)] @@ -41,8 +45,8 @@ pub struct TokenData { pub delegate: Option, /// The account's state pub state: u8, - /// Placeholder for TokenExtension tlv data (unimplemented) - pub tlv: Option>, + /// Extensions for the compressed token account + pub tlv: Option>, } impl TokenData { @@ -52,8 +56,9 @@ impl TokenData { } // Implementation for zero-copy mutable TokenData -impl ZTokenDataMut<'_> { - /// Set all fields of the TokenData struct at once +impl<'a> ZTokenDataMut<'a> { + /// Set all fields of the TokenData struct at once. + /// All data must be allocated before calling this function. #[inline] #[profile] pub fn set( @@ -63,6 +68,7 @@ impl ZTokenDataMut<'_> { amount: impl ZeroCopyNumTrait, delegate: Option, state: CompressedTokenAccountState, + tlv_data: Option<&[ZExtensionInstructionData<'_>]>, ) -> Result<(), CTokenError> { self.mint = mint; self.owner = owner; @@ -76,9 +82,20 @@ impl ZTokenDataMut<'_> { *self.state = state as u8; - if self.tlv.is_some() { - return Err(CTokenError::TokenDataTlvUnimplemented); + // Set TLV extension values (space was pre-allocated via new_zero_copy) + if let (Some(tlv_vec), Some(exts)) = (self.tlv.as_mut(), tlv_data) { + for (tlv_ext, instruction_ext) in tlv_vec.iter_mut().zip(exts.iter()) { + if let ( + ZExtensionStructMut::CompressedOnly(compressed_only), + ZExtensionInstructionData::CompressedOnly(data), + ) = (tlv_ext, instruction_ext) + { + compressed_only.delegated_amount = data.delegated_amount; + compressed_only.withheld_transfer_fee = data.withheld_transfer_fee; + } + } } + Ok(()) } } diff --git a/program-libs/ctoken-types/src/state/ctoken/mod.rs b/program-libs/ctoken-types/src/state/ctoken/mod.rs index 9f1cd1caec..0cc5b7edf4 100644 --- a/program-libs/ctoken-types/src/state/ctoken/mod.rs +++ b/program-libs/ctoken-types/src/state/ctoken/mod.rs @@ -1,6 +1,8 @@ mod borsh; mod ctoken_struct; +mod size; mod zero_copy; pub use ctoken_struct::*; +pub use size::*; pub use zero_copy::*; diff --git a/program-libs/ctoken-types/src/state/ctoken/size.rs b/program-libs/ctoken-types/src/state/ctoken/size.rs new file mode 100644 index 0000000000..036f0462f6 --- /dev/null +++ b/program-libs/ctoken-types/src/state/ctoken/size.rs @@ -0,0 +1,63 @@ +use light_compressible::compression_info::CompressionInfo; + +use crate::{ + BASE_TOKEN_ACCOUNT_SIZE, EXTENSION_METADATA, TRANSFER_FEE_ACCOUNT_EXTENSION_LEN, + TRANSFER_HOOK_ACCOUNT_EXTENSION_LEN, +}; + +/// Calculates the size of a ctoken account based on which extensions are present. +/// +/// # Arguments +/// * `has_compressible` - Whether the account has the Compressible extension +/// * `has_pausable` - Whether the account has the PausableAccount extension (marker, 0 bytes) +/// * `has_permanent_delegate` - Whether the account has the PermanentDelegateAccount extension (marker, 0 bytes) +/// * `has_transfer_fee` - Whether the account has the TransferFeeAccount extension (8 bytes) +/// * `has_transfer_hook` - Whether the account has the TransferHookAccount extension (1 byte transferring) +/// +/// # Returns +/// The total account size in bytes +/// +/// # Extension Sizes +/// - Base account: 165 bytes +/// - Extension metadata (per extension): 7 bytes (1 AccountType + 1 Option + 4 Vec len + 1 discriminant) +/// - Compressible: 89 bytes (1 compression_only + 88 CompressionInfo::LEN) +/// - PausableAccount: 0 bytes (marker only, just discriminant) +/// - PermanentDelegateAccount: 0 bytes (marker only, just discriminant) +/// - TransferFeeAccount: 8 bytes (withheld_amount u64) +/// - TransferHookAccount: 1 byte (transferring flag, consistent with T22) +pub const fn calculate_ctoken_account_size( + has_compressible: bool, + has_pausable: bool, + has_permanent_delegate: bool, + has_transfer_fee: bool, + has_transfer_hook: bool, +) -> u64 { + let mut size = BASE_TOKEN_ACCOUNT_SIZE; + + if has_compressible { + // CompressibleExtension: 1 byte compression_only + CompressionInfo::LEN + size += 1 + CompressionInfo::LEN as u64 + EXTENSION_METADATA; + } + + if has_pausable { + // PausableAccount is a marker extension (0 data bytes), just adds discriminant + size += 1; + } + + if has_permanent_delegate { + // PermanentDelegateAccount is a marker extension (0 data bytes), just adds discriminant + size += 1; + } + + if has_transfer_fee { + // TransferFeeAccount: 1 discriminant + 8 withheld_amount + size += TRANSFER_FEE_ACCOUNT_EXTENSION_LEN; + } + + if has_transfer_hook { + // TransferHookAccount: 1 discriminant + 1 transferring flag (consistent with T22) + size += TRANSFER_HOOK_ACCOUNT_EXTENSION_LEN; + } + + size +} diff --git a/program-libs/ctoken-types/src/state/ctoken/zero_copy.rs b/program-libs/ctoken-types/src/state/ctoken/zero_copy.rs index 8460abb1ed..715ba714ad 100644 --- a/program-libs/ctoken-types/src/state/ctoken/zero_copy.rs +++ b/program-libs/ctoken-types/src/state/ctoken/zero_copy.rs @@ -10,8 +10,8 @@ use spl_pod::solana_msg::msg; use crate::{ state::{ - CToken, CompressionInfoConfig, ExtensionStruct, ExtensionStructConfig, ZExtensionStruct, - ZExtensionStructMut, + CToken, CompressibleExtensionConfig, CompressionInfoConfig, ExtensionStruct, + ExtensionStructConfig, ZExtensionStruct, ZExtensionStructMut, }, AnchorDeserialize, AnchorSerialize, }; @@ -288,63 +288,73 @@ impl PartialEq for ZCToken<'_> { crate::state::extensions::ZExtensionStruct::Compressible(zc_comp), crate::state::extensions::ExtensionStruct::Compressible(regular_comp), ) => { + // Compare compression_only + if (zc_comp.compression_only != 0) != regular_comp.compression_only { + return false; + } + // Compare config_account_version - if zc_comp.config_account_version != regular_comp.config_account_version + if zc_comp.info.config_account_version + != regular_comp.info.config_account_version { return false; } // Compare compress_to_pubkey - if zc_comp.compress_to_pubkey != regular_comp.compress_to_pubkey { + if zc_comp.info.compress_to_pubkey + != regular_comp.info.compress_to_pubkey + { return false; } // Compare account_version - if zc_comp.account_version != regular_comp.account_version { + if zc_comp.info.account_version != regular_comp.info.account_version { return false; } // Compare last_claimed_slot - if u64::from(zc_comp.last_claimed_slot) - != regular_comp.last_claimed_slot + if u64::from(zc_comp.info.last_claimed_slot) + != regular_comp.info.last_claimed_slot { return false; } // Compare rent_config fields - if u16::from(zc_comp.rent_config.base_rent) - != regular_comp.rent_config.base_rent + if u16::from(zc_comp.info.rent_config.base_rent) + != regular_comp.info.rent_config.base_rent { return false; } - if u16::from(zc_comp.rent_config.compression_cost) - != regular_comp.rent_config.compression_cost + if u16::from(zc_comp.info.rent_config.compression_cost) + != regular_comp.info.rent_config.compression_cost { return false; } - if zc_comp.rent_config.lamports_per_byte_per_epoch - != regular_comp.rent_config.lamports_per_byte_per_epoch + if zc_comp.info.rent_config.lamports_per_byte_per_epoch + != regular_comp.info.rent_config.lamports_per_byte_per_epoch { return false; } - if zc_comp.rent_config.max_funded_epochs - != regular_comp.rent_config.max_funded_epochs + if zc_comp.info.rent_config.max_funded_epochs + != regular_comp.info.rent_config.max_funded_epochs { return false; } // Compare compression_authority ([u8; 32]) - if zc_comp.compression_authority != regular_comp.compression_authority { + if zc_comp.info.compression_authority + != regular_comp.info.compression_authority + { return false; } // Compare rent_sponsor ([u8; 32]) - if zc_comp.rent_sponsor != regular_comp.rent_sponsor { + if zc_comp.info.rent_sponsor != regular_comp.info.rent_sponsor { return false; } // Compare lamports_per_write (u32) - if u32::from(zc_comp.lamports_per_write) - != regular_comp.lamports_per_write + if u32::from(zc_comp.info.lamports_per_write) + != regular_comp.info.lamports_per_write { return false; } @@ -472,6 +482,7 @@ impl CToken { ) -> Result<(ZCompressedTokenMut<'_>, &mut [u8]), crate::error::CTokenError> { // Check minimum size for state field at byte 108 if bytes.len() < 109 { + msg!("zero_copy_at_mut_checked bytes.len() < 109 {}", bytes.len()); return Err(crate::error::CTokenError::InvalidAccountData); } @@ -624,9 +635,11 @@ impl CompressedTokenConfig { delegate, is_native, close_authority, - extensions: vec![ExtensionStructConfig::Compressible(CompressionInfoConfig { - rent_config: (), - })], + extensions: vec![ExtensionStructConfig::Compressible( + CompressibleExtensionConfig { + info: CompressionInfoConfig { rent_config: () }, + }, + )], } } } diff --git a/program-libs/ctoken-types/src/state/extensions/compressed_only.rs b/program-libs/ctoken-types/src/state/extensions/compressed_only.rs new file mode 100644 index 0000000000..5e9bfffc9b --- /dev/null +++ b/program-libs/ctoken-types/src/state/extensions/compressed_only.rs @@ -0,0 +1,31 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// CompressedOnly extension for compressed token accounts. +/// This extension marks a compressed account as decompress-only (cannot be transferred). +/// It stores the delegated amount from the source CToken account when it was compressed-and-closed. +#[derive( + Debug, + Clone, + Hash, + Copy, + PartialEq, + Eq, + AnchorSerialize, + AnchorDeserialize, + ZeroCopy, + ZeroCopyMut, +)] +#[repr(C)] +pub struct CompressedOnlyExtension { + /// The delegated amount from the source CToken account's delegate field. + /// When decompressing, the decompression amount must match this value. + pub delegated_amount: u64, + /// Withheld transfer fee amount from the source CToken account. + pub withheld_transfer_fee: u64, +} + +impl CompressedOnlyExtension { + pub const LEN: usize = std::mem::size_of::(); +} diff --git a/program-libs/ctoken-types/src/state/extensions/extension_struct.rs b/program-libs/ctoken-types/src/state/extensions/extension_struct.rs index 8a94a29323..ad2b2eff83 100644 --- a/program-libs/ctoken-types/src/state/extensions/extension_struct.rs +++ b/program-libs/ctoken-types/src/state/extensions/extension_struct.rs @@ -1,10 +1,15 @@ -use light_zero_copy::ZeroCopy; +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; use spl_pod::solana_msg::msg; use crate::{ - state::{ - extensions::{CompressionInfo, TokenMetadata, TokenMetadataConfig, ZTokenMetadataMut}, - CompressionInfoConfig, + state::extensions::{ + CompressedOnlyExtension, CompressedOnlyExtensionConfig, CompressionInfo, + PausableAccountExtension, PausableAccountExtensionConfig, + PermanentDelegateAccountExtension, PermanentDelegateAccountExtensionConfig, TokenMetadata, + TokenMetadataConfig, TransferFeeAccountExtension, TransferFeeAccountExtensionConfig, + TransferHookAccountExtension, TransferHookAccountExtensionConfig, + ZPausableAccountExtensionMut, ZPermanentDelegateAccountExtensionMut, ZTokenMetadataMut, + ZTransferFeeAccountExtensionMut, ZTransferHookAccountExtensionMut, }, AnchorDeserialize, AnchorSerialize, }; @@ -48,7 +53,35 @@ pub enum ExtensionStruct { Placeholder30, Placeholder31, /// Account contains compressible timing data and rent authority - Compressible(CompressionInfo), + Compressible(CompressibleExtension), + /// Marker extension indicating the account belongs to a pausable mint + PausableAccount(PausableAccountExtension), + /// Marker extension indicating the account belongs to a mint with permanent delegate + PermanentDelegateAccount(PermanentDelegateAccountExtension), + /// Transfer fee extension storing withheld fees from transfers + TransferFeeAccount(TransferFeeAccountExtension), + /// Marker extension indicating the account belongs to a mint with transfer hook + TransferHookAccount(TransferHookAccountExtension), + /// CompressedOnly extension for compressed token accounts (stores delegated amount) + CompressedOnly(CompressedOnlyExtension), +} + +#[derive( + Debug, + ZeroCopy, + ZeroCopyMut, + Clone, + Copy, + PartialEq, + Hash, + Eq, + AnchorSerialize, + AnchorDeserialize, +)] +#[repr(C)] +pub struct CompressibleExtension { + pub compression_only: bool, + pub info: CompressionInfo, } #[derive(Debug)] @@ -89,7 +122,21 @@ pub enum ZExtensionStructMut<'a> { Placeholder30, Placeholder31, /// Account contains compressible timing data and rent authority - Compressible(>::ZeroCopyAtMut), + Compressible( + >::ZeroCopyAtMut, + ), + /// Marker extension indicating the account belongs to a pausable mint + PausableAccount(ZPausableAccountExtensionMut<'a>), + /// Marker extension indicating the account belongs to a mint with permanent delegate + PermanentDelegateAccount(ZPermanentDelegateAccountExtensionMut<'a>), + /// Transfer fee extension storing withheld fees from transfers + TransferFeeAccount(ZTransferFeeAccountExtensionMut<'a>), + /// Marker extension indicating the account belongs to a mint with transfer hook + TransferHookAccount(ZTransferHookAccountExtensionMut<'a>), + /// CompressedOnly extension for compressed token accounts + CompressedOnly( + >::ZeroCopyAtMut, + ), } impl<'a> light_zero_copy::traits::ZeroCopyAtMut<'a> for ExtensionStruct { @@ -120,12 +167,57 @@ impl<'a> light_zero_copy::traits::ZeroCopyAtMut<'a> for ExtensionStruct { 32 => { // Compressible variant (index 32 to avoid Token-2022 overlap) let (compressible_ext, remaining_bytes) = - CompressionInfo::zero_copy_at_mut(remaining_data)?; + CompressibleExtension::zero_copy_at_mut(remaining_data)?; Ok(( ZExtensionStructMut::Compressible(compressible_ext), remaining_bytes, )) } + 27 => { + // PausableAccount variant (marker extension, no data) + let (pausable_ext, remaining_bytes) = + PausableAccountExtension::zero_copy_at_mut(remaining_data)?; + Ok(( + ZExtensionStructMut::PausableAccount(pausable_ext), + remaining_bytes, + )) + } + 28 => { + // PermanentDelegateAccount variant (marker extension, no data) + let (permanent_delegate_ext, remaining_bytes) = + PermanentDelegateAccountExtension::zero_copy_at_mut(remaining_data)?; + Ok(( + ZExtensionStructMut::PermanentDelegateAccount(permanent_delegate_ext), + remaining_bytes, + )) + } + 29 => { + // TransferFeeAccount variant + let (transfer_fee_ext, remaining_bytes) = + TransferFeeAccountExtension::zero_copy_at_mut(remaining_data)?; + Ok(( + ZExtensionStructMut::TransferFeeAccount(transfer_fee_ext), + remaining_bytes, + )) + } + 30 => { + // TransferHookAccount variant + let (transfer_hook_ext, remaining_bytes) = + TransferHookAccountExtension::zero_copy_at_mut(remaining_data)?; + Ok(( + ZExtensionStructMut::TransferHookAccount(transfer_hook_ext), + remaining_bytes, + )) + } + 31 => { + // CompressedOnly variant + let (compressed_only_ext, remaining_bytes) = + CompressedOnlyExtension::zero_copy_at_mut(remaining_data)?; + Ok(( + ZExtensionStructMut::CompressedOnly(compressed_only_ext), + remaining_bytes, + )) + } _ => Err(light_zero_copy::errors::ZeroCopyError::InvalidConversion), } } @@ -144,8 +236,28 @@ impl<'a> light_zero_copy::ZeroCopyNew<'a> for ExtensionStruct { 1 + TokenMetadata::byte_len(token_metadata_config)? } ExtensionStructConfig::Compressible(config) => { - // 1 byte for discriminant + CompressionInfo size - 1 + CompressionInfo::byte_len(config)? + // 1 byte for discriminant + CompressibleExtension size + 1 + CompressibleExtension::byte_len(config)? + } + ExtensionStructConfig::PausableAccount(config) => { + // 1 byte for discriminant + 0 bytes for marker extension + 1 + PausableAccountExtension::byte_len(config)? + } + ExtensionStructConfig::PermanentDelegateAccount(config) => { + // 1 byte for discriminant + 0 bytes for marker extension + 1 + PermanentDelegateAccountExtension::byte_len(config)? + } + ExtensionStructConfig::TransferFeeAccount(config) => { + // 1 byte for discriminant + 8 bytes for withheld_amount + 1 + TransferFeeAccountExtension::byte_len(config)? + } + ExtensionStructConfig::TransferHookAccount(config) => { + // 1 byte for discriminant + 1 byte for transferring flag + 1 + TransferHookAccountExtension::byte_len(config)? + } + ExtensionStructConfig::CompressedOnly(_) => { + // 1 byte for discriminant + 16 bytes for CompressedOnlyExtension (2 * u64) + 1 + CompressedOnlyExtension::LEN } _ => { msg!("Invalid extension type returning"); @@ -187,12 +299,97 @@ impl<'a> light_zero_copy::ZeroCopyNew<'a> for ExtensionStruct { bytes[0] = 32u8; let (compressible_ext, remaining_bytes) = - CompressionInfo::new_zero_copy(&mut bytes[1..], config)?; + CompressibleExtension::new_zero_copy(&mut bytes[1..], config)?; Ok(( ZExtensionStructMut::Compressible(compressible_ext), remaining_bytes, )) } + ExtensionStructConfig::PausableAccount(config) => { + // Write discriminant (27 for PausableAccount) + if bytes.is_empty() { + return Err(light_zero_copy::errors::ZeroCopyError::ArraySize( + 1, + bytes.len(), + )); + } + bytes[0] = 27u8; + + let (pausable_ext, remaining_bytes) = + PausableAccountExtension::new_zero_copy(&mut bytes[1..], config)?; + Ok(( + ZExtensionStructMut::PausableAccount(pausable_ext), + remaining_bytes, + )) + } + ExtensionStructConfig::PermanentDelegateAccount(config) => { + // Write discriminant (28 for PermanentDelegateAccount) + if bytes.is_empty() { + return Err(light_zero_copy::errors::ZeroCopyError::ArraySize( + 1, + bytes.len(), + )); + } + bytes[0] = 28u8; + + let (permanent_delegate_ext, remaining_bytes) = + PermanentDelegateAccountExtension::new_zero_copy(&mut bytes[1..], config)?; + Ok(( + ZExtensionStructMut::PermanentDelegateAccount(permanent_delegate_ext), + remaining_bytes, + )) + } + ExtensionStructConfig::TransferFeeAccount(config) => { + // Write discriminant (29 for TransferFeeAccount) + if bytes.is_empty() { + return Err(light_zero_copy::errors::ZeroCopyError::ArraySize( + 1, + bytes.len(), + )); + } + bytes[0] = 29u8; + + let (transfer_fee_ext, remaining_bytes) = + TransferFeeAccountExtension::new_zero_copy(&mut bytes[1..], config)?; + Ok(( + ZExtensionStructMut::TransferFeeAccount(transfer_fee_ext), + remaining_bytes, + )) + } + ExtensionStructConfig::TransferHookAccount(config) => { + // Write discriminant (30 for TransferHookAccount) + if bytes.is_empty() { + return Err(light_zero_copy::errors::ZeroCopyError::ArraySize( + 1, + bytes.len(), + )); + } + bytes[0] = 30u8; + + let (transfer_hook_ext, remaining_bytes) = + TransferHookAccountExtension::new_zero_copy(&mut bytes[1..], config)?; + Ok(( + ZExtensionStructMut::TransferHookAccount(transfer_hook_ext), + remaining_bytes, + )) + } + ExtensionStructConfig::CompressedOnly(config) => { + // Write discriminant (31 for CompressedOnly) + if bytes.len() < 1 + CompressedOnlyExtension::LEN { + return Err(light_zero_copy::errors::ZeroCopyError::ArraySize( + 1 + CompressedOnlyExtension::LEN, + bytes.len(), + )); + } + bytes[0] = 31u8; + + let (compressed_only_ext, remaining_bytes) = + CompressedOnlyExtension::new_zero_copy(&mut bytes[1..], config)?; + Ok(( + ZExtensionStructMut::CompressedOnly(compressed_only_ext), + remaining_bytes, + )) + } _ => Err(light_zero_copy::errors::ZeroCopyError::InvalidConversion), } } @@ -228,12 +425,10 @@ pub enum ExtensionStructConfig { Placeholder25, /// Reserved for Token-2022 Pausable compatibility Placeholder26, - /// Reserved for Token-2022 PausableAccount compatibility - Placeholder27, - /// Reserved for Token-2022 extensions - Placeholder28, - Placeholder29, - Placeholder30, - Placeholder31, - Compressible(CompressionInfoConfig), + PausableAccount(PausableAccountExtensionConfig), + PermanentDelegateAccount(PermanentDelegateAccountExtensionConfig), + TransferFeeAccount(TransferFeeAccountExtensionConfig), + TransferHookAccount(TransferHookAccountExtensionConfig), + CompressedOnly(CompressedOnlyExtensionConfig), + Compressible(CompressibleExtensionConfig), } diff --git a/program-libs/ctoken-types/src/state/extensions/extension_type.rs b/program-libs/ctoken-types/src/state/extensions/extension_type.rs index 5193b44966..04e173233b 100644 --- a/program-libs/ctoken-types/src/state/extensions/extension_type.rs +++ b/program-libs/ctoken-types/src/state/extensions/extension_type.rs @@ -33,13 +33,20 @@ pub enum ExtensionType { Placeholder25, /// Reserved for Token-2022 Pausable compatibility Placeholder26, - /// Reserved for Token-2022 PausableAccount compatibility - Placeholder27, - /// Reserved for Token-2022 extensions - Placeholder28, - Placeholder29, - Placeholder30, - Placeholder31, + /// Marker extension indicating the account belongs to a pausable mint. + /// When the SPL mint has PausableConfig and is paused, token operations are blocked. + PausableAccount = 27, + /// Marker extension indicating the account belongs to a mint with permanent delegate. + /// When the SPL mint has PermanentDelegate extension, the delegate can transfer/burn any tokens. + PermanentDelegateAccount = 28, + /// Transfer fee extension storing withheld fees from transfers. + TransferFeeAccount = 29, + /// Marker extension indicating the account belongs to a mint with transfer hook. + /// We only support mints where program_id is nil (no hook invoked). + TransferHookAccount = 30, + /// CompressedOnly extension for compressed token accounts. + /// Marks account as decompress-only (cannot be transferred) and stores delegated amount. + CompressedOnly = 31, /// Account contains compressible timing data and rent authority Compressible = 32, } @@ -50,6 +57,11 @@ impl TryFrom for ExtensionType { fn try_from(value: u8) -> Result { match value { 19 => Ok(ExtensionType::TokenMetadata), + 27 => Ok(ExtensionType::PausableAccount), + 28 => Ok(ExtensionType::PermanentDelegateAccount), + 29 => Ok(ExtensionType::TransferFeeAccount), + 30 => Ok(ExtensionType::TransferHookAccount), + 31 => Ok(ExtensionType::CompressedOnly), 32 => Ok(ExtensionType::Compressible), _ => Err(crate::CTokenError::UnsupportedExtension), } diff --git a/program-libs/ctoken-types/src/state/extensions/mod.rs b/program-libs/ctoken-types/src/state/extensions/mod.rs index 3326032915..9aba70bd05 100644 --- a/program-libs/ctoken-types/src/state/extensions/mod.rs +++ b/program-libs/ctoken-types/src/state/extensions/mod.rs @@ -1,8 +1,18 @@ +mod compressed_only; mod extension_struct; mod extension_type; +mod pausable; +mod permanent_delegate; +mod token_metadata; +mod transfer_fee; +mod transfer_hook; +pub use compressed_only::*; pub use extension_struct::*; pub use extension_type::*; -mod token_metadata; pub use light_compressible::compression_info::{CompressionInfo, CompressionInfoConfig}; +pub use pausable::*; +pub use permanent_delegate::*; pub use token_metadata::*; +pub use transfer_fee::*; +pub use transfer_hook::*; diff --git a/program-libs/ctoken-types/src/state/extensions/pausable.rs b/program-libs/ctoken-types/src/state/extensions/pausable.rs new file mode 100644 index 0000000000..c20f3a804a --- /dev/null +++ b/program-libs/ctoken-types/src/state/extensions/pausable.rs @@ -0,0 +1,25 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Marker extension indicating the account belongs to a pausable mint. +/// This is a zero-size marker (no data) that indicates the token account's +/// mint has the SPL Token 2022 Pausable extension. +/// +/// When present, token operations must check the SPL mint's PausableConfig +/// to determine if the mint is paused before allowing transfers. +#[derive( + Debug, + Clone, + Copy, + Hash, + PartialEq, + Eq, + Default, + AnchorSerialize, + AnchorDeserialize, + ZeroCopy, + ZeroCopyMut, +)] +#[repr(C)] +pub struct PausableAccountExtension; diff --git a/program-libs/ctoken-types/src/state/extensions/permanent_delegate.rs b/program-libs/ctoken-types/src/state/extensions/permanent_delegate.rs new file mode 100644 index 0000000000..0ff9ed67a8 --- /dev/null +++ b/program-libs/ctoken-types/src/state/extensions/permanent_delegate.rs @@ -0,0 +1,25 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Marker extension indicating the account belongs to a mint with permanent delegate. +/// This is a zero-size marker (no data) that indicates the token account's +/// mint has the SPL Token 2022 Permanent Delegate extension. +/// +/// When present, token operations must check the SPL mint's PermanentDelegate +/// to determine the delegate authority before allowing transfers/burns. +#[derive( + Debug, + Clone, + Copy, + Hash, + PartialEq, + Eq, + Default, + AnchorSerialize, + AnchorDeserialize, + ZeroCopy, + ZeroCopyMut, +)] +#[repr(C)] +pub struct PermanentDelegateAccountExtension; diff --git a/program-libs/ctoken-types/src/state/extensions/transfer_fee.rs b/program-libs/ctoken-types/src/state/extensions/transfer_fee.rs new file mode 100644 index 0000000000..672121cee3 --- /dev/null +++ b/program-libs/ctoken-types/src/state/extensions/transfer_fee.rs @@ -0,0 +1,40 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Transfer fee extension for CToken accounts. +/// Stores withheld fees that accumulate during transfers. +/// Mirrors SPL Token-2022's TransferFeeAmount extension. +#[derive( + Debug, + Clone, + Copy, + Hash, + PartialEq, + Eq, + Default, + AnchorSerialize, + AnchorDeserialize, + ZeroCopy, + ZeroCopyMut, +)] +#[repr(C)] +pub struct TransferFeeAccountExtension { + /// Amount withheld during transfers, to be harvested on decompress + pub withheld_amount: u64, +} + +/// Error returned when arithmetic operation overflows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArithmeticOverflow; + +impl<'a> ZTransferFeeAccountExtensionMut<'a> { + /// Add fee to withheld amount (used during transfers). + /// Returns error if addition would overflow. + pub fn add_withheld_amount(&mut self, fee: u64) -> Result<(), ArithmeticOverflow> { + let current: u64 = self.withheld_amount.get(); + let new_amount = current.checked_add(fee).ok_or(ArithmeticOverflow)?; + self.withheld_amount.set(new_amount); + Ok(()) + } +} diff --git a/program-libs/ctoken-types/src/state/extensions/transfer_hook.rs b/program-libs/ctoken-types/src/state/extensions/transfer_hook.rs new file mode 100644 index 0000000000..edb9be15ac --- /dev/null +++ b/program-libs/ctoken-types/src/state/extensions/transfer_hook.rs @@ -0,0 +1,27 @@ +use light_zero_copy::{ZeroCopy, ZeroCopyMut}; + +use crate::{AnchorDeserialize, AnchorSerialize}; + +/// Extension indicating the account belongs to a mint with transfer hook. +/// Contains a `transferring` flag used as a reentrancy guard during hook CPI. +/// Consistent with SPL Token-2022 TransferHookAccount layout. +#[derive( + Debug, + Clone, + Copy, + Hash, + PartialEq, + Eq, + Default, + AnchorSerialize, + AnchorDeserialize, + ZeroCopy, + ZeroCopyMut, +)] +#[repr(C)] +pub struct TransferHookAccountExtension { + /// Flag to indicate that the account is in the middle of a transfer. + /// Used as reentrancy guard when transfer hook program is called via CPI. + /// Always false at rest since we only support nil program_id (no hook invoked). + pub transferring: u8, +} diff --git a/program-libs/ctoken-types/tests/ctoken/mod.rs b/program-libs/ctoken-types/tests/ctoken/mod.rs index 84143da26d..bc3c1fcb23 100644 --- a/program-libs/ctoken-types/tests/ctoken/mod.rs +++ b/program-libs/ctoken-types/tests/ctoken/mod.rs @@ -1,4 +1,5 @@ pub mod failing; pub mod randomized_solana_ctoken; +pub mod size; pub mod spl_compat; pub mod zero_copy_new; diff --git a/program-libs/ctoken-types/tests/ctoken/size.rs b/program-libs/ctoken-types/tests/ctoken/size.rs new file mode 100644 index 0000000000..4ec2691ca1 --- /dev/null +++ b/program-libs/ctoken-types/tests/ctoken/size.rs @@ -0,0 +1,85 @@ +use light_ctoken_types::{ + state::calculate_ctoken_account_size, BASE_TOKEN_ACCOUNT_SIZE, + COMPRESSIBLE_PAUSABLE_TOKEN_ACCOUNT_SIZE, COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, +}; + +#[test] +fn test_ctoken_account_size_calculation() { + // Base only (no extensions) + assert_eq!( + calculate_ctoken_account_size(false, false, false, false, false), + BASE_TOKEN_ACCOUNT_SIZE + ); + + // With compressible only + assert_eq!( + calculate_ctoken_account_size(true, false, false, false, false), + COMPRESSIBLE_TOKEN_ACCOUNT_SIZE + ); + + // With compressible + pausable + assert_eq!( + calculate_ctoken_account_size(true, true, false, false, false), + COMPRESSIBLE_PAUSABLE_TOKEN_ACCOUNT_SIZE + ); + + // With compressible + pausable + permanent_delegate (262 + 1 = 263) + assert_eq!( + calculate_ctoken_account_size(true, true, true, false, false), + 263 + ); + + // With pausable only (165 + 1 = 166) + assert_eq!( + calculate_ctoken_account_size(false, true, false, false, false), + 166 + ); + + // With permanent_delegate only (165 + 1 = 166) + assert_eq!( + calculate_ctoken_account_size(false, false, true, false, false), + 166 + ); + + // With pausable + permanent_delegate (165 + 1 + 1 = 167) + assert_eq!( + calculate_ctoken_account_size(false, true, true, false, false), + 167 + ); + + // With compressible + permanent_delegate (261 + 1 = 262) + assert_eq!( + calculate_ctoken_account_size(true, false, true, false, false), + 262 + ); + + // With transfer_fee only (165 + 9 = 174) + assert_eq!( + calculate_ctoken_account_size(false, false, false, true, false), + 174 + ); + + // With compressible + transfer_fee (261 + 9 = 270) + assert_eq!( + calculate_ctoken_account_size(true, false, false, true, false), + 270 + ); + + // With 4 extensions (261 + 1 + 1 + 9 = 272) + assert_eq!( + calculate_ctoken_account_size(true, true, true, true, false), + 272 + ); + + // With all 5 extensions (261 + 1 + 1 + 9 + 2 = 274) + assert_eq!( + calculate_ctoken_account_size(true, true, true, true, true), + 274 + ); + + // With transfer_hook only (165 + 2 = 167) + assert_eq!( + calculate_ctoken_account_size(false, false, false, false, true), + 167 + ); +} diff --git a/program-libs/ctoken-types/tests/ctoken/spl_compat.rs b/program-libs/ctoken-types/tests/ctoken/spl_compat.rs index eca5cb71b7..eae7441257 100644 --- a/program-libs/ctoken-types/tests/ctoken/spl_compat.rs +++ b/program-libs/ctoken-types/tests/ctoken/spl_compat.rs @@ -8,7 +8,7 @@ use light_compressed_account::Pubkey; use light_ctoken_types::state::{ ctoken::{CToken, CompressedTokenConfig, ZCToken}, - CompressionInfoConfig, ExtensionStructConfig, + CompressibleExtensionConfig, CompressionInfoConfig, ExtensionStructConfig, }; use light_zero_copy::traits::{ZeroCopyAt, ZeroCopyAtMut, ZeroCopyNew}; use rand::Rng; @@ -390,9 +390,11 @@ fn test_compressed_token_with_compressible_extension() { delegate: false, is_native: false, close_authority: false, - extensions: vec![ExtensionStructConfig::Compressible(CompressionInfoConfig { - rent_config: (), - })], + extensions: vec![ExtensionStructConfig::Compressible( + CompressibleExtensionConfig { + info: CompressionInfoConfig { rent_config: () }, + }, + )], }; // Calculate required buffer size (165 base + 1 AccountType + 1 Option + extension data) @@ -451,9 +453,11 @@ fn test_account_type_compatibility_with_spl_parsing() { delegate: false, is_native: false, close_authority: false, - extensions: vec![ExtensionStructConfig::Compressible(CompressionInfoConfig { - rent_config: (), - })], + extensions: vec![ExtensionStructConfig::Compressible( + CompressibleExtensionConfig { + info: CompressionInfoConfig { rent_config: () }, + }, + )], }; let mut buffer = vec![0u8; CToken::byte_len(&config).unwrap()]; @@ -491,16 +495,19 @@ fn test_account_type_compatibility_with_spl_parsing() { fn test_compressible_extension_partial_eq() { use light_compressible::{compression_info::CompressionInfo, rent::RentConfig}; use light_ctoken_types::state::{ - ctoken::AccountState as CtokenAccountState, extensions::ExtensionStruct, + ctoken::AccountState as CtokenAccountState, + extensions::{CompressibleExtension, ExtensionStruct}, }; let config = CompressedTokenConfig { delegate: false, is_native: false, close_authority: false, - extensions: vec![ExtensionStructConfig::Compressible(CompressionInfoConfig { - rent_config: (), - })], + extensions: vec![ExtensionStructConfig::Compressible( + CompressibleExtensionConfig { + info: CompressionInfoConfig { rent_config: () }, + }, + )], }; let mut buffer = vec![0u8; CToken::byte_len(&config).unwrap()]; @@ -514,13 +521,13 @@ fn test_compressible_extension_partial_eq() { ref mut comp, ) = ext { - comp.config_account_version = 1.into(); - comp.compress_to_pubkey = 1; - comp.account_version = 2; - comp.lamports_per_write = 100.into(); - comp.compression_authority = [1u8; 32]; - comp.rent_sponsor = [2u8; 32]; - comp.last_claimed_slot = 1000.into(); + comp.info.config_account_version = 1.into(); + comp.info.compress_to_pubkey = 1; + comp.info.account_version = 2; + comp.info.lamports_per_write = 100.into(); + comp.info.compression_authority = [1u8; 32]; + comp.info.rent_sponsor = [2u8; 32]; + comp.info.last_claimed_slot = 1000.into(); } } } @@ -553,7 +560,10 @@ fn test_compressible_extension_partial_eq() { is_native: None, delegated_amount: 0, close_authority: None, - extensions: Some(vec![ExtensionStruct::Compressible(compression_info)]), + extensions: Some(vec![ExtensionStruct::Compressible(CompressibleExtension { + compression_only: false, + info: compression_info, + })]), }; // Parse zero-copy view @@ -565,9 +575,12 @@ fn test_compressible_extension_partial_eq() { // Test compress_to_pubkey mismatch let ctoken_diff_compress = CToken { - extensions: Some(vec![ExtensionStruct::Compressible(CompressionInfo { - compress_to_pubkey: 0, - ..compression_info + extensions: Some(vec![ExtensionStruct::Compressible(CompressibleExtension { + compression_only: false, + info: CompressionInfo { + compress_to_pubkey: 0, + ..compression_info + }, })]), ..ctoken.clone() }; @@ -576,9 +589,12 @@ fn test_compressible_extension_partial_eq() { // Test account_version mismatch let ctoken_diff_version = CToken { - extensions: Some(vec![ExtensionStruct::Compressible(CompressionInfo { - account_version: 0, - ..compression_info + extensions: Some(vec![ExtensionStruct::Compressible(CompressibleExtension { + compression_only: false, + info: CompressionInfo { + account_version: 0, + ..compression_info + }, })]), ..ctoken.clone() }; diff --git a/program-libs/zero-copy-derive/src/shared/utils.rs b/program-libs/zero-copy-derive/src/shared/utils.rs index b1dee887ce..7646f177ca 100644 --- a/program-libs/zero-copy-derive/src/shared/utils.rs +++ b/program-libs/zero-copy-derive/src/shared/utils.rs @@ -23,6 +23,7 @@ fn create_unique_type_key(ident: &Ident) -> String { /// Represents the type of input data (struct or enum) pub enum InputType<'a> { Struct(&'a FieldsNamed), + UnitStruct, // Unit struct with no fields (e.g., `struct Foo;`) Enum(&'a DataEnum), } @@ -30,10 +31,10 @@ pub enum InputType<'a> { pub fn process_input( input: &DeriveInput, ) -> syn::Result<( - &Ident, // Original struct name - proc_macro2::Ident, // Z-struct name - proc_macro2::Ident, // Z-struct meta name - &FieldsNamed, // Struct fields + &Ident, // Original struct name + proc_macro2::Ident, // Z-struct name + proc_macro2::Ident, // Z-struct meta name + Option<&FieldsNamed>, // Struct fields (None for unit structs) )> { let name = &input.ident; let z_struct_name = format_ident!("Z{}", name); @@ -44,11 +45,12 @@ pub fn process_input( let fields = match &input.data { Data::Struct(data) => match &data.fields { - Fields::Named(fields) => fields, + Fields::Named(fields) => Some(fields), + Fields::Unit => None, // Support unit structs (e.g., `struct Foo;`) _ => { return Err(syn::Error::new_spanned( &data.fields, - "ZeroCopy only supports structs with named fields", + "ZeroCopy only supports structs with named fields or unit structs", )) } }, @@ -80,10 +82,11 @@ pub fn process_input_generic( let input_type = match &input.data { Data::Struct(data) => match &data.fields { Fields::Named(fields) => InputType::Struct(fields), + Fields::Unit => InputType::UnitStruct, // Support unit structs _ => { return Err(syn::Error::new_spanned( &data.fields, - "ZeroCopy only supports structs with named fields", + "ZeroCopy only supports structs with named fields or unit structs", )) } }, diff --git a/program-libs/zero-copy-derive/src/zero_copy.rs b/program-libs/zero-copy-derive/src/zero_copy.rs index 059c9e8778..bb484b769c 100644 --- a/program-libs/zero-copy-derive/src/zero_copy.rs +++ b/program-libs/zero-copy-derive/src/zero_copy.rs @@ -300,6 +300,37 @@ pub fn derive_zero_copy_impl(input: ProcTokenStream) -> syn::Result { + // Unit struct has no fields - generate minimal implementations + let z_struct_name = z_name; + + let zero_copy_struct_inner_impl = + generate_zero_copy_struct_inner::(name, &z_struct_name)?; + + // Generate a simple unit ZStruct type alias + let z_struct_def = quote! { + /// Zero-copy reference type for unit struct #name + pub type #z_struct_name<'a> = &'a #name; + }; + + // Generate minimal deserialize impl for unit struct + let deserialize_impl = quote! { + impl<'a> ::light_zero_copy::traits::ZeroCopyAt<'a> for #name { + type ZeroCopyAt = #z_struct_name<'a>; + fn zero_copy_at(bytes: &'a [u8]) -> ::core::result::Result<(Self::ZeroCopyAt, &'a [u8]), ::light_zero_copy::errors::ZeroCopyError> { + // Unit struct has zero size, return reference to static instance + static UNIT: #name = #name; + Ok((&UNIT, bytes)) + } + } + }; + + Ok(quote! { + #z_struct_def + #zero_copy_struct_inner_impl + #deserialize_impl + }) + } utils::InputType::Enum(enum_data) => { let z_enum_name = z_name; diff --git a/program-libs/zero-copy-derive/src/zero_copy_eq.rs b/program-libs/zero-copy-derive/src/zero_copy_eq.rs index bd095e9d6e..53ca4ca31b 100644 --- a/program-libs/zero-copy-derive/src/zero_copy_eq.rs +++ b/program-libs/zero-copy-derive/src/zero_copy_eq.rs @@ -276,7 +276,27 @@ pub fn derive_zero_copy_eq_impl(input: ProcTokenStream) -> syn::Result ::core::cmp::PartialEq<#name> for #z_struct_name<'a> { + fn eq(&self, _other: &#name) -> bool { + true // Unit structs are always equal + } + } + + impl<'a> ::core::cmp::PartialEq<#z_struct_name<'a>> for #name { + fn eq(&self, _other: &#z_struct_name<'a>) -> bool { + true // Unit structs are always equal + } + } + }); + }; + + let z_struct_meta_name = quote::format_ident!("Z{}Meta", name); // Process the fields to separate meta fields and struct fields let (meta_fields, struct_fields) = utils::process_fields(fields); diff --git a/program-libs/zero-copy-derive/src/zero_copy_mut.rs b/program-libs/zero-copy-derive/src/zero_copy_mut.rs index ee7e901b90..3767349d60 100644 --- a/program-libs/zero-copy-derive/src/zero_copy_mut.rs +++ b/program-libs/zero-copy-derive/src/zero_copy_mut.rs @@ -23,6 +23,77 @@ pub fn derive_zero_copy_mut_impl(fn_input: TokenStream) -> syn::Result(name, &z_struct_name_mut)?; + + // Generate a simple unit ZStruct type alias for mut + let z_struct_def_mut = quote::quote! { + /// Zero-copy mutable reference type for unit struct #name + pub type #z_struct_name_mut<'a> = &'a mut #name; + }; + + // Generate minimal deserialize impl for unit struct + let deserialize_impl_mut = quote::quote! { + impl<'a> ::light_zero_copy::traits::ZeroCopyAtMut<'a> for #name { + type ZeroCopyAtMut = #z_struct_name_mut<'a>; + fn zero_copy_at_mut(bytes: &'a mut [u8]) -> ::core::result::Result<(Self::ZeroCopyAtMut, &'a mut [u8]), ::light_zero_copy::errors::ZeroCopyError> { + // For zero-sized types (ZSTs), Box::new does not allocate heap memory; + // it returns a dangling-but-aligned pointer, so leaking it is safe and + // does not cause a memory leak. This pattern avoids returning a mutable + // reference to a static for ZSTs, which would be unsound. + let unit: &'a mut #name = Box::leak(Box::new(#name)); + Ok((unit, bytes)) + } + } + }; + + // Generate unit type config + let config_name = quote::format_ident!("{}Config", name); + let config_struct = quote::quote! { + pub type #config_name = (); + }; + + // Generate ZeroCopyNew impl for unit struct (specialized version) + let init_mut_impl = quote::quote! { + impl<'a> ::light_zero_copy::traits::ZeroCopyNew<'a> for #name { + type ZeroCopyConfig = #config_name; + type Output = #z_struct_name_mut<'a>; + + fn byte_len(_config: &Self::ZeroCopyConfig) -> Result { + // Unit struct has 0 bytes + Ok(0) + } + + fn new_zero_copy( + bytes: &'a mut [u8], + _config: Self::ZeroCopyConfig, + ) -> Result<(Self::Output, &'a mut [u8]), ::light_zero_copy::errors::ZeroCopyError> { + // For zero-sized types (ZSTs), Box::new does not allocate heap memory; + // it returns a dangling-but-aligned pointer, so leaking it is safe and + // does not cause a memory leak. + let unit: &'a mut #name = Box::leak(Box::new(#name)); + Ok((unit, bytes)) + } + } + }; + + return Ok(quote::quote! { + #config_struct + #z_struct_def_mut + + const _: () = { + #zero_copy_struct_inner_impl_mut + #deserialize_impl_mut + #init_mut_impl + }; + }); + }; + // Process the fields to separate meta fields and struct fields let (meta_fields, struct_fields) = utils::process_fields(fields); diff --git a/program-tests/compressed-token-test/Cargo.toml b/program-tests/compressed-token-test/Cargo.toml index 6be4045545..67368f9fdb 100644 --- a/program-tests/compressed-token-test/Cargo.toml +++ b/program-tests/compressed-token-test/Cargo.toml @@ -50,3 +50,4 @@ light-compressed-token-sdk = { workspace = true } spl-token-2022 = { workspace = true } spl-pod = { workspace = true } light-zero-copy = { workspace = true , features = ["std", "derive", "mut"]} +borsh = { workspace = true } diff --git a/program-tests/compressed-token-test/tests/ctoken.rs b/program-tests/compressed-token-test/tests/ctoken.rs index b5b1841b26..c1605cca2a 100644 --- a/program-tests/compressed-token-test/tests/ctoken.rs +++ b/program-tests/compressed-token-test/tests/ctoken.rs @@ -31,3 +31,12 @@ mod create_ata2; #[path = "ctoken/spl_instruction_compat.rs"] mod spl_instruction_compat; + +#[path = "ctoken/extensions.rs"] +mod extensions; + +#[path = "ctoken/freeze_thaw.rs"] +mod freeze_thaw; + +#[path = "ctoken/approve_revoke.rs"] +mod approve_revoke; diff --git a/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs new file mode 100644 index 0000000000..23fa2330cc --- /dev/null +++ b/program-tests/compressed-token-test/tests/ctoken/approve_revoke.rs @@ -0,0 +1,230 @@ +//! Tests for CToken approve and revoke instructions +//! +//! Tests verify that approve and revoke work correctly for compressible +//! CToken accounts with extensions. + +use borsh::BorshDeserialize; +use light_compressed_token_sdk::ctoken::{CompressibleParams, CreateCTokenAccount}; +use light_ctoken_types::state::{ + AccountState, CToken, ExtensionStruct, PausableAccountExtension, + PermanentDelegateAccountExtension, TokenDataVersion, TransferFeeAccountExtension, + TransferHookAccountExtension, +}; +use light_program_test::program_test::TestRpc; +use light_test_utils::{Rpc, RpcError}; +use serial_test::serial; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + program_pack::Pack, + signature::Keypair, + signer::Signer, +}; + +use super::extensions::setup_extensions_test; + +/// Helper to build an approve instruction +fn build_approve_instruction( + token_account: &solana_sdk::pubkey::Pubkey, + delegate: &solana_sdk::pubkey::Pubkey, + owner: &solana_sdk::pubkey::Pubkey, + amount: u64, +) -> Instruction { + let mut data = vec![4]; // CTokenApprove discriminator + data.extend_from_slice(&amount.to_le_bytes()); + + Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(*token_account, false), + AccountMeta::new_readonly(*delegate, false), + AccountMeta::new(*owner, true), // owner is signer and payer for top-up + ], + data, + } +} + +/// Helper to build a revoke instruction +fn build_revoke_instruction( + token_account: &solana_sdk::pubkey::Pubkey, + owner: &solana_sdk::pubkey::Pubkey, +) -> Instruction { + Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(*token_account, false), + AccountMeta::new(*owner, true), // owner is signer and payer for top-up + ], + data: vec![5], // CTokenRevoke discriminator + } +} + +/// Test approve and revoke with a compressible CToken account with extensions. +/// 1. Create compressible CToken account with all extensions +/// 2. Set token balance to 100 using set_account +/// 3. Approve 10 tokens to delegate +/// 4. Assert delegate and delegated_amount fields +/// 5. Revoke delegation +/// 6. Assert delegate cleared and delegated_amount is 0 +#[tokio::test] +#[serial] +async fn test_approve_revoke_compressible() -> Result<(), RpcError> { + use anchor_spl::token_2022::spl_token_2022; + + let mut context = setup_extensions_test().await?; + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + let owner = Keypair::new(); + let delegate = Keypair::new(); + + // 1. Create compressible CToken account with all extensions + let account_keypair = Keypair::new(); + let account_pubkey = account_keypair.pubkey(); + + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_pubkey, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .map_err(|e| RpcError::AssertRpcError(format!("Failed to create instruction: {}", e)))?; + + context + .rpc + .create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await?; + + // 2. Set token balance to 100 using set_account + let token_balance = 100u64; + let mut token_account_info = context + .rpc + .get_account(account_pubkey) + .await? + .ok_or_else(|| RpcError::AssertRpcError("Token account not found".to_string()))?; + + let mut spl_token_account = + spl_token_2022::state::Account::unpack_unchecked(&token_account_info.data[..165]) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to unpack: {:?}", e)))?; + spl_token_account.amount = token_balance; + spl_token_2022::state::Account::pack(spl_token_account, &mut token_account_info.data[..165]) + .map_err(|e| RpcError::AssertRpcError(format!("Failed to pack: {:?}", e)))?; + context.rpc.set_account(account_pubkey, token_account_info); + + // Verify initial state + let account_data_initial = context.rpc.get_account(account_pubkey).await?.unwrap(); + let ctoken_initial = CToken::deserialize(&mut &account_data_initial.data[..]) + .expect("Failed to deserialize CToken"); + assert_eq!(ctoken_initial.amount, token_balance); + assert!(ctoken_initial.delegate.is_none()); + assert_eq!(ctoken_initial.delegated_amount, 0); + + // Extract CompressionInfo for expected comparisons + let compression_info = ctoken_initial + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Should have Compressible extension"); + + // 3. Approve 10 tokens to delegate + let approve_amount = 10u64; + let approve_ix = build_approve_instruction( + &account_pubkey, + &delegate.pubkey(), + &owner.pubkey(), + approve_amount, + ); + + context + .rpc + .create_and_send_transaction(&[approve_ix], &payer.pubkey(), &[&payer, &owner]) + .await?; + + // 4. Assert delegate and delegated_amount fields after approve + let account_data_approved = context.rpc.get_account(account_pubkey).await?.unwrap(); + let ctoken_approved = CToken::deserialize(&mut &account_data_approved.data[..]) + .expect("Failed to deserialize CToken after approve"); + + let expected_approved = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: token_balance, + delegate: Some(delegate.pubkey().to_bytes().into()), + state: AccountState::Initialized, + is_native: None, + delegated_amount: approve_amount, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + ctoken_approved, expected_approved, + "CToken after approve should have delegate set and delegated_amount=10" + ); + + // 5. Revoke delegation + let revoke_ix = build_revoke_instruction(&account_pubkey, &owner.pubkey()); + + context + .rpc + .create_and_send_transaction(&[revoke_ix], &payer.pubkey(), &[&payer, &owner]) + .await?; + + // 6. Assert delegate cleared and delegated_amount is 0 after revoke + let account_data_revoked = context.rpc.get_account(account_pubkey).await?.unwrap(); + let ctoken_revoked = CToken::deserialize(&mut &account_data_revoked.data[..]) + .expect("Failed to deserialize CToken after revoke"); + + let expected_revoked = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: token_balance, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + ctoken_revoked, expected_revoked, + "CToken after revoke should have delegate cleared and delegated_amount=0" + ); + + println!("Successfully tested approve and revoke with compressible CToken"); + Ok(()) +} diff --git a/program-tests/compressed-token-test/tests/ctoken/close.rs b/program-tests/compressed-token-test/tests/ctoken/close.rs index 4d006beafa..cf0f0d8007 100644 --- a/program-tests/compressed-token-test/tests/ctoken/close.rs +++ b/program-tests/compressed-token-test/tests/ctoken/close.rs @@ -102,7 +102,7 @@ async fn test_close_token_account_fails() { &wrong_owner, Some(rent_sponsor), "wrong_owner", - 75, // ErrorCode::OwnerMismatch + 6075, // ErrorCode::OwnerMismatch ) .await; } @@ -210,7 +210,7 @@ async fn test_close_token_account_fails() { &owner_keypair, Some(rent_sponsor), "non_zero_balance", - 74, // ErrorCode::NonNativeHasBalance + 6074, // ErrorCode::NonNativeHasBalance ) .await; } diff --git a/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs b/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs index 6747e4b389..ee9f932cfa 100644 --- a/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs +++ b/program-tests/compressed-token-test/tests/ctoken/compress_and_close.rs @@ -441,7 +441,7 @@ async fn test_compress_and_close_compress_to_pubkey() { if let Some(extensions) = ctoken.extensions.as_mut() { for ext in extensions.iter_mut() { if let ZExtensionStructMut::Compressible(ref mut comp) = ext { - comp.compress_to_pubkey = 1; + comp.info.compress_to_pubkey = 1; break; } } @@ -522,6 +522,7 @@ async fn test_compressible_account_with_custom_rent_payer_close_with_compression lamports_per_write, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -739,7 +740,7 @@ async fn test_compress_and_close_output_validation_errors() { &mut context, CompressAndCloseValidationError::OwnerMismatch(wrong_owner.pubkey()), None, // Default destination - 89, // CompressAndCloseInvalidOwner + 6089, // CompressAndCloseInvalidOwner ) .await; } @@ -778,7 +779,7 @@ async fn test_compress_and_close_output_validation_errors() { if let Some(extensions) = ctoken.extensions.as_mut() { for ext in extensions.iter_mut() { if let ZExtensionStructMut::Compressible(ref mut comp) = ext { - comp.compress_to_pubkey = 1; + comp.info.compress_to_pubkey = 1; break; } } @@ -793,7 +794,7 @@ async fn test_compress_and_close_output_validation_errors() { &mut context, CompressAndCloseValidationError::OwnerNotAccountPubkey(owner_pubkey), None, // Default destination - 89, // CompressAndCloseInvalidOwner + 6089, // CompressAndCloseInvalidOwner ) .await; } @@ -859,11 +860,12 @@ async fn test_compress_and_close_output_validation_errors() { .await; // Assert that the transaction failed with delegate not allowed error - light_program_test::utils::assert::assert_rpc_error(result, 0, 92).unwrap(); + light_program_test::utils::assert::assert_rpc_error(result, 0, 6092).unwrap(); } - // Test 9: Frozen account cannot be closed - // The validation checks that account state must be Initialized, not Frozen + // Test 9: Frozen account handling differs between authority and forester + // - Authority (owner) CANNOT compress and close frozen accounts + // - Forester CAN compress and close frozen accounts (skips state validation) { let mut context = setup_compress_and_close_test( 2, // 2 prepaid epochs @@ -897,7 +899,19 @@ async fn test_compress_and_close_output_validation_errors() { .unwrap(); context.rpc.set_account(token_account_pubkey, token_account); - // Get forester keypair and setup for compress_and_close + // Test 9a: Authority (owner) CANNOT close frozen accounts + // Error: CannotModifyFrozenAccount (76 = 0x4c) + let owner_keypair = context.owner_keypair.insecure_clone(); + compress_and_close_and_assert_fails( + &mut context, + &owner_keypair, + None, // Default destination + "authority_frozen_account", + 6076, // CannotModifyFrozenAccount + ) + .await; + + // Test 9b: Forester CAN close frozen accounts (skips state validation) let forester_keypair = context.rpc.test_accounts.protocol.forester.insecure_clone(); // Create destination for compression incentive @@ -908,19 +922,32 @@ async fn test_compress_and_close_output_validation_errors() { .await .unwrap(); - // Try to compress and close via forester (should fail because account is frozen) - // Error: AccountFrozen - let result = compress_and_close_forester( + // Compress and close via forester (should succeed) + compress_and_close_forester( &mut context.rpc, &[token_account_pubkey], &forester_keypair, &context.payer, Some(destination.pubkey()), ) - .await; + .await + .unwrap(); + + // Assert compress and close succeeded + use light_test_utils::assert_transfer2::assert_transfer2_compress_and_close; + use light_token_client::instructions::transfer2::CompressAndCloseInput; - // Assert that the transaction failed with account frozen error - // Error: InvalidAccountState (18036) - light_program_test::utils::assert::assert_rpc_error(result, 0, 18036).unwrap(); + let output_queue = context.rpc.get_random_state_tree_info().unwrap().queue; + assert_transfer2_compress_and_close( + &mut context.rpc, + CompressAndCloseInput { + solana_ctoken_account: token_account_pubkey, + authority: context.compression_authority, + output_queue, + destination: Some(destination.pubkey()), + is_compressible: true, + }, + ) + .await; } } diff --git a/program-tests/compressed-token-test/tests/ctoken/create.rs b/program-tests/compressed-token-test/tests/ctoken/create.rs index 97cff677bb..710df3d7cb 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create.rs @@ -177,7 +177,7 @@ async fn test_create_compressible_token_account_failing() { &mut context, compressible_data, "one_epoch_prefunding_forbidden", - 101, // OneEpochPrefundingNotAllowed (0x65 hex = 101 decimal) + 6101, // OneEpochPrefundingNotAllowed ) .await; } @@ -234,6 +234,7 @@ async fn test_create_compressible_token_account_failing() { lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -345,8 +346,8 @@ async fn test_create_compressible_token_account_failing() { ) .await; - // Should fail with AlreadyInitialized (78) from our program - light_program_test::utils::assert::assert_rpc_error(result, 0, 78).unwrap(); + // Should fail with AlreadyInitialized (6078) from our program + light_program_test::utils::assert::assert_rpc_error(result, 0, 6078).unwrap(); } // Test 5: Invalid PDA seeds for compress_to_account_pubkey @@ -373,6 +374,7 @@ async fn test_create_compressible_token_account_failing() { lamports_per_write: Some(100), compress_to_account_pubkey: Some(invalid_compress_to_pubkey), token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -423,6 +425,7 @@ async fn test_create_compressible_token_account_failing() { lamports_per_write: Some(100), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -494,6 +497,7 @@ async fn test_create_compressible_token_account_failing() { lamports_per_write: Some(100), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( diff --git a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs index 2c7fc0d628..6066a72868 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create_ata.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create_ata.rs @@ -215,7 +215,7 @@ async fn test_create_ata_failing() { Some(compressible_data), false, // Non-idempotent "one_epoch_prefunding_forbidden", - 101, // OneEpochPrefundingNotAllowed (0x65 hex = 101 decimal) + 6101, // OneEpochPrefundingNotAllowed ) .await; } @@ -282,6 +282,7 @@ async fn test_create_ata_failing() { lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_ix = CreateAssociatedTokenAccount::new( @@ -334,6 +335,7 @@ async fn test_create_ata_failing() { token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat as u8, rent_payment: 2, has_top_up: 1, + compression_only: 0, write_top_up: 100, compress_to_account_pubkey: Some(compress_to_pubkey), // Forbidden for ATAs! }), @@ -401,6 +403,7 @@ async fn test_create_ata_failing() { token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat as u8, rent_payment: 2, has_top_up: 1, + compression_only: 0, write_top_up: 100, compress_to_account_pubkey: None, }), @@ -461,6 +464,7 @@ async fn test_create_ata_failing() { lamports_per_write: Some(100), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_ix = CreateAssociatedTokenAccount::new( @@ -530,6 +534,7 @@ async fn test_create_ata_failing() { lamports_per_write: Some(100), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_ix = CreateAssociatedTokenAccount::new( @@ -649,6 +654,7 @@ async fn test_ata_multiple_owners_same_mint() { lamports_per_write: Some(100), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_ix1 = CreateAssociatedTokenAccount::new(payer_pubkey, owner1, mint) diff --git a/program-tests/compressed-token-test/tests/ctoken/create_ata2.rs b/program-tests/compressed-token-test/tests/ctoken/create_ata2.rs index 1be5c8f663..55265b7d95 100644 --- a/program-tests/compressed-token-test/tests/ctoken/create_ata2.rs +++ b/program-tests/compressed-token-test/tests/ctoken/create_ata2.rs @@ -21,6 +21,7 @@ async fn create_and_assert_ata2( lamports_per_write: compressible.lamports_per_write, compress_to_account_pubkey: None, token_account_version: compressible.account_version, + compression_only: false, }; let mut builder = diff --git a/program-tests/compressed-token-test/tests/ctoken/extensions.rs b/program-tests/compressed-token-test/tests/ctoken/extensions.rs new file mode 100644 index 0000000000..3f57fc8006 --- /dev/null +++ b/program-tests/compressed-token-test/tests/ctoken/extensions.rs @@ -0,0 +1,1702 @@ +//! Tests for Token 2022 mint with multiple extensions +//! +//! This module tests the creation and verification of Token 2022 mints +//! with all supported extensions. + +use borsh::BorshDeserialize; +use light_ctoken_types::state::{ + AccountState, CToken, PausableAccountExtension, PermanentDelegateAccountExtension, + TransferFeeAccountExtension, TransferHookAccountExtension, +}; +use light_program_test::{ + program_test::TestRpc, utils::assert::assert_rpc_error, LightProgramTest, ProgramTestConfig, +}; +use light_test_utils::{ + mint_2022::{ + create_mint_22_with_extensions, create_mint_22_with_frozen_default_state, + create_token_22_account, mint_spl_tokens_22, verify_mint_extensions, + Token22ExtensionConfig, + }, + Rpc, RpcError, +}; +use light_token_client::instructions::transfer2::{ + create_generic_transfer2_instruction, CompressInput, Transfer2InstructionType, +}; +use serial_test::serial; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; + +/// Test context for extension-related tests +pub struct ExtensionsTestContext { + pub rpc: LightProgramTest, + pub payer: Keypair, + pub _mint_keypair: Keypair, + pub mint_pubkey: Pubkey, + pub extension_config: Token22ExtensionConfig, +} + +/// Set up test environment with a Token 2022 mint with all extensions +pub async fn setup_extensions_test() -> Result { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)).await?; + let payer = rpc.get_payer().insecure_clone(); + + // Create mint with all extensions + let (mint_keypair, extension_config) = + create_mint_22_with_extensions(&mut rpc, &payer, 9).await; + + let mint_pubkey = mint_keypair.pubkey(); + + Ok(ExtensionsTestContext { + rpc, + payer, + _mint_keypair: mint_keypair, + mint_pubkey, + extension_config, + }) +} + +#[tokio::test] +#[serial] +async fn test_setup_mint_22_with_all_extensions() { + let mut context = setup_extensions_test().await.unwrap(); + + // Verify all extensions are present + verify_mint_extensions(&mut context.rpc, &context.mint_pubkey) + .await + .unwrap(); + + // Verify the extension config has correct values + assert_eq!(context.extension_config.mint, context.mint_pubkey); + + // Verify token pool was created + let token_pool_account = context + .rpc + .get_account(context.extension_config.token_pool) + .await + .unwrap(); + assert!( + token_pool_account.is_some(), + "Token pool account should exist" + ); + + assert_eq!( + context.extension_config.close_authority, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.transfer_fee_config_authority, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.withdraw_withheld_authority, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.permanent_delegate, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.metadata_update_authority, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.pause_authority, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.confidential_transfer_authority, + context.payer.pubkey() + ); + assert_eq!( + context.extension_config.confidential_transfer_fee_authority, + context.payer.pubkey() + ); + + println!( + "Mint with all extensions created successfully: {}", + context.mint_pubkey + ); +} + +/// Test minting SPL tokens and transferring to CToken using hot path with a Token 2022 mint with all extensions. +/// Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) require hot path. +#[tokio::test] +#[serial] +async fn test_mint_and_compress_with_extensions() { + use light_compressed_token_sdk::{ + ctoken::{CompressibleParams, CreateCTokenAccount, TransferSplToCtoken}, + token_pool::find_token_pool_pda_with_index, + }; + use light_ctoken_types::state::TokenDataVersion; + + let mut context = setup_extensions_test().await.unwrap(); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // 1. Create a Token 2022 token account for the payer (SPL source) + let spl_account = + create_token_22_account(&mut context.rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + + println!("Created SPL token account: {}", spl_account); + + // 2. Mint SPL tokens to the token account + let mint_amount = 1_000_000_000u64; // 1 token with 9 decimals + mint_spl_tokens_22( + &mut context.rpc, + &payer, + &mint_pubkey, + &spl_account, + mint_amount, + ) + .await; + + println!("Minted {} tokens to {}", mint_amount, spl_account); + + // 3. Create CToken account with extensions (destination for hot path transfer) + let owner = Keypair::new(); + let account_keypair = Keypair::new(); + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_keypair.pubkey(), + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + context + .rpc + .create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await + .unwrap(); + + println!("Created CToken account: {}", account_keypair.pubkey()); + + // 4. Transfer SPL to CToken using hot path (compress + decompress in same tx) + let transfer_amount = 500_000_000u64; // Transfer half + let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint_pubkey, 0); + let transfer_ix = TransferSplToCtoken { + amount: transfer_amount, + token_pool_pda_bump, + source_spl_token_account: spl_account, + destination_ctoken_account: account_keypair.pubkey(), + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + token_pool_pda, + spl_token_program: spl_token_2022::ID, + decimals: 9, + } + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Verify CToken account has the tokens + let ctoken_account_data = context + .rpc + .get_account(account_keypair.pubkey()) + .await + .unwrap() + .unwrap(); + let ctoken_account = spl_pod::bytemuck::pod_from_bytes::( + &ctoken_account_data.data[..165], + ) + .unwrap(); + assert_eq!( + u64::from(ctoken_account.amount), + transfer_amount, + "CToken account should have {} tokens", + transfer_amount + ); + + println!( + "Successfully transferred {} tokens from SPL to CToken using hot path", + transfer_amount + ); +} + +/// Test creating a CToken account for a Token-2022 mint with permanent delegate extension +/// Verifies that the account gets all extensions: compressible, pausable, permanent_delegate, transfer_fee, transfer_hook +#[tokio::test] +#[serial] +async fn test_create_ctoken_with_extensions() { + use borsh::BorshDeserialize; + use light_compressed_token_sdk::ctoken::{CompressibleParams, CreateCTokenAccount}; + use light_ctoken_types::state::{ + AccountState, CToken, ExtensionStruct, PausableAccountExtension, + PermanentDelegateAccountExtension, TokenDataVersion, TransferFeeAccountExtension, + TransferHookAccountExtension, + }; + + let mut context = setup_extensions_test().await.unwrap(); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // Create a compressible CToken account for the Token-2022 mint + let account_keypair = Keypair::new(); + let account_pubkey = account_keypair.pubkey(); + + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_pubkey, + mint_pubkey, + payer.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await + .unwrap(); + + // Verify account was created with correct size (273 bytes) + let account = context + .rpc + .get_account(account_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!( + account.data.len(), + 274, + "CToken account should be 274 bytes (165 base + 7 metadata + 89 compressible + 1 pausable + 1 permanent_delegate + 9 transfer_fee + 2 transfer_hook)" + ); + + // Deserialize the CToken account + let ctoken = + CToken::deserialize(&mut &account.data[..]).expect("Failed to deserialize CToken account"); + + // Extract CompressionInfo from the deserialized account (contains runtime-specific values) + let compression_info = ctoken + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Should have Compressible extension"); + + // Build expected CToken account for comparison + let expected_ctoken = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: payer.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + ctoken, expected_ctoken, + "CToken account should match expected with all 5 extensions" + ); + + println!( + "Successfully created CToken account with all 5 extensions: compressible, pausable, permanent_delegate, transfer_fee, transfer_hook" + ); +} + +/// Test complete flow: Create Token-2022 mint -> SPL account -> Mint -> Create CToken accounts -> Transfer SPL to CToken (hot path) -> Transfer with permanent delegate +#[tokio::test] +#[serial] +async fn test_transfer_with_permanent_delegate() { + use anchor_lang::prelude::AccountMeta; + use anchor_spl::token_2022::spl_token_2022; + use light_compressed_token_sdk::{ + ctoken::{CompressibleParams, CreateCTokenAccount, TransferSplToCtoken}, + token_pool::find_token_pool_pda_with_index, + }; + use light_ctoken_types::state::TokenDataVersion; + use solana_sdk::{instruction::Instruction, program_pack::Pack}; + + let mut context = setup_extensions_test().await.unwrap(); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + let permanent_delegate = context.extension_config.permanent_delegate; + + // Step 1: Create SPL Token-2022 account and mint tokens + let spl_account = + create_token_22_account(&mut context.rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22( + &mut context.rpc, + &payer, + &mint_pubkey, + &spl_account, + mint_amount, + ) + .await; + + // Step 2: Create two compressible CToken accounts (A and B) - must be created before transfer + let owner = Keypair::new(); + let account_a_keypair = Keypair::new(); + let account_a_pubkey = account_a_keypair.pubkey(); + + let create_a_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_a_pubkey, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_a_ix], + &payer.pubkey(), + &[&payer, &account_a_keypair], + ) + .await + .unwrap(); + + let account_b_keypair = Keypair::new(); + let account_b_pubkey = account_b_keypair.pubkey(); + + let create_b_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_b_pubkey, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_b_ix], + &payer.pubkey(), + &[&payer, &account_b_keypair], + ) + .await + .unwrap(); + + // Step 3: Transfer SPL to CToken account A using hot path (compress + decompress in same tx) + let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint_pubkey, 0); + + let transfer_spl_to_ctoken_ix = TransferSplToCtoken { + amount: mint_amount, + token_pool_pda_bump, + source_spl_token_account: spl_account, + destination_ctoken_account: account_a_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + token_pool_pda, + spl_token_program: spl_token_2022::ID, + decimals: 9, + } + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[transfer_spl_to_ctoken_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Step 5: Transfer from A to B using permanent delegate as authority + let transfer_amount = 500_000_000u64; + let mut data = vec![3]; // CTokenTransfer discriminator + data.extend_from_slice(&transfer_amount.to_le_bytes()); + + let transfer_ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(account_a_pubkey, false), + AccountMeta::new(account_b_pubkey, false), + AccountMeta::new(permanent_delegate, true), // Permanent delegate must sign + AccountMeta::new_readonly(mint_pubkey, false), // Mint required for extension check + ], + data, + }; + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Step 6: Verify balances + let account_a = context + .rpc + .get_account(account_a_pubkey) + .await + .unwrap() + .unwrap(); + let account_b = context + .rpc + .get_account(account_b_pubkey) + .await + .unwrap() + .unwrap(); + + let token_a = spl_token_2022::state::Account::unpack_unchecked(&account_a.data[..165]).unwrap(); + let token_b = spl_token_2022::state::Account::unpack_unchecked(&account_b.data[..165]).unwrap(); + + assert_eq!( + token_a.amount, + mint_amount - transfer_amount, + "Account A should have 500M tokens" + ); + assert_eq!( + token_b.amount, transfer_amount, + "Account B should have 500M tokens" + ); + + println!( + "Successfully completed full flow: compressed {} tokens, decompressed to account A, transferred {} using permanent delegate to account B", + mint_amount, transfer_amount + ); +} + +/// Test creating a CToken account for a mint with DefaultAccountState set to Frozen. +/// Verifies that the account is created with state = Frozen (2) at offset 108. +#[tokio::test] +#[serial] +async fn test_create_ctoken_with_frozen_default_state() { + use light_compressed_token_sdk::ctoken::{CompressibleParams, CreateCTokenAccount}; + use light_ctoken_types::state::TokenDataVersion; + + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + // Create mint with DefaultAccountState = Frozen + let (mint_keypair, extension_config) = + create_mint_22_with_frozen_default_state(&mut rpc, &payer, 9).await; + let mint_pubkey = mint_keypair.pubkey(); + + assert!( + extension_config.default_account_state_frozen, + "Mint should have default_account_state_frozen = true" + ); + + // Create a compressible CToken account for the frozen mint + let account_keypair = Keypair::new(); + let account_pubkey = account_keypair.pubkey(); + + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_pubkey, + mint_pubkey, + payer.pubkey(), + CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await + .unwrap(); + + // Verify account was created with correct size (263 bytes = 165 base + 7 metadata + 88 compressible + 2 markers) + let account = rpc.get_account(account_pubkey).await.unwrap().unwrap(); + assert_eq!( + account.data.len(), + 263, + "CToken account should be 263 bytes" + ); + + // Deserialize the CToken account using borsh + use borsh::BorshDeserialize; + use light_ctoken_types::state::{ + AccountState, CToken, ExtensionStruct, PausableAccountExtension, + PermanentDelegateAccountExtension, + }; + + let ctoken = + CToken::deserialize(&mut &account.data[..]).expect("Failed to deserialize CToken account"); + + // Extract CompressionInfo from the deserialized account (contains runtime-specific values) + let compression_info = ctoken + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Should have Compressible extension"); + + // Build expected CToken account for comparison + let expected_ctoken = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: payer.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Frozen, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ]), + }; + + assert_eq!( + ctoken, expected_ctoken, + "CToken account should match expected" + ); + + println!( + "Successfully created frozen CToken account: state={:?}, extensions={}", + ctoken.state, + ctoken.extensions.as_ref().map(|e| e.len()).unwrap_or(0) + ); +} + +/// Test complete flow with owner as transfer authority: +/// Create mint -> Create CToken accounts -> Transfer SPL to CToken (hot path) -> Transfer using owner +/// Verifies that transfer works with owner authority and all extensions are preserved +#[tokio::test] +#[serial] +async fn test_transfer_with_owner_authority() { + use anchor_lang::prelude::AccountMeta; + use anchor_spl::token_2022::spl_token_2022; + use borsh::BorshDeserialize; + use light_compressed_token_sdk::{ + ctoken::{CompressibleParams, CreateCTokenAccount, TransferSplToCtoken}, + token_pool::find_token_pool_pda_with_index, + }; + use light_ctoken_types::state::{ + AccountState, CToken, ExtensionStruct, PausableAccountExtension, + PermanentDelegateAccountExtension, TokenDataVersion, TransferFeeAccountExtension, + TransferHookAccountExtension, + }; + use solana_sdk::{instruction::Instruction, program_pack::Pack}; + + let mut context = setup_extensions_test().await.unwrap(); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // Step 1: Create SPL Token-2022 account and mint tokens + let spl_account = + create_token_22_account(&mut context.rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22( + &mut context.rpc, + &payer, + &mint_pubkey, + &spl_account, + mint_amount, + ) + .await; + + // Step 2: Create two compressible CToken accounts (A and B) with all extensions + let owner = Keypair::new(); + let account_a_keypair = Keypair::new(); + let account_a_pubkey = account_a_keypair.pubkey(); + + let create_a_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_a_pubkey, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_a_ix], + &payer.pubkey(), + &[&payer, &account_a_keypair], + ) + .await + .unwrap(); + + let account_b_keypair = Keypair::new(); + let account_b_pubkey = account_b_keypair.pubkey(); + + let create_b_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_b_pubkey, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_b_ix], + &payer.pubkey(), + &[&payer, &account_b_keypair], + ) + .await + .unwrap(); + + // Verify both accounts have correct size (274 bytes with all extensions) + let account_a_data = context + .rpc + .get_account(account_a_pubkey) + .await + .unwrap() + .unwrap(); + let account_b_data = context + .rpc + .get_account(account_b_pubkey) + .await + .unwrap() + .unwrap(); + assert_eq!(account_a_data.data.len(), 274); + assert_eq!(account_b_data.data.len(), 274); + + // Step 3: Transfer SPL to CToken account A using hot path (compress + decompress in same tx) + let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint_pubkey, 0); + + let transfer_spl_to_ctoken_ix = TransferSplToCtoken { + amount: mint_amount, + token_pool_pda_bump, + source_spl_token_account: spl_account, + destination_ctoken_account: account_a_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + token_pool_pda, + spl_token_program: spl_token_2022::ID, + decimals: 9, + } + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[transfer_spl_to_ctoken_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Step 4: Transfer from A to B using owner as authority + let transfer_amount = 500_000_000u64; + let mut data = vec![3]; // CTokenTransfer discriminator + data.extend_from_slice(&transfer_amount.to_le_bytes()); + + let transfer_ix = Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(account_a_pubkey, false), + AccountMeta::new(account_b_pubkey, false), + AccountMeta::new(owner.pubkey(), true), // Owner must sign + AccountMeta::new_readonly(mint_pubkey, false), // Mint required for extension check + ], + data, + }; + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer, &owner]) + .await + .unwrap(); + + // Step 6: Verify balances and TransferFeeAccount extension + let account_a = context + .rpc + .get_account(account_a_pubkey) + .await + .unwrap() + .unwrap(); + let account_b = context + .rpc + .get_account(account_b_pubkey) + .await + .unwrap() + .unwrap(); + + // Verify token balances using SPL unpacking + let token_a = spl_token_2022::state::Account::unpack_unchecked(&account_a.data[..165]).unwrap(); + let token_b = spl_token_2022::state::Account::unpack_unchecked(&account_b.data[..165]).unwrap(); + + assert_eq!( + token_a.amount, + mint_amount - transfer_amount, + "Account A should have 500M tokens" + ); + assert_eq!( + token_b.amount, transfer_amount, + "Account B should have 500M tokens" + ); + + // Deserialize and verify TransferFeeAccount extension on both accounts + let ctoken_a = CToken::deserialize(&mut &account_a.data[..]).unwrap(); + let ctoken_b = CToken::deserialize(&mut &account_b.data[..]).unwrap(); + + // Extract CompressionInfo from account A + let compression_info_a = ctoken_a + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Account A should have Compressible extension"); + + // Extract CompressionInfo from account B + let compression_info_b = ctoken_b + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Account B should have Compressible extension"); + + // Build expected CToken accounts + let expected_ctoken_a = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: mint_amount - transfer_amount, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info_a), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + let expected_ctoken_b = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: transfer_amount, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info_b), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + ctoken_a, expected_ctoken_a, + "Account A should match expected with withheld_amount=0" + ); + assert_eq!( + ctoken_b, expected_ctoken_b, + "Account B should match expected with withheld_amount=0" + ); + + println!( + "Successfully completed transfer with owner authority: A={} tokens, B={} tokens", + token_a.amount, token_b.amount + ); +} + +/// Test that compressing SPL tokens with restricted extensions outside the hot path fails. +/// Mints with restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) require hot path. +#[tokio::test] +#[serial] +async fn test_compress_with_restricted_extensions_fails() { + let mut context = setup_extensions_test().await.unwrap(); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // Create SPL account and mint tokens + let spl_account = + create_token_22_account(&mut context.rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22( + &mut context.rpc, + &payer, + &mint_pubkey, + &spl_account, + mint_amount, + ) + .await; + + // Try to compress to compressed accounts (NOT hot path) - should fail + let owner = Keypair::new(); + let output_queue = context.rpc.get_random_state_tree_info().unwrap().queue; + let compress_ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::Compress(CompressInput { + compressed_token_account: None, + solana_token_account: spl_account, + to: owner.pubkey(), + mint: mint_pubkey, + amount: mint_amount, + authority: payer.pubkey(), + output_queue, + pool_index: None, + decimals: 9, + })], + payer.pubkey(), + true, + ) + .await + .unwrap(); + let result = context + .rpc + .create_and_send_transaction(&[compress_ix], &payer.pubkey(), &[&payer]) + .await; + // Mint has restricted extensions - hot path required (error code 6124) + assert_rpc_error(result, 0, 6124).unwrap(); + + println!("Correctly rejected compress operation for mint with restricted extensions"); +} + +/// Test that forester can compress and close a CToken account with Token-2022 extensions +/// after prepaid epochs expire, and then decompress it back to a CToken account. +#[tokio::test] +#[serial] +async fn test_compress_and_close_ctoken_with_extensions() { + #[allow(unused_imports)] + use light_client::indexer::CompressedTokenAccount; + use light_client::indexer::Indexer; + use light_compressed_token_sdk::{ + ctoken::{CompressibleParams, CreateCTokenAccount, TransferSplToCtoken}, + token_pool::find_token_pool_pda_with_index, + }; + use light_ctoken_types::{ + instructions::extensions::{ + CompressedOnlyExtensionInstructionData, ExtensionInstructionData, + }, + state::TokenDataVersion, + }; + use light_token_client::instructions::transfer2::{ + create_generic_transfer2_instruction, DecompressInput, Transfer2InstructionType, + }; + + let mut context = setup_extensions_test().await.unwrap(); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // 1. Create SPL Token-2022 account and mint tokens + let spl_account = + create_token_22_account(&mut context.rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22( + &mut context.rpc, + &payer, + &mint_pubkey, + &spl_account, + mint_amount, + ) + .await; + + // 2. Create CToken account with 0 prepaid epochs (immediately compressible) + let owner = Keypair::new(); + let account_keypair = Keypair::new(); + let ctoken_account = account_keypair.pubkey(); + + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + ctoken_account, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 0, // Immediately compressible after 1 epoch + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await + .unwrap(); + + // 3. Transfer tokens to CToken using hot path (required for mints with restricted extensions) + let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint_pubkey, 0); + let transfer_ix = TransferSplToCtoken { + amount: mint_amount, + token_pool_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination_ctoken_account: ctoken_account, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + token_pool_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Verify tokens are in the CToken account + let account_before = context + .rpc + .get_account(ctoken_account) + .await + .unwrap() + .unwrap(); + assert!( + account_before.lamports > 0, + "Account should exist before compression" + ); + + // 4. Advance 2 epochs to trigger forester compression + // Account created with 0 prepaid epochs needs time to become compressible + context.rpc.warp_epoch_forward(30).await.unwrap(); + + // 5. Assert the account has been compressed (closed) and compressed token account exists + let account_after = context.rpc.get_account(ctoken_account).await.unwrap(); + assert!( + account_after.is_none() || account_after.unwrap().lamports == 0, + "CToken account should be closed" + ); + + let compressed_accounts = context + .rpc + .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) + .await + .unwrap() + .value + .items; + + assert_eq!( + compressed_accounts.len(), + 1, + "Should have exactly 1 compressed token account" + ); + + // Build expected TokenData with CompressedOnly extension + // The CToken had marker extensions (PausableAccount, PermanentDelegateAccount), + // so the compressed token should have CompressedOnly TLV extension + use light_ctoken_types::state::{ + CompressedOnlyExtension, CompressedTokenAccountState, ExtensionStruct, TokenData, + }; + + let expected_token_data = TokenData { + mint: mint_pubkey.into(), + owner: owner.pubkey().into(), + amount: mint_amount, + delegate: None, + state: CompressedTokenAccountState::Initialized as u8, + tlv: Some(vec![ExtensionStruct::CompressedOnly( + CompressedOnlyExtension { + delegated_amount: 0, + withheld_transfer_fee: 0, + }, + )]), + }; + + assert_eq!( + compressed_accounts[0].token, + expected_token_data.into(), + "Compressed token account should match expected TokenData" + ); + + // 6. Create a new CToken account for decompress destination + let decompress_dest_keypair = Keypair::new(); + let decompress_dest_account = decompress_dest_keypair.pubkey(); + + let create_dest_ix = CreateCTokenAccount::new( + payer.pubkey(), + decompress_dest_account, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, // More epochs so account won't be compressed again + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &payer.pubkey(), + &[&payer, &decompress_dest_keypair], + ) + .await + .unwrap(); + + println!( + "Created decompress destination CToken account: {}", + decompress_dest_account + ); + + // 7. Decompress the compressed account back to the new CToken account + // Need to include in_tlv for the CompressedOnly extension + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + }, + )]]; + + let decompress_ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[0].clone()], + decompress_amount: mint_amount, + solana_token_account: decompress_dest_account, + amount: mint_amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + })], + payer.pubkey(), + true, + ) + .await + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer, &owner]) + .await + .unwrap(); + + // 8. Verify the CToken account has the tokens and proper extension state + + let dest_account_data = context + .rpc + .get_account(decompress_dest_account) + .await + .unwrap() + .unwrap(); + + let dest_ctoken = CToken::deserialize(&mut &dest_account_data.data[..]) + .expect("Failed to deserialize destination CToken account"); + + // Extract CompressionInfo for comparison (it has runtime values) + let compression_info = dest_ctoken + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Should have Compressible extension"); + + // Build expected CToken account + let expected_dest_ctoken = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: mint_amount, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + dest_ctoken, expected_dest_ctoken, + "Decompressed CToken account should match expected with all extensions" + ); + + // Verify no more compressed accounts for this owner + let remaining_compressed = context + .rpc + .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) + .await + .unwrap() + .value + .items; + + assert_eq!( + remaining_compressed.len(), + 0, + "Should have no more compressed token accounts after full decompress" + ); + + println!( + "Successfully completed compress-and-close -> decompress cycle with extension state transfer" + ); +} + +/// Configuration for parameterized compress and close extension tests +#[derive(Debug, Clone)] +struct CompressAndCloseTestConfig { + /// Set delegate and delegated_amount before compress (delegate pubkey, amount) + delegate_config: Option<(Pubkey, u64)>, + /// Set account state to frozen before compress + is_frozen: bool, + /// Use permanent delegate as authority for decompress (instead of owner) + use_permanent_delegate_for_decompress: bool, +} + +/// Helper to modify CToken account state for testing using set_account +/// Only modifies the SPL token portion (first 165 bytes) - CToken::deserialize reads from there +async fn set_ctoken_account_state( + rpc: &mut LightProgramTest, + account_pubkey: Pubkey, + delegate: Option, + delegated_amount: u64, + is_frozen: bool, +) -> Result<(), RpcError> { + use anchor_spl::token_2022::spl_token_2022; + use solana_sdk::{program_option::COption, program_pack::Pack}; + + let mut account_info = rpc + .get_account(account_pubkey) + .await? + .ok_or_else(|| RpcError::CustomError("Account not found".to_string()))?; + + // Update SPL token state (first 165 bytes) + // CToken::deserialize reads delegate/delegated_amount/state from the SPL portion + let mut spl_account = + spl_token_2022::state::Account::unpack_unchecked(&account_info.data[..165]) + .map_err(|e| RpcError::CustomError(format!("Failed to unpack SPL account: {:?}", e)))?; + + spl_account.delegate = match delegate { + Some(d) => COption::Some(d), + None => COption::None, + }; + spl_account.delegated_amount = delegated_amount; + if is_frozen { + spl_account.state = spl_token_2022::state::AccountState::Frozen; + } + + spl_token_2022::state::Account::pack(spl_account, &mut account_info.data[..165]) + .map_err(|e| RpcError::CustomError(format!("Failed to pack SPL account: {:?}", e)))?; + + rpc.set_account(account_pubkey, account_info); + Ok(()) +} + +/// Core parameterized test function for compress -> decompress cycle with configurable state +async fn run_compress_and_close_extension_test( + config: CompressAndCloseTestConfig, +) -> Result<(), RpcError> { + use light_client::indexer::Indexer; + use light_compressed_token_sdk::{ + ctoken::{CompressibleParams, CreateCTokenAccount, TransferSplToCtoken}, + token_pool::find_token_pool_pda_with_index, + }; + use light_ctoken_types::{ + instructions::extensions::{ + CompressedOnlyExtensionInstructionData, ExtensionInstructionData, + }, + state::{ + CompressedOnlyExtension, CompressedTokenAccountState, ExtensionStruct, TokenData, + TokenDataVersion, + }, + }; + use light_token_client::instructions::transfer2::{ + create_generic_transfer2_instruction, DecompressInput, Transfer2InstructionType, + }; + + let mut context = setup_extensions_test().await?; + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + let _permanent_delegate = context.extension_config.permanent_delegate; + + // 1. Create SPL Token-2022 account and mint tokens + let spl_account = + create_token_22_account(&mut context.rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22( + &mut context.rpc, + &payer, + &mint_pubkey, + &spl_account, + mint_amount, + ) + .await; + + // 2. Create CToken account with 0 prepaid epochs (immediately compressible) + let owner = Keypair::new(); + let account_keypair = Keypair::new(); + let ctoken_account = account_keypair.pubkey(); + + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + ctoken_account, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 0, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create instruction: {:?}", e)))?; + + context + .rpc + .create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await?; + + // 3. Transfer tokens to CToken using hot path + let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint_pubkey, 0); + let transfer_ix = TransferSplToCtoken { + amount: mint_amount, + token_pool_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination_ctoken_account: ctoken_account, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + token_pool_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .map_err(|e| { + RpcError::CustomError(format!("Failed to create transfer instruction: {:?}", e)) + })?; + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await?; + + // 4. Modify CToken state based on config BEFORE warp + let delegate_pubkey = config.delegate_config.map(|(d, _)| d); + let delegated_amount = config.delegate_config.map(|(_, a)| a).unwrap_or(0); + + if config.delegate_config.is_some() || config.is_frozen { + set_ctoken_account_state( + &mut context.rpc, + ctoken_account, + delegate_pubkey, + delegated_amount, + config.is_frozen, + ) + .await?; + } + + // 5. Warp epoch to trigger forester compression + context.rpc.warp_epoch_forward(30).await?; + + // 6. Assert the account has been compressed (closed) + let account_after = context.rpc.get_account(ctoken_account).await?; + assert!( + account_after.is_none() || account_after.unwrap().lamports == 0, + "CToken account should be closed after compression" + ); + + // 7. Get compressed accounts and verify state + let compressed_accounts = context + .rpc + .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) + .await? + .value + .items; + + assert_eq!( + compressed_accounts.len(), + 1, + "Should have exactly 1 compressed token account" + ); + + // Build expected TokenData based on config + let expected_state = if config.is_frozen { + CompressedTokenAccountState::Frozen as u8 + } else { + CompressedTokenAccountState::Initialized as u8 + }; + + let expected_token_data = TokenData { + mint: mint_pubkey.into(), + owner: owner.pubkey().into(), + amount: mint_amount, + delegate: delegate_pubkey.map(|d| d.into()), + state: expected_state, + tlv: Some(vec![ExtensionStruct::CompressedOnly( + CompressedOnlyExtension { + delegated_amount, + withheld_transfer_fee: 0, + }, + )]), + }; + + assert_eq!( + compressed_accounts[0].token, + expected_token_data.into(), + "Compressed token account should match expected TokenData with config: {:?}", + config + ); + + // 8. Create destination CToken account for decompress + let decompress_dest_keypair = Keypair::new(); + let decompress_dest_account = decompress_dest_keypair.pubkey(); + + let create_dest_ix = CreateCTokenAccount::new( + payer.pubkey(), + decompress_dest_account, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .map_err(|e| RpcError::CustomError(format!("Failed to create dest instruction: {:?}", e)))?; + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &payer.pubkey(), + &[&payer, &decompress_dest_keypair], + ) + .await?; + + // 9. Decompress with correct in_tlv including is_frozen + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount, + withheld_transfer_fee: 0, + is_frozen: config.is_frozen, + }, + )]]; + + let mut decompress_ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[0].clone()], + decompress_amount: mint_amount, + solana_token_account: decompress_dest_account, + amount: mint_amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + })], + payer.pubkey(), + true, + ) + .await + .map_err(|e| { + RpcError::CustomError(format!("Failed to create decompress instruction: {:?}", e)) + })?; + + // 10. Sign with owner or permanent delegate based on config + let signers: Vec<&Keypair> = if config.use_permanent_delegate_for_decompress { + // Permanent delegate is the payer in this test setup. + // Find owner in account metas and set is_signer = false since permanent delegate acts on behalf. + let owner_pubkey = owner.pubkey(); + for account_meta in decompress_ix.accounts.iter_mut() { + if account_meta.pubkey == owner_pubkey { + account_meta.is_signer = false; + } + } + vec![&payer] + } else { + vec![&payer, &owner] + }; + + context + .rpc + .create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &signers) + .await?; + + // 11. Verify decompressed CToken state + let dest_account_data = context + .rpc + .get_account(decompress_dest_account) + .await? + .ok_or_else(|| RpcError::CustomError("Dest account not found".to_string()))?; + + let dest_ctoken = CToken::deserialize(&mut &dest_account_data.data[..]) + .map_err(|e| RpcError::CustomError(format!("Failed to deserialize CToken: {:?}", e)))?; + + // Verify state matches config + let expected_ctoken_state = if config.is_frozen { + AccountState::Frozen + } else { + AccountState::Initialized + }; + + assert_eq!( + dest_ctoken.state, expected_ctoken_state, + "Decompressed CToken state should match config" + ); + + assert_eq!( + dest_ctoken.delegated_amount, delegated_amount, + "Decompressed CToken delegated_amount should match" + ); + + if let Some((delegate, _)) = config.delegate_config { + assert_eq!( + dest_ctoken.delegate, + Some(delegate.to_bytes().into()), + "Decompressed CToken delegate should match" + ); + } else { + assert!( + dest_ctoken.delegate.is_none(), + "Decompressed CToken should have no delegate" + ); + } + + // 12. Verify no more compressed accounts + let remaining_compressed = context + .rpc + .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) + .await? + .value + .items; + + assert_eq!( + remaining_compressed.len(), + 0, + "Should have no more compressed token accounts after decompress" + ); + + println!( + "Successfully completed compress-and-close -> decompress cycle with config: {:?}", + config + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn test_compress_and_close_with_delegated_amount() { + let delegate = Keypair::new(); + run_compress_and_close_extension_test(CompressAndCloseTestConfig { + delegate_config: Some((delegate.pubkey(), 500_000_000)), + is_frozen: false, + use_permanent_delegate_for_decompress: false, + }) + .await + .unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_compress_and_close_frozen() { + run_compress_and_close_extension_test(CompressAndCloseTestConfig { + delegate_config: None, + is_frozen: true, + use_permanent_delegate_for_decompress: false, + }) + .await + .unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_compress_and_close_with_permanent_delegate() { + run_compress_and_close_extension_test(CompressAndCloseTestConfig { + delegate_config: None, + is_frozen: false, + use_permanent_delegate_for_decompress: true, + }) + .await + .unwrap(); +} diff --git a/program-tests/compressed-token-test/tests/ctoken/freeze_thaw.rs b/program-tests/compressed-token-test/tests/ctoken/freeze_thaw.rs new file mode 100644 index 0000000000..478622e0f8 --- /dev/null +++ b/program-tests/compressed-token-test/tests/ctoken/freeze_thaw.rs @@ -0,0 +1,347 @@ +//! Tests for CToken freeze and thaw instructions +//! +//! These tests verify that freeze and thaw instructions work correctly +//! for both basic mints and Token-2022 mints with extensions. + +use borsh::{BorshDeserialize, BorshSerialize}; +use light_compressed_token_sdk::ctoken::{CompressibleParams, CreateCTokenAccount}; +use light_ctoken_types::{ + instructions::create_ctoken_account::CreateTokenAccountInstructionData, + state::{ + AccountState, CToken, ExtensionStruct, PausableAccountExtension, + PermanentDelegateAccountExtension, TokenDataVersion, TransferFeeAccountExtension, + TransferHookAccountExtension, + }, +}; +use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_test_utils::{spl::create_mint_helper, Rpc, RpcError}; +use serial_test::serial; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + program_error::ProgramError, + signature::Keypair, + signer::Signer, + system_instruction::create_account, +}; + +use super::extensions::setup_extensions_test; + +/// Helper to build a basic (non-compressible) CToken account initialization instruction +fn create_token_account( + token_account: solana_sdk::pubkey::Pubkey, + mint: solana_sdk::pubkey::Pubkey, + owner: solana_sdk::pubkey::Pubkey, +) -> Result { + let instruction_data = CreateTokenAccountInstructionData { + owner: owner.to_bytes().into(), + compressible_config: None, + }; + + let mut data = Vec::new(); + data.push(18u8); // CreateTokenAccount discriminator + instruction_data + .serialize(&mut data) + .map_err(|e| ProgramError::BorshIoError(e.to_string()))?; + + Ok(Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(token_account, false), + AccountMeta::new_readonly(mint, false), + ], + data, + }) +} + +/// Helper to build a freeze instruction +fn build_freeze_instruction( + token_account: &solana_sdk::pubkey::Pubkey, + mint: &solana_sdk::pubkey::Pubkey, + freeze_authority: &solana_sdk::pubkey::Pubkey, +) -> Instruction { + Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(*token_account, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(*freeze_authority, true), + ], + data: vec![10], // CTokenFreezeAccount discriminator + } +} + +/// Helper to build a thaw instruction +fn build_thaw_instruction( + token_account: &solana_sdk::pubkey::Pubkey, + mint: &solana_sdk::pubkey::Pubkey, + freeze_authority: &solana_sdk::pubkey::Pubkey, +) -> Instruction { + Instruction { + program_id: light_compressed_token::ID, + accounts: vec![ + AccountMeta::new(*token_account, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(*freeze_authority, true), + ], + data: vec![11], // CTokenThawAccount discriminator + } +} + +/// Test freeze and thaw with a basic SPL Token mint (not Token-2022) +/// Uses create_mint_helper which creates a mint with freeze_authority = payer +#[tokio::test] +#[serial] +async fn test_freeze_thaw_with_basic_mint() -> Result<(), RpcError> { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)).await?; + let payer = rpc.get_payer().insecure_clone(); + let owner = Keypair::new(); + + // 1. Create SPL Token mint with freeze_authority = payer + let mint_pubkey = create_mint_helper(&mut rpc, &payer).await; + + // 2. Create basic CToken account (no extensions, just 165 bytes) + let token_account_keypair = Keypair::new(); + let token_account_pubkey = token_account_keypair.pubkey(); + + let rent_exemption = rpc.get_minimum_balance_for_rent_exemption(165).await?; + + let create_account_ix = create_account( + &payer.pubkey(), + &token_account_pubkey, + rent_exemption, + 165, + &light_compressed_token::ID, + ); + + let mut initialize_account_ix = + create_token_account(token_account_pubkey, mint_pubkey, owner.pubkey()).map_err(|e| { + RpcError::AssertRpcError(format!("Failed to create token account instruction: {}", e)) + })?; + initialize_account_ix.data.push(0); // Append version byte + + rpc.create_and_send_transaction( + &[create_account_ix, initialize_account_ix], + &payer.pubkey(), + &[&payer, &token_account_keypair], + ) + .await?; + + // Verify initial state is Initialized + let account_data = rpc.get_account(token_account_pubkey).await?.unwrap(); + let ctoken_before = + CToken::deserialize(&mut &account_data.data[..]).expect("Failed to deserialize CToken"); + assert_eq!( + ctoken_before.state, + AccountState::Initialized, + "Initial state should be Initialized" + ); + + // 3. Freeze the account + let freeze_ix = build_freeze_instruction(&token_account_pubkey, &mint_pubkey, &payer.pubkey()); + + rpc.create_and_send_transaction(&[freeze_ix], &payer.pubkey(), &[&payer]) + .await?; + + // 4. Assert state is Frozen + let account_data_frozen = rpc.get_account(token_account_pubkey).await?.unwrap(); + let ctoken_frozen = CToken::deserialize(&mut &account_data_frozen.data[..]) + .expect("Failed to deserialize CToken after freeze"); + + let expected_frozen = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Frozen, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: None, + }; + + assert_eq!( + ctoken_frozen, expected_frozen, + "CToken account should be frozen with all fields preserved" + ); + + // 5. Thaw the account + let thaw_ix = build_thaw_instruction(&token_account_pubkey, &mint_pubkey, &payer.pubkey()); + + rpc.create_and_send_transaction(&[thaw_ix], &payer.pubkey(), &[&payer]) + .await?; + + // 6. Assert state is Initialized again + let account_data_thawed = rpc.get_account(token_account_pubkey).await?.unwrap(); + let ctoken_thawed = CToken::deserialize(&mut &account_data_thawed.data[..]) + .expect("Failed to deserialize CToken after thaw"); + + let expected_thawed = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: None, + }; + + assert_eq!( + ctoken_thawed, expected_thawed, + "CToken account should be thawed with all fields preserved" + ); + + println!("Successfully tested freeze and thaw with basic mint"); + Ok(()) +} + +/// Test freeze and thaw with a Token-2022 mint that has all extensions +/// Verifies that extensions are preserved through freeze/thaw cycle +#[tokio::test] +#[serial] +async fn test_freeze_thaw_with_extensions() -> Result<(), RpcError> { + let mut context = setup_extensions_test().await?; + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + let owner = Keypair::new(); + + // 1. Create compressible CToken account with all extensions + let account_keypair = Keypair::new(); + let account_pubkey = account_keypair.pubkey(); + + let create_ix = CreateCTokenAccount::new( + payer.pubkey(), + account_pubkey, + mint_pubkey, + owner.pubkey(), + CompressibleParams { + compressible_config: context + .rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: context + .rpc + .test_accounts + .funding_pool_config + .rent_sponsor_pda, + pre_pay_num_epochs: 2, + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }, + ) + .instruction() + .map_err(|e| RpcError::AssertRpcError(format!("Failed to create instruction: {}", e)))?; + + context + .rpc + .create_and_send_transaction(&[create_ix], &payer.pubkey(), &[&payer, &account_keypair]) + .await?; + + // Verify account was created with correct size (274 bytes with all extensions) + let account_data_initial = context.rpc.get_account(account_pubkey).await?.unwrap(); + assert_eq!( + account_data_initial.data.len(), + 274, + "CToken account should be 274 bytes with all extensions" + ); + + // Deserialize and verify initial state + let ctoken_initial = CToken::deserialize(&mut &account_data_initial.data[..]) + .expect("Failed to deserialize CToken"); + assert_eq!( + ctoken_initial.state, + AccountState::Initialized, + "Initial state should be Initialized" + ); + + // Extract CompressionInfo (contains runtime values we need to preserve in expected) + let compression_info = ctoken_initial + .extensions + .as_ref() + .and_then(|exts| { + exts.iter().find_map(|e| match e { + ExtensionStruct::Compressible(info) => Some(*info), + _ => None, + }) + }) + .expect("Should have Compressible extension"); + + // 2. Freeze the account + let freeze_ix = build_freeze_instruction(&account_pubkey, &mint_pubkey, &payer.pubkey()); + + context + .rpc + .create_and_send_transaction(&[freeze_ix], &payer.pubkey(), &[&payer]) + .await?; + + // 3. Assert state is Frozen with all extensions preserved + let account_data_frozen = context.rpc.get_account(account_pubkey).await?.unwrap(); + let ctoken_frozen = CToken::deserialize(&mut &account_data_frozen.data[..]) + .expect("Failed to deserialize CToken after freeze"); + + let expected_frozen = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Frozen, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + ctoken_frozen, expected_frozen, + "Frozen CToken should have state=Frozen with all 5 extensions preserved" + ); + + // 4. Thaw the account + let thaw_ix = build_thaw_instruction(&account_pubkey, &mint_pubkey, &payer.pubkey()); + + context + .rpc + .create_and_send_transaction(&[thaw_ix], &payer.pubkey(), &[&payer]) + .await?; + + // 5. Assert state is Initialized again with all extensions preserved + let account_data_thawed = context.rpc.get_account(account_pubkey).await?.unwrap(); + let ctoken_thawed = CToken::deserialize(&mut &account_data_thawed.data[..]) + .expect("Failed to deserialize CToken after thaw"); + + let expected_thawed = CToken { + mint: mint_pubkey.to_bytes().into(), + owner: owner.pubkey().to_bytes().into(), + amount: 0, + delegate: None, + state: AccountState::Initialized, + is_native: None, + delegated_amount: 0, + close_authority: None, + extensions: Some(vec![ + ExtensionStruct::Compressible(compression_info), + ExtensionStruct::PausableAccount(PausableAccountExtension), + ExtensionStruct::PermanentDelegateAccount(PermanentDelegateAccountExtension), + ExtensionStruct::TransferFeeAccount(TransferFeeAccountExtension { withheld_amount: 0 }), + ExtensionStruct::TransferHookAccount(TransferHookAccountExtension { transferring: 0 }), + ]), + }; + + assert_eq!( + ctoken_thawed, expected_thawed, + "Thawed CToken should have state=Initialized with all 5 extensions preserved" + ); + + println!("Successfully tested freeze and thaw with Token-2022 extensions"); + Ok(()) +} diff --git a/program-tests/compressed-token-test/tests/ctoken/functional.rs b/program-tests/compressed-token-test/tests/ctoken/functional.rs index 3924336121..c147a9e449 100644 --- a/program-tests/compressed-token-test/tests/ctoken/functional.rs +++ b/program-tests/compressed-token-test/tests/ctoken/functional.rs @@ -134,6 +134,7 @@ async fn test_compressible_account_with_compression_authority_lifecycle() { lamports_per_write, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -245,6 +246,7 @@ async fn test_compressible_account_with_compression_authority_lifecycle() { context.owner_keypair.pubkey(), &context.owner_keypair, &context.payer, + 9, ) .await .unwrap(); @@ -259,6 +261,7 @@ async fn test_compressible_account_with_compression_authority_lifecycle() { authority: context.owner_keypair.pubkey(), output_queue, pool_index: None, + decimals: 9, }; assert_transfer2_compress(&mut context.rpc, compress_input).await; } diff --git a/program-tests/compressed-token-test/tests/ctoken/functional_ata.rs b/program-tests/compressed-token-test/tests/ctoken/functional_ata.rs index 895183a5d1..41153c952f 100644 --- a/program-tests/compressed-token-test/tests/ctoken/functional_ata.rs +++ b/program-tests/compressed-token-test/tests/ctoken/functional_ata.rs @@ -62,6 +62,7 @@ async fn test_associated_token_account_operations() { lamports_per_write, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let compressible_instruction = CreateAssociatedTokenAccount::new( diff --git a/program-tests/compressed-token-test/tests/ctoken/shared.rs b/program-tests/compressed-token-test/tests/ctoken/shared.rs index ce1149c6f0..ff150df25f 100644 --- a/program-tests/compressed-token-test/tests/ctoken/shared.rs +++ b/program-tests/compressed-token-test/tests/ctoken/shared.rs @@ -96,6 +96,7 @@ pub async fn create_and_assert_token_account( lamports_per_write: compressible_data.lamports_per_write, compress_to_account_pubkey: None, token_account_version: compressible_data.account_version, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -150,6 +151,7 @@ pub async fn create_and_assert_token_account_fails( lamports_per_write: compressible_data.lamports_per_write, compress_to_account_pubkey: None, token_account_version: compressible_data.account_version, + compression_only: false, }; let create_token_account_ix = CreateCTokenAccount::new( @@ -312,7 +314,9 @@ pub async fn close_and_assert_token_account( extensions .iter() .find_map(|ext| match ext { - ZExtensionStruct::Compressible(comp) => Some(Pubkey::from(comp.rent_sponsor)), + ZExtensionStruct::Compressible(comp) => { + Some(Pubkey::from(comp.info.rent_sponsor)) + } _ => None, }) .unwrap() @@ -422,6 +426,7 @@ pub async fn create_and_assert_ata( lamports_per_write: compressible.lamports_per_write, compress_to_account_pubkey: None, token_account_version: compressible.account_version, + compression_only: false, }; let mut builder = @@ -492,6 +497,7 @@ pub async fn create_and_assert_ata_fails( lamports_per_write: compressible.lamports_per_write, compress_to_account_pubkey: None, token_account_version: compressible.account_version, + compression_only: false, } } else { CompressibleParams::default() @@ -728,7 +734,7 @@ pub async fn compress_and_close_forester_with_invalid_output( }) .unwrap(); - let rent_sponsor = Pubkey::from(compressible_ext.rent_sponsor); + let rent_sponsor = Pubkey::from(compressible_ext.info.rent_sponsor); // Get output queue for compression let output_queue = context @@ -762,6 +768,7 @@ pub async fn compress_and_close_forester_with_invalid_output( mint_index, owner_index, rent_sponsor_index, + delegate_index: 0, // No delegate in validation tests }; // Add system accounts diff --git a/program-tests/compressed-token-test/tests/mint/cpi_context.rs b/program-tests/compressed-token-test/tests/mint/cpi_context.rs index dcfff089f3..97afdd639c 100644 --- a/program-tests/compressed-token-test/tests/mint/cpi_context.rs +++ b/program-tests/compressed-token-test/tests/mint/cpi_context.rs @@ -310,8 +310,8 @@ async fn test_write_to_cpi_context_invalid_address_tree() { .await; // Assert that the transaction failed with MintActionInvalidCpiContextAddressTreePubkey error - // Error code 105 = MintActionInvalidCpiContextAddressTreePubkey - assert_rpc_error(result, 0, 105).unwrap(); + // Error code 6105 = MintActionInvalidCpiContextAddressTreePubkey + assert_rpc_error(result, 0, 6105).unwrap(); } #[tokio::test] @@ -402,8 +402,8 @@ async fn test_write_to_cpi_context_invalid_compressed_address() { .await; // Assert that the transaction failed with MintActionInvalidCompressedMintAddress error - // Error code 103 = MintActionInvalidCompressedMintAddress - assert_rpc_error(result, 0, 103).unwrap(); + // Error code 6103 = MintActionInvalidCompressedMintAddress + assert_rpc_error(result, 0, 6103).unwrap(); } #[tokio::test] @@ -497,6 +497,6 @@ async fn test_execute_cpi_context_invalid_tree_index() { .await; // Assert that the transaction failed with MintActionInvalidCpiContextForCreateMint error - // Error code 104 = MintActionInvalidCpiContextForCreateMint - assert_rpc_error(result, 0, 104).unwrap(); + // Error code 6104 = MintActionInvalidCpiContextForCreateMint + assert_rpc_error(result, 0, 6104).unwrap(); } diff --git a/program-tests/compressed-token-test/tests/mint/edge_cases.rs b/program-tests/compressed-token-test/tests/mint/edge_cases.rs index 3b42a5af65..22cc85bab1 100644 --- a/program-tests/compressed-token-test/tests/mint/edge_cases.rs +++ b/program-tests/compressed-token-test/tests/mint/edge_cases.rs @@ -157,6 +157,7 @@ async fn functional_all_in_one_instruction() { lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, + compression_only: false, }; let create_compressible_ata_ix = diff --git a/program-tests/compressed-token-test/tests/mint/failing.rs b/program-tests/compressed-token-test/tests/mint/failing.rs index b1fdf70de0..99b86eed66 100644 --- a/program-tests/compressed-token-test/tests/mint/failing.rs +++ b/program-tests/compressed-token-test/tests/mint/failing.rs @@ -204,7 +204,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, 18, // light_compressed_token::ErrorCode::InvalidAuthorityMint.into(), + result, 0, 6018, // light_compressed_token::ErrorCode::InvalidAuthorityMint ) .unwrap(); } @@ -278,7 +278,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, 18, // light_compressed_token::ErrorCode::InvalidAuthorityMint.into(), + result, 0, 6018, // light_compressed_token::ErrorCode::InvalidAuthorityMint ) .unwrap(); } @@ -349,8 +349,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, - 18, // InvalidAuthorityMint error code (authority validation always returns 18) + result, 0, 6018, // InvalidAuthorityMint error code ) .unwrap(); } @@ -431,8 +430,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, - 18, // light_compressed_token::ErrorCode::InvalidAuthorityMint.into(), + result, 0, 6018, // light_compressed_token::ErrorCode::InvalidAuthorityMint ) .unwrap(); } @@ -529,7 +527,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, 18, // light_compressed_token::ErrorCode::InvalidAuthorityMint.into(), + result, 0, 6018, // light_compressed_token::ErrorCode::InvalidAuthorityMint ) .unwrap(); } @@ -610,7 +608,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, 18, // light_compressed_token::ErrorCode::InvalidAuthorityMint.into(), + result, 0, 6018, // light_compressed_token::ErrorCode::InvalidAuthorityMint ) .unwrap(); } @@ -690,7 +688,7 @@ async fn functional_and_failing_tests() { .await; assert_rpc_error( - result, 0, 18, // light_compressed_token::ErrorCode::InvalidAuthorityMint.into(), + result, 0, 6018, // light_compressed_token::ErrorCode::InvalidAuthorityMint ) .unwrap(); } @@ -867,6 +865,7 @@ async fn test_mint_to_ctoken_max_top_up_exceeded() { lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_ix = diff --git a/program-tests/compressed-token-test/tests/mint/functional.rs b/program-tests/compressed-token-test/tests/mint/functional.rs index 80f88cc56e..8500efb0b7 100644 --- a/program-tests/compressed-token-test/tests/mint/functional.rs +++ b/program-tests/compressed-token-test/tests/mint/functional.rs @@ -263,6 +263,7 @@ async fn test_create_compressed_mint() { decompress_amount, ctoken_ata_pubkey, payer.pubkey(), + 9, // decimals ) .await .unwrap(); @@ -287,6 +288,8 @@ async fn test_create_compressed_mint() { decompress_amount, solana_token_account: ctoken_ata_pubkey, amount: decompress_amount, + decimals: 9, + in_tlv: None, }, ) .await; @@ -317,6 +320,7 @@ async fn test_create_compressed_mint() { authority: new_recipient_keypair.pubkey(), // Authority for compression output_queue, pool_index: None, + decimals: 9, })], payer.pubkey(), true, @@ -345,6 +349,7 @@ async fn test_create_compressed_mint() { authority: new_recipient_keypair.pubkey(), output_queue, pool_index: None, + decimals: 9, }, ) .await; @@ -363,6 +368,7 @@ async fn test_create_compressed_mint() { authority: new_recipient_keypair.pubkey(), // Authority for compression output_queue, pool_index: None, + decimals: 9, })], payer.pubkey(), true, @@ -401,6 +407,7 @@ async fn test_create_compressed_mint() { authority: new_recipient_keypair.pubkey(), // Authority for compression output_queue, pool_index: None, + decimals: 9, })], payer.pubkey(), true, @@ -484,6 +491,8 @@ async fn test_create_compressed_mint() { solana_token_account: decompress_dest_ata, amount: decompress_amount, pool_index: None, + decimals: 9, + in_tlv: None, }), // 3. Compress SPL tokens to compressed tokens Transfer2InstructionType::Compress(CompressInput { @@ -495,6 +504,7 @@ async fn test_create_compressed_mint() { authority: new_recipient_keypair.pubkey(), // Authority for compression output_queue: multi_output_queue, pool_index: None, + decimals: 9, }), ]; // Create the combined multi-transfer instruction @@ -710,6 +720,7 @@ async fn test_ctoken_transfer() { lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_instruction = @@ -897,6 +908,7 @@ async fn test_ctoken_transfer() { authority: second_recipient_keypair.pubkey(), // Authority for compression output_queue, pool_index: None, + decimals: 9, })], payer.pubkey(), true, @@ -944,6 +956,7 @@ async fn test_ctoken_transfer() { amount: compress_amount, authority: second_recipient_keypair.pubkey(), output_queue, + decimals: 9, }, ) .await; diff --git a/program-tests/compressed-token-test/tests/token_pool.rs b/program-tests/compressed-token-test/tests/token_pool.rs new file mode 100644 index 0000000000..020c6f1aa0 --- /dev/null +++ b/program-tests/compressed-token-test/tests/token_pool.rs @@ -0,0 +1,545 @@ +#![cfg(feature = "test-sbf")] + +use anchor_lang::{system_program, InstructionData, ToAccountMetas}; +use anchor_spl::{ + token::{Mint, TokenAccount}, + token_2022::spl_token_2022::{self, extension::ExtensionType}, +}; +use forester_utils::instructions::create_account_instruction; +use light_compressed_token::{ + constants::NUM_MAX_POOL_ACCOUNTS, get_token_pool_pda, get_token_pool_pda_with_index, + mint_sdk::create_create_token_pool_instruction, process_transfer::get_cpi_authority_pda, + spl_compression::check_spl_token_pool_derivation_with_index, ErrorCode, +}; +use light_program_test::{utils::assert::assert_rpc_error, LightProgramTest, ProgramTestConfig}; +use light_test_utils::{ + spl::{create_additional_token_pools, create_mint_22_helper, create_mint_helper}, + Rpc, RpcError, +}; +use serial_test::serial; +use solana_sdk::{ + instruction::Instruction, + pubkey::Pubkey, + signature::{Keypair, Signature}, + signer::Signer, +}; +use solana_system_interface::instruction as system_instruction; +use spl_token::instruction::initialize_mint; + +#[serial] +#[tokio::test] +async fn test_create_mint() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint = create_mint_helper(&mut rpc, &payer).await; + create_additional_token_pools(&mut rpc, &payer, &mint, false, NUM_MAX_POOL_ACCOUNTS) + .await + .unwrap(); + let mint_22 = create_mint_22_helper(&mut rpc, &payer).await; + create_additional_token_pools(&mut rpc, &payer, &mint_22, true, NUM_MAX_POOL_ACCOUNTS) + .await + .unwrap(); +} + +#[serial] +#[tokio::test] +async fn test_failing_create_token_pool() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let rent = rpc + .get_minimum_balance_for_rent_exemption(Mint::LEN) + .await + .unwrap(); + + let mint_1_keypair = Keypair::new(); + let mint_1_account_create_ix = create_account_instruction( + &payer.pubkey(), + Mint::LEN, + rent, + &spl_token::ID, + Some(&mint_1_keypair), + ); + let create_mint_1_ix = initialize_mint( + &spl_token::ID, + &mint_1_keypair.pubkey(), + &payer.pubkey(), + Some(&payer.pubkey()), + 2, + ) + .unwrap(); + rpc.create_and_send_transaction( + &[mint_1_account_create_ix, create_mint_1_ix], + &payer.pubkey(), + &[&payer, &mint_1_keypair], + ) + .await + .unwrap(); + let mint_1_pool_pda = get_token_pool_pda(&mint_1_keypair.pubkey()); + + let mint_2_keypair = Keypair::new(); + let mint_2_account_create_ix = create_account_instruction( + &payer.pubkey(), + Mint::LEN, + rent, + &spl_token::ID, + Some(&mint_2_keypair), + ); + let create_mint_2_ix = initialize_mint( + &spl_token::ID, + &mint_2_keypair.pubkey(), + &payer.pubkey(), + Some(&payer.pubkey()), + 2, + ) + .unwrap(); + rpc.create_and_send_transaction( + &[mint_2_account_create_ix, create_mint_2_ix], + &payer.pubkey(), + &[&payer, &mint_2_keypair], + ) + .await + .unwrap(); + let mint_2_pool_pda = get_token_pool_pda(&mint_2_keypair.pubkey()); + + // Try to create pool for `mint_1` while using seeds of `mint_2` for PDAs. + { + let instruction_data = light_compressed_token::instruction::CreateTokenPool {}; + let accounts = light_compressed_token::accounts::CreateTokenPoolInstruction { + fee_payer: payer.pubkey(), + token_pool_pda: mint_2_pool_pda, + system_program: system_program::ID, + mint: mint_1_keypair.pubkey(), + token_program: anchor_spl::token::ID, + cpi_authority_pda: get_cpi_authority_pda().0, + }; + let instruction = Instruction { + program_id: light_compressed_token::ID, + accounts: accounts.to_account_metas(Some(true)), + data: instruction_data.data(), + }; + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::ConstraintSeeds.into(), + ) + .unwrap(); + } + // Invalid program id. + { + let instruction_data = light_compressed_token::instruction::CreateTokenPool {}; + let accounts = light_compressed_token::accounts::CreateTokenPoolInstruction { + fee_payer: payer.pubkey(), + token_pool_pda: mint_1_pool_pda, + system_program: system_program::ID, + mint: mint_1_keypair.pubkey(), + token_program: light_system_program::ID, // invalid program id should be spl token program or token 2022 program + cpi_authority_pda: get_cpi_authority_pda().0, + }; + let instruction = Instruction { + program_id: light_compressed_token::ID, + accounts: accounts.to_account_metas(Some(true)), + data: instruction_data.data(), + }; + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::InvalidProgramId.into(), + ) + .unwrap(); + } + // Try to create pool for `mint_2` while using seeds of `mint_1` for PDAs. + { + let instruction_data = light_compressed_token::instruction::CreateTokenPool {}; + let accounts = light_compressed_token::accounts::CreateTokenPoolInstruction { + fee_payer: payer.pubkey(), + token_pool_pda: mint_1_pool_pda, + system_program: system_program::ID, + mint: mint_2_keypair.pubkey(), + token_program: anchor_spl::token::ID, + cpi_authority_pda: get_cpi_authority_pda().0, + }; + let instruction = Instruction { + program_id: light_compressed_token::ID, + accounts: accounts.to_account_metas(Some(true)), + data: instruction_data.data(), + }; + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::ConstraintSeeds.into(), + ) + .unwrap(); + } + // failing test try to create a token pool with mint with non-whitelisted token extension + { + let payer = rpc.get_payer().insecure_clone(); + let payer_pubkey = payer.pubkey(); + let mint = Keypair::new(); + let token_authority = payer.insecure_clone(); + let space = ExtensionType::try_calculate_account_len::(&[ + ExtensionType::NonTransferable, + ]) + .unwrap(); + + let mut instructions = vec![system_instruction::create_account( + &payer.pubkey(), + &mint.pubkey(), + rpc.get_minimum_balance_for_rent_exemption(space) + .await + .unwrap(), + space as u64, + &spl_token_2022::ID, + )]; + let invalid_token_extension_ix = + spl_token_2022::instruction::initialize_non_transferable_mint( + &spl_token_2022::ID, + &mint.pubkey(), + ) + .unwrap(); + instructions.push(invalid_token_extension_ix); + instructions.push( + spl_token_2022::instruction::initialize_mint( + &spl_token_2022::ID, + &mint.pubkey(), + &token_authority.pubkey(), + None, + 2, + ) + .unwrap(), + ); + instructions.push(create_create_token_pool_instruction( + &payer_pubkey, + &mint.pubkey(), + true, + )); + + let result = rpc + .create_and_send_transaction(&instructions, &payer_pubkey, &[&payer, &mint]) + .await; + assert_rpc_error(result, 3, ErrorCode::MintWithInvalidExtension.into()).unwrap(); + } + // functional create token pool account with token 2022 mint with allowed metadata pointer extension + { + let payer = rpc.get_payer().insecure_clone(); + // create_mint_helper(&mut rpc, &payer).await; + let payer_pubkey = payer.pubkey(); + + let mint = Keypair::new(); + let token_authority = payer.insecure_clone(); + let space = ExtensionType::try_calculate_account_len::(&[ + ExtensionType::MetadataPointer, + ]) + .unwrap(); + + let mut instructions = vec![system_instruction::create_account( + &payer.pubkey(), + &mint.pubkey(), + rpc.get_minimum_balance_for_rent_exemption(space) + .await + .unwrap(), + space as u64, + &spl_token_2022::ID, + )]; + let token_extension_ix = + spl_token_2022::extension::metadata_pointer::instruction::initialize( + &spl_token_2022::ID, + &mint.pubkey(), + Some(token_authority.pubkey()), + None, + ) + .unwrap(); + instructions.push(token_extension_ix); + instructions.push( + spl_token_2022::instruction::initialize_mint( + &spl_token_2022::ID, + &mint.pubkey(), + &token_authority.pubkey(), + None, + 2, + ) + .unwrap(), + ); + instructions.push(create_create_token_pool_instruction( + &payer_pubkey, + &mint.pubkey(), + true, + )); + rpc.create_and_send_transaction(&instructions, &payer_pubkey, &[&payer, &mint]) + .await + .unwrap(); + + let token_pool_pubkey = get_token_pool_pda(&mint.pubkey()); + let token_pool_account = rpc.get_account(token_pool_pubkey).await.unwrap().unwrap(); + check_spl_token_pool_derivation_with_index( + &mint.pubkey().to_bytes(), + &token_pool_pubkey, + &[0], + ) + .unwrap(); + // MetadataPointer is a mint-only extension, so token account has base size (165 bytes) + assert_eq!(token_pool_account.data.len(), TokenAccount::LEN); + } +} + +#[serial] +#[tokio::test] +async fn failing_tests_add_token_pool() { + for is_token_22 in [false, true] { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let mint = if !is_token_22 { + create_mint_helper(&mut rpc, &payer).await + } else { + create_mint_22_helper(&mut rpc, &payer).await + }; + let invalid_mint = if !is_token_22 { + create_mint_helper(&mut rpc, &payer).await + } else { + create_mint_22_helper(&mut rpc, &payer).await + }; + let mut current_token_pool_bump = 1; + create_additional_token_pools(&mut rpc, &payer, &mint, is_token_22, 2) + .await + .unwrap(); + create_additional_token_pools(&mut rpc, &payer, &invalid_mint, is_token_22, 2) + .await + .unwrap(); + current_token_pool_bump += 2; + // 1. failing invalid existing token pool pda + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InvalidExistingTokenPoolPda, + ) + .await; + assert_rpc_error(result, 0, ErrorCode::InvalidTokenPoolPda.into()).unwrap(); + } + // 2. failing InvalidTokenPoolPda + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InvalidTokenPoolPda, + ) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::ConstraintSeeds.into(), + ) + .unwrap(); + } + // 3. failing invalid system program id + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InvalidSystemProgramId, + ) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::InvalidProgramId.into(), + ) + .unwrap(); + } + // 4. failing invalid mint - now fails with ConstraintSeeds because mint validation + // happens after PDA derivation (mint changed from InterfaceAccount to AccountInfo) + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InvalidMint, + ) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::ConstraintSeeds.into(), + ) + .unwrap(); + } + // 5. failing inconsistent mints + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + Some(invalid_mint), + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InconsistentMints, + ) + .await; + assert_rpc_error(result, 0, ErrorCode::InvalidTokenPoolPda.into()).unwrap(); + } + // 6. failing invalid program id + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InvalidTokenProgramId, + ) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::InvalidProgramId.into(), + ) + .unwrap(); + } + // 7. failing invalid cpi authority pda + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + current_token_pool_bump, + is_token_22, + FailingTestsAddTokenPool::InvalidCpiAuthorityPda, + ) + .await; + assert_rpc_error( + result, + 0, + anchor_lang::error::ErrorCode::ConstraintSeeds.into(), + ) + .unwrap(); + } + // create all remaining token pools + create_additional_token_pools(&mut rpc, &payer, &mint, is_token_22, 5) + .await + .unwrap(); + // 8. failing invalid token pool bump (too large) + { + let result = add_token_pool( + &mut rpc, + &payer, + &mint, + None, + NUM_MAX_POOL_ACCOUNTS, + is_token_22, + FailingTestsAddTokenPool::Functional, + ) + .await; + assert_rpc_error(result, 0, ErrorCode::InvalidTokenPoolBump.into()).unwrap(); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FailingTestsAddTokenPool { + Functional, + InvalidMint, + InconsistentMints, + InvalidTokenPoolPda, + InvalidSystemProgramId, + InvalidExistingTokenPoolPda, + InvalidCpiAuthorityPda, + InvalidTokenProgramId, +} + +pub async fn add_token_pool( + rpc: &mut R, + fee_payer: &Keypair, + mint: &Pubkey, + invalid_mint: Option, + token_pool_index: u8, + is_token_22: bool, + mode: FailingTestsAddTokenPool, +) -> Result { + let token_pool_pda = if mode == FailingTestsAddTokenPool::InvalidTokenPoolPda { + Pubkey::new_unique() + } else { + get_token_pool_pda_with_index(mint, token_pool_index) + }; + let existing_token_pool_pda = if mode == FailingTestsAddTokenPool::InvalidExistingTokenPoolPda { + get_token_pool_pda_with_index(mint, token_pool_index.saturating_sub(2)) + } else if let Some(invalid_mint) = invalid_mint { + get_token_pool_pda_with_index(&invalid_mint, token_pool_index.saturating_sub(1)) + } else { + get_token_pool_pda_with_index(mint, token_pool_index.saturating_sub(1)) + }; + let instruction_data = light_compressed_token::instruction::AddTokenPool { token_pool_index }; + + let token_program: Pubkey = if mode == FailingTestsAddTokenPool::InvalidTokenProgramId { + Pubkey::new_unique() + } else if is_token_22 { + anchor_spl::token_2022::ID + } else { + anchor_spl::token::ID + }; + let cpi_authority_pda = if mode == FailingTestsAddTokenPool::InvalidCpiAuthorityPda { + Pubkey::new_unique() + } else { + get_cpi_authority_pda().0 + }; + let system_program = if mode == FailingTestsAddTokenPool::InvalidSystemProgramId { + Pubkey::new_unique() + } else { + system_program::ID + }; + let mint = if mode == FailingTestsAddTokenPool::InvalidMint { + Pubkey::new_unique() + } else { + *mint + }; + + let accounts = light_compressed_token::accounts::AddTokenPoolInstruction { + fee_payer: fee_payer.pubkey(), + token_pool_pda, + system_program, + mint, + token_program, + cpi_authority_pda, + existing_token_pool_pda, + }; + + let instruction = Instruction { + program_id: light_compressed_token::ID, + accounts: accounts.to_account_metas(Some(true)), + data: instruction_data.data(), + }; + rpc.create_and_send_transaction(&[instruction], &fee_payer.pubkey(), &[fee_payer]) + .await +} diff --git a/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs b/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs index eafb4d5538..d10310f3ca 100644 --- a/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/compress_failing.rs @@ -48,7 +48,9 @@ use light_compressed_token_sdk::{ ValidityProof, }; use light_ctoken_types::{instructions::mint_action::Recipient, state::TokenDataVersion}; -use light_program_test::{LightProgramTest, ProgramTestConfig, Rpc}; +use light_program_test::{ + utils::assert::assert_rpc_error, LightProgramTest, ProgramTestConfig, Rpc, +}; use light_sdk::instruction::PackedAccounts; use light_test_utils::RpcError; use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; @@ -98,6 +100,7 @@ async fn setup_compression_test(token_amount: u64) -> Result Result<(), RpcError> { .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &invalid_authority]) .await; - // Should fail with OwnerMismatch (custom program error 0x4b = 75) - Authority doesn't match account owner or delegate - assert!( - result - .as_ref() - .unwrap_err() - .to_string() - .contains("custom program error: 0x4b"), - "Expected custom program error 0x4b, got: {}", - result.unwrap_err().to_string() - ); + // Should fail with OwnerMismatch (6075) - Authority doesn't match account owner or delegate + assert_rpc_error(result, 0, 6075).unwrap(); Ok(()) } @@ -620,6 +616,7 @@ async fn test_compression_max_top_up_exceeded() -> Result<(), RpcError> { lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_instruction = @@ -692,6 +689,7 @@ async fn test_compression_max_top_up_exceeded() -> Result<(), RpcError> { in_lamports: None, out_lamports: None, output_queue: 0, + in_tlv: None, }; // Create instruction diff --git a/program-tests/compressed-token-test/tests/transfer2/compress_spl_failing.rs b/program-tests/compressed-token-test/tests/transfer2/compress_spl_failing.rs index a4bdb714de..ae246417e2 100644 --- a/program-tests/compressed-token-test/tests/transfer2/compress_spl_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/compress_spl_failing.rs @@ -45,7 +45,9 @@ use light_program_test::{utils::assert::assert_rpc_error, LightProgramTest, Prog use light_sdk::instruction::PackedAccounts; use light_test_utils::{ airdrop_lamports, - spl::{create_mint_helper, create_token_2022_account, mint_spl_tokens}, + spl::{ + create_mint_helper, create_token_2022_account, mint_spl_tokens, CREATE_MINT_HELPER_DECIMALS, + }, Rpc, RpcError, }; use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer}; @@ -204,6 +206,7 @@ fn create_spl_compression_inputs( pool_account_index, pool_index, bump, + CREATE_MINT_HELPER_DECIMALS, ) .map_err(|e| RpcError::AssertRpcError(format!("Failed to compress SPL: {:?}", e)))?; @@ -221,6 +224,7 @@ fn create_spl_compression_inputs( in_lamports: None, out_lamports: None, output_queue: shared_output_queue, + in_tlv: None, }) } diff --git a/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs b/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs index fd9dc877f9..9815aeaa01 100644 --- a/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/decompress_failing.rs @@ -102,6 +102,7 @@ async fn setup_decompression_test( lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_instruction = @@ -264,6 +265,7 @@ async fn create_decompression_inputs( in_lamports: None, out_lamports: None, output_queue: queue_index, + in_tlv: None, }) } @@ -530,8 +532,8 @@ async fn test_decompression_has_delegate_false_but_delegate_nonzero() -> Result< .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) .await; - // Should fail with InvalidSigner (20009) since owner must sign ╎│ - light_program_test::utils::assert::assert_rpc_error(result, 0, 20009).unwrap(); + // Should fail with OwnerMismatch (6075 = 6000 + 75) since owner must sign + light_program_test::utils::assert::assert_rpc_error(result, 0, 6075).unwrap(); Ok(()) } diff --git a/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs b/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs index c5dff07fc6..0cfc36adaf 100644 --- a/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/no_system_program_cpi_failing.rs @@ -33,11 +33,11 @@ // 9. Decompress with nonzero authority → InvalidInstructionData (string match, not error code) // // Multi-Mint Validation: -// 10. Too many mints (>5) → TooManyMints (6039) +// 10. Too many mints (>5) → MintCacheCapacityExceeded (6126) // 11. Duplicate mint validation → DuplicateMint (6102) // // Index Out of Bounds: -// 12. Mint index out of bounds → DuplicateMint (6102) - out of bounds masked in validate_mint_uniqueness +// 12. Mint index out of bounds → NotEnoughAccountKeys (20014) - mint extension cache validates bounds // 13. Account index out of bounds → NotEnoughAccountKeys (20014) // 14. Authority index out of bounds → SigningError - client-side error, can't send transaction // @@ -330,9 +330,9 @@ async fn test_empty_compressions_array() -> Result<(), RpcError> { .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &owner]) .await; - // Should fail with NoInputsProvided (error code 25, which is 6025 - 6000) + // Should fail with NoInputsProvided (error code 6025) assert_rpc_error( - result, 0, 25, // NoInputsProvided + result, 0, 6025, // NoInputsProvided )?; Ok(()) @@ -543,8 +543,8 @@ async fn test_invalid_authority_compress() { .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &wrong_authority]) .await; - // Should fail with OwnerMismatch (error code 75, which is 6075 - 6000) - assert_rpc_error(result, 0, 75).unwrap(); + // Should fail with OwnerMismatch (error code 6075) + assert_rpc_error(result, 0, 6075).unwrap(); } #[tokio::test] @@ -822,8 +822,8 @@ async fn test_too_many_mints() { ) .await; - // Should fail with TooManyMints - assert_rpc_error(result, 0, 6039).unwrap(); + // Should fail with MintCacheCapacityExceeded (6126 = 6000 + 126) + assert_rpc_error(result, 0, 6126).unwrap(); } /// Test 13: Duplicate mint validation @@ -890,7 +890,7 @@ async fn test_duplicate_mint_validation() { } /// Test 14: Mint index out of bounds -/// Expected: DuplicateMint (6102) - out of bounds is masked as DuplicateMint in validate_mint_uniqueness +/// Expected: NotEnoughAccountKeys (20014) - mint extension cache validates account bounds #[tokio::test] async fn test_mint_index_out_of_bounds() { let mut context = setup_no_system_program_cpi_test(1000).await.unwrap(); @@ -930,8 +930,8 @@ async fn test_mint_index_out_of_bounds() { ) .await; - // Should fail with DuplicateMint (out of bounds is masked) - assert_rpc_error(result, 0, 6102).unwrap(); + // Should fail with NotEnoughAccountKeys - mint extension cache validates bounds first + assert_rpc_error(result, 0, 20014).unwrap(); } /// Test 15: Account index out of bounds diff --git a/program-tests/compressed-token-test/tests/transfer2/shared.rs b/program-tests/compressed-token-test/tests/transfer2/shared.rs index b45e2fcc90..27d54803e3 100644 --- a/program-tests/compressed-token-test/tests/transfer2/shared.rs +++ b/program-tests/compressed-token-test/tests/transfer2/shared.rs @@ -16,6 +16,7 @@ use light_test_utils::{ assert_transfer2::assert_transfer2, spl::{ create_additional_token_pools, create_mint_helper, create_token_account, mint_spl_tokens, + CREATE_MINT_HELPER_DECIMALS, }, }; use light_token_client::{ @@ -458,6 +459,7 @@ impl TestContext { lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, // CompressAndClose requires ShaFlat + compression_only: false, }; CreateAssociatedTokenAccount::new(payer.pubkey(), signer.pubkey(), mint) .with_compressible(compressible_params) @@ -657,6 +659,7 @@ impl TestContext { authority: signer.pubkey(), output_queue, pool_index: None, + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Create and execute the compress instruction @@ -714,6 +717,7 @@ impl TestContext { authority: signer.pubkey(), output_queue, pool_index: None, + decimals: CREATE_MINT_HELPER_DECIMALS, }; let ix = create_generic_transfer2_instruction( @@ -1205,6 +1209,7 @@ impl TestContext { authority: self.keypairs[meta.signer_index].pubkey(), output_queue, pool_index: meta.pool_index, + decimals: CREATE_MINT_HELPER_DECIMALS, }) } @@ -1258,6 +1263,8 @@ impl TestContext { solana_token_account: recipient_account, amount: meta.amount, pool_index: meta.pool_index, + decimals: CREATE_MINT_HELPER_DECIMALS, + in_tlv: None, }) } diff --git a/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs b/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs index 26b9cf5350..9f5feadf60 100644 --- a/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs +++ b/program-tests/compressed-token-test/tests/transfer2/spl_ctoken.rs @@ -18,7 +18,9 @@ use light_program_test::utils::assert::assert_rpc_error; pub use light_program_test::{LightProgramTest, ProgramTestConfig}; pub use light_test_utils::{ airdrop_lamports, - spl::{create_mint_helper, create_token_2022_account, mint_spl_tokens}, + spl::{ + create_mint_helper, create_token_2022_account, mint_spl_tokens, CREATE_MINT_HELPER_DECIMALS, + }, Rpc, RpcError, }; pub use light_token_client::actions::transfer2::{self}; @@ -89,6 +91,7 @@ async fn test_spl_to_ctoken_transfer() { assert_eq!(initial_spl_balance, amount); // Use the new spl_to_ctoken_transfer action from light-token-client + // Note: create_mint_helper creates mints with 2 decimals transfer2::spl_to_ctoken_transfer( &mut rpc, spl_token_account_keypair.pubkey(), @@ -96,6 +99,7 @@ async fn test_spl_to_ctoken_transfer() { transfer_amount, &sender, &payer, + 2, // decimals - must match mint decimals (create_mint_helper uses 2) ) .await .unwrap(); @@ -148,6 +152,7 @@ async fn test_spl_to_ctoken_transfer() { &recipient, mint, &payer, + 2, // decimals - must match mint decimals (create_mint_helper uses 2) ) .await .unwrap(); @@ -261,6 +266,7 @@ async fn test_failing_ctoken_to_spl_with_compress_and_close() { transfer_amount, &sender, &payer, + 2, // decimals - must match mint decimals (create_mint_helper uses 2) ) .await .unwrap(); @@ -301,6 +307,7 @@ async fn test_failing_ctoken_to_spl_with_compress_and_close() { token_pool_pda, token_pool_pda_bump, spl_token_program: anchor_spl::token::ID, + decimals: CREATE_MINT_HELPER_DECIMALS, } .instruction() .unwrap(); @@ -322,6 +329,7 @@ pub struct CtokenToSplTransferAndClose { pub token_pool_pda: Pubkey, pub token_pool_pda_bump: u8, pub spl_token_program: Pubkey, + pub decimals: u8, } impl CtokenToSplTransferAndClose { @@ -353,6 +361,7 @@ impl CtokenToSplTransferAndClose { 0, // no rent sponsor 0, // no compressed account 3, // destination is authority + false, )), delegate_is_set: false, method_used: true, @@ -369,6 +378,7 @@ impl CtokenToSplTransferAndClose { 4, // pool_account_index 0, // pool_index (TODO: make dynamic) self.token_pool_pda_bump, + self.decimals, )), delegate_is_set: false, method_used: true, @@ -385,6 +395,7 @@ impl CtokenToSplTransferAndClose { out_lamports: None, token_accounts: vec![compress_to_pool, decompress_to_spl], output_queue: 0, // Decompressed accounts only, no output queue needed + in_tlv: None, }; create_transfer2_instruction(inputs).map_err(ProgramError::from) diff --git a/program-tests/compressed-token-test/tests/transfer2/transfer_failing.rs b/program-tests/compressed-token-test/tests/transfer2/transfer_failing.rs index 06ca021b37..4ee3a85850 100644 --- a/program-tests/compressed-token-test/tests/transfer2/transfer_failing.rs +++ b/program-tests/compressed-token-test/tests/transfer2/transfer_failing.rs @@ -225,6 +225,7 @@ fn create_transfer2_inputs( in_lamports: None, out_lamports: None, output_queue: output_merkle_tree_index, + in_tlv: None, }) } @@ -295,8 +296,8 @@ async fn test_owner_not_signer() -> Result<(), RpcError> { .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) .await; - // Should fail with InvalidSigner - assert_rpc_error(result, 0, 20009).unwrap(); + // Should fail with OwnerMismatch (6075 = 6000 + 75) + assert_rpc_error(result, 0, 6075).unwrap(); Ok(()) } @@ -917,8 +918,8 @@ async fn test_has_delegate_flag_mismatch() -> Result<(), RpcError> { .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) .await; - // Should fail with InvalidSigner (20009) because no valid authority signed - assert_rpc_error(result, 0, 20009).unwrap(); + // Should fail with OwnerMismatch (6075) because no valid authority signed + assert_rpc_error(result, 0, 6075).unwrap(); } // 11.5. Valid delegate signing (should succeed) diff --git a/program-tests/compressed-token-test/tests/v1.rs b/program-tests/compressed-token-test/tests/v1.rs index f666bafd61..4a89b73e9f 100644 --- a/program-tests/compressed-token-test/tests/v1.rs +++ b/program-tests/compressed-token-test/tests/v1.rs @@ -7,10 +7,10 @@ use anchor_lang::{ InstructionData, ToAccountMetas, }; use anchor_spl::{ - token::{Mint, TokenAccount}, - token_2022::spl_token_2022::{self, extension::ExtensionType}, + token::TokenAccount, + token_2022::spl_token_2022::{self}, }; -use forester_utils::{instructions::create_account_instruction, utils::airdrop_lamports}; +use forester_utils::utils::airdrop_lamports; use light_client::{ indexer::Indexer, local_test_validator::{spawn_validator, LightValidatorConfig}, @@ -35,7 +35,6 @@ use light_compressed_token::{ process_transfer::{ get_cpi_authority_pda, transfer_sdk::create_transfer_instruction, TokenTransferOutputData, }, - spl_compression::check_spl_token_pool_derivation_with_index, ErrorCode, TokenData, }; use light_compressed_token_sdk::compat::{AccountState, TokenDataWithMerkleContext}; @@ -70,526 +69,9 @@ use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signature}, signer::Signer, - system_instruction, transaction::Transaction, }; -use spl_token::{error::TokenError, instruction::initialize_mint}; -#[serial] -#[tokio::test] -async fn test_create_mint() { - let mut rpc = LightProgramTest::new(ProgramTestConfig::new(false, None)) - .await - .unwrap(); - let payer = rpc.get_payer().insecure_clone(); - let mint = create_mint_helper(&mut rpc, &payer).await; - create_additional_token_pools(&mut rpc, &payer, &mint, false, NUM_MAX_POOL_ACCOUNTS) - .await - .unwrap(); - let mint_22 = create_mint_22_helper(&mut rpc, &payer).await; - create_additional_token_pools(&mut rpc, &payer, &mint_22, true, NUM_MAX_POOL_ACCOUNTS) - .await - .unwrap(); -} - -#[serial] -#[tokio::test] -async fn test_failing_create_token_pool() { - let mut rpc = LightProgramTest::new(ProgramTestConfig::new(false, None)) - .await - .unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let rent = rpc - .get_minimum_balance_for_rent_exemption(Mint::LEN) - .await - .unwrap(); - - let mint_1_keypair = Keypair::new(); - let mint_1_account_create_ix = create_account_instruction( - &payer.pubkey(), - Mint::LEN, - rent, - &spl_token::ID, - Some(&mint_1_keypair), - ); - let create_mint_1_ix = initialize_mint( - &spl_token::ID, - &mint_1_keypair.pubkey(), - &payer.pubkey(), - Some(&payer.pubkey()), - 2, - ) - .unwrap(); - rpc.create_and_send_transaction( - &[mint_1_account_create_ix, create_mint_1_ix], - &payer.pubkey(), - &[&payer, &mint_1_keypair], - ) - .await - .unwrap(); - let mint_1_pool_pda = get_token_pool_pda(&mint_1_keypair.pubkey()); - - let mint_2_keypair = Keypair::new(); - let mint_2_account_create_ix = create_account_instruction( - &payer.pubkey(), - Mint::LEN, - rent, - &spl_token::ID, - Some(&mint_2_keypair), - ); - let create_mint_2_ix = initialize_mint( - &spl_token::ID, - &mint_2_keypair.pubkey(), - &payer.pubkey(), - Some(&payer.pubkey()), - 2, - ) - .unwrap(); - rpc.create_and_send_transaction( - &[mint_2_account_create_ix, create_mint_2_ix], - &payer.pubkey(), - &[&payer, &mint_2_keypair], - ) - .await - .unwrap(); - let mint_2_pool_pda = get_token_pool_pda(&mint_2_keypair.pubkey()); - - // Try to create pool for `mint_1` while using seeds of `mint_2` for PDAs. - { - let instruction_data = light_compressed_token::instruction::CreateTokenPool {}; - let accounts = light_compressed_token::accounts::CreateTokenPoolInstruction { - fee_payer: payer.pubkey(), - token_pool_pda: mint_2_pool_pda, - system_program: system_program::ID, - mint: mint_1_keypair.pubkey(), - token_program: anchor_spl::token::ID, - cpi_authority_pda: get_cpi_authority_pda().0, - }; - let instruction = Instruction { - program_id: light_compressed_token::ID, - accounts: accounts.to_account_metas(Some(true)), - data: instruction_data.data(), - }; - let result = rpc - .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::ConstraintSeeds.into(), - ) - .unwrap(); - } - // Invalid program id. - { - let instruction_data = light_compressed_token::instruction::CreateTokenPool {}; - let accounts = light_compressed_token::accounts::CreateTokenPoolInstruction { - fee_payer: payer.pubkey(), - token_pool_pda: mint_1_pool_pda, - system_program: system_program::ID, - mint: mint_1_keypair.pubkey(), - token_program: light_system_program::ID, // invalid program id should be spl token program or token 2022 program - cpi_authority_pda: get_cpi_authority_pda().0, - }; - let instruction = Instruction { - program_id: light_compressed_token::ID, - accounts: accounts.to_account_metas(Some(true)), - data: instruction_data.data(), - }; - let result = rpc - .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::InvalidProgramId.into(), - ) - .unwrap(); - } - // Try to create pool for `mint_2` while using seeds of `mint_1` for PDAs. - { - let instruction_data = light_compressed_token::instruction::CreateTokenPool {}; - let accounts = light_compressed_token::accounts::CreateTokenPoolInstruction { - fee_payer: payer.pubkey(), - token_pool_pda: mint_1_pool_pda, - system_program: system_program::ID, - mint: mint_2_keypair.pubkey(), - token_program: anchor_spl::token::ID, - cpi_authority_pda: get_cpi_authority_pda().0, - }; - let instruction = Instruction { - program_id: light_compressed_token::ID, - accounts: accounts.to_account_metas(Some(true)), - data: instruction_data.data(), - }; - let result = rpc - .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::ConstraintSeeds.into(), - ) - .unwrap(); - } - // failing test try to create a token pool with mint with non-whitelisted token extension - { - let payer = rpc.get_payer().insecure_clone(); - let payer_pubkey = payer.pubkey(); - let mint = Keypair::new(); - let token_authority = payer.insecure_clone(); - let space = ExtensionType::try_calculate_account_len::(&[ - ExtensionType::MintCloseAuthority, - ]) - .unwrap(); - - let mut instructions = vec![system_instruction::create_account( - &payer.pubkey(), - &mint.pubkey(), - rpc.get_minimum_balance_for_rent_exemption(space) - .await - .unwrap(), - space as u64, - &spl_token_2022::ID, - )]; - let invalid_token_extension_ix = - spl_token_2022::instruction::initialize_mint_close_authority( - &spl_token_2022::ID, - &mint.pubkey(), - Some(&token_authority.pubkey()), - ) - .unwrap(); - instructions.push(invalid_token_extension_ix); - instructions.push( - spl_token_2022::instruction::initialize_mint( - &spl_token_2022::ID, - &mint.pubkey(), - &token_authority.pubkey(), - None, - 2, - ) - .unwrap(), - ); - instructions.push(create_create_token_pool_instruction( - &payer_pubkey, - &mint.pubkey(), - true, - )); - - let result = rpc - .create_and_send_transaction(&instructions, &payer_pubkey, &[&payer, &mint]) - .await; - assert_rpc_error(result, 3, ErrorCode::MintWithInvalidExtension.into()).unwrap(); - } - // functional create token pool account with token 2022 mint with allowed metadata pointer extension - { - let payer = rpc.get_payer().insecure_clone(); - // create_mint_helper(&mut rpc, &payer).await; - let payer_pubkey = payer.pubkey(); - - let mint = Keypair::new(); - let token_authority = payer.insecure_clone(); - let space = ExtensionType::try_calculate_account_len::(&[ - ExtensionType::MetadataPointer, - ]) - .unwrap(); - - let mut instructions = vec![system_instruction::create_account( - &payer.pubkey(), - &mint.pubkey(), - rpc.get_minimum_balance_for_rent_exemption(space) - .await - .unwrap(), - space as u64, - &spl_token_2022::ID, - )]; - let token_extension_ix = - spl_token_2022::extension::metadata_pointer::instruction::initialize( - &spl_token_2022::ID, - &mint.pubkey(), - Some(token_authority.pubkey()), - None, - ) - .unwrap(); - instructions.push(token_extension_ix); - instructions.push( - spl_token_2022::instruction::initialize_mint( - &spl_token_2022::ID, - &mint.pubkey(), - &token_authority.pubkey(), - None, - 2, - ) - .unwrap(), - ); - instructions.push(create_create_token_pool_instruction( - &payer_pubkey, - &mint.pubkey(), - true, - )); - rpc.create_and_send_transaction(&instructions, &payer_pubkey, &[&payer, &mint]) - .await - .unwrap(); - - let token_pool_pubkey = get_token_pool_pda(&mint.pubkey()); - let token_pool_account = rpc.get_account(token_pool_pubkey).await.unwrap().unwrap(); - check_spl_token_pool_derivation_with_index( - &mint.pubkey().to_bytes(), - &token_pool_pubkey, - &[0], - ) - .unwrap(); - assert_eq!(token_pool_account.data.len(), TokenAccount::LEN); - } -} - -#[serial] -#[tokio::test] -async fn failing_tests_add_token_pool() { - for is_token_22 in [false, true] { - let mut rpc = LightProgramTest::new(ProgramTestConfig::new(false, None)) - .await - .unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let mint = if !is_token_22 { - create_mint_helper(&mut rpc, &payer).await - } else { - create_mint_22_helper(&mut rpc, &payer).await - }; - let invalid_mint = if !is_token_22 { - create_mint_helper(&mut rpc, &payer).await - } else { - create_mint_22_helper(&mut rpc, &payer).await - }; - let mut current_token_pool_bump = 1; - create_additional_token_pools(&mut rpc, &payer, &mint, is_token_22, 2) - .await - .unwrap(); - create_additional_token_pools(&mut rpc, &payer, &invalid_mint, is_token_22, 2) - .await - .unwrap(); - current_token_pool_bump += 2; - // 1. failing invalid existing token pool pda - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InvalidExistingTokenPoolPda, - ) - .await; - assert_rpc_error(result, 0, ErrorCode::InvalidTokenPoolPda.into()).unwrap(); - } - // 2. failing InvalidTokenPoolPda - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InvalidTokenPoolPda, - ) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::ConstraintSeeds.into(), - ) - .unwrap(); - } - // 3. failing invalid system program id - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InvalidSystemProgramId, - ) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::InvalidProgramId.into(), - ) - .unwrap(); - } - // 4. failing invalid mint - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InvalidMint, - ) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::AccountNotInitialized.into(), - ) - .unwrap(); - } - // 5. failing inconsistent mints - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - Some(invalid_mint), - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InconsistentMints, - ) - .await; - assert_rpc_error(result, 0, ErrorCode::InvalidTokenPoolPda.into()).unwrap(); - } - // 6. failing invalid program id - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InvalidTokenProgramId, - ) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::InvalidProgramId.into(), - ) - .unwrap(); - } - // 7. failing invalid cpi authority pda - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - current_token_pool_bump, - is_token_22, - FailingTestsAddTokenPool::InvalidCpiAuthorityPda, - ) - .await; - assert_rpc_error( - result, - 0, - anchor_lang::error::ErrorCode::ConstraintSeeds.into(), - ) - .unwrap(); - } - // create all remaining token pools - create_additional_token_pools(&mut rpc, &payer, &mint, is_token_22, 5) - .await - .unwrap(); - // 8. failing invalid token pool bump (too large) - { - let result = add_token_pool( - &mut rpc, - &payer, - &mint, - None, - NUM_MAX_POOL_ACCOUNTS, - is_token_22, - FailingTestsAddTokenPool::Functional, - ) - .await; - assert_rpc_error(result, 0, ErrorCode::InvalidTokenPoolBump.into()).unwrap(); - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum FailingTestsAddTokenPool { - Functional, - InvalidMint, - InconsistentMints, - InvalidTokenPoolPda, - InvalidSystemProgramId, - InvalidExistingTokenPoolPda, - InvalidCpiAuthorityPda, - InvalidTokenProgramId, -} - -pub async fn add_token_pool( - rpc: &mut R, - fee_payer: &Keypair, - mint: &Pubkey, - invalid_mint: Option, - token_pool_index: u8, - is_token_22: bool, - mode: FailingTestsAddTokenPool, -) -> Result { - let token_pool_pda = if mode == FailingTestsAddTokenPool::InvalidTokenPoolPda { - Pubkey::new_unique() - } else { - get_token_pool_pda_with_index(mint, token_pool_index) - }; - let existing_token_pool_pda = if mode == FailingTestsAddTokenPool::InvalidExistingTokenPoolPda { - get_token_pool_pda_with_index(mint, token_pool_index.saturating_sub(2)) - } else if let Some(invalid_mint) = invalid_mint { - get_token_pool_pda_with_index(&invalid_mint, token_pool_index.saturating_sub(1)) - } else { - get_token_pool_pda_with_index(mint, token_pool_index.saturating_sub(1)) - }; - let instruction_data = light_compressed_token::instruction::AddTokenPool { token_pool_index }; - - let token_program: Pubkey = if mode == FailingTestsAddTokenPool::InvalidTokenProgramId { - Pubkey::new_unique() - } else if is_token_22 { - anchor_spl::token_2022::ID - } else { - anchor_spl::token::ID - }; - let cpi_authority_pda = if mode == FailingTestsAddTokenPool::InvalidCpiAuthorityPda { - Pubkey::new_unique() - } else { - get_cpi_authority_pda().0 - }; - let system_program = if mode == FailingTestsAddTokenPool::InvalidSystemProgramId { - Pubkey::new_unique() - } else { - system_program::ID - }; - let mint = if mode == FailingTestsAddTokenPool::InvalidMint { - Pubkey::new_unique() - } else { - *mint - }; - - let accounts = light_compressed_token::accounts::AddTokenPoolInstruction { - fee_payer: fee_payer.pubkey(), - token_pool_pda, - system_program, - mint, - token_program, - cpi_authority_pda, - existing_token_pool_pda, - }; - - let instruction = Instruction { - program_id: light_compressed_token::ID, - accounts: accounts.to_account_metas(Some(true)), - data: instruction_data.data(), - }; - rpc.create_and_send_transaction(&[instruction], &fee_payer.pubkey(), &[fee_payer]) - .await -} +use spl_token::error::TokenError; #[serial] #[tokio::test] diff --git a/program-tests/registry-test/tests/compressible.rs b/program-tests/registry-test/tests/compressible.rs index 5b9f5026af..bde55486b2 100644 --- a/program-tests/registry-test/tests/compressible.rs +++ b/program-tests/registry-test/tests/compressible.rs @@ -167,7 +167,7 @@ async fn test_claim_multiple_accounts_different_epochs() { num_prepaid_epochs: i as u8, payer: &payer, token_account_keypair: None, - lamports_per_write: Some(100), + lamports_per_write: Some(400), token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, }, ) @@ -495,6 +495,7 @@ async fn test_pause_compressible_config_with_valid_authority() -> Result<(), Rpc lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let compressible_instruction = @@ -628,6 +629,7 @@ async fn test_unpause_compressible_config_with_valid_authority() -> Result<(), R lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let compressible_instruction = @@ -674,6 +676,7 @@ async fn test_unpause_compressible_config_with_valid_authority() -> Result<(), R lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let compressible_instruction = @@ -763,6 +766,7 @@ async fn test_deprecate_compressible_config_with_valid_authority() -> Result<(), lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let compressible_instruction = @@ -810,6 +814,7 @@ async fn test_deprecate_compressible_config_with_valid_authority() -> Result<(), lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let compressible_instruction = @@ -1132,6 +1137,10 @@ async fn assert_not_compressible( .await? .ok_or_else(|| RpcError::AssertRpcError(format!("{} account not found", name)))?; + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(account.data.len()) + .await?; + let ctoken = CToken::deserialize(&mut account.data.as_slice()) .map_err(|e| RpcError::AssertRpcError(format!("Failed to deserialize CToken: {:?}", e)))?; @@ -1145,12 +1154,10 @@ async fn assert_not_compressible( num_bytes: account.data.len() as u64, current_slot, current_lamports: account.lamports, - last_claimed_slot: compressible_ext.last_claimed_slot, + last_claimed_slot: compressible_ext.info.last_claimed_slot, }; - let is_compressible = state.is_compressible( - &compressible_ext.rent_config, - light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, - ); + let is_compressible = + state.is_compressible(&compressible_ext.info.rent_config, rent_exemption); assert!( is_compressible.is_none(), @@ -1161,10 +1168,11 @@ async fn assert_not_compressible( // Also verify last_funded_epoch is ahead of current let last_funded_epoch = compressible_ext + .info .get_last_funded_epoch( account.data.len() as u64, account.lamports, - light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + rent_exemption, ) .map_err(|e| { RpcError::AssertRpcError(format!( @@ -1226,7 +1234,7 @@ async fn test_compressible_account_infinite_funding() -> Result<(), RpcError> { num_prepaid_epochs: 2, payer: &payer, token_account_keypair: None, - lamports_per_write: Some(100), + lamports_per_write: Some(400), token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, }, ) @@ -1242,7 +1250,7 @@ async fn test_compressible_account_infinite_funding() -> Result<(), RpcError> { num_prepaid_epochs: 2, payer: &payer, token_account_keypair: None, - lamports_per_write: Some(100), + lamports_per_write: Some(400), token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, }, ) @@ -1272,7 +1280,7 @@ async fn test_compressible_account_infinite_funding() -> Result<(), RpcError> { .and_then(|exts| { exts.iter().find_map(|ext| { if let ExtensionStruct::Compressible(comp) = ext { - Some(comp.rent_config) + Some(comp.info.rent_config) } else { None } @@ -1301,7 +1309,7 @@ async fn test_compressible_account_infinite_funding() -> Result<(), RpcError> { if let Some(extensions) = ctoken.extensions.as_ref() { for ext in extensions.iter() { if let ExtensionStruct::Compressible(comp) = ext { - return Ok(comp.last_claimed_slot); + return Ok(comp.info.last_claimed_slot); } } } diff --git a/program-tests/utils/Cargo.toml b/program-tests/utils/Cargo.toml index 056f368cc4..00a5f6f563 100644 --- a/program-tests/utils/Cargo.toml +++ b/program-tests/utils/Cargo.toml @@ -18,6 +18,7 @@ anchor-spl = { workspace = true } num-bigint = { workspace = true, features = ["rand"] } num-traits = { workspace = true } solana-sdk = { workspace = true } +solana-system-interface = { workspace = true } thiserror = { workspace = true } account-compression = { workspace = true, features = ["cpi"] } light-compressed-token = { workspace = true, features = ["cpi"] } @@ -41,6 +42,7 @@ log = { workspace = true } light-client = { workspace = true, features = ["devenv"] } create-address-test-program = { workspace = true } spl-token-2022 = { workspace = true } +spl-pod = { workspace = true } light-batched-merkle-tree = { workspace = true, features = ["test-only"] } light-merkle-tree-metadata = { workspace = true } reqwest = { workspace = true } diff --git a/program-tests/utils/src/assert_claim.rs b/program-tests/utils/src/assert_claim.rs index ad0d2705a7..ec9a349b2a 100644 --- a/program-tests/utils/src/assert_claim.rs +++ b/program-tests/utils/src/assert_claim.rs @@ -42,17 +42,17 @@ pub async fn assert_claim( if let Some(extensions) = pre_compressed_token.extensions.as_mut() { for extension in extensions { if let ZExtensionStructMut::Compressible(compressible_ext) = extension { - pre_last_claimed_slot = u64::from(compressible_ext.last_claimed_slot); + pre_last_claimed_slot = u64::from(compressible_ext.info.last_claimed_slot); // Check if compression_authority is set (non-zero) pre_compression_authority = - if compressible_ext.compression_authority != [0u8; 32] { - Some(Pubkey::from(compressible_ext.compression_authority)) + if compressible_ext.info.compression_authority != [0u8; 32] { + Some(Pubkey::from(compressible_ext.info.compression_authority)) } else { None }; // Check if rent_sponsor is set (non-zero) - pre_rent_sponsor = if compressible_ext.rent_sponsor != [0u8; 32] { - Some(Pubkey::from(compressible_ext.rent_sponsor)) + pre_rent_sponsor = if compressible_ext.info.rent_sponsor != [0u8; 32] { + Some(Pubkey::from(compressible_ext.info.rent_sponsor)) } else { None }; @@ -63,7 +63,7 @@ pub async fn assert_claim( ) .await .unwrap(); - let lamports_result = compressible_ext.claim( + let lamports_result = compressible_ext.info.claim( COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, current_slot, pre_token_account.lamports, @@ -110,7 +110,7 @@ pub async fn assert_claim( if let Some(extensions) = post_compressed_token.extensions.as_ref() { for extension in extensions { if let ZExtensionStruct::Compressible(compressible_ext) = extension { - post_last_claimed_slot = u64::from(compressible_ext.last_claimed_slot); + post_last_claimed_slot = u64::from(compressible_ext.info.last_claimed_slot); println!("post_last_claimed_slot {}", post_last_claimed_slot); break; diff --git a/program-tests/utils/src/assert_close_token_account.rs b/program-tests/utils/src/assert_close_token_account.rs index 960cc1a620..e36fbdc6b6 100644 --- a/program-tests/utils/src/assert_close_token_account.rs +++ b/program-tests/utils/src/assert_close_token_account.rs @@ -161,17 +161,17 @@ async fn assert_compressible_extension( // Verify compressible extension fields are valid let current_slot = rpc.get_slot().await.expect("Failed to get current slot"); assert!( - u64::from(compressible_extension.last_claimed_slot) <= current_slot, + u64::from(compressible_extension.info.last_claimed_slot) <= current_slot, "Last claimed slot ({}) should not be greater than current slot ({})", - u64::from(compressible_extension.last_claimed_slot), + u64::from(compressible_extension.info.last_claimed_slot), current_slot ); // Verify config_account_version is initialized assert!( - compressible_extension.config_account_version == 1, + compressible_extension.info.config_account_version == 1, "Config account version should be 1 (initialized), got {}", - compressible_extension.config_account_version + compressible_extension.info.config_account_version ); // Calculate expected lamport distribution using the same function as the program @@ -186,24 +186,28 @@ async fn assert_compressible_extension( num_bytes: account_size, current_slot, current_lamports: account_lamports_before_close, - last_claimed_slot: u64::from(compressible_extension.last_claimed_slot), + last_claimed_slot: u64::from(compressible_extension.info.last_claimed_slot), }; let distribution = - state.calculate_close_distribution(&compressible_extension.rent_config, base_lamports); + state.calculate_close_distribution(&compressible_extension.info.rent_config, base_lamports); let (mut lamports_to_rent_sponsor, mut lamports_to_destination) = (distribution.to_rent_sponsor, distribution.to_user); - let compression_cost: u64 = compressible_extension.rent_config.compression_cost.into(); + let compression_cost: u64 = compressible_extension + .info + .rent_config + .compression_cost + .into(); // Get the rent recipient from the extension - let rent_sponsor = Pubkey::from(compressible_extension.rent_sponsor); + let rent_sponsor = Pubkey::from(compressible_extension.info.rent_sponsor); // Check if rent authority is the signer // Check if compression_authority is set (non-zero) let is_compression_authority_signer = - if compressible_extension.compression_authority != [0u8; 32] { - authority_pubkey == Pubkey::from(compressible_extension.compression_authority) + if compressible_extension.info.compression_authority != [0u8; 32] { + authority_pubkey == Pubkey::from(compressible_extension.info.compression_authority) } else { false }; diff --git a/program-tests/utils/src/assert_create_token_account.rs b/program-tests/utils/src/assert_create_token_account.rs index c4585f4a2c..412c761fb2 100644 --- a/program-tests/utils/src/assert_create_token_account.rs +++ b/program-tests/utils/src/assert_create_token_account.rs @@ -3,7 +3,11 @@ use light_client::rpc::Rpc; use light_compressed_token_sdk::ctoken::derive_ctoken_ata; use light_compressible::rent::RentConfig; use light_ctoken_types::{ - state::{ctoken::CToken, extensions::CompressionInfo, AccountState}, + state::{ + ctoken::CToken, + extensions::{CompressibleExtension, CompressionInfo}, + AccountState, + }, BASE_TOKEN_ACCOUNT_SIZE, COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, }; use light_program_test::LightProgramTest; @@ -91,17 +95,22 @@ pub async fn assert_create_token_account_internal( close_authority: None, extensions: Some(vec![ light_ctoken_types::state::extensions::ExtensionStruct::Compressible( - CompressionInfo { - config_account_version: 1, - last_claimed_slot: current_slot, - rent_config: RentConfig::default(), - lamports_per_write: compressible_info.lamports_per_write.unwrap_or(0), - compression_authority: compressible_info - .compression_authority - .to_bytes(), - rent_sponsor: compressible_info.rent_sponsor.to_bytes(), - compress_to_pubkey: compressible_info.compress_to_pubkey as u8, - account_version: compressible_info.account_version as u8, + CompressibleExtension { + compression_only: false, + info: CompressionInfo { + config_account_version: 1, + last_claimed_slot: current_slot, + rent_config: RentConfig::default(), + lamports_per_write: compressible_info + .lamports_per_write + .unwrap_or(0), + compression_authority: compressible_info + .compression_authority + .to_bytes(), + rent_sponsor: compressible_info.rent_sponsor.to_bytes(), + compress_to_pubkey: compressible_info.compress_to_pubkey as u8, + account_version: compressible_info.account_version as u8, + }, }, ), ]), diff --git a/program-tests/utils/src/assert_ctoken_transfer.rs b/program-tests/utils/src/assert_ctoken_transfer.rs index 07ecce771c..c2ff1f7c6e 100644 --- a/program-tests/utils/src/assert_ctoken_transfer.rs +++ b/program-tests/utils/src/assert_ctoken_transfer.rs @@ -11,7 +11,6 @@ pub async fn assert_compressible_for_account( name: &str, account_pubkey: Pubkey, ) { - println!("account_pubkey {:?}", account_pubkey); // Get pre-transaction state from cache let pre_account = rpc .get_pre_transaction_account(&account_pubkey) @@ -30,17 +29,12 @@ pub async fn assert_compressible_for_account( let data_after = post_account.data.as_slice(); let lamports_after = post_account.lamports; - // Get current slot - let current_slot = rpc.get_slot().await.unwrap(); - - println!("{} current_slot", current_slot); // Parse tokens let token_before = if data_before.len() > 165 { CToken::zero_copy_at(data_before).ok() } else { None }; - println!("{:?} token_before", token_before); let token_after = if data_after.len() > 165 { CToken::zero_copy_at(data_after).ok() @@ -76,36 +70,41 @@ pub async fn assert_compressible_for_account( }); assert_eq!( - u64::from(compressible_after.last_claimed_slot), - u64::from(compressible_before.last_claimed_slot), + u64::from(compressible_after.info.last_claimed_slot), + u64::from(compressible_before.info.last_claimed_slot), "{} last_claimed_slot should be different from current slot before transfer", name ); assert_eq!( - compressible_before.compression_authority, - compressible_after.compression_authority, + compressible_before.info.compression_authority, + compressible_after.info.compression_authority, "{} compression_authority should not change", name ); assert_eq!( - compressible_before.rent_sponsor, compressible_after.rent_sponsor, + compressible_before.info.rent_sponsor, compressible_after.info.rent_sponsor, "{} rent_sponsor should not change", name ); assert_eq!( - compressible_before.config_account_version, - compressible_after.config_account_version, + compressible_before.info.config_account_version, + compressible_after.info.config_account_version, "{} config_account_version should not change", name ); let current_slot = rpc.get_slot().await.unwrap(); + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(data_before.len()) + .await + .unwrap(); let top_up = compressible_before + .info .calculate_top_up_lamports( - 260, + data_before.len() as u64, current_slot, lamports_before, - light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + rent_exemption, ) .unwrap(); // Check if top-up was applied @@ -124,8 +123,6 @@ pub async fn assert_compressible_for_account( name ); } - println!("{:?} compressible_before", compressible_before); - println!("{:?} compressible_after", compressible_after); } } } diff --git a/program-tests/utils/src/assert_mint_action.rs b/program-tests/utils/src/assert_mint_action.rs index 4a3f1da15b..9bcff7c0d1 100644 --- a/program-tests/utils/src/assert_mint_action.rs +++ b/program-tests/utils/src/assert_mint_action.rs @@ -180,13 +180,18 @@ pub async fn assert_mint_action( // Account has compressible extension - calculate expected top-up let current_slot = rpc.get_slot().await.unwrap(); let account_size = pre_account.data.len() as u64; + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(pre_account.data.len()) + .await + .unwrap(); let expected_top_up = compressible + .info .calculate_top_up_lamports( account_size, current_slot, pre_lamports, - light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + rent_exemption, ) .unwrap(); diff --git a/program-tests/utils/src/assert_transfer2.rs b/program-tests/utils/src/assert_transfer2.rs index 2eab0525c4..9f8f71811a 100644 --- a/program-tests/utils/src/assert_transfer2.rs +++ b/program-tests/utils/src/assert_transfer2.rs @@ -400,7 +400,7 @@ pub async fn assert_transfer2_with_delegate( .iter() .find_map(|ext| match ext { ZExtensionStruct::Compressible(comp) => { - Some(comp.compress_to_pubkey == 1) + Some(comp.info.compress_to_pubkey == 1) } _ => None, }) @@ -456,39 +456,58 @@ pub async fn assert_transfer2_with_delegate( let compressed_account = mint_accounts[0]; - // Verify the compressed account has the correct data - assert_eq!( - compressed_account.token.amount, expected_amount, - "CompressAndClose compressed amount should match original balance" - ); + // Determine expected state - frozen state should be preserved + let is_frozen = + pre_token_account.state == spl_token_2022::state::AccountState::Frozen; + let expected_state = if is_frozen { + light_compressed_token_sdk::compat::AccountState::Frozen + } else { + light_compressed_token_sdk::compat::AccountState::Initialized + }; + + // Delegate is preserved from the original account + use spl_token_2022::solana_program::program_option::COption; + let expected_delegate: Option = match pre_token_account.delegate { + COption::Some(d) => Some(d), + COption::None => None, + }; + + // Build expected TLV based on account state + // TLV contains CompressedOnly extension when: + // - Account is frozen (is_frozen=true) + // - Account has delegated_amount > 0 + // - Account has withheld_transfer_fee > 0 (from TransferFeeAccount extension) + let has_delegated_amount = pre_token_account.delegated_amount > 0; + let needs_tlv = is_frozen || has_delegated_amount; + + let expected_tlv = if needs_tlv { + Some(vec![ + light_ctoken_types::state::ExtensionStruct::CompressedOnly( + light_ctoken_types::state::CompressedOnlyExtension { + delegated_amount: pre_token_account.delegated_amount, + withheld_transfer_fee: 0, // TODO: extract from TransferFeeAccount if present + }, + ), + ]) + } else { + None + }; + + // Build expected token data for single assert comparison + let expected_token = light_compressed_token_sdk::compat::TokenData { + mint: expected_mint, + owner: expected_owner, + amount: expected_amount, + delegate: expected_delegate, + state: expected_state, + tlv: expected_tlv, + }; + assert_eq!( - compressed_account.token.owner, - expected_owner, - "CompressAndClose owner should be {} (compress_to_pubkey={})", - if compress_to_pubkey { - "account pubkey" - } else { - "original owner" - }, + compressed_account.token, expected_token, + "CompressAndClose compressed token should match expected (compress_to_pubkey={})", compress_to_pubkey ); - assert_eq!( - compressed_account.token.mint, expected_mint, - "CompressAndClose mint should match original mint" - ); - assert_eq!( - compressed_account.token.delegate, None, - "CompressAndClose compressed account should have no delegate" - ); - assert_eq!( - compressed_account.token.state, - light_compressed_token_sdk::compat::AccountState::Initialized, - "CompressAndClose compressed account should be initialized" - ); - assert_eq!( - compressed_account.token.tlv, None, - "CompressAndClose compressed account should have no TLV data" - ); // Verify compressed account metadata assert_eq!( diff --git a/program-tests/utils/src/lib.rs b/program-tests/utils/src/lib.rs index fe0ed981b4..2fe3cc3f37 100644 --- a/program-tests/utils/src/lib.rs +++ b/program-tests/utils/src/lib.rs @@ -38,6 +38,7 @@ pub mod conversions; pub mod create_address_test_program_sdk; pub mod e2e_test_env; pub mod legacy_cpi_context_account; +pub mod mint_2022; pub mod mint_assert; pub mod mock_batched_forester; pub mod pack; diff --git a/program-tests/utils/src/mint_2022.rs b/program-tests/utils/src/mint_2022.rs new file mode 100644 index 0000000000..5fd0016c90 --- /dev/null +++ b/program-tests/utils/src/mint_2022.rs @@ -0,0 +1,541 @@ +//! Helper functions for creating Token 2022 mints with multiple extensions. +//! +//! This module provides utilities to create Token 2022 mints with various extensions +//! enabled for testing purposes. + +use forester_utils::instructions::create_account::create_account_instruction; +use light_client::rpc::Rpc; +use light_compressed_token::{get_token_pool_pda, mint_sdk::create_create_token_pool_instruction}; +use solana_sdk::{ + instruction::Instruction, + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; +use spl_token_2022::{ + extension::{ + confidential_transfer::{ + instruction::initialize_mint as initialize_confidential_transfer_mint, + ConfidentialTransferMint, + }, + confidential_transfer_fee::{ + instruction::initialize_confidential_transfer_fee_config, ConfidentialTransferFeeConfig, + }, + default_account_state::{ + instruction::initialize_default_account_state, DefaultAccountState, + }, + metadata_pointer::{ + instruction::initialize as initialize_metadata_pointer, MetadataPointer, + }, + mint_close_authority::MintCloseAuthority, + pausable::{instruction::initialize as initialize_pausable, PausableConfig}, + permanent_delegate::PermanentDelegate, + transfer_fee::{instruction::initialize_transfer_fee_config, TransferFeeConfig}, + transfer_hook::{instruction::initialize as initialize_transfer_hook, TransferHook}, + BaseStateWithExtensions, ExtensionType, StateWithExtensions, + }, + instruction::{ + initialize_mint, initialize_mint_close_authority, initialize_permanent_delegate, + }, + solana_zk_sdk::encryption::pod::elgamal::PodElGamalPubkey, + state::{AccountState, Mint}, +}; + +/// Configuration returned after creating a Token 2022 mint with extensions. +/// Contains the mint pubkey and all the authorities for the various extensions. +#[derive(Debug, Clone)] +pub struct Token22ExtensionConfig { + /// The mint pubkey + pub mint: Pubkey, + /// The token pool PDA for compressed tokens + pub token_pool: Pubkey, + /// Authority that can close the mint account + pub close_authority: Pubkey, + /// Authority that can update transfer fee configuration + pub transfer_fee_config_authority: Pubkey, + /// Authority that can withdraw withheld transfer fees + pub withdraw_withheld_authority: Pubkey, + /// Permanent delegate that can transfer/burn any tokens + pub permanent_delegate: Pubkey, + /// Authority that can update metadata + pub metadata_update_authority: Pubkey, + /// Authority that can pause/unpause the mint + pub pause_authority: Pubkey, + /// Authority for confidential transfer configuration + pub confidential_transfer_authority: Pubkey, + /// Authority for confidential transfer fee withdraw + pub confidential_transfer_fee_authority: Pubkey, + /// Whether the mint has DefaultAccountState set to Frozen + pub default_account_state_frozen: bool, +} + +/// Creates a Token 2022 mint with all supported extensions initialized. +/// +/// The following extensions are initialized: +/// - Mint close authority +/// - Transfer fees (set to zero) +/// - Default account state (set to Initialized) +/// - Permanent delegate +/// - Transfer hook (set to nil program id) +/// - Metadata pointer (points to mint itself) +/// - Pausable +/// - Confidential transfers (initialized, not enabled) +/// - Confidential transfer fee (set to zero) +/// +/// Note: Confidential mint/burn requires additional setup after mint initialization +/// and is not included in this helper. +/// +/// # Arguments +/// * `rpc` - RPC client +/// * `payer` - Transaction fee payer and authority for all extensions +/// * `decimals` - Token decimals +/// +/// # Returns +/// A tuple of (mint_keypair, extension_config) +pub async fn create_mint_22_with_extensions( + rpc: &mut R, + payer: &Keypair, + decimals: u8, +) -> (Keypair, Token22ExtensionConfig) { + let mint_keypair = Keypair::new(); + let mint_pubkey = mint_keypair.pubkey(); + let authority = payer.pubkey(); + + // Define all extensions we want to initialize + let extension_types = [ + ExtensionType::MintCloseAuthority, + ExtensionType::TransferFeeConfig, + ExtensionType::DefaultAccountState, + ExtensionType::PermanentDelegate, + ExtensionType::TransferHook, + ExtensionType::MetadataPointer, + ExtensionType::Pausable, + ExtensionType::ConfidentialTransferMint, + ExtensionType::ConfidentialTransferFeeConfig, + ]; + + // Calculate the account size needed for all extensions + let mint_len = ExtensionType::try_calculate_account_len::(&extension_types).unwrap(); + + let rent = rpc + .get_minimum_balance_for_rent_exemption(mint_len) + .await + .unwrap(); + + // Create the mint account + let create_account_ix = create_account_instruction( + &authority, + mint_len, + rent, + &spl_token_2022::ID, + Some(&mint_keypair), + ); + + // Initialize extensions in the correct order (before initialize_mint) + // Order matters - some extensions must be initialized before others + + // 1. Mint close authority + let init_close_authority_ix = + initialize_mint_close_authority(&spl_token_2022::ID, &mint_pubkey, Some(&authority)) + .unwrap(); + + // 2. Transfer fee config (fees set to zero) + let init_transfer_fee_ix = initialize_transfer_fee_config( + &spl_token_2022::ID, + &mint_pubkey, + Some(&authority), // transfer_fee_config_authority + Some(&authority), // withdraw_withheld_authority + 0, // fee_basis_points (0 = no fee) + 0, // max_fee (0 = no max) + ) + .unwrap(); + + // 3. Default account state (Initialized - not frozen by default) + let init_default_state_ix = initialize_default_account_state( + &spl_token_2022::ID, + &mint_pubkey, + &AccountState::Initialized, + ) + .unwrap(); + + // 4. Permanent delegate + let init_permanent_delegate_ix = + initialize_permanent_delegate(&spl_token_2022::ID, &mint_pubkey, &authority).unwrap(); + + // 5. Transfer hook (nil program - no hook) + let init_transfer_hook_ix = initialize_transfer_hook( + &spl_token_2022::ID, + &mint_pubkey, + Some(authority), + None, // No transfer hook program + ) + .unwrap(); + + // 6. Metadata pointer (points to mint itself for embedded metadata) + let init_metadata_pointer_ix = initialize_metadata_pointer( + &spl_token_2022::ID, + &mint_pubkey, + Some(authority), // authority + Some(mint_pubkey), // metadata address (self-referential) + ) + .unwrap(); + + // 7. Pausable + let init_pausable_ix = + initialize_pausable(&spl_token_2022::ID, &mint_pubkey, &authority).unwrap(); + + // 8. Confidential transfer mint (initialized but not auto-approve, no auditor) + let init_confidential_transfer_ix = initialize_confidential_transfer_mint( + &spl_token_2022::ID, + &mint_pubkey, + Some(authority), // authority + false, // auto_approve_new_accounts + None, // auditor_elgamal_pubkey (none) + ) + .unwrap(); + + // 9. Confidential transfer fee config (fees set to zero, no authority) + // Using zeroed ElGamal pubkey since we're not enabling confidential fees + let init_confidential_fee_ix = initialize_confidential_transfer_fee_config( + &spl_token_2022::ID, + &mint_pubkey, + Some(authority), // authority + &PodElGamalPubkey::default(), // zeroed withdraw_withheld_authority_elgamal_pubkey + ) + .unwrap(); + + // 10. Initialize mint (must come after extension inits) + let init_mint_ix = initialize_mint( + &spl_token_2022::ID, + &mint_pubkey, + &authority, // mint_authority + Some(&authority), // freeze_authority (required for DefaultAccountState) + decimals, + ) + .unwrap(); + + // 11. Create token pool for compressed tokens + let token_pool_pubkey = get_token_pool_pda(&mint_pubkey); + let create_token_pool_ix = create_create_token_pool_instruction(&authority, &mint_pubkey, true); + + // Combine all instructions + let instructions: Vec = vec![ + create_account_ix, + init_close_authority_ix, + init_transfer_fee_ix, + init_default_state_ix, + init_permanent_delegate_ix, + init_transfer_hook_ix, + init_metadata_pointer_ix, + init_pausable_ix, + init_confidential_transfer_ix, + init_confidential_fee_ix, + init_mint_ix, + create_token_pool_ix, + ]; + + // Send transaction + rpc.create_and_send_transaction(&instructions, &authority, &[payer, &mint_keypair]) + .await + .unwrap(); + + let config = Token22ExtensionConfig { + mint: mint_pubkey, + token_pool: token_pool_pubkey, + close_authority: authority, + transfer_fee_config_authority: authority, + withdraw_withheld_authority: authority, + permanent_delegate: authority, + metadata_update_authority: authority, + pause_authority: authority, + confidential_transfer_authority: authority, + confidential_transfer_fee_authority: authority, + default_account_state_frozen: false, + }; + + (mint_keypair, config) +} + +/// Creates a Token 2022 mint with DefaultAccountState set to Frozen. +/// This creates a minimal mint with only the extensions needed for testing frozen default state. +/// +/// Extensions initialized: +/// - DefaultAccountState (Frozen) +/// - PermanentDelegate (required for frozen accounts - allows transfers by delegate) +/// - Pausable (for testing pausable + frozen combination) +/// +/// # Arguments +/// * `rpc` - RPC client +/// * `payer` - Transaction fee payer and authority for all extensions +/// * `decimals` - Token decimals +/// +/// # Returns +/// A tuple of (mint_keypair, extension_config) +pub async fn create_mint_22_with_frozen_default_state( + rpc: &mut R, + payer: &Keypair, + decimals: u8, +) -> (Keypair, Token22ExtensionConfig) { + let mint_keypair = Keypair::new(); + let mint_pubkey = mint_keypair.pubkey(); + let authority = payer.pubkey(); + + // Extensions for frozen default state testing + let extension_types = [ + ExtensionType::DefaultAccountState, + ExtensionType::PermanentDelegate, + ExtensionType::Pausable, + ]; + + let mint_len = ExtensionType::try_calculate_account_len::(&extension_types).unwrap(); + + let rent = rpc + .get_minimum_balance_for_rent_exemption(mint_len) + .await + .unwrap(); + + let create_account_ix = create_account_instruction( + &authority, + mint_len, + rent, + &spl_token_2022::ID, + Some(&mint_keypair), + ); + + // 1. Default account state (Frozen) + let init_default_state_ix = + initialize_default_account_state(&spl_token_2022::ID, &mint_pubkey, &AccountState::Frozen) + .unwrap(); + + // 2. Permanent delegate (useful for frozen accounts) + let init_permanent_delegate_ix = + initialize_permanent_delegate(&spl_token_2022::ID, &mint_pubkey, &authority).unwrap(); + + // 3. Pausable + let init_pausable_ix = + initialize_pausable(&spl_token_2022::ID, &mint_pubkey, &authority).unwrap(); + + // 4. Initialize mint (freeze_authority required for DefaultAccountState) + let init_mint_ix = initialize_mint( + &spl_token_2022::ID, + &mint_pubkey, + &authority, // mint_authority + Some(&authority), // freeze_authority (required for DefaultAccountState) + decimals, + ) + .unwrap(); + + // 5. Create token pool for compressed tokens + let token_pool_pubkey = get_token_pool_pda(&mint_pubkey); + let create_token_pool_ix = create_create_token_pool_instruction(&authority, &mint_pubkey, true); + + let instructions: Vec = vec![ + create_account_ix, + init_default_state_ix, + init_permanent_delegate_ix, + init_pausable_ix, + init_mint_ix, + create_token_pool_ix, + ]; + + rpc.create_and_send_transaction(&instructions, &authority, &[payer, &mint_keypair]) + .await + .unwrap(); + + let config = Token22ExtensionConfig { + mint: mint_pubkey, + token_pool: token_pool_pubkey, + close_authority: Pubkey::default(), + transfer_fee_config_authority: Pubkey::default(), + withdraw_withheld_authority: Pubkey::default(), + permanent_delegate: authority, + metadata_update_authority: Pubkey::default(), + pause_authority: authority, + confidential_transfer_authority: Pubkey::default(), + confidential_transfer_fee_authority: Pubkey::default(), + default_account_state_frozen: true, + }; + + (mint_keypair, config) +} + +/// Verifies that a mint has all expected extensions by reading the account data. +/// +/// # Arguments +/// * `rpc` - RPC client +/// * `mint` - The mint pubkey to verify +/// +/// # Returns +/// Ok(()) if all extensions are present, or an error message describing what's missing +pub async fn verify_mint_extensions(rpc: &mut R, mint: &Pubkey) -> Result<(), String> { + let account = rpc + .get_account(*mint) + .await + .map_err(|e| format!("Failed to get mint account: {:?}", e))? + .ok_or_else(|| "Mint account not found".to_string())?; + + let mint_state = StateWithExtensions::::unpack(&account.data) + .map_err(|e| format!("Failed to unpack mint: {:?}", e))?; + + // Verify each extension is present using concrete types + let mut missing = Vec::new(); + + if mint_state.get_extension::().is_err() { + missing.push("MintCloseAuthority"); + } + if mint_state.get_extension::().is_err() { + missing.push("TransferFeeConfig"); + } + if mint_state.get_extension::().is_err() { + missing.push("DefaultAccountState"); + } + if mint_state.get_extension::().is_err() { + missing.push("PermanentDelegate"); + } + if mint_state.get_extension::().is_err() { + missing.push("TransferHook"); + } + if mint_state.get_extension::().is_err() { + missing.push("MetadataPointer"); + } + if mint_state.get_extension::().is_err() { + missing.push("PausableConfig"); + } + if mint_state + .get_extension::() + .is_err() + { + missing.push("ConfidentialTransferMint"); + } + if mint_state + .get_extension::() + .is_err() + { + missing.push("ConfidentialTransferFeeConfig"); + } + + if missing.is_empty() { + Ok(()) + } else { + Err(format!("Missing extensions: {:?}", missing)) + } +} + +/// Creates a Token 2022 token account for the given mint. +/// +/// # Arguments +/// * `rpc` - RPC client +/// * `payer` - Transaction fee payer +/// * `mint` - The mint pubkey +/// * `owner` - The owner of the new token account +/// +/// # Returns +/// The pubkey of the created token account +pub async fn create_token_22_account( + rpc: &mut R, + payer: &Keypair, + mint: &Pubkey, + owner: &Pubkey, +) -> Pubkey { + use solana_system_interface::instruction as system_instruction; + + let token_account = Keypair::new(); + + // Get mint account to determine extensions needed for token account + let mint_account = rpc.get_account(*mint).await.unwrap().unwrap(); + let mint_state = StateWithExtensions::::unpack(&mint_account.data).unwrap(); + let mint_extensions = mint_state.get_extension_types().unwrap(); + + // Calculate token account size with required extensions + let account_len = ExtensionType::try_calculate_account_len::( + &mint_extensions, + ) + .unwrap(); + + let rent = rpc + .get_minimum_balance_for_rent_exemption(account_len) + .await + .unwrap(); + + // Create account instruction + let create_account_ix = system_instruction::create_account( + &payer.pubkey(), + &token_account.pubkey(), + rent, + account_len as u64, + &spl_token_2022::ID, + ); + + // Initialize token account + let init_account_ix = spl_token_2022::instruction::initialize_account3( + &spl_token_2022::ID, + &token_account.pubkey(), + mint, + owner, + ) + .unwrap(); + + rpc.create_and_send_transaction( + &[create_account_ix, init_account_ix], + &payer.pubkey(), + &[payer, &token_account], + ) + .await + .unwrap(); + + token_account.pubkey() +} + +/// Mints Token 2022 tokens to a token account. +/// +/// # Arguments +/// * `rpc` - RPC client +/// * `mint_authority` - The mint authority keypair (must sign) +/// * `mint` - The mint pubkey +/// * `token_account` - The destination token account +/// * `amount` - Amount to mint +pub async fn mint_spl_tokens_22( + rpc: &mut R, + mint_authority: &Keypair, + mint: &Pubkey, + token_account: &Pubkey, + amount: u64, +) { + let mint_to_ix = spl_token_2022::instruction::mint_to( + &spl_token_2022::ID, + mint, + token_account, + &mint_authority.pubkey(), + &[], + amount, + ) + .unwrap(); + + rpc.create_and_send_transaction(&[mint_to_ix], &mint_authority.pubkey(), &[mint_authority]) + .await + .unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extension_config_struct() { + // Basic struct test + let config = Token22ExtensionConfig { + mint: Pubkey::new_unique(), + token_pool: Pubkey::new_unique(), + close_authority: Pubkey::new_unique(), + transfer_fee_config_authority: Pubkey::new_unique(), + withdraw_withheld_authority: Pubkey::new_unique(), + permanent_delegate: Pubkey::new_unique(), + metadata_update_authority: Pubkey::new_unique(), + pause_authority: Pubkey::new_unique(), + confidential_transfer_authority: Pubkey::new_unique(), + confidential_transfer_fee_authority: Pubkey::new_unique(), + default_account_state_frozen: false, + }; + + assert_ne!(config.mint, config.close_authority); + } +} diff --git a/program-tests/utils/src/spl.rs b/program-tests/utils/src/spl.rs index 6780f1e5f5..8c07797f6b 100644 --- a/program-tests/utils/src/spl.rs +++ b/program-tests/utils/src/spl.rs @@ -1,4 +1,7 @@ use anchor_spl::token::{Mint, TokenAccount}; + +/// Default decimals used by `create_mint_helper` and related functions +pub const CREATE_MINT_HELPER_DECIMALS: u8 = 2; use forester_utils::instructions::create_account::create_account_instruction; use light_client::{ fee::TransactionParams, @@ -273,8 +276,13 @@ pub async fn create_mint_helper_with_keypair( .await .unwrap(); - let (instructions, pool) = - create_initialize_mint_instructions(&payer_pubkey, &payer_pubkey, rent, 2, mint); + let (instructions, pool) = create_initialize_mint_instructions( + &payer_pubkey, + &payer_pubkey, + rent, + CREATE_MINT_HELPER_DECIMALS, + mint, + ); let _ = rpc .create_and_send_transaction(&instructions, &payer_pubkey, &[payer, mint]) diff --git a/program-tests/zero-copy-derive-test/tests/instruction_data.rs b/program-tests/zero-copy-derive-test/tests/instruction_data.rs index 9518cd9bac..5f0fbfbdac 100644 --- a/program-tests/zero-copy-derive-test/tests/instruction_data.rs +++ b/program-tests/zero-copy-derive-test/tests/instruction_data.rs @@ -1292,3 +1292,62 @@ impl PartialEq for ZInstructionDataInvokeCpi<'_> { other.eq(self) } } + +/// Unit struct for testing ZeroCopyNew derive on empty structs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(C)] +#[derive(ZeroCopy, ZeroCopyMut)] +pub struct UnitStruct; + +#[test] +fn test_unit_struct_zero_copy_new() { + use light_zero_copy::traits::ZeroCopyNew; + + // Test byte_len returns 0 for unit struct + let byte_len = UnitStruct::byte_len(&()).unwrap(); + assert_eq!(byte_len, 0, "Unit struct should have byte_len of 0"); + + // Test new_zero_copy works with empty buffer + let mut bytes: [u8; 0] = []; + let (result, remaining) = UnitStruct::new_zero_copy(&mut bytes, ()).unwrap(); + + // Verify remaining bytes is empty (we consumed nothing) + assert_eq!(remaining.len(), 0, "Should have no remaining bytes"); + + // Verify we got a valid reference to the unit struct + assert_eq!(*result, UnitStruct, "Should get UnitStruct reference"); + + // Test new_zero_copy also works with non-empty buffer (should leave all bytes) + let mut bytes_with_extra = [1u8, 2, 3, 4]; + let (result2, remaining2) = UnitStruct::new_zero_copy(&mut bytes_with_extra, ()).unwrap(); + + // Verify all bytes remain (unit struct consumes 0 bytes) + assert_eq!( + remaining2.len(), + 4, + "Should have all 4 bytes remaining after unit struct" + ); + assert_eq!(*result2, UnitStruct, "Should get UnitStruct reference"); +} + +#[test] +fn test_unit_struct_zero_copy_at() { + // Test ZeroCopyAt for unit struct + let bytes: [u8; 4] = [1, 2, 3, 4]; + let (result, remaining) = UnitStruct::zero_copy_at(&bytes).unwrap(); + + // Unit struct consumes 0 bytes + assert_eq!(remaining.len(), 4, "Should have all bytes remaining"); + assert_eq!(*result, UnitStruct, "Should get UnitStruct reference"); +} + +#[test] +fn test_unit_struct_zero_copy_at_mut() { + // Test ZeroCopyAtMut for unit struct + let mut bytes: [u8; 4] = [1, 2, 3, 4]; + let (result, remaining) = UnitStruct::zero_copy_at_mut(&mut bytes).unwrap(); + + // Unit struct consumes 0 bytes + assert_eq!(remaining.len(), 4, "Should have all bytes remaining"); + assert_eq!(*result, UnitStruct, "Should get UnitStruct reference"); +} diff --git a/program-tests/zero-copy-derive-test/tests/ui/fail/01_empty_struct.stderr b/program-tests/zero-copy-derive-test/tests/ui/fail/01_empty_struct.stderr deleted file mode 100644 index f3d5bf094b..0000000000 --- a/program-tests/zero-copy-derive-test/tests/ui/fail/01_empty_struct.stderr +++ /dev/null @@ -1,15 +0,0 @@ -error: ZeroCopy only supports structs with named fields - --> tests/ui/fail/01_empty_struct.rs:5:10 - | -5 | #[derive(ZeroCopy, ZeroCopyMut)] - | ^^^^^^^^ - | - = note: this error originates in the derive macro `ZeroCopy` (in Nightly builds, run with -Z macro-backtrace for more info) - -error: ZeroCopy only supports structs with named fields - --> tests/ui/fail/01_empty_struct.rs:5:20 - | -5 | #[derive(ZeroCopy, ZeroCopyMut)] - | ^^^^^^^^^^^ - | - = note: this error originates in the derive macro `ZeroCopyMut` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/program-tests/zero-copy-derive-test/tests/ui/fail/01_empty_struct.rs b/program-tests/zero-copy-derive-test/tests/ui/pass/01_empty_struct.rs similarity index 100% rename from program-tests/zero-copy-derive-test/tests/ui/fail/01_empty_struct.rs rename to program-tests/zero-copy-derive-test/tests/ui/pass/01_empty_struct.rs diff --git a/programs/compressed-token/anchor/src/instructions/create_token_pool.rs b/programs/compressed-token/anchor/src/instructions/create_token_pool.rs index 325f33cbf3..b263990dc1 100644 --- a/programs/compressed-token/anchor/src/instructions/create_token_pool.rs +++ b/programs/compressed-token/anchor/src/instructions/create_token_pool.rs @@ -1,8 +1,11 @@ use account_compression::utils::constants::CPI_AUTHORITY_PDA_SEED; use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use anchor_spl::token_interface::{TokenAccount, TokenInterface}; use spl_token_2022::{ - extension::{BaseStateWithExtensions, ExtensionType, PodStateWithExtensions}, + extension::{ + transfer_fee::TransferFeeConfig, transfer_hook::TransferHook, BaseStateWithExtensions, + ExtensionType, PodStateWithExtensions, + }, pod::PodMint, }; @@ -12,32 +15,75 @@ use crate::{ }; /// Creates an SPL or token-2022 token pool account, which is owned by the token authority PDA. +/// We use manual token account initialization via CPI instead of Anchor's `token::mint` constraint +/// because Anchor's constraint internally deserializes the mint account, which fails for Token 2022 +/// mints with variable-length extensions like ConfidentialTransferMint. #[derive(Accounts)] pub struct CreateTokenPoolInstruction<'info> { /// UNCHECKED: only pays fees. #[account(mut)] pub fee_payer: Signer<'info>, + /// CHECK: Token pool account. Initialized manually via CPI because Anchor's token::mint + /// constraint cannot handle Token 2022 mints with variable-length extensions. #[account( init, - seeds = [ - POOL_SEED, &mint.key().to_bytes(), - ], + seeds = [POOL_SEED, &mint.key().to_bytes()], bump, payer = fee_payer, - token::mint = mint, - token::authority = cpi_authority_pda, + space = get_token_account_space(&mint)?, + owner = token_program.key(), )] - pub token_pool_pda: InterfaceAccount<'info, TokenAccount>, + pub token_pool_pda: AccountInfo<'info>, pub system_program: Program<'info, System>, - /// CHECK: is mint account. - #[account(mut)] - pub mint: InterfaceAccount<'info, Mint>, + /// CHECK: Mint account. We use AccountInfo instead of InterfaceAccount because + /// Anchor's InterfaceAccount cannot deserialize Token 2022 mints with variable-length + /// extensions like ConfidentialTransferMint. The mint is validated manually using + /// PodStateWithExtensions::unpack() in assert_mint_extensions(). + #[account(owner = token_program.key())] + pub mint: AccountInfo<'info>, pub token_program: Interface<'info, TokenInterface>, /// CHECK: (seeds anchor constraint). #[account(seeds = [CPI_AUTHORITY_PDA_SEED], bump)] pub cpi_authority_pda: AccountInfo<'info>, } +/// Calculates the space needed for a token account based on the mint's extensions. +/// Uses `get_required_init_account_extensions` to map mint extensions to required token account extensions. +pub fn get_token_account_space(mint: &AccountInfo) -> Result { + let mint_data = mint.try_borrow_data()?; + let mint_state = PodStateWithExtensions::::unpack(&mint_data) + .map_err(|_| crate::ErrorCode::InvalidMint)?; + let mint_extensions = mint_state.get_extension_types().unwrap_or_default(); + let account_extensions = ExtensionType::get_required_init_account_extensions(&mint_extensions); + ExtensionType::try_calculate_account_len::(&account_extensions) + .map_err(|_| crate::ErrorCode::InvalidMint.into()) +} + +/// Initializes a token account via CPI to the token program. +pub fn initialize_token_account<'info>( + token_account: &AccountInfo<'info>, + mint: &AccountInfo<'info>, + authority: &AccountInfo<'info>, + token_program: &AccountInfo<'info>, +) -> Result<()> { + let ix = spl_token_2022::instruction::initialize_account3( + token_program.key, + token_account.key, + mint.key, + authority.key, + )?; + anchor_lang::solana_program::program::invoke( + &ix, + &[ + token_account.clone(), + mint.clone(), + authority.clone(), + token_program.clone(), + ], + )?; + Ok(()) +} + pub fn get_token_pool_pda(mint: &Pubkey) -> Pubkey { get_token_pool_pda_with_index(mint, 0) } @@ -56,51 +102,105 @@ pub fn get_token_pool_pda_with_index(mint: &Pubkey, token_pool_index: u8) -> Pub find_token_pool_pda_with_index(mint, token_pool_index).0 } -const ALLOWED_EXTENSION_TYPES: [ExtensionType; 7] = [ +/// Allowed mint extension types for CToken accounts. +/// Extensions not in this list will cause account creation to fail. +/// +/// Runtime constraints enforced by check_mint_extensions(): +/// - TransferFeeConfig: fees must be zero +/// - DefaultAccountState: any state allowed (Initialized or Frozen) +/// - TransferHook: program_id must be nil (no hook execution) +/// - ConfidentialTransferMint: initialized but not enabled +/// - ConfidentialMintBurn: initialized but not enabled +/// - ConfidentialTransferFeeConfig: fees must be zero +pub const ALLOWED_EXTENSION_TYPES: [ExtensionType; 16] = [ + // Metadata extensions ExtensionType::MetadataPointer, ExtensionType::TokenMetadata, + // Group extensions ExtensionType::InterestBearingConfig, ExtensionType::GroupPointer, ExtensionType::GroupMemberPointer, ExtensionType::TokenGroup, ExtensionType::TokenGroupMember, + // Token 2022 extensions with runtime constraints + ExtensionType::MintCloseAuthority, + ExtensionType::TransferFeeConfig, + ExtensionType::DefaultAccountState, + ExtensionType::PermanentDelegate, + ExtensionType::TransferHook, + ExtensionType::Pausable, + ExtensionType::ConfidentialTransferMint, + ExtensionType::ConfidentialTransferFeeConfig, + ExtensionType::ConfidentialMintBurn, ]; pub fn assert_mint_extensions(account_data: &[u8]) -> Result<()> { - let mint = PodStateWithExtensions::::unpack(account_data).unwrap(); - let mint_extensions = mint.get_extension_types().unwrap(); + let mint = PodStateWithExtensions::::unpack(account_data) + .map_err(|_| crate::ErrorCode::InvalidMint)?; + let mint_extensions = mint.get_extension_types().unwrap_or_default(); + + // Check all extensions are in the allowed list if !mint_extensions .iter() .all(|item| ALLOWED_EXTENSION_TYPES.contains(item)) { return err!(crate::ErrorCode::MintWithInvalidExtension); } + + // TransferFeeConfig: fees must be zero + if let Ok(transfer_fee_config) = mint.get_extension::() { + let older_fee = &transfer_fee_config.older_transfer_fee; + let newer_fee = &transfer_fee_config.newer_transfer_fee; + if u16::from(older_fee.transfer_fee_basis_points) != 0 + || u64::from(older_fee.maximum_fee) != 0 + || u16::from(newer_fee.transfer_fee_basis_points) != 0 + || u64::from(newer_fee.maximum_fee) != 0 + { + return err!(crate::ErrorCode::NonZeroTransferFeeNotSupported); + } + } + + // TransferHook: program_id must be nil + if let Ok(transfer_hook) = mint.get_extension::() { + if Option::::from(transfer_hook.program_id) + .is_some() + { + return err!(crate::ErrorCode::TransferHookNotSupported); + } + } + Ok(()) } -/// Creates an SPL or token-2022 token pool account, which is owned by the token authority PDA. +/// Creates an additional SPL or token-2022 token pool account, which is owned by the token authority PDA. +/// We use manual token account initialization via CPI instead of Anchor's `token::mint` constraint +/// because Anchor's constraint internally deserializes the mint account, which fails for Token 2022 +/// mints with variable-length extensions like ConfidentialTransferMint. #[derive(Accounts)] #[instruction(token_pool_index: u8)] pub struct AddTokenPoolInstruction<'info> { /// UNCHECKED: only pays fees. #[account(mut)] pub fee_payer: Signer<'info>, + /// CHECK: Token pool account. Initialized manually via CPI because Anchor's token::mint + /// constraint cannot handle Token 2022 mints with variable-length extensions. #[account( init, - seeds = [ - POOL_SEED, &mint.key().to_bytes(), &[token_pool_index], - ], + seeds = [POOL_SEED, &mint.key().to_bytes(), &[token_pool_index]], bump, payer = fee_payer, - token::mint = mint, - token::authority = cpi_authority_pda, + space = get_token_account_space(&mint)?, + owner = token_program.key(), )] - pub token_pool_pda: InterfaceAccount<'info, TokenAccount>, + pub token_pool_pda: AccountInfo<'info>, pub existing_token_pool_pda: InterfaceAccount<'info, TokenAccount>, pub system_program: Program<'info, System>, - /// CHECK: is mint account. - #[account(mut)] - pub mint: InterfaceAccount<'info, Mint>, + /// CHECK: Mint account. We use AccountInfo instead of InterfaceAccount because + /// Anchor's InterfaceAccount cannot deserialize Token 2022 mints with variable-length + /// extensions like ConfidentialTransferMint. The mint is validated manually using + /// PodStateWithExtensions::unpack() in assert_mint_extensions(). + #[account(owner = token_program.key())] + pub mint: AccountInfo<'info>, pub token_program: Interface<'info, TokenInterface>, /// CHECK: (seeds anchor constraint). #[account(seeds = [CPI_AUTHORITY_PDA_SEED], bump)] diff --git a/programs/compressed-token/anchor/src/lib.rs b/programs/compressed-token/anchor/src/lib.rs index b442ca4b55..093c42ce9f 100644 --- a/programs/compressed-token/anchor/src/lib.rs +++ b/programs/compressed-token/anchor/src/lib.rs @@ -51,6 +51,13 @@ pub mod light_compressed_token { ) -> Result<()> { instructions::create_token_pool::assert_mint_extensions( &ctx.accounts.mint.to_account_info().try_borrow_data()?, + )?; + // Initialize the token account via CPI (Anchor's init constraint only allocated space) + instructions::create_token_pool::initialize_token_account( + &ctx.accounts.token_pool_pda, + &ctx.accounts.mint, + &ctx.accounts.cpi_authority_pda, + &ctx.accounts.token_program.to_account_info(), ) } @@ -68,6 +75,13 @@ pub mod light_compressed_token { &ctx.accounts.mint.key().to_bytes(), &ctx.accounts.existing_token_pool_pda.key(), &[token_pool_index.saturating_sub(1)], + )?; + // Initialize the token account via CPI (Anchor's init constraint only allocated space) + instructions::create_token_pool::initialize_token_account( + &ctx.accounts.token_pool_pda, + &ctx.accounts.mint, + &ctx.accounts.cpi_authority_pda, + &ctx.accounts.token_program.to_account_info(), ) } @@ -428,11 +442,54 @@ pub enum ErrorCode { "CompressAndClose by compression authority requires compressed token account in outputs" )] CompressAndCloseOutputMissing, + #[msg("Invalid mint account data")] + InvalidMint, + #[msg("Token operations blocked - mint is paused")] + MintPaused, + #[msg("Mint account required for transfer when account has PausableAccount extension")] + MintRequiredForTransfer, + #[msg("Non-zero transfer fees are not supported")] + NonZeroTransferFeeNotSupported, + #[msg("Transfer hooks with non-nil program_id are not supported")] + TransferHookNotSupported, + #[msg("Mint has extensions that require compression_only mode")] + CompressionOnlyRequired, + #[msg("CompressAndClose: Compressed token mint does not match source token account mint")] + CompressAndCloseInvalidMint, + #[msg("CompressAndClose: Missing required CompressedOnly extension in output TLV")] + CompressAndCloseMissingCompressedOnlyExtension, + #[msg("CompressAndClose: CompressedOnly mint_account_index must be 0")] + CompressAndCloseInvalidMintAccountIndex, + #[msg( + "CompressAndClose: Delegated amount mismatch between ctoken and CompressedOnly extension" + )] + CompressAndCloseDelegatedAmountMismatch, + #[msg("CompressAndClose: Delegate mismatch between ctoken and compressed token output")] + CompressAndCloseInvalidDelegate, + #[msg("CompressAndClose: Withheld transfer fee mismatch")] + CompressAndCloseWithheldFeeMismatch, + #[msg("CompressAndClose: Frozen state mismatch")] + CompressAndCloseFrozenMismatch, + #[msg("TLV extensions require version 3 (ShaFlat)")] + TlvRequiresVersion3, + #[msg("CToken account has extensions that cannot be compressed. Only Compressible extension or no extensions allowed.")] + CTokenHasDisallowedExtensions, + #[msg("CompressAndClose: rent_sponsor_is_signer flag does not match actual signer")] + RentSponsorIsSignerMismatch, + #[msg("Mint has restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) must not create compressed token accounts.")] + MintHasRestrictedExtensions, + #[msg("Decompress: CToken delegate does not match input compressed account delegate")] + DecompressDelegateMismatch, + #[msg("Mint cache capacity exceeded (max 5 unique mints)")] + MintCacheCapacityExceeded, } +/// Anchor error code offset - error codes start at 6000 +pub const ERROR_CODE_OFFSET: u32 = 6000; + impl From for ProgramError { fn from(e: ErrorCode) -> Self { - ProgramError::Custom(e as u32) + ProgramError::Custom(ERROR_CODE_OFFSET + e as u32) } } diff --git a/programs/compressed-token/program/Cargo.toml b/programs/compressed-token/program/Cargo.toml index 672b979bf6..171c2371b0 100644 --- a/programs/compressed-token/program/Cargo.toml +++ b/programs/compressed-token/program/Cargo.toml @@ -36,9 +36,6 @@ cpi-without-program-ids = [] [dependencies] light-program-profiler = { workspace = true } -light-token-22 = { package = "spl-token-2022", git = "https://github.com/Lightprotocol/token-2022", rev = "06d12f50a06db25d73857d253b9a82857d6f4cdf", features = [ - "no-entrypoint", -] } anchor-lang = { workspace = true } spl-token = { workspace = true, features = ["no-entrypoint"] } account-compression = { workspace = true, features = ["cpi", "no-idl"] } diff --git a/programs/compressed-token/program/src/claim.rs b/programs/compressed-token/program/src/claim.rs index 5a6c1d18eb..11956028c5 100644 --- a/programs/compressed-token/program/src/claim.rs +++ b/programs/compressed-token/program/src/claim.rs @@ -111,6 +111,7 @@ fn validate_and_claim( for extension in extensions { if let ZExtensionStructMut::Compressible(compressible_ext) = extension { return compressible_ext + .info .claim_and_update(ClaimAndUpdate { compression_authority: accounts.compression_authority.key(), rent_sponsor: accounts.rent_sponsor.key(), diff --git a/programs/compressed-token/program/src/close_token_account/processor.rs b/programs/compressed-token/program/src/close_token_account/processor.rs index 829b045686..aa4343fac7 100644 --- a/programs/compressed-token/program/src/close_token_account/processor.rs +++ b/programs/compressed-token/program/src/close_token_account/processor.rs @@ -2,13 +2,12 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::{checks::check_signer, AccountInfoTrait}; use light_compressible::rent::{get_rent_exemption_lamports, AccountRentState}; -use light_ctoken_types::state::{CToken, ZCompressedTokenMut, ZExtensionStructMut}; +use light_ctoken_types::state::{AccountState, CToken, ZCompressedTokenMut, ZExtensionStructMut}; use light_program_profiler::profile; #[cfg(target_os = "solana")] use pinocchio::sysvars::Sysvar; use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use spl_pod::solana_msg::msg; -use spl_token_2022::state::AccountState; use super::accounts::CloseTokenAccountAccounts; use crate::shared::{convert_program_error, transfer_lamports}; @@ -62,12 +61,6 @@ fn validate_token_account( return Err(ProgramError::InvalidAccountData); } - // Check account state - reject frozen and uninitialized - match *ctoken.state { - state if state == AccountState::Initialized as u8 => {} // OK to proceed - state if state == AccountState::Frozen as u8 => return Err(ErrorCode::AccountFrozen.into()), - _ => return Err(ProgramError::UninitializedAccount), - } // For compress and close we compress the balance and close. if !COMPRESS_AND_CLOSE { // Check that the account has zero balance @@ -83,14 +76,14 @@ fn validate_token_account( let rent_sponsor = accounts .rent_sponsor .ok_or(ProgramError::NotEnoughAccountKeys)?; - if compressible_ext.rent_sponsor != *rent_sponsor.key() { + if compressible_ext.info.rent_sponsor != *rent_sponsor.key() { msg!("rent recipient mismatch"); return Err(ProgramError::InvalidAccountData); } if COMPRESS_AND_CLOSE { // For CompressAndClose: ONLY compression_authority can compress and close - if compressible_ext.compression_authority != *accounts.authority.key() { + if compressible_ext.info.compression_authority != *accounts.authority.key() { msg!("compress and close requires compression authority"); return Err(ProgramError::InvalidAccountData); } @@ -103,6 +96,7 @@ fn validate_token_account( #[cfg(target_os = "solana")] { let is_compressible = compressible_ext + .info .is_compressible( accounts.token_account.data_len() as u64, current_slot, @@ -116,19 +110,25 @@ fn validate_token_account( } } - return Ok(compressible_ext.compress_to_pubkey()); + return Ok(compressible_ext.info.compress_to_pubkey()); } // For regular close (!COMPRESS_AND_CLOSE): fall through to owner check } } } - // CompressAndClose requires Compressible extension - if we reach here without returning, reject if COMPRESS_AND_CLOSE { msg!("compress and close requires compressible extension"); return Err(ProgramError::InvalidAccountData); } + // Check account state - reject frozen and uninitialized + match *ctoken.state { + state if state == AccountState::Initialized as u8 => {} // OK to proceed + state if state == AccountState::Frozen as u8 => return Err(ErrorCode::AccountFrozen.into()), + _ => return Err(ProgramError::UninitializedAccount), + } + // For regular close: verify authority matches owner if !pubkey_eq(ctoken.owner.array_ref(), accounts.authority.key()) { msg!( @@ -171,7 +171,8 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( .slot; #[cfg(not(target_os = "solana"))] let current_slot = 0; - let compression_cost: u64 = compressible_ext.rent_config.compression_cost.into(); + let compression_cost: u64 = + compressible_ext.info.rent_config.compression_cost.into(); let (mut lamports_to_rent_sponsor, mut lamports_to_destination) = { let base_lamports = @@ -182,11 +183,13 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( num_bytes: accounts.token_account.data_len() as u64, current_slot, current_lamports: token_account_lamports, - last_claimed_slot: compressible_ext.last_claimed_slot.into(), + last_claimed_slot: compressible_ext.info.last_claimed_slot.into(), }; - let distribution = state - .calculate_close_distribution(&compressible_ext.rent_config, base_lamports); + let distribution = state.calculate_close_distribution( + &compressible_ext.info.rent_config, + base_lamports, + ); (distribution.to_rent_sponsor, distribution.to_user) }; @@ -194,7 +197,7 @@ pub fn distribute_lamports(accounts: &CloseTokenAccountAccounts<'_>) -> Result<( .rent_sponsor .ok_or(ProgramError::NotEnoughAccountKeys)?; - if accounts.authority.key() == &compressible_ext.compression_authority { + if accounts.authority.key() == &compressible_ext.info.compression_authority { // When compressing via compression_authority: // Extract compression incentive from rent_sponsor portion to give to forester // The compression incentive is included in lamports_to_rent_sponsor diff --git a/programs/compressed-token/program/src/create_associated_token_account.rs b/programs/compressed-token/program/src/create_associated_token_account.rs index dc19e6c5cd..e1831e143e 100644 --- a/programs/compressed-token/program/src/create_associated_token_account.rs +++ b/programs/compressed-token/program/src/create_associated_token_account.rs @@ -12,10 +12,11 @@ use spl_pod::solana_msg::msg; use crate::{ create_token_account::next_config_account, + extensions::{has_mint_extensions, MintExtensionFlags}, shared::{ convert_program_error, create_pda_account, - initialize_ctoken_account::initialize_ctoken_account, transfer_lamports_via_cpi, - validate_ata_derivation, + initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, + transfer_lamports_via_cpi, validate_ata_derivation, }, }; @@ -59,6 +60,7 @@ pub(crate) fn process_create_associated_token_account_with_mode, + // Optional mint account for checking pausable extension (used by create_ata2) + mint_account: Option<&AccountInfo>, ) -> Result<(), ProgramError> { let mut iter = AccountIterator::new(account_infos); @@ -93,12 +97,16 @@ pub(crate) fn process_create_associated_token_account_inner( mint.key(), instruction_inputs.bump, instruction_inputs.compressible_config, + Some(mint), // Pass mint account for pausable extension check ) } diff --git a/programs/compressed-token/program/src/create_token_account.rs b/programs/compressed-token/program/src/create_token_account.rs index a86e00f38c..9cc0437696 100644 --- a/programs/compressed-token/program/src/create_token_account.rs +++ b/programs/compressed-token/program/src/create_token_account.rs @@ -6,10 +6,7 @@ use light_account_checks::{ }; use light_compressed_account::Pubkey; use light_compressible::config::CompressibleConfig; -use light_ctoken_types::{ - instructions::create_ctoken_account::CreateTokenAccountInstructionData, - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, -}; +use light_ctoken_types::instructions::create_ctoken_account::CreateTokenAccountInstructionData; use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, @@ -19,9 +16,13 @@ use pinocchio::{ use pinocchio_system::instructions::CreateAccount; use spl_pod::{bytemuck, solana_msg::msg}; -use crate::shared::{ - convert_program_error, create_pda_account, - initialize_ctoken_account::initialize_ctoken_account, transfer_lamports_via_cpi, +use crate::{ + extensions::{has_mint_extensions, MintExtensionFlags}, + shared::{ + convert_program_error, create_pda_account, + initialize_ctoken_account::{initialize_ctoken_account, CTokenInitConfig}, + transfer_lamports_via_cpi, + }, }; /// Validated accounts for the create token account instruction @@ -153,7 +154,9 @@ pub fn process_create_token_account( let accounts = CreateCTokenAccounts::parse(account_infos, &inputs)?; // Create account via cpi - let (compressible_config_account, custom_rent_payer) = if let Some(compressible) = + let (compressible_config_account, custom_rent_payer, mint_extensions) = if let Some( + compressible, + ) = accounts.compressible.as_ref() { let compressible_config = inputs @@ -176,15 +179,30 @@ pub fn process_create_token_account( compress_to_pubkey.check_seeds(accounts.token_account.key())?; } + // Check which extensions the mint has (single deserialization) + let mint_extensions = has_mint_extensions(accounts.mint)?; + + // Check if mint has restricted extensions that require compression_only mode + let has_restricted_extensions = mint_extensions.has_pausable + || mint_extensions.has_permanent_delegate + || mint_extensions.has_transfer_fee + || mint_extensions.has_transfer_hook; + + // If restricted extensions exist, compression_only must be set + if has_restricted_extensions && compressible_config.compression_only == 0 { + msg!("Mint has restricted extensions - compression_only must be set"); + return Err(anchor_compressed_token::ErrorCode::CompressionOnlyRequired.into()); + } + + // Calculate account size based on extensions + let account_size = mint_extensions.calculate_account_size(true /* has_compressible */); + let config_account = &compressible.parsed_config; let rent = compressible .parsed_config .rent_config - .get_rent_with_compression_cost( - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, - compressible_config.rent_payment as u64, - ); - let account_size = COMPRESSIBLE_TOKEN_ACCOUNT_SIZE as usize; + .get_rent_with_compression_cost(account_size, compressible_config.rent_payment as u64); + let account_size = account_size as usize; let custom_rent_payer = *compressible.rent_payer.key() != config_account.rent_sponsor.to_bytes(); @@ -199,7 +217,11 @@ pub fn process_create_token_account( ) .map_err(convert_program_error)?; - (Some(*config_account), Some(*compressible.rent_payer.key())) + ( + Some(*config_account), + Some(*compressible.rent_payer.key()), + mint_extensions, + ) } else { // Rent recipient is fee payer for account creation -> pays rent exemption let version_bytes = config_account.version.to_le_bytes(); @@ -224,20 +246,23 @@ pub fn process_create_token_account( // Payer transfers the additional rent (compression incentive) transfer_lamports_via_cpi(rent, compressible.payer, accounts.token_account) .map_err(convert_program_error)?; - (Some(*config_account), None) + (Some(*config_account), None, mint_extensions) } } else { - (None, None) + (None, None, MintExtensionFlags::default()) }; // Initialize the token account (assumes account already exists and is owned by our program) initialize_ctoken_account( accounts.token_account, - accounts.mint.key(), - &inputs.owner.to_bytes(), - inputs.compressible_config, - compressible_config_account, - custom_rent_payer, + CTokenInitConfig { + mint: accounts.mint.key(), + owner: &inputs.owner.to_bytes(), + compressible: inputs.compressible_config, + compressible_config_account, + custom_rent_payer, + mint_extensions, + }, ) } diff --git a/programs/compressed-token/program/src/ctoken_approve_revoke.rs b/programs/compressed-token/program/src/ctoken_approve_revoke.rs new file mode 100644 index 0000000000..eafe25893b --- /dev/null +++ b/programs/compressed-token/program/src/ctoken_approve_revoke.rs @@ -0,0 +1,140 @@ +use anchor_lang::solana_program::program_error::ProgramError; +use light_ctoken_types::{state::CToken, CTokenError, BASE_TOKEN_ACCOUNT_SIZE}; +use pinocchio::account_info::AccountInfo; +use pinocchio_token_program::processor::{approve::process_approve, revoke::process_revoke}; + +use crate::{ + shared::{convert_program_error, transfer_lamports_via_cpi}, + transfer2::compression::ctoken::process_compressible_extension, +}; + +/// Account indices for approve instruction +const APPROVE_ACCOUNT_SOURCE: usize = 0; +const APPROVE_ACCOUNT_OWNER: usize = 2; // owner is payer for top-up + +/// Account indices for revoke instruction +const REVOKE_ACCOUNT_SOURCE: usize = 0; +const REVOKE_ACCOUNT_OWNER: usize = 1; // owner is payer for top-up + +/// Process CToken approve instruction. +/// Handles compressible extension top-up before delegating to pinocchio. +/// +/// Instruction data format (backwards compatible): +/// - 8 bytes: amount (legacy, no max_top_up enforcement) +/// - 10 bytes: amount + max_top_up (u16, 0 = no limit) +#[inline(always)] +pub fn process_ctoken_approve( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + let source = accounts + .get(APPROVE_ACCOUNT_SOURCE) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts + .get(APPROVE_ACCOUNT_OWNER) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + + // Parse max_top_up based on instruction data length (0 = no limit) + let max_top_up = match instruction_data.len() { + 8 => 0u16, // Legacy: no max_top_up + 10 => u16::from_le_bytes( + instruction_data[8..10] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + // Handle compressible top-up before pinocchio call + process_compressible_top_up(source, payer, max_top_up)?; + + // Only pass the first 8 bytes (amount) to the SPL approve processor + process_approve(accounts, &instruction_data[..8]).map_err(convert_program_error) +} + +/// Process CToken revoke instruction. +/// Handles compressible extension top-up before delegating to pinocchio. +/// +/// Instruction data format (backwards compatible): +/// - 0 bytes: legacy, no max_top_up enforcement +/// - 2 bytes: max_top_up (u16, 0 = no limit) +#[inline(always)] +pub fn process_ctoken_revoke( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), ProgramError> { + let source = accounts + .get(REVOKE_ACCOUNT_SOURCE) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let payer = accounts + .get(REVOKE_ACCOUNT_OWNER) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + + // Parse max_top_up based on instruction data length (0 = no limit) + let max_top_up = match instruction_data.len() { + 0 => 0u16, // Legacy: no max_top_up + 2 => u16::from_le_bytes( + instruction_data[0..2] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + ), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + // Handle compressible top-up before pinocchio call + process_compressible_top_up(source, payer, max_top_up)?; + + process_revoke(accounts).map_err(convert_program_error) +} + +/// Calculate and transfer compressible top-up for a single account. +/// +/// # Arguments +/// * `max_top_up` - Maximum lamports for top-up. Transaction fails if exceeded. (0 = no limit) +#[inline(always)] +fn process_compressible_top_up( + account: &AccountInfo, + payer: &AccountInfo, + max_top_up: u16, +) -> Result<(), ProgramError> { + // Fast path: base account with no extensions + if account.data_len() == BASE_TOKEN_ACCOUNT_SIZE as usize { + return Ok(()); + } + + // Borrow account data to get extensions + let mut account_data = account + .try_borrow_mut_data() + .map_err(convert_program_error)?; + let (ctoken, _) = CToken::zero_copy_at_mut_checked(&mut account_data)?; + + let mut current_slot = 0; + let mut transfer_amount = 0u64; + let mut lamports_budget = if max_top_up == 0 { + u64::MAX + } else { + (max_top_up as u64).saturating_add(1) + }; + + process_compressible_extension( + ctoken.extensions.as_deref(), + account, + &mut current_slot, + &mut transfer_amount, + &mut lamports_budget, + )?; + + // Drop borrow before CPI + drop(account_data); + + if transfer_amount > 0 { + // Check budget if max_top_up is set (non-zero) + if max_top_up != 0 && transfer_amount > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + transfer_lamports_via_cpi(transfer_amount, payer, account) + .map_err(convert_program_error)?; + } + + Ok(()) +} diff --git a/programs/compressed-token/program/src/ctoken_freeze_thaw.rs b/programs/compressed-token/program/src/ctoken_freeze_thaw.rs new file mode 100644 index 0000000000..a81c60091c --- /dev/null +++ b/programs/compressed-token/program/src/ctoken_freeze_thaw.rs @@ -0,0 +1,19 @@ +use anchor_lang::solana_program::program_error::ProgramError; +use pinocchio::account_info::AccountInfo; +use pinocchio_token_program::processor::{ + freeze_account::process_freeze_account, thaw_account::process_thaw_account, +}; + +/// Process CToken freeze account instruction. +/// Direct passthrough to pinocchio-token-program - no extension processing needed. +#[inline(always)] +pub fn process_ctoken_freeze_account(accounts: &[AccountInfo]) -> Result<(), ProgramError> { + process_freeze_account(accounts).map_err(|e| ProgramError::Custom(u64::from(e) as u32)) +} + +/// Process CToken thaw account instruction. +/// Direct passthrough to pinocchio-token-program - no extension processing needed. +#[inline(always)] +pub fn process_ctoken_thaw_account(accounts: &[AccountInfo]) -> Result<(), ProgramError> { + process_thaw_account(accounts).map_err(|e| ProgramError::Custom(u64::from(e) as u32)) +} diff --git a/programs/compressed-token/program/src/ctoken_transfer.rs b/programs/compressed-token/program/src/ctoken_transfer.rs index 6fdc489fba..493bb09380 100644 --- a/programs/compressed-token/program/src/ctoken_transfer.rs +++ b/programs/compressed-token/program/src/ctoken_transfer.rs @@ -1,17 +1,27 @@ +use anchor_compressed_token::ErrorCode; use anchor_lang::solana_program::{msg, program_error::ProgramError}; use light_ctoken_types::{ - state::{CToken, ZExtensionStruct}, + state::{CToken, ZExtensionStructMut}, CTokenError, }; use light_program_profiler::profile; -use pinocchio::account_info::AccountInfo; +use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq}; use pinocchio_token_program::processor::transfer::process_transfer; -use crate::shared::{ - convert_program_error, - transfer_lamports::{multi_transfer_lamports, Transfer}, +use crate::{ + extensions::{check_mint_extensions, MintExtensionChecks}, + shared::{ + convert_program_error, + transfer_lamports::{multi_transfer_lamports, Transfer}, + }, }; +/// Account indices for CToken transfer instruction +const ACCOUNT_SOURCE: usize = 0; +const ACCOUNT_DESTINATION: usize = 1; +const ACCOUNT_AUTHORITY: usize = 2; +const ACCOUNT_MINT: usize = 3; + /// Process ctoken transfer instruction /// /// Instruction data format (backwards compatible): @@ -48,91 +58,253 @@ pub fn process_ctoken_transfer( _ => return Err(ProgramError::InvalidInstructionData), }; + let signer_is_validated = process_extensions(accounts, max_top_up)?; + // Only pass the first 8 bytes (amount) to the SPL transfer processor - process_transfer(accounts, &instruction_data[..8]) - .map_err(|e| ProgramError::Custom(u64::from(e) as u32))?; - calculate_and_execute_top_up_transfers(accounts, max_top_up) + process_transfer(accounts, &instruction_data[..8], signer_is_validated) + .map_err(|e| ProgramError::Custom(u64::from(e) as u32)) +} + +/// Extension information detected from a single account deserialization +#[derive(Debug, Default)] +struct AccountExtensionInfo { + has_compressible: bool, + has_pausable: bool, + has_permanent_delegate: bool, + has_transfer_fee: bool, + has_transfer_hook: bool, + top_up_amount: u64, +} +impl AccountExtensionInfo { + fn t22_extensions_eq(&self, other: &Self) -> bool { + self.has_pausable == other.has_pausable + && self.has_permanent_delegate == other.has_permanent_delegate + && self.has_transfer_fee == other.has_transfer_fee + && self.has_transfer_hook == other.has_transfer_hook + } + + fn check_t22_extensions(&self, other: &Self) -> Result<(), ProgramError> { + if !self.t22_extensions_eq(other) { + Err(ProgramError::InvalidInstructionData) + } else { + Ok(()) + } + } } -/// Calculate and execute top-up transfers for compressible accounts +/// Process extensions (pausable check, permanent delegate validation, transfer fee withholding) +/// and calculate/execute top-up transfers. +/// Each account is deserialized exactly once. Mint is checked once if any account has extensions. /// /// # Arguments -/// * `accounts` - The account infos (source, dest, authority/payer) +/// * `accounts` - The account infos (source, dest, authority/payer, optional mint) /// * `max_top_up` - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) +/// +/// Returns: +/// - `Ok(true)` - Permanent delegate is validated as authority/signer, skip pinocchio validation +/// - `Ok(false)` - Use normal pinocchio owner/delegate validation #[inline(always)] #[profile] -fn calculate_and_execute_top_up_transfers( +fn process_extensions( accounts: &[pinocchio::account_info::AccountInfo], max_top_up: u16, -) -> Result<(), ProgramError> { - // Initialize transfers array with account references, amounts will be updated - let account0 = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - let account1 = accounts.get(1).ok_or(ProgramError::NotEnoughAccountKeys)?; - let mut transfers = [ - Transfer { - account: account0, - amount: 0, - }, - Transfer { - account: account1, - amount: 0, - }, - ]; +) -> Result { + let account0 = accounts + .get(ACCOUNT_SOURCE) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let account1 = accounts + .get(ACCOUNT_DESTINATION) + .ok_or(ProgramError::NotEnoughAccountKeys)?; let mut current_slot = 0; - // Initialize budget: +1 allows exact match (total == max_top_up) - let mut lamports_budget = (max_top_up as u64).saturating_add(1); - - // Calculate transfer amounts for accounts with compressible extensions - for transfer in transfers.iter_mut() { - if transfer.account.data_len() > light_ctoken_types::BASE_TOKEN_ACCOUNT_SIZE as usize { - let account_data = transfer - .account - .try_borrow_data() - .map_err(convert_program_error)?; - let (token, _) = CToken::zero_copy_at_checked(&account_data)?; - if let Some(extensions) = token.extensions.as_ref() { - for extension in extensions.iter() { - if let ZExtensionStruct::Compressible(compressible_extension) = extension { - if current_slot == 0 { - use pinocchio::sysvars::{clock::Clock, Sysvar}; - current_slot = Clock::get() - .map_err(|_| CTokenError::SysvarAccessError)? - .slot; - } - - transfer.amount = compressible_extension - .calculate_top_up_lamports( - transfer.account.data_len() as u64, - current_slot, - transfer.account.lamports(), - light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, - ) - .map_err(|_| CTokenError::InvalidAccountData)?; - - lamports_budget = lamports_budget.saturating_sub(transfer.amount); - } + + let (sender_info, signer_is_validated) = validate_sender(accounts, &mut current_slot)?; + + // Process recipient + let recipient_info = validate_recipient(account1, &mut current_slot)?; + // Sender and recipient must have matching T22 extension markers + sender_info.check_t22_extensions(&recipient_info)?; + + // Perform compressible top-up if needed + transfer_top_up( + accounts, + account0, + account1, + sender_info.top_up_amount, + recipient_info.top_up_amount, + max_top_up, + )?; + + Ok(signer_is_validated) +} + +fn transfer_top_up( + accounts: &[AccountInfo], + account0: &AccountInfo, + account1: &AccountInfo, + sender_top_up: u64, + recipient_top_up: u64, + max_top_up: u16, +) -> Result<(), ProgramError> { + if sender_top_up > 0 || recipient_top_up > 0 { + // Check budget if max_top_up is set (non-zero) + let total_top_up = sender_top_up.saturating_add(recipient_top_up); + if max_top_up != 0 && total_top_up > max_top_up as u64 { + return Err(CTokenError::MaxTopUpExceeded.into()); + } + + let payer = accounts + .get(ACCOUNT_AUTHORITY) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let transfers = [ + Transfer { + account: account0, + amount: sender_top_up, + }, + Transfer { + account: account1, + amount: recipient_top_up, + }, + ]; + multi_transfer_lamports(payer, &transfers).map_err(convert_program_error) + } else { + Ok(()) + } +} + +fn validate_sender( + accounts: &[AccountInfo], + current_slot: &mut u64, +) -> Result<(AccountExtensionInfo, bool), ProgramError> { + let account0 = accounts + .get(ACCOUNT_SOURCE) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + + // Process sender once + let sender_info = process_account_extensions(account0, current_slot)?; + + // Get mint checks if any account has extensions (single mint deserialization) + let mint_checks = if sender_info.has_pausable + || sender_info.has_permanent_delegate + || sender_info.has_transfer_fee + || sender_info.has_transfer_hook + { + let mint_account = accounts + .get(ACCOUNT_MINT) + .ok_or(ErrorCode::MintRequiredForTransfer)?; + Some(check_mint_extensions(mint_account, false)?) + } else { + None + }; + + // Validate permanent delegate for sender + let signer_is_validated = validate_permanent_delegate(mint_checks.as_ref(), accounts)?; + + Ok((sender_info, signer_is_validated)) +} + +#[inline(always)] +fn validate_recipient( + account: &AccountInfo, + current_slot: &mut u64, +) -> Result { + process_account_extensions(account, current_slot) +} + +/// Validate permanent delegate authority. +/// Returns true if authority is the permanent delegate and is a signer. +#[inline(always)] +fn validate_permanent_delegate( + mint_checks: Option<&MintExtensionChecks>, + accounts: &[AccountInfo], +) -> Result { + if let Some(checks) = mint_checks { + if let Some(permanent_delegate_pubkey) = checks.permanent_delegate { + let authority = accounts + .get(ACCOUNT_AUTHORITY) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + if pubkey_eq(authority.key(), &permanent_delegate_pubkey) { + if !authority.is_signer() { + return Err(ProgramError::MissingRequiredSignature); } - } else { - // Only Compressible extensions are implemented for ctoken accounts. - return Err(CTokenError::InvalidAccountData.into()); + return Ok(true); } } } - // Exit early in case none of the accounts is compressible. - if current_slot == 0 { - return Ok(()); + Ok(false) +} + +/// Process account extensions with mutable access. +/// Performs extension detection and compressible top-up calculation. +#[inline(always)] +#[profile] +fn process_account_extensions( + account: &AccountInfo, + current_slot: &mut u64, +) -> Result { + // Fast path: base account with no extensions + if account.data_len() == light_ctoken_types::BASE_TOKEN_ACCOUNT_SIZE as usize { + return Ok(AccountExtensionInfo::default()); } - if transfers[0].amount == 0 && transfers[1].amount == 0 { - return Ok(()); + let mut account_data = account + .try_borrow_mut_data() + .map_err(convert_program_error)?; + let (token, remaining) = CToken::zero_copy_at_mut_checked(&mut account_data)?; + if !remaining.is_empty() { + return Err(ProgramError::InvalidAccountData); } - // Check budget wasn't exhausted (0 means exceeded max_top_up) - if max_top_up != 0 && lamports_budget == 0 { - return Err(CTokenError::MaxTopUpExceeded.into()); + let extensions = token.extensions.ok_or(CTokenError::InvalidAccountData)?; + + let mut info = AccountExtensionInfo::default(); + + for extension in extensions { + match extension { + ZExtensionStructMut::Compressible(compressible_extension) => { + info.has_compressible = true; + // Get current slot for compressible top-up calculation + use pinocchio::sysvars::{clock::Clock, rent::Rent, Sysvar}; + if *current_slot == 0 { + *current_slot = Clock::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .slot; + } + + let rent_exemption = Rent::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .minimum_balance(account.data_len()); + + info.top_up_amount = compressible_extension + .info + .calculate_top_up_lamports( + account.data_len() as u64, + *current_slot, + account.lamports(), + rent_exemption, + ) + .map_err(|_| CTokenError::InvalidAccountData)?; + } + ZExtensionStructMut::PausableAccount(_) => { + info.has_pausable = true; + } + ZExtensionStructMut::PermanentDelegateAccount(_) => { + info.has_permanent_delegate = true; + } + ZExtensionStructMut::TransferFeeAccount(_transfer_fee_ext) => { + info.has_transfer_fee = true; + // Note: Non-zero transfer fees are rejected by check_mint_extensions, + // so no fee withholding is needed here. + } + ZExtensionStructMut::TransferHookAccount(_) => { + info.has_transfer_hook = true; + // No runtime logic needed - we only support nil program_id + } + // Placeholder and TokenMetadata variants are not valid for CToken accounts + _ => { + return Err(CTokenError::InvalidAccountData.into()); + } + } } - let payer = accounts.get(2).ok_or(ProgramError::NotEnoughAccountKeys)?; - multi_transfer_lamports(payer, &transfers).map_err(convert_program_error)?; - Ok(()) + Ok(info) } diff --git a/programs/compressed-token/program/src/extensions/check_mint_extensions.rs b/programs/compressed-token/program/src/extensions/check_mint_extensions.rs new file mode 100644 index 0000000000..e0f05e7cf3 --- /dev/null +++ b/programs/compressed-token/program/src/extensions/check_mint_extensions.rs @@ -0,0 +1,218 @@ +use anchor_compressed_token::{ErrorCode, ALLOWED_EXTENSION_TYPES}; +use anchor_lang::prelude::ProgramError; +use light_account_checks::AccountInfoTrait; +use pinocchio::{account_info::AccountInfo, msg, pubkey::Pubkey}; +use spl_token_2022::{ + extension::{ + default_account_state::DefaultAccountState, pausable::PausableConfig, + permanent_delegate::PermanentDelegate, transfer_fee::TransferFeeConfig, + transfer_hook::TransferHook, BaseStateWithExtensions, ExtensionType, + PodStateWithExtensions, + }, + pod::PodMint, + state::AccountState, +}; + +const SPL_TOKEN_2022_ID: [u8; 32] = spl_token_2022::ID.to_bytes(); + +/// Result of checking mint extensions (runtime validation) +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MintExtensionChecks { + /// The permanent delegate pubkey if the mint has the PermanentDelegate extension and it's set + pub permanent_delegate: Option, + /// Whether the mint has the TransferFeeConfig extension (non-zero fees are rejected) + pub has_transfer_fee: bool, + /// Whether the mint has restricted extensions (Pausable, PermanentDelegate, TransferFee, TransferHook) + /// Used to require CompressedOnly output when compressing tokens from restricted mints + pub has_restricted_extensions: bool, +} + +/// Flags for mint extensions that affect CToken account initialization and transfers +#[derive(Default, Clone, Copy)] +pub struct MintExtensionFlags { + /// Whether the mint has the PausableAccount extension + pub has_pausable: bool, + /// Whether the mint has the PermanentDelegate extension + pub has_permanent_delegate: bool, + /// Whether the mint has DefaultAccountState set to Frozen + pub default_state_frozen: bool, + /// Whether the mint has the TransferFeeConfig extension + pub has_transfer_fee: bool, + /// Whether the mint has the TransferHook extension (with nil program_id) + pub has_transfer_hook: bool, +} + +impl MintExtensionFlags { + /// Calculate the ctoken account size based on extension flags. + /// + /// # Arguments + /// * `has_compressible` - Whether the account has the Compressible extension + /// (this is an account-level choice, not a mint extension) + pub const fn calculate_account_size(&self, has_compressible: bool) -> u64 { + light_ctoken_types::state::calculate_ctoken_account_size( + has_compressible, + self.has_pausable, + self.has_permanent_delegate, + self.has_transfer_fee, + self.has_transfer_hook, + ) + } +} + +/// Check mint extensions in a single pass with zero-copy deserialization. +/// This function deserializes the mint once and checks both pausable and permanent delegate extensions. +/// +/// # Arguments +/// * `mint_account` - The SPL Token 2022 mint account to check +/// +/// # Returns +/// * `Ok(MintExtensionChecks)` - Extension check results +/// * `Err(ErrorCode::MintPaused)` - If the mint is paused +/// * `Err(ProgramError)` - If there's an error parsing the mint account +pub fn check_mint_extensions( + mint_account: &AccountInfo, + deny_restricted_extensions: bool, +) -> Result { + // Only Token-2022 mints can have extensions + if !mint_account.is_owned_by(&SPL_TOKEN_2022_ID) { + return Ok(MintExtensionChecks { + permanent_delegate: None, + has_transfer_fee: false, + has_restricted_extensions: false, + }); + } + + let mint_data = AccountInfoTrait::try_borrow_data(mint_account)?; + + // Zero-copy parse mint with extensions using PodStateWithExtensions + let mint_state = PodStateWithExtensions::::unpack(&mint_data)?; + + // Always compute has_restricted_extensions (needed for CompressAndClose validation) + let extension_types = mint_state.get_extension_types().unwrap_or_default(); + let has_restricted_extensions = extension_types.iter().any(|ext| { + matches!( + ext, + ExtensionType::Pausable + | ExtensionType::PermanentDelegate + | ExtensionType::TransferFeeConfig + | ExtensionType::TransferHook + ) + }); + + // When there are output compressed accounts, mint must not contain restricted extensions. + // Restricted extensions require compression_only mode (no compressed outputs). + if deny_restricted_extensions && has_restricted_extensions { + msg!("Mint has restricted extensions - compression_only mode required"); + return Err(ErrorCode::MintHasRestrictedExtensions.into()); + } + + // Check pausable extension first (early return if paused) + if let Ok(pausable_config) = mint_state.get_extension::() { + if bool::from(pausable_config.paused) { + return Err(ErrorCode::MintPaused.into()); + } + } + + // Check permanent delegate extension + let permanent_delegate = + if let Ok(permanent_delegate_ext) = mint_state.get_extension::() { + // Convert OptionalNonZeroPubkey to Option + Option::::from(permanent_delegate_ext.delegate) + .map(|delegate| Pubkey::from(delegate.to_bytes())) + } else { + None + }; + + // Check transfer fee extension - non-zero fees not supported + let has_transfer_fee = + if let Ok(transfer_fee_config) = mint_state.get_extension::() { + // Check both older and newer fee configs for non-zero values + let older_fee = &transfer_fee_config.older_transfer_fee; + let newer_fee = &transfer_fee_config.newer_transfer_fee; + if u16::from(older_fee.transfer_fee_basis_points) != 0 + || u64::from(older_fee.maximum_fee) != 0 + || u16::from(newer_fee.transfer_fee_basis_points) != 0 + || u64::from(newer_fee.maximum_fee) != 0 + { + return Err(ErrorCode::NonZeroTransferFeeNotSupported.into()); + } + true + } else { + false + }; + + // Check transfer hook extension - only nil program_id supported + if let Ok(transfer_hook) = mint_state.get_extension::() { + if Option::::from(transfer_hook.program_id).is_some() { + return Err(ErrorCode::TransferHookNotSupported.into()); + } + } + + Ok(MintExtensionChecks { + permanent_delegate, + has_transfer_fee, + has_restricted_extensions, + }) +} + +/// Hash which extensions a mint has in a single zero-copy deserialization. +/// This function is used during account creation to determine which marker extensions +/// should be added to the ctoken account. +/// +/// Note: This function only checks which extensions exist, not their values. +/// For runtime validation (checking if paused, getting delegate pubkey), use `check_mint_extensions` instead. +/// +/// # Arguments +/// * `mint_account` - The SPL Token 2022 mint account to check +/// +/// # Returns +/// * `Ok(MintExtensionFlags)` - Flags indicating which extensions the mint has +/// * `Err(ProgramError)` - If there's an error parsing the mint account +pub fn has_mint_extensions(mint_account: &AccountInfo) -> Result { + // Only Token-2022 mints can have extensions + if !mint_account.is_owned_by(&SPL_TOKEN_2022_ID) { + return Ok(MintExtensionFlags::default()); + } + + let mint_data = AccountInfoTrait::try_borrow_data(mint_account)?; + + // Zero-copy parse mint with extensions using PodStateWithExtensions + let mint_state = PodStateWithExtensions::::unpack(&mint_data)?; + + // Get all extension types in a single call + let extension_types = mint_state.get_extension_types().unwrap_or_default(); + + // Check for unsupported extensions + for ext in &extension_types { + if !ALLOWED_EXTENSION_TYPES.contains(ext) { + msg!("Unsupported mint extension: {:?}", ext); + return Err(ErrorCode::MintWithInvalidExtension.into()); + } + } + + // Check which extensions exist using the extension_types list + let has_pausable = extension_types.contains(&ExtensionType::Pausable); + let has_permanent_delegate = extension_types.contains(&ExtensionType::PermanentDelegate); + let has_transfer_fee = extension_types.contains(&ExtensionType::TransferFeeConfig); + let has_transfer_hook = extension_types.contains(&ExtensionType::TransferHook); + + // Check if DefaultAccountState is set to Frozen + // AccountState::Frozen as u8 = 2, ext.state is PodAccountState (u8) + let default_account_state_frozen = + if extension_types.contains(&ExtensionType::DefaultAccountState) { + mint_state + .get_extension::() + .map(|ext| ext.state == AccountState::Frozen as u8) + .unwrap_or(false) + } else { + false + }; + + Ok(MintExtensionFlags { + has_pausable, + has_permanent_delegate, + default_state_frozen: default_account_state_frozen, + has_transfer_fee, + has_transfer_hook, + }) +} diff --git a/programs/compressed-token/program/src/extensions/mod.rs b/programs/compressed-token/program/src/extensions/mod.rs index 11b99e4050..963ec0d2ea 100644 --- a/programs/compressed-token/program/src/extensions/mod.rs +++ b/programs/compressed-token/program/src/extensions/mod.rs @@ -1,6 +1,11 @@ +pub mod check_mint_extensions; pub mod processor; pub mod token_metadata; +// Re-export extension checking functions +pub use check_mint_extensions::{ + check_mint_extensions, has_mint_extensions, MintExtensionChecks, MintExtensionFlags, +}; // Import from ctoken-types instead of local modules use light_ctoken_types::{ instructions::{ diff --git a/programs/compressed-token/program/src/lib.rs b/programs/compressed-token/program/src/lib.rs index c845890ca9..f0a662117c 100644 --- a/programs/compressed-token/program/src/lib.rs +++ b/programs/compressed-token/program/src/lib.rs @@ -11,6 +11,8 @@ pub mod convert_account_infos; pub mod create_associated_token_account; pub mod create_associated_token_account2; pub mod create_token_account; +pub mod ctoken_approve_revoke; +pub mod ctoken_freeze_thaw; pub mod ctoken_transfer; pub mod extensions; pub mod mint_action; @@ -29,6 +31,8 @@ use create_associated_token_account2::{ process_create_associated_token_account2, process_create_associated_token_account2_idempotent, }; use create_token_account::process_create_token_account; +use ctoken_approve_revoke::{process_ctoken_approve, process_ctoken_revoke}; +use ctoken_freeze_thaw::{process_ctoken_freeze_account, process_ctoken_thaw_account}; use ctoken_transfer::process_ctoken_transfer; use withdraw_funding_pool::process_withdraw_funding_pool; @@ -48,8 +52,16 @@ pub(crate) const MAX_PACKED_ACCOUNTS: usize = 40; pub enum InstructionType { /// CToken transfer CTokenTransfer = 3, + /// CToken Approve + CTokenApprove = 4, + /// CToken Revoke + CTokenRevoke = 5, /// CToken CloseAccount CloseTokenAccount = 9, + /// CToken FreezeAccount + CTokenFreezeAccount = 10, + /// CToken ThawAccount + CTokenThawAccount = 11, /// Create CToken, equivalent to SPL Token InitializeAccount3 CreateTokenAccount = 18, CreateAssociatedTokenAccount = 100, @@ -87,7 +99,11 @@ impl From for InstructionType { fn from(value: u8) -> Self { match value { 3 => InstructionType::CTokenTransfer, + 4 => InstructionType::CTokenApprove, + 5 => InstructionType::CTokenRevoke, 9 => InstructionType::CloseTokenAccount, + 10 => InstructionType::CTokenFreezeAccount, + 11 => InstructionType::CTokenThawAccount, 18 => InstructionType::CreateTokenAccount, 100 => InstructionType::CreateAssociatedTokenAccount, 101 => InstructionType::Transfer2, @@ -124,6 +140,14 @@ pub fn process_instruction( // msg!("CTokenTransfer"); process_ctoken_transfer(accounts, &instruction_data[1..])?; } + InstructionType::CTokenApprove => { + msg!("CTokenApprove"); + process_ctoken_approve(accounts, &instruction_data[1..])?; + } + InstructionType::CTokenRevoke => { + msg!("CTokenRevoke"); + process_ctoken_revoke(accounts, &instruction_data[1..])?; + } InstructionType::CreateAssociatedTokenAccount => { msg!("CreateAssociatedTokenAccount"); process_create_associated_token_account(accounts, &instruction_data[1..])?; @@ -140,6 +164,14 @@ pub fn process_instruction( msg!("CloseTokenAccount"); process_close_token_account(accounts, &instruction_data[1..])?; } + InstructionType::CTokenFreezeAccount => { + msg!("CTokenFreezeAccount"); + process_ctoken_freeze_account(accounts)?; + } + InstructionType::CTokenThawAccount => { + msg!("CTokenThawAccount"); + process_ctoken_thaw_account(accounts)?; + } InstructionType::Transfer2 => { msg!("Transfer2"); process_transfer2(accounts, &instruction_data[1..])?; diff --git a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs b/programs/compressed-token/program/src/mint_action/actions/mint_to.rs index b0b4679884..6973e5daf2 100644 --- a/programs/compressed-token/program/src/mint_action/actions/mint_to.rs +++ b/programs/compressed-token/program/src/mint_action/actions/mint_to.rs @@ -108,6 +108,8 @@ fn create_output_compressed_token_accounts<'a>( mint, queue_pubkey_index, parsed_instruction_data.token_account_version, + None, // No TLV for mint_to + false, // Minted tokens are always initialized (not frozen) )?; processed_count += 1; } diff --git a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs index dd7c91f9d6..ead81677c3 100644 --- a/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs +++ b/programs/compressed-token/program/src/shared/initialize_ctoken_account.rs @@ -3,7 +3,8 @@ use light_account_checks::AccountInfoTrait; use light_compressible::{compression_info::ZCompressionInfoMut, config::CompressibleConfig}; use light_ctoken_types::{ instructions::extensions::compressible::CompressibleExtensionInstructionData, - state::CompressionInfo, CTokenError, COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, + state::{calculate_ctoken_account_size, CompressionInfo}, + CTokenError, }; use light_program_profiler::profile; use light_zero_copy::traits::ZeroCopyAtMut; @@ -11,24 +12,54 @@ use light_zero_copy::traits::ZeroCopyAtMut; use pinocchio::sysvars::{clock::Clock, Sysvar}; use pinocchio::{account_info::AccountInfo, msg, pubkey::Pubkey}; -use crate::ErrorCode; +use crate::{extensions::MintExtensionFlags, ErrorCode}; + +/// Configuration for initializing a CToken account +pub struct CTokenInitConfig<'a> { + /// The mint pubkey (32 bytes) + pub mint: &'a [u8; 32], + /// The owner pubkey (32 bytes) + pub owner: &'a [u8; 32], + /// Compressible extension instruction data (if compressible) + pub compressible: Option, + /// Compressible config account (required if compressible is Some) + pub compressible_config_account: Option<&'a CompressibleConfig>, + /// Custom rent payer pubkey (if not using default rent sponsor) + pub custom_rent_payer: Option, + /// Mint extension flags + pub mint_extensions: MintExtensionFlags, +} /// Initialize a token account using spl-pod with zero balance and default settings #[profile] pub fn initialize_ctoken_account( token_account_info: &AccountInfo, - mint_pubkey: &[u8; 32], - owner_pubkey: &[u8; 32], - compressible_config: Option, - compressible_config_account: Option<&CompressibleConfig>, - // account is compressible but with custom fee payer -> rent recipient is fee payer - custom_rent_payer: Option, + config: CTokenInitConfig<'_>, ) -> Result<(), ProgramError> { - let required_size = if compressible_config.is_none() { - 165 - } else { - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE as usize - }; + let CTokenInitConfig { + mint, + owner, + compressible, + compressible_config_account, + custom_rent_payer, + mint_extensions: + MintExtensionFlags { + has_pausable, + has_permanent_delegate, + default_state_frozen, + has_transfer_fee, + has_transfer_hook, + }, + } = config; + + let has_compressible = compressible.is_some(); + let required_size = calculate_ctoken_account_size( + has_compressible, + has_pausable, + has_permanent_delegate, + has_transfer_fee, + has_transfer_hook, + ) as usize; // Access the token account data as mutable bytes let mut token_account_data = AccountInfoTrait::try_borrow_mut_data(token_account_info)?; let actual_size = token_account_data.len(); @@ -58,19 +89,21 @@ pub fn initialize_ctoken_account( } // Copy mint (32 bytes at offset 0) - base_token_bytes[0..32].copy_from_slice(mint_pubkey); + base_token_bytes[0..32].copy_from_slice(mint); // Copy owner (32 bytes at offset 32) - base_token_bytes[32..64].copy_from_slice(owner_pubkey); + base_token_bytes[32..64].copy_from_slice(owner); - // Set state to Initialized (1 byte at offset 108) - base_token_bytes[108] = 1; + // Set state to Initialized (1) or Frozen (2) at offset 108 + // AccountState: Uninitialized = 0, Initialized = 1, Frozen = 2 + base_token_bytes[108] = if default_state_frozen { 2 } else { 1 }; // Configure compressible extension if present - if let Some(compressible_config) = compressible_config { + if let Some(compressible_ix_data) = compressible { let compressible_config_account = compressible_config_account.ok_or(ErrorCode::InvalidCompressAuthority)?; - // Split to get the actual CompressionInfo data starting at byte 7 + // Split to get the actual CompressibleExtension data starting at byte 7 + // CompressibleExtension layout: 1 byte compression_only + CompressionInfo let (extension_bytes, compressible_data) = extension_bytes.split_at_mut(7); // Manually set extension metadata @@ -80,15 +113,36 @@ pub fn initialize_ctoken_account( // Byte 1: Option::Some = 1 (for Option>) extension_bytes[1] = 1; - // Bytes 2-5: Vec length = 1 (little-endian u32) - extension_bytes[2..6].copy_from_slice(&[1, 0, 0, 0]); + // Bytes 2-5: Vec length (number of extensions) + let mut extension_count = 1u32; // Always at least compressible + if has_pausable { + extension_count += 1; + } + if has_permanent_delegate { + extension_count += 1; + } + if has_transfer_fee { + extension_count += 1; + } + if has_transfer_hook { + extension_count += 1; + } + extension_bytes[2..6].copy_from_slice(&extension_count.to_le_bytes()); // Byte 6: Compressible enum discriminator = 32 (avoids Token-2022 overlap) extension_bytes[6] = 32; + // Write compression_only flag (1 byte) + if compressible_data.is_empty() { + msg!("Not enough space for compression_only flag"); + return Err(ErrorCode::InsufficientAccountSize.into()); + } + compressible_data[0] = compressible_ix_data.compression_only; + let compression_info_data = &mut compressible_data[1..]; + // Create zero-copy mutable reference to CompressionInfo - let (mut compressible_extension, _) = CompressionInfo::zero_copy_at_mut(compressible_data) - .map_err(|e| { + let (mut compressible_extension, remaining) = + CompressionInfo::zero_copy_at_mut(compression_info_data).map_err(|e| { msg!( "Failed to create CompressionInfo zero-copy reference: {:?}", e @@ -98,10 +152,57 @@ pub fn initialize_ctoken_account( configure_compressible_extension( &mut compressible_extension, - compressible_config, + compressible_ix_data, compressible_config_account, custom_rent_payer, )?; + + // Add PausableAccount and PermanentDelegateAccount extensions if needed + let mut remaining = remaining; + + if has_pausable { + if remaining.is_empty() { + msg!("Not enough space for PausableAccount extension"); + return Err(ErrorCode::InsufficientAccountSize.into()); + } + let (pausable_bytes, rest) = remaining.split_at_mut(1); + // Write PausableAccount discriminator (27) + pausable_bytes[0] = 27; + remaining = rest; + } + + if has_permanent_delegate { + if remaining.is_empty() { + msg!("Not enough space for PermanentDelegateAccount extension"); + return Err(ErrorCode::InsufficientAccountSize.into()); + } + let (permanent_delegate_bytes, rest) = remaining.split_at_mut(1); + // Write PermanentDelegateAccount discriminator (28) + permanent_delegate_bytes[0] = 28; + remaining = rest; + } + + if has_transfer_fee { + if remaining.len() < 9 { + msg!("Not enough space for TransferFeeAccount extension"); + return Err(ErrorCode::InsufficientAccountSize.into()); + } + let (transfer_fee_bytes, rest) = remaining.split_at_mut(9); + // Write TransferFeeAccount discriminator (29), withheld_amount already zeros + transfer_fee_bytes[0] = 29; + remaining = rest; + } + + if has_transfer_hook { + if remaining.len() < 2 { + msg!("Not enough space for TransferHookAccount extension"); + return Err(ErrorCode::InsufficientAccountSize.into()); + } + let (transfer_hook_bytes, _) = remaining.split_at_mut(2); + // Write TransferHookAccount discriminator (30) + transferring flag (0) + transfer_hook_bytes[0] = 30; + transfer_hook_bytes[1] = 0; // transferring = false + } } Ok(()) @@ -111,7 +212,7 @@ pub fn initialize_ctoken_account( #[inline(always)] fn configure_compressible_extension( compressible_extension: &mut ZCompressionInfoMut<'_>, - compressible_config: CompressibleExtensionInstructionData, + compressible_ix_data: CompressibleExtensionInstructionData, compressible_config_account: &CompressibleConfig, custom_rent_payer: Option, ) -> Result<(), ProgramError> { @@ -154,28 +255,28 @@ fn configure_compressible_extension( } // Validate write_top_up doesn't exceed max_top_up - if compressible_config.write_top_up > compressible_config_account.rent_config.max_top_up as u32 + if compressible_ix_data.write_top_up > compressible_config_account.rent_config.max_top_up as u32 { msg!( "write_top_up {} exceeds max_top_up {}", - compressible_config.write_top_up, + compressible_ix_data.write_top_up, compressible_config_account.rent_config.max_top_up ); return Err(CTokenError::WriteTopUpExceedsMaximum.into()); } compressible_extension .lamports_per_write - .set(compressible_config.write_top_up); + .set(compressible_ix_data.write_top_up); compressible_extension.compress_to_pubkey = - compressible_config.compress_to_account_pubkey.is_some() as u8; + compressible_ix_data.compress_to_account_pubkey.is_some() as u8; // Validate token_account_version is ShaFlat (3) - if compressible_config.token_account_version != 3 { + if compressible_ix_data.token_account_version != 3 { msg!( "Invalid token_account_version: {}. Only version 3 (ShaFlat) is supported", - compressible_config.token_account_version + compressible_ix_data.token_account_version ); return Err(ProgramError::InvalidInstructionData); } - compressible_extension.account_version = compressible_config.token_account_version; + compressible_extension.account_version = compressible_ix_data.token_account_version; Ok(()) } diff --git a/programs/compressed-token/program/src/shared/mod.rs b/programs/compressed-token/program/src/shared/mod.rs index 1b0f097c71..1fa46f30ab 100644 --- a/programs/compressed-token/program/src/shared/mod.rs +++ b/programs/compressed-token/program/src/shared/mod.rs @@ -11,7 +11,6 @@ pub mod token_output; pub mod transfer_lamports; pub mod validate_ata_derivation; -// Re-export AccountIterator from light-account-checks pub use convert_program_error::convert_program_error; pub use create_pda_account::{create_pda_account, verify_pda}; pub use light_account_checks::AccountIterator; diff --git a/programs/compressed-token/program/src/shared/owner_validation.rs b/programs/compressed-token/program/src/shared/owner_validation.rs index 49dcda0df9..1d0d6571e5 100644 --- a/programs/compressed-token/program/src/shared/owner_validation.rs +++ b/programs/compressed-token/program/src/shared/owner_validation.rs @@ -5,56 +5,69 @@ use light_ctoken_types::state::ZCompressedTokenMut; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; -/// Verify owner or delegate signer authorization for token operations -/// Returns the delegate account info if delegate is used, None otherwise +use crate::extensions::MintExtensionChecks; + +/// Verify owner, delegate, or permanent delegate signer authorization for token operations. +/// Accepts optional permanent delegate pubkey from mint extension for additional authorization. #[profile] pub fn verify_owner_or_delegate_signer<'a>( owner_account: &'a AccountInfo, delegate_account: Option<&'a AccountInfo>, + permanent_delegate: Option<&pinocchio::pubkey::Pubkey>, + accounts: &[AccountInfo], ) -> Result<(), ProgramError> { + // Check if owner is signer + if check_signer(owner_account).is_ok() { + return Ok(()); + } + + // Check if delegate is signer if let Some(delegate_account) = delegate_account { - // If delegate is used, delegate or owner must be signer - match check_signer(delegate_account) { - Ok(()) => {} - Err(delegate_error) => { - check_signer(owner_account).map_err(|e| { - anchor_lang::solana_program::msg!( - "Checking owner signer: {:?}", - solana_pubkey::Pubkey::new_from_array(*owner_account.key()) - ); - anchor_lang::solana_program::msg!("Owner signer check failed: {:?}", e); - anchor_lang::solana_program::msg!( - "Delegate signer: {:?}", - solana_pubkey::Pubkey::new_from_array(*delegate_account.key()) - ); - anchor_lang::solana_program::msg!( - "Delegate signer check failed: {:?}", - delegate_error - ); - ProgramError::from(e) - })?; + if check_signer(delegate_account).is_ok() { + return Ok(()); + } + } + + // Check if permanent delegate is signer (search through all accounts) + if let Some(perm_delegate) = permanent_delegate { + for account in accounts { + if account.key() == perm_delegate && account.is_signer() { + return Ok(()); } } - Ok(()) - } else { - // If no delegate, owner must be signer - check_signer(owner_account).map_err(|e| { - anchor_lang::solana_program::msg!( - "Checking owner signer: {:?}", - solana_pubkey::Pubkey::new_from_array(*owner_account.key()) - ); - anchor_lang::solana_program::msg!("Owner signer check failed: {:?}", e); - ProgramError::from(e) - })?; - Ok(()) } + + // No valid signer found + anchor_lang::solana_program::msg!( + "Checking owner signer: {:?}", + solana_pubkey::Pubkey::new_from_array(*owner_account.key()) + ); + anchor_lang::solana_program::msg!("Owner signer check failed: InvalidSigner"); + if let Some(delegate_account) = delegate_account { + anchor_lang::solana_program::msg!( + "Delegate signer: {:?}", + solana_pubkey::Pubkey::new_from_array(*delegate_account.key()) + ); + anchor_lang::solana_program::msg!("Delegate signer check failed: InvalidSigner"); + } + if let Some(perm_delegate) = permanent_delegate { + anchor_lang::solana_program::msg!( + "Permanent delegate: {:?}", + solana_pubkey::Pubkey::new_from_array(*perm_delegate) + ); + anchor_lang::solana_program::msg!("Permanent delegate signer check failed: InvalidSigner"); + } + Err(ErrorCode::OwnerMismatch.into()) } -/// Verify and update token account authority using zero-copy compressed token format +/// Verify and update token account authority using zero-copy compressed token format. +/// Allows owner, account delegate, or permanent delegate (from mint) to authorize compression operations. #[profile] pub fn check_ctoken_owner( compressed_token: &mut ZCompressedTokenMut, authority_account: &AccountInfo, + mint_checks: Option<&MintExtensionChecks>, + _compression_amount: u64, ) -> Result<(), ProgramError> { // Verify authority is signer check_signer(authority_account).map_err(|e| { @@ -67,33 +80,18 @@ pub fn check_ctoken_owner( // Check if authority is the owner if *authority_key == owner_key { - Ok(()) // Owner can always compress, no delegation update needed - } else { - Err(ErrorCode::OwnerMismatch.into()) + return Ok(()); // Owner can always compress } - // delegation is unimplemented. - // // Check if authority is a valid delegate - // if let Some(delegate) = &compressed_token.delegate { - // let delegate_key = delegate.to_bytes(); - // if *authority_key == delegate_key { - // // Verify delegated amount is sufficient - // let delegated_amount: u64 = u64::from(*compressed_token.delegated_amount); - // if delegated_amount >= compression_amount { - // // Decrease delegated amount by compression amount - // let new_delegated_amount = delegated_amount - // .checked_sub(compression_amount) - // .ok_or(ProgramError::ArithmeticOverflow)?; - // *compressed_token.delegated_amount = new_delegated_amount.into(); - // return Ok(()); - // } else { - // anchor_lang::solana_program::msg!( - // "Insufficient delegated amount: {} < {}", - // delegated_amount, - // compression_amount - // ); - // return Err(ProgramError::InsufficientFunds); - // } - // } - // } - // Authority is neither owner, valid delegate, nor rent authority + + // Check if authority is the permanent delegate from the mint + if let Some(checks) = mint_checks { + if let Some(permanent_delegate) = &checks.permanent_delegate { + if authority_key == permanent_delegate { + return Ok(()); // Permanent delegate can compress any account of this mint + } + } + } + + // Authority is neither owner, account delegate, nor permanent delegate + Err(ErrorCode::OwnerMismatch.into()) } diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index f8e4306f32..db13c9b0e5 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -6,60 +6,75 @@ use light_account_checks::AccountError; use light_compressed_account::instruction_data::with_readonly::ZInAccountMut; use light_ctoken_types::{ hash_cache::HashCache, - instructions::transfer2::ZMultiInputTokenDataWithContext, - state::{CompressedTokenAccountState, TokenDataVersion}, + instructions::{ + extensions::ZExtensionInstructionData, transfer2::ZMultiInputTokenDataWithContext, + }, + state::{ + CompressedOnlyExtension, CompressedTokenAccountState, ExtensionStruct, TokenDataVersion, + }, }; use pinocchio::account_info::AccountInfo; -use crate::shared::owner_validation::verify_owner_or_delegate_signer; - -#[inline(always)] -pub fn set_input_compressed_account( - input_compressed_account: &mut ZInAccountMut, - hash_cache: &mut HashCache, - input_token_data: &ZMultiInputTokenDataWithContext, - accounts: &[AccountInfo], - lamports: u64, -) -> std::result::Result<(), ProgramError> { - set_input_compressed_account_inner::( - input_compressed_account, - hash_cache, - input_token_data, - accounts, - lamports, - ) -} +use crate::{ + shared::owner_validation::verify_owner_or_delegate_signer, + transfer2::check_extensions::MintExtensionCache, +}; #[inline(always)] -pub fn set_input_compressed_account_frozen( +#[allow(clippy::too_many_arguments)] +pub fn set_input_compressed_account<'a>( input_compressed_account: &mut ZInAccountMut, hash_cache: &mut HashCache, input_token_data: &ZMultiInputTokenDataWithContext, - accounts: &[AccountInfo], + packed_accounts: &[AccountInfo], + all_accounts: &[AccountInfo], lamports: u64, + tlv_data: Option<&'a [ZExtensionInstructionData<'a>]>, + is_frozen: bool, + mint_cache: &MintExtensionCache, ) -> std::result::Result<(), ProgramError> { - set_input_compressed_account_inner::( - input_compressed_account, - hash_cache, - input_token_data, - accounts, - lamports, - ) + if is_frozen { + set_input_compressed_account_inner::( + input_compressed_account, + hash_cache, + input_token_data, + packed_accounts, + all_accounts, + lamports, + tlv_data, + mint_cache, + ) + } else { + set_input_compressed_account_inner::( + input_compressed_account, + hash_cache, + input_token_data, + packed_accounts, + all_accounts, + lamports, + tlv_data, + mint_cache, + ) + } } /// Creates an input compressed account using zero-copy patterns and index-based account lookup. /// -/// Validates signer authorization (owner or delegate), populates the zero-copy account structure, -/// and computes the appropriate token data hash based on frozen state. -fn set_input_compressed_account_inner( +/// Validates signer authorization (owner, delegate, or permanent delegate), populates the +/// zero-copy account structure, and computes the appropriate token data hash based on frozen state. +#[allow(clippy::too_many_arguments)] +fn set_input_compressed_account_inner<'a, const IS_FROZEN: bool>( input_compressed_account: &mut ZInAccountMut, hash_cache: &mut HashCache, input_token_data: &ZMultiInputTokenDataWithContext, - accounts: &[AccountInfo], + packed_accounts: &[AccountInfo], + all_accounts: &[AccountInfo], lamports: u64, + tlv_data: Option<&'a [ZExtensionInstructionData<'a>]>, + mint_cache: &MintExtensionCache, ) -> std::result::Result<(), ProgramError> { - // Get owner from remaining accounts using the owner index - let owner_account = accounts + // Get owner from packed accounts using the owner index + let owner_account = packed_accounts .get(input_token_data.owner as usize) .ok_or_else(|| { print_on_error_pubkey(input_token_data.owner, "owner", Location::caller()); @@ -69,7 +84,7 @@ fn set_input_compressed_account_inner( // Verify signer authorization using shared function let delegate_account = if input_token_data.has_delegate() { Some( - accounts + packed_accounts .get(input_token_data.delegate as usize) .ok_or_else(|| { print_on_error_pubkey( @@ -84,15 +99,27 @@ fn set_input_compressed_account_inner( None }; - verify_owner_or_delegate_signer(owner_account, delegate_account)?; - let token_version = TokenDataVersion::try_from(input_token_data.version)?; - let mint_account = &accounts + // Get mint account early for hashing + let mint_account = &packed_accounts .get(input_token_data.mint as usize) .ok_or_else(|| { print_on_error_pubkey(input_token_data.mint, "mint", Location::caller()); ProgramError::Custom(AccountError::NotEnoughAccountKeys.into()) })?; + // Lookup permanent delegate for mint account. + let permanent_delegate = mint_cache + .get_by_key(&input_token_data.mint) + .and_then(|c| c.permanent_delegate.as_ref()); + + verify_owner_or_delegate_signer( + owner_account, + delegate_account, + permanent_delegate, + all_accounts, + )?; + let token_version = TokenDataVersion::try_from(input_token_data.version)?; + let data_hash = { match token_version { TokenDataVersion::ShaFlat => { @@ -101,13 +128,27 @@ fn set_input_compressed_account_inner( } else { CompressedTokenAccountState::Initialized as u8 }; + // Convert instruction TLV data to state TLV + let tlv: Option> = tlv_data.map(|exts| { + exts.iter() + .filter_map(|ext| match ext { + ZExtensionInstructionData::CompressedOnly(data) => { + Some(ExtensionStruct::CompressedOnly(CompressedOnlyExtension { + delegated_amount: data.delegated_amount.into(), + withheld_transfer_fee: data.withheld_transfer_fee.into(), + })) + } + _ => None, + }) + .collect() + }); let token_data = TokenData { mint: mint_account.key().into(), owner: owner_account.key().into(), amount: input_token_data.amount.into(), delegate: delegate_account.map(|x| (*x.key()).into()), state, - tlv: None, + tlv, }; token_data.hash_sha_flat()? } diff --git a/programs/compressed-token/program/src/shared/token_output.rs b/programs/compressed-token/program/src/shared/token_output.rs index 13d3383e97..068cc2938c 100644 --- a/programs/compressed-token/program/src/shared/token_output.rs +++ b/programs/compressed-token/program/src/shared/token_output.rs @@ -5,7 +5,11 @@ use light_compressed_account::{ }; use light_ctoken_types::{ hash_cache::HashCache, - state::{CompressedTokenAccountState, TokenData, TokenDataConfig, TokenDataVersion}, + instructions::extensions::ZExtensionInstructionData, + state::{ + CompressedTokenAccountState, ExtensionStructConfig, TokenData, TokenDataConfig, + TokenDataVersion, + }, }; use light_hasher::{sha256::Sha256BE, Hasher}; use light_program_profiler::profile; @@ -17,7 +21,7 @@ use light_zero_copy::{num_trait::ZeroCopyNumTrait, ZeroCopyNew}; #[inline(always)] #[allow(clippy::too_many_arguments)] #[profile] -pub fn set_output_compressed_account( +pub fn set_output_compressed_account<'a>( output_compressed_account: &mut ZOutputCompressedAccountWithPackedContextMut<'_>, hash_cache: &mut HashCache, owner: Pubkey, @@ -27,48 +31,40 @@ pub fn set_output_compressed_account( mint_pubkey: Pubkey, merkle_tree_index: u8, version: u8, + tlv_data: Option<&'a [ZExtensionInstructionData<'a>]>, + is_frozen: bool, ) -> Result<(), ProgramError> { - set_output_compressed_account_inner::( - output_compressed_account, - hash_cache, - owner, - delegate, - amount, - lamports, - mint_pubkey, - merkle_tree_index, - version, - ) -} - -#[inline(always)] -#[allow(clippy::too_many_arguments)] -pub fn set_output_compressed_account_frozen( - output_compressed_account: &mut ZOutputCompressedAccountWithPackedContextMut<'_>, - hash_cache: &mut HashCache, - owner: Pubkey, - delegate: Option, - amount: impl ZeroCopyNumTrait, - lamports: Option, - mint_pubkey: Pubkey, - merkle_tree_index: u8, - version: u8, -) -> Result<(), ProgramError> { - set_output_compressed_account_inner::( - output_compressed_account, - hash_cache, - owner, - delegate, - amount, - lamports, - mint_pubkey, - merkle_tree_index, - version, - ) + if is_frozen { + set_output_compressed_account_inner::( + output_compressed_account, + hash_cache, + owner, + delegate, + amount, + lamports, + mint_pubkey, + merkle_tree_index, + version, + tlv_data, + ) + } else { + set_output_compressed_account_inner::( + output_compressed_account, + hash_cache, + owner, + delegate, + amount, + lamports, + mint_pubkey, + merkle_tree_index, + version, + tlv_data, + ) + } } #[allow(clippy::too_many_arguments)] -fn set_output_compressed_account_inner( +fn set_output_compressed_account_inner<'a, const IS_FROZEN: bool>( output_compressed_account: &mut ZOutputCompressedAccountWithPackedContextMut<'_>, hash_cache: &mut HashCache, owner: Pubkey, @@ -78,6 +74,7 @@ fn set_output_compressed_account_inner( mint_pubkey: Pubkey, merkle_tree_index: u8, version: u8, + tlv_data: Option<&'a [ZExtensionInstructionData<'a>]>, ) -> Result<(), ProgramError> { // Get compressed account data from CPI struct to temporarily create TokenData let compressed_account_data = output_compressed_account @@ -85,24 +82,39 @@ fn set_output_compressed_account_inner( .data .as_mut() .ok_or(ProgramError::InvalidAccountData)?; + + // Extract config from tlv_data for allocation + let tlv_config: Option> = tlv_data.map(|exts| { + exts.iter() + .filter_map(|ext| match ext { + ZExtensionInstructionData::CompressedOnly(_) => { + Some(ExtensionStructConfig::CompressedOnly(())) + } + _ => None, + }) + .collect() + }); + // 1. Set token account data { - // Create token data config based on delegate presence + // Create token data config based on delegate presence and TLV let token_config = TokenDataConfig { delegate: (delegate.is_some(), ()), - tlv: (false, vec![]), + tlv: match &tlv_config { + Some(configs) if !configs.is_empty() => (true, configs.clone()), + _ => (false, vec![]), + }, }; let (mut token_data, _) = TokenData::new_zero_copy(compressed_account_data.data, token_config) .map_err(ProgramError::from)?; - token_data.set( - mint_pubkey, - owner, - amount, - delegate, - CompressedTokenAccountState::Initialized, - )?; + let state = if IS_FROZEN { + CompressedTokenAccountState::Frozen + } else { + CompressedTokenAccountState::Initialized + }; + token_data.set(mint_pubkey, owner, amount, delegate, state, tlv_data)?; } let token_version = TokenDataVersion::try_from(version)?; // 2. Create TokenData using zero-copy to compute the data hash diff --git a/programs/compressed-token/program/src/transfer2/check_extensions.rs b/programs/compressed-token/program/src/transfer2/check_extensions.rs new file mode 100644 index 0000000000..89cea08353 --- /dev/null +++ b/programs/compressed-token/program/src/transfer2/check_extensions.rs @@ -0,0 +1,113 @@ +use anchor_compressed_token::ErrorCode; +use anchor_lang::prelude::ProgramError; +use light_account_checks::packed_accounts::ProgramPackedAccounts; +use light_array_map::ArrayMap; +use light_ctoken_types::instructions::{ + extensions::ZExtensionInstructionData, + transfer2::{ZCompressedTokenInstructionDataTransfer2, ZCompressionMode}, +}; +use light_program_profiler::profile; +use pinocchio::account_info::AccountInfo; +use spl_pod::solana_msg::msg; + +use crate::extensions::{check_mint_extensions, MintExtensionChecks}; + +/// Cache for mint extension checks to avoid deserializing the same mint multiple times. +pub type MintExtensionCache = ArrayMap; + +/// Build mint extension cache for all unique mints in the instruction. +/// +/// # Checks performed per mint (via `check_mint_extensions`): +/// - **Pausable**: Fails with `MintPaused` if mint is paused +/// - **Restricted extensions**: When `has_output_compressed_accounts=true`, fails with +/// `MintHasRestrictedExtensions` if mint has Pausable, PermanentDelegate, TransferFeeConfig, +/// or TransferHook extensions +/// - **TransferFeeConfig**: Fails with `NonZeroTransferFeeNotSupported` if fees are non-zero +/// - **TransferHook**: Fails with `TransferHookNotSupported` if program_id is non-nil +/// +/// # Cached data: +/// - `permanent_delegate`: Pubkey if PermanentDelegate extension exists and is set +/// - `has_transfer_fee`: Whether TransferFeeConfig extension exists (non-zero fees are rejected) +/// - `has_restricted_extensions`: Whether mint has restricted extensions (for CompressAndClose validation) +#[profile] +#[inline(always)] +pub fn build_mint_extension_cache<'a>( + inputs: &ZCompressedTokenInstructionDataTransfer2, + packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, + deny_restricted_extensions: bool, // true if has_output_compressed_accounts +) -> Result { + let mut cache: MintExtensionCache = ArrayMap::new(); + + // Collect mints from input token data + for input in inputs.in_token_data.iter() { + let mint_index = input.mint; + if cache.get_by_key(&mint_index).is_none() { + let mint_account = packed_accounts.get_u8(mint_index, "mint cache: input")?; + let checks = check_mint_extensions(mint_account, deny_restricted_extensions)?; + cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; + } + } + + // Collect mints from compressions + if let Some(compressions) = inputs.compressions.as_ref() { + for compression in compressions.iter() { + let mint_index = compression.mint; + + if cache.get_by_key(&mint_index).is_none() { + let mint_account = packed_accounts.get_u8(mint_index, "mint cache: compression")?; + let checks = if compression.rent_sponsor_is_signer() + && compression.mode == ZCompressionMode::CompressAndClose + { + check_mint_extensions( + mint_account, + false, // Allow restricted extensions, also if instruction has has_output_compressed_accounts + )? + } else { + check_mint_extensions(mint_account, deny_restricted_extensions)? + }; + + // Validate mints with restricted extensions: + // - CompressAndClose with rent_sponsor_is_signer: OK if output has CompressedOnly + // - Compress: NOT allowed (mints with restricted extensions must not be compressed) + // - Decompress: OK (no output compressed accounts, handled by check_restricted) + if checks.has_restricted_extensions { + match compression.mode { + ZCompressionMode::CompressAndClose => { + // Verify output has CompressedOnly extension + let output_idx = compression.get_compressed_token_account_index()?; + let has_compressed_only = inputs + .out_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(output_idx as usize)) + .map(|tlv| { + tlv.iter().any(|e| { + matches!(e, ZExtensionInstructionData::CompressedOnly(_)) + }) + }) + .unwrap_or(false); + if !has_compressed_only { + msg!("Mint has restricted extensions - CompressedOnly output required"); + return Err( + ErrorCode::CompressAndCloseMissingCompressedOnlyExtension + .into(), + ); + } + } + ZCompressionMode::Compress => { + // msg!("Mints with restricted extensions cannot be compressed"); + // return Err(ErrorCode::MintHasRestrictedExtensions.into()); + } + ZCompressionMode::Decompress => { + // OK - if we reach here, has_output_compressed_accounts=false + // (otherwise check_mint_extensions would have failed earlier) + } + } + } + + cache.insert(mint_index, checks, ErrorCode::MintCacheCapacityExceeded)?; + } + } + } + + Ok(cache) +} diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs index f7e52b6086..ad64524e68 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_and_close.rs @@ -3,7 +3,10 @@ use anchor_lang::prelude::ProgramError; use bitvec::prelude::*; use light_account_checks::{checks::check_signer, packed_accounts::ProgramPackedAccounts}; use light_ctoken_types::{ - instructions::transfer2::{ZCompression, ZCompressionMode, ZMultiTokenTransferOutputData}, + instructions::{ + extensions::ZExtensionInstructionData, + transfer2::{ZCompression, ZCompressionMode, ZMultiTokenTransferOutputData}, + }, state::{ZCompressedTokenMut, ZExtensionStructMut}, }; use light_program_profiler::profile; @@ -62,9 +65,13 @@ pub fn process_compress_and_close( ctoken, compress_to_pubkey, token_account_info.key(), + close_inputs.tlv, )?; *ctoken.amount = 0.into(); + // Unfreeze the account if frozen (frozen state is preserved in compressed token TLV) + // This allows the close_token_account validation to pass for frozen accounts + *ctoken.state = 1; // AccountState::Initialized Ok(()) } @@ -76,24 +83,8 @@ fn validate_compressed_token_account( ctoken: &ZCompressedTokenMut, compress_to_pubkey: bool, token_account_pubkey: &Pubkey, + out_tlv: Option<&[ZExtensionInstructionData<'_>]>, ) -> Result<(), ProgramError> { - // Source token account must not have a delegate - // Compressed tokens don't support delegation, so we reject accounts with delegates - if ctoken.delegate.is_some() { - msg!("Source token account has delegate, cannot compress and close"); - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } - - if !pubkey_eq( - ctoken.mint.array_ref(), - packed_accounts - .get_u8(compressed_token_account.mint, "CompressAndClose: mint")? - .key(), - ) { - msg!("Invalid mint PDA derivation"); - return Err(ErrorCode::MintActionInvalidMintPda.into()); - } - // Owners should match if not compressing to pubkey if compress_to_pubkey { // Owner should match token account pubkey if compressing to pubkey @@ -148,25 +139,35 @@ fn validate_compressed_token_account( ); return Err(ErrorCode::CompressAndCloseBalanceMismatch.into()); } - // Delegate should be None - if compressed_token_account.has_delegate() { - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); - } - if compressed_token_account.delegate != 0 { - return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); + + // Mint must match + let output_mint = packed_accounts + .get_u8(compressed_token_account.mint, "CompressAndClose: mint")? + .key(); + if *output_mint != ctoken.mint.to_bytes() { + msg!( + "mint mismatch: ctoken {:?} != output {:?}", + solana_pubkey::Pubkey::new_from_array(ctoken.mint.to_bytes()), + solana_pubkey::Pubkey::new_from_array(*output_mint) + ); + return Err(ErrorCode::CompressAndCloseInvalidMint.into()); } + // Version should be ShaFlat if compressed_token_account.version != 3 { return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); } // Version should also match what's specified in the compressible extension - let expected_version = ctoken + let (expected_version, compression_only) = ctoken .extensions .as_ref() .and_then(|ext| { - if let Some(ZExtensionStructMut::Compressible(ext)) = ext.first() { - Some(ext.account_version) + if let Some(ZExtensionStructMut::Compressible(ext)) = ext + .iter() + .find(|e| matches!(e, ZExtensionStructMut::Compressible(_))) + { + Some((ext.info.account_version, ext.compression_only())) } else { None } @@ -176,6 +177,116 @@ fn validate_compressed_token_account( if compressed_token_account.version != expected_version { return Err(ErrorCode::CompressAndCloseInvalidVersion.into()); } + let compression_only_extension = out_tlv.as_ref().and_then(|ext| { + ext.iter() + .find(|e| matches!(e, ZExtensionInstructionData::CompressedOnly(_))) + }); + + if compression_only && compression_only_extension.is_none() { + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); + } + + if let Some(ZExtensionInstructionData::CompressedOnly(compression_only_extension)) = + compression_only_extension + { + // Delegated amounts must match + if compression_only_extension.delegated_amount != *ctoken.delegated_amount { + msg!( + "delegated_amount mismatch: ctoken {} != extension {}", + u64::from(*ctoken.delegated_amount), + u64::from(compression_only_extension.delegated_amount) + ); + return Err(ErrorCode::CompressAndCloseDelegatedAmountMismatch.into()); + } + // if delegated amount is not zero, delegate must match + if compression_only_extension.delegated_amount != 0 { + let delegate = ctoken + .delegate + .as_ref() + .ok_or(ErrorCode::CompressAndCloseInvalidDelegate)?; + if !compressed_token_account.has_delegate() { + msg!("ctoken has delegate but compressed token output does not"); + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); + } + let token_data_delegate = packed_accounts.get_u8( + compressed_token_account.delegate, + "compressed_token_account delegate", + )?; + if !pubkey_eq(token_data_delegate.key(), &delegate.to_bytes()) { + msg!( + "delegate mismatch: ctoken {:?} != output {:?}", + solana_pubkey::Pubkey::new_from_array(delegate.to_bytes()), + solana_pubkey::Pubkey::new_from_array(*token_data_delegate.key()) + ); + return Err(ErrorCode::CompressAndCloseInvalidDelegate.into()); + } + } + // if ctoken has fee extension withheld amount must match + let ctoken_withheld_fee = ctoken.extensions.as_ref().and_then(|exts| { + exts.iter().find_map(|ext| { + if let ZExtensionStructMut::TransferFeeAccount(fee_ext) = ext { + Some(fee_ext.withheld_amount) + } else { + None + } + }) + }); + + if let Some(withheld_fee) = ctoken_withheld_fee { + if compression_only_extension.withheld_transfer_fee != withheld_fee { + msg!( + "withheld_transfer_fee mismatch: ctoken {} != extension {}", + withheld_fee, + u64::from(compression_only_extension.withheld_transfer_fee) + ); + return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); + } + } else if u64::from(compression_only_extension.withheld_transfer_fee) != 0 { + msg!( + "withheld_transfer_fee must be 0 when ctoken has no fee extension, got {}", + u64::from(compression_only_extension.withheld_transfer_fee) + ); + return Err(ErrorCode::CompressAndCloseWithheldFeeMismatch.into()); + } + + // Frozen state must match between CToken and extension data + // AccountState::Frozen = 2 in CToken + // ZeroCopy converts bool to u8: 0 = false, non-zero = true + let ctoken_is_frozen = *ctoken.state == 2; + let extension_is_frozen = compression_only_extension.is_frozen != 0; + if extension_is_frozen != ctoken_is_frozen { + msg!( + "is_frozen mismatch: ctoken {} != extension {}", + ctoken_is_frozen, + compression_only_extension.is_frozen + ); + return Err(ErrorCode::CompressAndCloseFrozenMismatch.into()); + } + } else { + // Frozen accounts require CompressedOnly extension to preserve frozen state + // AccountState::Frozen = 2 in CToken + let ctoken_is_frozen = *ctoken.state == 2; + if ctoken_is_frozen { + msg!("Frozen account requires CompressedOnly extension with is_frozen=true"); + return Err(ErrorCode::CompressAndCloseMissingCompressedOnlyExtension.into()); + } + + // Source token account must not have a delegate + // Compressed tokens don't support delegation, so we reject accounts with delegates + if ctoken.delegate.is_some() { + msg!("Source token account has delegate, cannot compress and close"); + return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); + } + + // Delegate should be None + if compressed_token_account.has_delegate() { + return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); + } + if compressed_token_account.delegate != 0 { + return Err(ErrorCode::CompressAndCloseDelegateNotAllowed.into()); + } + } + Ok(()) } diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index c90fce8cc1..13a625f913 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -1,16 +1,16 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::checks::check_owner; +use light_compressed_account::Pubkey; use light_ctoken_types::{ - instructions::transfer2::ZCompressionMode, - state::{CToken, ZExtensionStructMut}, + instructions::{extensions::ZExtensionInstructionData, transfer2::ZCompressionMode}, + state::{CToken, ZCompressedTokenMut, ZExtensionStructMut}, CTokenError, }; use light_program_profiler::profile; use pinocchio::{ account_info::AccountInfo, - pubkey::pubkey_eq, - sysvars::{clock::Clock, Sysvar}, + sysvars::{clock::Clock, rent::Rent, Sysvar}, }; use spl_pod::solana_msg::msg; @@ -35,6 +35,9 @@ pub fn compress_or_decompress_ctokens( token_account_info, mode, packed_accounts, + mint_checks, + input_tlv, + input_delegate, } = inputs; check_owner(&crate::LIGHT_CPI_SIGNER.program_id, token_account_info)?; @@ -44,7 +47,12 @@ pub fn compress_or_decompress_ctokens( let (mut ctoken, _) = CToken::zero_copy_at_mut_checked(&mut token_account_data)?; - if !pubkey_eq(ctoken.mint.array_ref(), &mint) { + // Reject uninitialized accounts (state == 0) + if *ctoken.state == 0 { + msg!("Account is uninitialized"); + return Err(CTokenError::InvalidAccountState.into()); + } + if ctoken.mint.to_bytes() != mint { msg!( "mint mismatch account: ctoken.mint {:?}, mint {:?}", solana_pubkey::Pubkey::new_from_array(ctoken.mint.to_bytes()), @@ -54,11 +62,14 @@ pub fn compress_or_decompress_ctokens( } // Check if account is frozen (SPL Token-2022 compatibility) - // Frozen accounts cannot have their balance modified in any way - // TODO: Once freezing ctoken accounts is implemented, we need to allow - // CompressAndClose with rent authority for frozen accounts (similar to - // how rent authority can compress expired accounts) - if *ctoken.state == 2 { + // Frozen accounts cannot have their balance modified except for CompressAndClose + // with rent authority (compression authority can compress expired frozen accounts) + let is_compress_and_close_with_rent_sponsor = mode == ZCompressionMode::CompressAndClose + && compress_and_close_inputs + .as_ref() + .map(|inputs| inputs.rent_sponsor_is_signer_flag) + .unwrap_or(false); + if *ctoken.state == 2 && !is_compress_and_close_with_rent_sponsor { msg!("Cannot modify frozen account"); return Err(ErrorCode::AccountFrozen.into()); } @@ -71,7 +82,7 @@ pub fn compress_or_decompress_ctokens( ZCompressionMode::Compress => { // Verify authority for compression operations and update delegated amount if needed let authority_account = authority.ok_or(ErrorCode::InvalidCompressAuthority)?; - check_ctoken_owner(&mut ctoken, authority_account)?; + check_ctoken_owner(&mut ctoken, authority_account, mint_checks.as_ref(), amount)?; // Compress: subtract from solana account // Update the balance in the ctoken solana account @@ -96,6 +107,9 @@ pub fn compress_or_decompress_ctokens( .ok_or(ProgramError::ArithmeticOverflow)? .into(); + // Handle extension state transfer from input compressed account + apply_decompress_extension_state(&mut ctoken, input_tlv, input_delegate)?; + process_compressible_extension( ctoken.extensions.as_deref(), token_account_info, @@ -115,8 +129,101 @@ pub fn compress_or_decompress_ctokens( } } +/// Apply extension state from the input compressed account during decompress. +/// This transfers delegate, delegated_amount, and withheld_transfer_fee from +/// the compressed account's CompressedOnly extension to the CToken account. #[inline(always)] -fn process_compressible_extension( +fn apply_decompress_extension_state( + ctoken: &mut ZCompressedTokenMut, + input_tlv: Option<&[ZExtensionInstructionData]>, + input_delegate: Option<&AccountInfo>, +) -> Result<(), ProgramError> { + // Extract CompressedOnly extension data from input TLV + let compressed_only_data = input_tlv.and_then(|tlv| { + tlv.iter().find_map(|ext| { + if let ZExtensionInstructionData::CompressedOnly(data) = ext { + Some(data) + } else { + None + } + }) + }); + + // If no CompressedOnly extension, nothing to transfer + let Some(ext_data) = compressed_only_data else { + return Ok(()); + }; + + let delegated_amount: u64 = ext_data.delegated_amount.into(); + let withheld_transfer_fee: u64 = ext_data.withheld_transfer_fee.into(); + + // Handle delegate and delegated_amount + if delegated_amount > 0 || input_delegate.is_some() { + let input_delegate_pubkey = input_delegate.map(|acc| Pubkey::from(*acc.key())); + + // Validate delegate compatibility + if let Some(ctoken_delegate) = ctoken.delegate.as_ref() { + // CToken has a delegate - check if it matches the input delegate + if let Some(input_del) = input_delegate_pubkey.as_ref() { + if ctoken_delegate.to_bytes() != input_del.to_bytes() { + msg!( + "Decompress delegate mismatch: CToken delegate {:?} != input delegate {:?}", + ctoken_delegate.to_bytes(), + input_del.to_bytes() + ); + return Err(ErrorCode::DecompressDelegateMismatch.into()); + } + } + // Delegates match - add to delegated_amount + } else if let Some(input_del) = input_delegate_pubkey { + // CToken has no delegate - set it from the input + ctoken.set_delegate(Some(input_del))?; + } else if delegated_amount > 0 { + // Has delegated_amount but no delegate pubkey - invalid state + msg!("Decompress: delegated_amount > 0 but no delegate pubkey provided"); + return Err(CTokenError::InvalidAccountData.into()); + } + + // Add delegated_amount to CToken's delegated_amount + if delegated_amount > 0 { + let current_delegated: u64 = (*ctoken.delegated_amount).into(); + *ctoken.delegated_amount = current_delegated + .checked_add(delegated_amount) + .ok_or(ProgramError::ArithmeticOverflow)? + .into(); + } + } + + // Handle withheld_transfer_fee + if withheld_transfer_fee > 0 { + let mut fee_applied = false; + if let Some(extensions) = ctoken.extensions.as_deref_mut() { + for extension in extensions.iter_mut() { + if let ZExtensionStructMut::TransferFeeAccount(ref mut fee_ext) = extension { + fee_ext + .add_withheld_amount(withheld_transfer_fee) + .map_err(|_| ProgramError::ArithmeticOverflow)?; + fee_applied = true; + break; + } + } + } + if !fee_applied { + msg!("Decompress: withheld_transfer_fee > 0 but no TransferFeeAccount extension found"); + return Err(CTokenError::InvalidAccountData.into()); + } + } + + // Handle is_frozen - restore frozen state from compressed token + if ext_data.is_frozen != 0 { + *ctoken.state = 2; // AccountState::Frozen + } + + Ok(()) +} + +#[inline(always)] +pub fn process_compressible_extension( extensions: Option<&[ZExtensionStructMut]>, token_account_info: &AccountInfo, current_slot: &mut u64, @@ -135,12 +242,17 @@ fn process_compressible_extension( .map_err(|_| CTokenError::SysvarAccessError)? .slot; } + let rent_exemption = Rent::get() + .map_err(|_| CTokenError::SysvarAccessError)? + .minimum_balance(token_account_info.data_len()); + *transfer_amount = compressible_extension + .info .calculate_top_up_lamports( token_account_info.data_len() as u64, *current_slot, token_account_info.lamports(), - light_ctoken_types::COMPRESSIBLE_TOKEN_RENT_EXEMPTION, + rent_exemption, ) .map_err(|_| CTokenError::InvalidAccountData)?; diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs index 4d54b9e062..4239d75f2e 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/inputs.rs @@ -1,15 +1,24 @@ use light_account_checks::packed_accounts::ProgramPackedAccounts; -use light_ctoken_types::instructions::transfer2::{ - ZCompressedTokenInstructionDataTransfer2, ZCompression, ZCompressionMode, - ZMultiTokenTransferOutputData, +use light_ctoken_types::instructions::{ + extensions::ZExtensionInstructionData, + transfer2::{ + ZCompressedTokenInstructionDataTransfer2, ZCompression, ZCompressionMode, + ZMultiTokenTransferOutputData, + }, }; use pinocchio::{account_info::AccountInfo, pubkey::Pubkey}; +use crate::extensions::MintExtensionChecks; + /// Compress and close specific inputs pub struct CompressAndCloseInputs<'a> { pub destination: &'a AccountInfo, pub rent_sponsor: &'a AccountInfo, pub compressed_token_account: Option<&'a ZMultiTokenTransferOutputData<'a>>, + pub tlv: Option<&'a [ZExtensionInstructionData<'a>]>, + /// Flag from instruction data indicating rent sponsor is signer. + /// Must be verified against actual signer in compress_and_close.rs. + pub rent_sponsor_is_signer_flag: bool, } /// Input struct for ctoken compression/decompression operations @@ -21,6 +30,13 @@ pub struct CTokenCompressionInputs<'a> { pub token_account_info: &'a AccountInfo, pub mode: ZCompressionMode, pub packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, + /// Mint extension checks result (permanent delegate, transfer fee info). + /// Used to validate permanent delegate authority for compression operations. + pub mint_checks: Option, + /// Input TLV for decompress operations (from the input compressed account being consumed). + pub input_tlv: Option<&'a [ZExtensionInstructionData<'a>]>, + /// Delegate pubkey from input compressed account (for decompress extension state transfer). + pub input_delegate: Option<&'a AccountInfo>, } impl<'a> CTokenCompressionInputs<'a> { @@ -28,8 +44,9 @@ impl<'a> CTokenCompressionInputs<'a> { pub fn from_compression( compression: &ZCompression, token_account_info: &'a AccountInfo, - inputs: &'a ZCompressedTokenInstructionDataTransfer2, + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, + mint_checks: Option, ) -> Result { let authority_account = if compression.mode != ZCompressionMode::Decompress { Some(packed_accounts.get_u8( @@ -58,11 +75,51 @@ impl<'a> CTokenCompressionInputs<'a> { compressed_token_account: inputs .out_token_data .get(compression.get_compressed_token_account_index()? as usize), + tlv: inputs + .out_tlv + .as_ref() + .and_then(|v| { + v.get(compression.get_compressed_token_account_index().ok()? as usize) + }) + .map(|data| data.as_slice()), + rent_sponsor_is_signer_flag: compression.rent_sponsor_is_signer(), }) } else { None }; + // For Decompress mode, find matching input by mint index and extract TLV and delegate + let (input_tlv, input_delegate) = if compression.mode == ZCompressionMode::Decompress { + // Find the input compressed account that matches this decompress by mint index + let matching_input_index = inputs + .in_token_data + .iter() + .position(|input| input.mint == compression.mint); + + let input_tlv = matching_input_index.and_then(|idx| { + inputs + .in_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(idx)) + .map(|v| v.as_slice()) + }); + + let input_delegate = matching_input_index.and_then(|idx| { + let input = inputs.in_token_data.get(idx)?; + if input.has_delegate() { + packed_accounts + .get_u8(input.delegate, "input delegate") + .ok() + } else { + None + } + }); + + (input_tlv, input_delegate) + } else { + (None, None) + }; + Ok(Self { authority: authority_account, compress_and_close_inputs, @@ -71,6 +128,9 @@ impl<'a> CTokenCompressionInputs<'a> { token_account_info, mode: compression.mode.clone(), packed_accounts, + mint_checks, + input_tlv, + input_delegate, }) } @@ -88,6 +148,9 @@ impl<'a> CTokenCompressionInputs<'a> { token_account_info, mode: ZCompressionMode::Decompress, packed_accounts, + mint_checks: None, + input_tlv: None, + input_delegate: None, } } } diff --git a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs b/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs index 3bf64a8cc3..5a3fa1d4dd 100644 --- a/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs +++ b/programs/compressed-token/program/src/transfer2/compression/ctoken/mod.rs @@ -6,22 +6,26 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use super::validate_compression_mode_fields; +use crate::extensions::MintExtensionChecks; mod compress_and_close; mod compress_or_decompress_ctokens; mod inputs; pub use compress_and_close::close_for_compress_and_close; -pub use compress_or_decompress_ctokens::compress_or_decompress_ctokens; +pub use compress_or_decompress_ctokens::{ + compress_or_decompress_ctokens, process_compressible_extension, +}; pub use inputs::{CTokenCompressionInputs, CompressAndCloseInputs}; /// Process compression/decompression for ctoken accounts. #[profile] -pub(super) fn process_ctoken_compressions( - inputs: &ZCompressedTokenInstructionDataTransfer2, +pub(super) fn process_ctoken_compressions<'a>( + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, compression: &ZCompression, - token_account_info: &AccountInfo, - packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + token_account_info: &'a AccountInfo, + packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, + mint_checks: Option, transfer_amount: &mut u64, lamports_budget: &mut u64, ) -> Result<(), anchor_lang::prelude::ProgramError> { @@ -34,6 +38,7 @@ pub(super) fn process_ctoken_compressions( token_account_info, inputs, packed_accounts, + mint_checks, )?; compress_or_decompress_ctokens(compression_inputs, transfer_amount, lamports_budget) diff --git a/programs/compressed-token/program/src/transfer2/compression/mod.rs b/programs/compressed-token/program/src/transfer2/compression/mod.rs index ba91a46b13..3cff103379 100644 --- a/programs/compressed-token/program/src/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/transfer2/compression/mod.rs @@ -13,6 +13,7 @@ use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; use spl_pod::solana_msg::msg; +use super::check_extensions::MintExtensionCache; use crate::{ shared::{ convert_program_error, @@ -37,12 +38,13 @@ const ID: &[u8; 32] = &LIGHT_CPI_SIGNER.program_id; /// # Arguments /// * `max_top_up` - Maximum lamports for rent and top-up combined. Transaction fails if exceeded. (0 = no limit) #[profile] -pub fn process_token_compression( +pub fn process_token_compression<'a>( fee_payer: &AccountInfo, - inputs: &ZCompressedTokenInstructionDataTransfer2, - packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, + packed_accounts: &'a ProgramPackedAccounts<'a, AccountInfo>, cpi_authority: &AccountInfo, max_top_up: u16, + mint_cache: &'a MintExtensionCache, ) -> Result<(), ProgramError> { if let Some(compressions) = inputs.compressions.as_ref() { let mut transfer_map = [0u64; MAX_PACKED_ACCOUNTS]; @@ -65,12 +67,16 @@ pub fn process_token_compression( "compression source or recipient", )?; + // Lookup cached mint extension checks (cache was built with skip logic already applied) + let mint_checks = mint_cache.get_by_key(&compression.mint).cloned(); + match source_or_recipient.owner() { ID => ctoken::process_ctoken_compressions( inputs, compression, source_or_recipient, packed_accounts, + mint_checks, &mut transfer_map[account_index], &mut lamports_budget, )?, diff --git a/programs/compressed-token/program/src/transfer2/compression/spl.rs b/programs/compressed-token/program/src/transfer2/compression/spl.rs index 425d1e20b5..733a82cdea 100644 --- a/programs/compressed-token/program/src/transfer2/compression/spl.rs +++ b/programs/compressed-token/program/src/transfer2/compression/spl.rs @@ -26,9 +26,12 @@ pub(super) fn process_spl_compressions( validate_compression_mode_fields(compression)?; - let mint_account = *packed_accounts - .get_u8(compression.mint, "process_spl_compression: token mint")? - .key(); + let mint_account_info = + packed_accounts.get_u8(compression.mint, "process_spl_compression: token mint")?; + let mint_account = *mint_account_info.key(); + + let decimals = compression.decimals; + let token_pool_account_info = packed_accounts.get_u8( compression.pool_account_index, "process_spl_compression: token pool account", @@ -45,20 +48,24 @@ pub(super) fn process_spl_compressions( compression.authority, "process_spl_compression: authority account", )?; - spl_token_transfer_invoke( + spl_token_transfer_checked_invoke( token_program, token_account_info, + mint_account_info, token_pool_account_info, authority, u64::from(*compression.amount), + decimals, )?; } - ZCompressionMode::Decompress => spl_token_transfer_invoke_cpi( + ZCompressionMode::Decompress => spl_token_transfer_checked_invoke_cpi( token_program, token_pool_account_info, + mint_account_info, token_account_info, cpi_authority, u64::from(*compression.amount), + decimals, )?, ZCompressionMode::CompressAndClose => { msg!("CompressAndClose is unimplemented for spl token accounts"); @@ -70,12 +77,14 @@ pub(super) fn process_spl_compressions( #[profile] #[inline(always)] -fn spl_token_transfer_invoke_cpi( +fn spl_token_transfer_checked_invoke_cpi( token_program: &[u8; 32], from: &AccountInfo, + mint: &AccountInfo, to: &AccountInfo, cpi_authority: &AccountInfo, amount: u64, + decimals: u8, ) -> Result<(), ProgramError> { let bump_seed = [BUMP_CPI_AUTHORITY]; let seed_array = [ @@ -84,43 +93,59 @@ fn spl_token_transfer_invoke_cpi( ]; let signer = Signer::from(&seed_array); - spl_token_transfer_common( + spl_token_transfer_checked_common( token_program, from, + mint, to, cpi_authority, amount, + decimals, Some(&[signer]), ) } #[profile] #[inline(always)] -fn spl_token_transfer_invoke( +fn spl_token_transfer_checked_invoke( program_id: &[u8; 32], from: &AccountInfo, + mint: &AccountInfo, to: &AccountInfo, authority: &AccountInfo, amount: u64, + decimals: u8, ) -> Result<(), ProgramError> { - spl_token_transfer_common(program_id, from, to, authority, amount, None) + spl_token_transfer_checked_common( + program_id, from, mint, to, authority, amount, decimals, None, + ) } +/// Performs a transfer_checked CPI to the token program. +/// transfer_checked is required for Token 2022 mints with TransferFeeConfig extension. +/// Account order: source, mint, destination, authority #[inline(always)] -fn spl_token_transfer_common( +#[allow(clippy::too_many_arguments)] +fn spl_token_transfer_checked_common( token_program: &[u8; 32], from: &AccountInfo, + mint: &AccountInfo, to: &AccountInfo, authority: &AccountInfo, amount: u64, + decimals: u8, signers: Option<&[pinocchio::instruction::Signer]>, ) -> Result<(), ProgramError> { - let mut instruction_data = [0u8; 9]; - instruction_data[0] = 3u8; // Transfer instruction discriminator + // TransferChecked instruction data: discriminator (1) + amount (8) + decimals (1) = 10 bytes + let mut instruction_data = [0u8; 10]; + instruction_data[0] = 12u8; // TransferChecked instruction discriminator instruction_data[1..9].copy_from_slice(&amount.to_le_bytes()); + instruction_data[9] = decimals; + // Account order for TransferChecked: source, mint, destination, authority let account_metas = [ AccountMeta::new(from.key(), true, false), + AccountMeta::new(mint.key(), false, false), // mint is not writable AccountMeta::new(to.key(), true, false), AccountMeta::new(authority.key(), false, true), ]; @@ -131,7 +156,7 @@ fn spl_token_transfer_common( data: &instruction_data, }; - let account_infos = &[from, to, authority]; + let account_infos = &[from, mint, to, authority]; match signers { Some(signers) => { diff --git a/programs/compressed-token/program/src/transfer2/config.rs b/programs/compressed-token/program/src/transfer2/config.rs index 6e44b030ab..91f463f0eb 100644 --- a/programs/compressed-token/program/src/transfer2/config.rs +++ b/programs/compressed-token/program/src/transfer2/config.rs @@ -18,7 +18,10 @@ pub struct Transfer2Config { pub total_input_lamports: u64, /// Total output lamports (checked arithmetic). pub total_output_lamports: u64, + /// No compressed accounts (neither input nor output) - determines system CPI path pub no_compressed_accounts: bool, + /// No output compressed accounts - determines mint extension hotpath + pub no_output_compressed_accounts: bool, } impl Transfer2Config { @@ -29,6 +32,7 @@ impl Transfer2Config { ) -> Result { let no_compressed_accounts = inputs.in_token_data.is_empty() && inputs.out_token_data.is_empty(); + let no_output_compressed_accounts = inputs.out_token_data.is_empty(); Ok(Self { sol_pool_required: false, sol_decompression_required: false, @@ -41,6 +45,7 @@ impl Transfer2Config { total_input_lamports: 0, total_output_lamports: 0, no_compressed_accounts, + no_output_compressed_accounts, }) } } diff --git a/programs/compressed-token/program/src/transfer2/cpi.rs b/programs/compressed-token/program/src/transfer2/cpi.rs index bfd991137c..a8e6d221a1 100644 --- a/programs/compressed-token/program/src/transfer2/cpi.rs +++ b/programs/compressed-token/program/src/transfer2/cpi.rs @@ -1,6 +1,12 @@ use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnlyConfig; -use light_ctoken_types::instructions::transfer2::ZCompressedTokenInstructionDataTransfer2; +use light_ctoken_types::{ + instructions::{ + extensions::ZExtensionInstructionData, transfer2::ZCompressedTokenInstructionDataTransfer2, + }, + state::{ExtensionStructConfig, TokenData, TokenDataConfig}, +}; use light_program_profiler::profile; +use light_zero_copy::ZeroCopyNew; use pinocchio::program_error::ProgramError; use tinyvec::ArrayVec; @@ -23,9 +29,42 @@ pub fn allocate_cpi_bytes( } let mut output_accounts = ArrayVec::new(); - for output_data in inputs.out_token_data.iter() { + for (i, output_data) in inputs.out_token_data.iter().enumerate() { let has_delegate = output_data.has_delegate(); - output_accounts.push((false, compressed_token_data_len(has_delegate))); // Token accounts don't have addresses + + // Check if there's TLV data for this output + let tlv_data: Option<&[ZExtensionInstructionData]> = inputs + .out_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(i).map(|ext_vec| ext_vec.as_slice())); + + let data_len = if let Some(tlv) = tlv_data { + if !tlv.is_empty() { + // Build TLV config for byte length calculation + let tlv_config: Vec = tlv + .iter() + .filter_map(|ext| match ext { + ZExtensionInstructionData::CompressedOnly(_) => { + Some(ExtensionStructConfig::CompressedOnly(())) + } + _ => None, + }) + .collect(); + + let token_config = TokenDataConfig { + delegate: (has_delegate, ()), + tlv: (true, tlv_config), + }; + TokenData::byte_len(&token_config).map_err(|_| ProgramError::InvalidAccountData)? + as u32 + } else { + compressed_token_data_len(has_delegate) + } + } else { + compressed_token_data_len(has_delegate) + }; + + output_accounts.push((false, data_len)); // Token accounts don't have addresses } // Add extra output account for change account if needed (no delegate, no token data) diff --git a/programs/compressed-token/program/src/transfer2/mod.rs b/programs/compressed-token/program/src/transfer2/mod.rs index a61a5859ba..b28155e73d 100644 --- a/programs/compressed-token/program/src/transfer2/mod.rs +++ b/programs/compressed-token/program/src/transfer2/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod check_extensions; pub mod compression; pub mod config; pub mod cpi; diff --git a/programs/compressed-token/program/src/transfer2/processor.rs b/programs/compressed-token/program/src/transfer2/processor.rs index fe0f846ced..8e3a3f3ecd 100644 --- a/programs/compressed-token/program/src/transfer2/processor.rs +++ b/programs/compressed-token/program/src/transfer2/processor.rs @@ -4,8 +4,11 @@ use light_array_map::ArrayMap; use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnly; use light_ctoken_types::{ hash_cache::HashCache, - instructions::transfer2::{ - CompressedTokenInstructionDataTransfer2, ZCompressedTokenInstructionDataTransfer2, + instructions::{ + extensions::ZExtensionInstructionData, + transfer2::{ + CompressedTokenInstructionDataTransfer2, ZCompressedTokenInstructionDataTransfer2, + }, }, CTokenError, }; @@ -14,6 +17,7 @@ use light_zero_copy::{traits::ZeroCopyAt, ZeroCopyNew}; use pinocchio::account_info::AccountInfo; use spl_pod::solana_msg::msg; +use super::check_extensions::{build_mint_extension_cache, MintExtensionCache}; use crate::{ shared::{convert_program_error, cpi::execute_cpi_invoke}, transfer2::{ @@ -53,12 +57,24 @@ pub fn process_transfer2( let validated_accounts = Transfer2Accounts::validate_and_parse(accounts, &transfer_config)?; + let mint_cache = build_mint_extension_cache( + &inputs, + &validated_accounts.packed_accounts, + !transfer_config.no_output_compressed_accounts, + )?; + if transfer_config.no_compressed_accounts { // No compressed accounts are invalidated or created in this transaction // -> no need to invoke the light system program. - process_no_system_program_cpi(&inputs, &validated_accounts) + process_no_system_program_cpi(&inputs, &validated_accounts, &mint_cache) } else { - process_with_system_program_cpi(accounts, &inputs, &validated_accounts, transfer_config) + process_with_system_program_cpi( + accounts, + &inputs, + &validated_accounts, + transfer_config, + &mint_cache, + ) } } @@ -86,11 +102,56 @@ pub fn validate_instruction_data( msg!("outlamports are unimplemented",); return Err(CTokenError::TokenDataTlvUnimplemented); } - if inputs.in_tlv.is_some() { - return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); + // Validate in_tlv length matches in_token_data if provided + if let Some(in_tlv) = inputs.in_tlv.as_ref() { + if in_tlv.len() != inputs.in_token_data.len() { + msg!( + "in_tlv length {} does not match in_token_data length {}", + in_tlv.len(), + inputs.in_token_data.len() + ); + return Err(CTokenError::InvalidInstructionData); + } + + // CompressedOnly inputs can only decompress - no compressed outputs allowed + let has_compressed_only = in_tlv.iter().any(|tlv_vec| { + tlv_vec + .iter() + .any(|ext| matches!(ext, ZExtensionInstructionData::CompressedOnly(_))) + }); + if has_compressed_only && !inputs.out_token_data.is_empty() { + msg!("CompressedOnly inputs cannot have compressed outputs"); + return Err(CTokenError::CompressedOnlyBlocksTransfer); + } } - if inputs.out_tlv.is_some() { - return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); + // out_tlv is only allowed for CompressAndClose when rent authority is signer + // (forester compressing accounts with marker extensions) + if let Some(out_tlv) = inputs.out_tlv.as_ref() { + // Length check (mirrors in_tlv check above) + if out_tlv.len() != inputs.out_token_data.len() { + msg!( + "out_tlv length {} does not match out_token_data length {}", + out_tlv.len(), + inputs.out_token_data.len() + ); + return Err(CTokenError::InvalidInstructionData); + } + + // All compressions must be CompressAndClose with rent_sponsor_is_signer + let allowed = inputs + .compressions + .as_ref() + .is_some_and(|compressions| compressions.iter().all(|c| c.rent_sponsor_is_signer())); + if !allowed { + return Err(CTokenError::CompressedTokenAccountTlvUnimplemented); + } + + // Output count must match compressions count (no extra outputs) + let compressions_len = inputs.compressions.as_ref().map(|c| c.len()).unwrap_or(0); + if inputs.out_token_data.len() != compressions_len { + msg!("out_tlv requires out_token_data.len() == compressions.len()"); + return Err(CTokenError::OutTlvOutputCountMismatch); + } } // Check CPI context write mode doesn't have compressions. @@ -110,9 +171,10 @@ pub fn validate_instruction_data( #[profile] #[inline(always)] -fn process_no_system_program_cpi( - inputs: &ZCompressedTokenInstructionDataTransfer2, - validated_accounts: &Transfer2Accounts, +fn process_no_system_program_cpi<'a>( + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, + validated_accounts: &'a Transfer2Accounts<'a>, + mint_cache: &'a MintExtensionCache, ) -> Result<(), ProgramError> { let fee_payer = validated_accounts .compressions_only_fee_payer @@ -134,12 +196,16 @@ fn process_no_system_program_cpi( validate_mint_uniqueness(&mint_map, &validated_accounts.packed_accounts) .map_err(|e| ProgramError::Custom(e as u32 + 6000))?; + // This is the compression-only hot path (no compressed inputs/outputs). + // Extension checks are skipped because balance must be restored immediately + // (compress + decompress in same tx) or sum check will fail. process_token_compression( fee_payer, inputs, &validated_accounts.packed_accounts, cpi_authority_pda, inputs.max_top_up.get(), + mint_cache, )?; close_for_compress_and_close(compressions.as_slice(), validated_accounts)?; @@ -149,11 +215,12 @@ fn process_no_system_program_cpi( #[profile] #[inline(always)] -fn process_with_system_program_cpi( +fn process_with_system_program_cpi<'a>( accounts: &[AccountInfo], - inputs: &ZCompressedTokenInstructionDataTransfer2, - validated_accounts: &Transfer2Accounts, + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, + validated_accounts: &'a Transfer2Accounts<'a>, transfer_config: Transfer2Config, + mint_cache: &'a MintExtensionCache, ) -> Result<(), ProgramError> { // Allocate CPI bytes for zero-copy structure let (mut cpi_bytes, config) = allocate_cpi_bytes(inputs).map_err(convert_program_error)?; @@ -180,6 +247,8 @@ fn process_with_system_program_cpi( &mut hash_cache, inputs, &validated_accounts.packed_accounts, + accounts, + mint_cache, )?; // Process output compressed accounts. @@ -204,12 +273,14 @@ fn process_with_system_program_cpi( if let Some(system_accounts) = validated_accounts.system.as_ref() { // Process token compressions/decompressions/close_and_compress + // Mint extension checks are already cached, so we pass the cache. process_token_compression( system_accounts.fee_payer, inputs, &validated_accounts.packed_accounts, system_accounts.cpi_authority_pda, inputs.max_top_up.get(), + mint_cache, )?; // Get CPI accounts slice and tree accounts for light-system-program invocation diff --git a/programs/compressed-token/program/src/transfer2/sum_check.rs b/programs/compressed-token/program/src/transfer2/sum_check.rs index a1cdf49d9f..10b7a035be 100644 --- a/programs/compressed-token/program/src/transfer2/sum_check.rs +++ b/programs/compressed-token/program/src/transfer2/sum_check.rs @@ -110,7 +110,7 @@ pub fn sum_check_multi_mint( // Verify all sums are zero for i in 0..mint_sums.len() { - if let Some((_mint_index, balance)) = mint_sums.get(i) { + if let Some((_mint_index, balance)) = mint_sums.get_by_index(i) { if *balance != 0 { return Err(ErrorCode::SumCheckFailed); } @@ -132,7 +132,7 @@ pub fn validate_mint_uniqueness( let mut seen_pubkeys: ArrayMap<[u8; 32], u8, 5> = ArrayMap::new(); for i in 0..mint_map.len() { - if let Some((mint_index, _balance)) = mint_map.get(i) { + if let Some((mint_index, _balance)) = mint_map.get_by_index(i) { // Get the mint account pubkey from packed accounts let mint_account = packed_accounts .get(*mint_index as usize, "mint") diff --git a/programs/compressed-token/program/src/transfer2/token_inputs.rs b/programs/compressed-token/program/src/transfer2/token_inputs.rs index 22004c9643..64e9248e0d 100644 --- a/programs/compressed-token/program/src/transfer2/token_inputs.rs +++ b/programs/compressed-token/program/src/transfer2/token_inputs.rs @@ -1,22 +1,30 @@ +use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_compressed_account::instruction_data::with_readonly::ZInstructionDataInvokeCpiWithReadOnlyMut; use light_ctoken_types::{ - hash_cache::HashCache, instructions::transfer2::ZCompressedTokenInstructionDataTransfer2, + hash_cache::HashCache, + instructions::{ + extensions::ZExtensionInstructionData, transfer2::ZCompressedTokenInstructionDataTransfer2, + }, }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; +use spl_pod::solana_msg::msg; +use super::check_extensions::MintExtensionCache; use crate::shared::token_input::set_input_compressed_account; /// Process input compressed accounts and return total input lamports #[profile] #[inline(always)] -pub fn set_input_compressed_accounts( +pub fn set_input_compressed_accounts<'a>( cpi_instruction_struct: &mut ZInstructionDataInvokeCpiWithReadOnlyMut, hash_cache: &mut HashCache, - inputs: &ZCompressedTokenInstructionDataTransfer2, + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, + all_accounts: &[AccountInfo], + mint_cache: &'a MintExtensionCache, ) -> Result<(), ProgramError> { for (i, input_data) in inputs.in_token_data.iter().enumerate() { let input_lamports = if let Some(lamports) = inputs.in_lamports.as_ref() { @@ -29,6 +37,32 @@ pub fn set_input_compressed_accounts( 0 }; + // Get TLV data for this input + let tlv_data: Option<&[ZExtensionInstructionData]> = inputs + .in_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(i).map(|ext_vec| ext_vec.as_slice())); + + // Validate TLV is only used with version 3 (ShaFlat) + if tlv_data.is_some_and(|v| !v.is_empty() && input_data.version != 3) { + msg!("TLV extensions only supported with version 3 (ShaFlat)"); + return Err(ErrorCode::TlvRequiresVersion3.into()); + } + + // Check if input is frozen based on CompressedOnly extension is_frozen field + // ZeroCopy converts bool to u8: 0 = false, non-zero = true + let is_frozen = tlv_data + .and_then(|exts| { + exts.iter().find_map(|ext| { + if let ZExtensionInstructionData::CompressedOnly(data) = ext { + Some(data.is_frozen != 0) + } else { + None + } + }) + }) + .unwrap_or(false); + set_input_compressed_account( cpi_instruction_struct .input_compressed_accounts @@ -37,7 +71,11 @@ pub fn set_input_compressed_accounts( hash_cache, input_data, packed_accounts.accounts, + all_accounts, input_lamports, + tlv_data, + is_frozen, + mint_cache, )?; } diff --git a/programs/compressed-token/program/src/transfer2/token_outputs.rs b/programs/compressed-token/program/src/transfer2/token_outputs.rs index 9444ac34c4..7f502022e7 100644 --- a/programs/compressed-token/program/src/transfer2/token_outputs.rs +++ b/programs/compressed-token/program/src/transfer2/token_outputs.rs @@ -1,21 +1,26 @@ +use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_compressed_account::instruction_data::with_readonly::ZInstructionDataInvokeCpiWithReadOnlyMut; use light_ctoken_types::{ - hash_cache::HashCache, instructions::transfer2::ZCompressedTokenInstructionDataTransfer2, + hash_cache::HashCache, + instructions::{ + extensions::ZExtensionInstructionData, transfer2::ZCompressedTokenInstructionDataTransfer2, + }, }; use light_program_profiler::profile; use pinocchio::account_info::AccountInfo; +use spl_pod::solana_msg::msg; use crate::shared::token_output::set_output_compressed_account; /// Process output compressed accounts and return total output lamports #[profile] #[inline(always)] -pub fn set_output_compressed_accounts( +pub fn set_output_compressed_accounts<'a>( cpi_instruction_struct: &mut ZInstructionDataInvokeCpiWithReadOnlyMut, hash_cache: &mut HashCache, - inputs: &ZCompressedTokenInstructionDataTransfer2, + inputs: &'a ZCompressedTokenInstructionDataTransfer2<'a>, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, ) -> Result<(), ProgramError> { for (i, output_data) in inputs.out_token_data.iter().enumerate() { @@ -39,7 +44,7 @@ pub fn set_output_compressed_accounts( // Get delegate if present let delegate_pubkey = if output_data.has_delegate() { let delegate_account = - packed_accounts.get_u8(output_data.delegate, "out token delegete")?; + packed_accounts.get_u8(output_data.delegate, "out token delegate")?; Some(*delegate_account.key()) } else { None @@ -49,6 +54,33 @@ pub fn set_output_compressed_accounts( } else { None }; + + // Get TLV data for this output + let tlv_data: Option<&[ZExtensionInstructionData]> = inputs + .out_tlv + .as_ref() + .and_then(|tlvs| tlvs.get(i).map(|ext_vec| ext_vec.as_slice())); + + // Validate TLV is only used with version 3 (ShaFlat) + if tlv_data.is_some_and(|v| !v.is_empty() && output_data.version != 3) { + msg!("TLV extensions only supported with version 3 (ShaFlat)"); + return Err(ErrorCode::TlvRequiresVersion3.into()); + } + + // Check if output should be frozen based on CompressedOnly extension is_frozen field + // ZeroCopy converts bool to u8: 0 = false, non-zero = true + let is_frozen = tlv_data + .and_then(|exts| { + exts.iter().find_map(|ext| { + if let ZExtensionInstructionData::CompressedOnly(data) = ext { + Some(data.is_frozen != 0) + } else { + None + } + }) + }) + .unwrap_or(false); + set_output_compressed_account( cpi_instruction_struct .output_compressed_accounts @@ -62,6 +94,8 @@ pub fn set_output_compressed_accounts( mint_account.key().into(), inputs.output_queue, output_data.version, + tlv_data, + is_frozen, )?; } diff --git a/programs/compressed-token/program/tests/compress_and_close.rs b/programs/compressed-token/program/tests/compress_and_close.rs index 9c336a1aa6..70fe8d2d65 100644 --- a/programs/compressed-token/program/tests/compress_and_close.rs +++ b/programs/compressed-token/program/tests/compress_and_close.rs @@ -39,11 +39,17 @@ fn create_compressible_ctoken_data( if let Some(light_ctoken_types::state::ZExtensionStructMut::Compressible(comp_ext)) = extensions.first_mut() { - comp_ext.config_account_version.set(1); - comp_ext.account_version = 3; // ShaFlat - comp_ext.compression_authority.copy_from_slice(owner_pubkey); - comp_ext.rent_sponsor.copy_from_slice(rent_sponsor_pubkey); - comp_ext.last_claimed_slot.set(0); + comp_ext.info.config_account_version.set(1); + comp_ext.info.account_version = 3; // ShaFlat + comp_ext + .info + .compression_authority + .copy_from_slice(owner_pubkey); + comp_ext + .info + .rent_sponsor + .copy_from_slice(rent_sponsor_pubkey); + comp_ext.info.last_claimed_slot.set(0); } } @@ -64,7 +70,7 @@ fn test_close_for_compress_and_close_duplicate_detection() { pool_account_index: 2, // rent_sponsor index pool_index: 0, // DUPLICATE: compressed_account_index = 0 bump: 3, // destination index - decimals: 0, + decimals: 9, }, Compression { mode: CompressionMode::CompressAndClose, @@ -75,7 +81,7 @@ fn test_close_for_compress_and_close_duplicate_detection() { pool_account_index: 2, // rent_sponsor index pool_index: 0, // DUPLICATE: compressed_account_index = 0 (SAME AS FIRST!) bump: 3, // destination index - decimals: 0, + decimals: 9, }, ]; diff --git a/programs/compressed-token/program/tests/mint_action.rs b/programs/compressed-token/program/tests/mint_action.rs index ae19c68487..08c7f121c1 100644 --- a/programs/compressed-token/program/tests/mint_action.rs +++ b/programs/compressed-token/program/tests/mint_action.rs @@ -224,7 +224,8 @@ fn compute_expected_config(data: &MintActionCompressedInstructionData) -> Accoun .map(|ctx| ctx.first_set_context || ctx.set_context) .unwrap_or(false); - // 3. has_mint_to_actions (only MintToCompressed needs tokens_out_queue, not MintToCToken) + // 3. has_mint_to_actions + // Only MintToCompressed counts - MintToCToken mints to existing decompressed accounts let has_mint_to_actions = data .actions .iter() diff --git a/programs/compressed-token/program/tests/multi_sum_check.rs b/programs/compressed-token/program/tests/multi_sum_check.rs index e1233bcf0d..4e843ee212 100644 --- a/programs/compressed-token/program/tests/multi_sum_check.rs +++ b/programs/compressed-token/program/tests/multi_sum_check.rs @@ -89,7 +89,7 @@ fn multi_sum_check_test( pool_account_index: 0, pool_index: 0, bump: 255, - decimals: 0, + decimals: 9, }] }); @@ -354,7 +354,7 @@ fn test_multi_mint_scenario( pool_account_index: 0, pool_index: 0, bump: 255, - decimals: 0, + decimals: 9, }) .collect(); diff --git a/programs/compressed-token/program/tests/token_input.rs b/programs/compressed-token/program/tests/token_input.rs index b26214c5dc..cec490d9d0 100644 --- a/programs/compressed-token/program/tests/token_input.rs +++ b/programs/compressed-token/program/tests/token_input.rs @@ -2,6 +2,7 @@ use anchor_compressed_token::TokenData as AnchorTokenData; use anchor_lang::prelude::*; use borsh::{BorshDeserialize, BorshSerialize}; use light_account_checks::account_info::test_account_info::pinocchio::get_account_info; +use light_array_map::ArrayMap; use light_compressed_account::instruction_data::with_readonly::{ InAccount, InstructionDataInvokeCpiWithReadOnly, }; @@ -10,11 +11,12 @@ use light_compressed_token::{ TOKEN_COMPRESSED_ACCOUNT_DISCRIMINATOR, TOKEN_COMPRESSED_ACCOUNT_V2_DISCRIMINATOR, TOKEN_COMPRESSED_ACCOUNT_V3_DISCRIMINATOR, }, + extensions::MintExtensionChecks, shared::{ cpi_bytes_size::{ allocate_invoke_with_read_only_cpi_bytes, cpi_bytes_config, CpiConfigInput, }, - token_input::{set_input_compressed_account, set_input_compressed_account_frozen}, + token_input::set_input_compressed_account, }, }; use light_ctoken_types::{ @@ -110,24 +112,24 @@ fn test_rnd_create_input_compressed_account() { let mut hash_cache = HashCache::new(); + // Create mint extension cache with default checks for mint at index 0 + let mut mint_cache: ArrayMap = ArrayMap::new(); + mint_cache + .insert(0, MintExtensionChecks::default(), ()) + .unwrap(); + // Call the function under test - let result = if is_frozen { - set_input_compressed_account_frozen( - input_account, - &mut hash_cache, - &z_input_data, - remaining_accounts.as_slice(), - lamports, - ) - } else { - set_input_compressed_account( - input_account, - &mut hash_cache, - &z_input_data, - remaining_accounts.as_slice(), - lamports, - ) - }; + let result = set_input_compressed_account( + input_account, + &mut hash_cache, + &z_input_data, + remaining_accounts.as_slice(), + remaining_accounts.as_slice(), + lamports, + None, // No TLV data in test + is_frozen, + &mint_cache, + ); assert!(result.is_ok(), "Function failed: {:?}", result.err()); diff --git a/programs/compressed-token/program/tests/token_output.rs b/programs/compressed-token/program/tests/token_output.rs index 4395f47fc6..49bfa62a05 100644 --- a/programs/compressed-token/program/tests/token_output.rs +++ b/programs/compressed-token/program/tests/token_output.rs @@ -9,19 +9,26 @@ use light_compressed_account::{ Pubkey, }; use light_compressed_token::{ - constants::TOKEN_COMPRESSED_ACCOUNT_V2_DISCRIMINATOR, + constants::{ + TOKEN_COMPRESSED_ACCOUNT_V2_DISCRIMINATOR, TOKEN_COMPRESSED_ACCOUNT_V3_DISCRIMINATOR, + }, shared::{ cpi_bytes_size::{ - allocate_invoke_with_read_only_cpi_bytes, compressed_token_data_len, cpi_bytes_config, - CpiConfigInput, + allocate_invoke_with_read_only_cpi_bytes, cpi_bytes_config, CpiConfigInput, }, token_output::set_output_compressed_account, }, }; use light_ctoken_types::{ - hash_cache::HashCache, state::CompressedTokenAccountState as AccountState, + hash_cache::HashCache, + instructions::extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, + state::{ + CompressedOnlyExtension, CompressedTokenAccountState as AccountState, ExtensionStruct, + ExtensionStructConfig, TokenData, TokenDataConfig, + }, }; -use light_zero_copy::ZeroCopyNew; +use light_hasher::Hasher; +use light_zero_copy::{traits::ZeroCopyAt, ZeroCopyNew}; #[test] fn test_rnd_create_output_compressed_accounts() { @@ -41,6 +48,9 @@ fn test_rnd_create_output_compressed_accounts() { let mut delegate_flags = Vec::new(); let mut lamports_vec = Vec::new(); let mut merkle_tree_indices = Vec::new(); + let mut tlv_flags = Vec::new(); + let mut tlv_delegated_amounts = Vec::new(); + let mut tlv_withheld_fees = Vec::new(); for _ in 0..num_outputs { owner_pubkeys.push(Pubkey::new_from_array(rng.gen::<[u8; 32]>())); @@ -52,6 +62,9 @@ fn test_rnd_create_output_compressed_accounts() { None }); merkle_tree_indices.push(rng.gen_range(0..=255u8)); + tlv_flags.push(rng.gen_bool(0.3)); // 30% chance of having TLV + tlv_delegated_amounts.push(rng.gen_range(0..=u64::MAX)); + tlv_withheld_fees.push(rng.gen_range(0..=u64::MAX)); } // Random delegate @@ -67,10 +80,20 @@ fn test_rnd_create_output_compressed_accounts() { None }; - // Create output config + // Create output config with proper TLV sizes let mut outputs = tinyvec::ArrayVec::<[(bool, u32); 35]>::new(); - for &has_delegate in &delegate_flags { - outputs.push((false, compressed_token_data_len(has_delegate))); // Token accounts don't have addresses + for i in 0..num_outputs { + let tlv_config = if tlv_flags[i] { + vec![ExtensionStructConfig::CompressedOnly(())] + } else { + vec![] + }; + let token_config = TokenDataConfig { + delegate: (delegate_flags[i], ()), + tlv: (!tlv_config.is_empty(), tlv_config), + }; + let data_len = TokenData::byte_len(&token_config).unwrap() as u32; + outputs.push((false, data_len)); // Token accounts don't have addresses } let config_input = CpiConfigInput { @@ -88,6 +111,39 @@ fn test_rnd_create_output_compressed_accounts() { ) .unwrap(); + // Create TLV instruction data for each output + let mut tlv_instruction_data_vecs: Vec> = Vec::new(); + let mut tlv_bytes_vecs: Vec> = Vec::new(); + + for i in 0..num_outputs { + if tlv_flags[i] { + let ext = ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: tlv_delegated_amounts[i], + withheld_transfer_fee: tlv_withheld_fees[i], + is_frozen: false, // TODO: make random + }, + ); + tlv_instruction_data_vecs.push(vec![ext.clone()]); + tlv_bytes_vecs.push(vec![ext].try_to_vec().unwrap()); + } else { + tlv_instruction_data_vecs.push(vec![]); + // Empty vec needs explicit type annotation and borsh serialization + let empty_vec: Vec = vec![]; + tlv_bytes_vecs.push(empty_vec.try_to_vec().unwrap()); + } + } + + // Parse TLV bytes to zero-copy for set_output_compressed_account calls + let tlv_zero_copy_vecs: Vec<_> = tlv_bytes_vecs + .iter() + .map(|bytes| { + Vec::::zero_copy_at(bytes.as_slice()) + .unwrap() + .0 + }) + .collect(); + let mut hash_cache = HashCache::new(); for (index, output_account) in cpi_instruction_struct .output_compressed_accounts @@ -100,6 +156,16 @@ fn test_rnd_create_output_compressed_accounts() { None }; + // Use version 3 when TLV is present, version 2 otherwise + let version = if tlv_flags[index] { 3 } else { 2 }; + + // Get TLV data slice (empty slice if no TLV) + let tlv_slice = if tlv_flags[index] && !tlv_zero_copy_vecs[index].is_empty() { + Some(tlv_zero_copy_vecs[index].as_slice()) + } else { + None + }; + set_output_compressed_account( output_account, &mut hash_cache, @@ -109,7 +175,9 @@ fn test_rnd_create_output_compressed_accounts() { lamports.as_ref().and_then(|l| l[index]), mint_pubkey, merkle_tree_indices[index], - 2, + version, + tlv_slice, + false, // Not frozen in tests ) .unwrap(); } @@ -124,15 +192,38 @@ fn test_rnd_create_output_compressed_accounts() { let token_delegate = if delegate_flags[i] { delegate } else { None }; let account_lamports = lamports_vec[i].unwrap_or(0); + // Build TLV if flag is set + let tlv = if tlv_flags[i] { + Some(vec![ExtensionStruct::CompressedOnly( + CompressedOnlyExtension { + delegated_amount: tlv_delegated_amounts[i], + withheld_transfer_fee: tlv_withheld_fees[i], + }, + )]) + } else { + None + }; + let token_data = AnchorTokenData { mint: mint_pubkey, owner: owner_pubkeys[i], amount: amounts[i], delegate: token_delegate, state: AccountState::Initialized as u8, - tlv: None, + tlv: tlv.clone(), + }; + + // Use V3 hash (SHA256 of serialized data) when TLV present, V2 hash otherwise + let (data_hash, discriminator) = if tlv_flags[i] { + let serialized = token_data.try_to_vec().unwrap(); + let hash = light_hasher::sha256::Sha256BE::hash(&serialized).unwrap(); + (hash, TOKEN_COMPRESSED_ACCOUNT_V3_DISCRIMINATOR) + } else { + ( + token_data.hash_v2().unwrap(), + TOKEN_COMPRESSED_ACCOUNT_V2_DISCRIMINATOR, + ) }; - let data_hash = token_data.hash_v2().unwrap(); expected_accounts.push(OutputCompressedAccountWithPackedContext { compressed_account: CompressedAccount { @@ -141,7 +232,7 @@ fn test_rnd_create_output_compressed_accounts() { lamports: account_lamports, data: Some(CompressedAccountData { data: token_data.try_to_vec().unwrap(), - discriminator: TOKEN_COMPRESSED_ACCOUNT_V2_DISCRIMINATOR, + discriminator, data_hash, }), }, diff --git a/programs/registry/Cargo.toml b/programs/registry/Cargo.toml index 0e03251499..0074680c57 100644 --- a/programs/registry/Cargo.toml +++ b/programs/registry/Cargo.toml @@ -26,6 +26,7 @@ anchor-lang = { workspace = true, features = ["init-if-needed"] } account-compression = { workspace = true } light-compressible = { workspace = true, features = ["anchor"] } light-ctoken-types = { workspace = true, features = ["anchor"] } +light-zero-copy = { workspace = true } light-system-program-anchor = { workspace = true, features = ["cpi"] } light-account-checks = { workspace = true, features = ["solana", "std", "msg"] } light-program-profiler = { workspace = true } diff --git a/programs/registry/src/compressible/compressed_token/compress_and_close.rs b/programs/registry/src/compressible/compressed_token/compress_and_close.rs index 80a4291ff7..44b747562b 100644 --- a/programs/registry/src/compressible/compressed_token/compress_and_close.rs +++ b/programs/registry/src/compressible/compressed_token/compress_and_close.rs @@ -1,13 +1,17 @@ use anchor_lang::{prelude::ProgramError, pubkey, AnchorDeserialize, AnchorSerialize, Result}; use light_account_checks::packed_accounts::ProgramPackedAccounts; use light_ctoken_types::{ - instructions::transfer2::{ - CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, - MultiTokenTransferOutputData, + instructions::{ + extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, + transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiTokenTransferOutputData, + }, }, - state::CToken, + state::{CToken, ZExtensionStruct}, }; use light_program_profiler::profile; +use light_zero_copy::traits::ZeroCopyAt; use solana_account_info::AccountInfo; use solana_instruction::{AccountMeta, Instruction}; use solana_pubkey::Pubkey; @@ -30,6 +34,7 @@ pub struct CompressAndCloseIndices { pub mint_index: u8, pub owner_index: u8, pub rent_sponsor_index: u8, // Can vary with custom rent sponsors + pub delegate_index: u8, // Index to delegate in packed_accounts, 0 if no delegate } /// Compress and close compressed token accounts with pre-computed indices @@ -74,6 +79,7 @@ pub fn compress_and_close_ctoken_accounts_with_indices<'info>( // Create one output per compression (no deduplication) let mut output_accounts = Vec::with_capacity(indices.len()); let mut compressions = Vec::with_capacity(indices.len()); + let mut out_tlv: Vec> = Vec::with_capacity(indices.len()); // Process each set of indices for (i, idx) in indices.iter().enumerate() { @@ -91,14 +97,68 @@ pub fn compress_and_close_ctoken_accounts_with_indices<'info>( RegistryError::InvalidSigner })?; + // Parse the full CToken to check for marker extensions + let (ctoken, _) = CToken::zero_copy_at(&account_data).map_err(|e| { + anchor_lang::prelude::msg!("Failed to parse CToken: {:?}", e); + RegistryError::InvalidSigner + })?; + + // Check if this account has marker extensions that require CompressedOnly in output + let mut has_marker_extensions = false; + let mut withheld_transfer_fee: u64 = 0; + let delegated_amount: u64 = (*ctoken.delegated_amount).into(); + // AccountState::Frozen = 2 in CToken + let is_frozen = ctoken.state == 2; + + // Frozen accounts require CompressedOnly extension to preserve frozen state + if is_frozen { + has_marker_extensions = true; + } + + if let Some(extensions) = &ctoken.extensions { + for ext in extensions.iter() { + match ext { + ZExtensionStruct::PausableAccount(_) + | ZExtensionStruct::PermanentDelegateAccount(_) + | ZExtensionStruct::TransferHookAccount(_) => { + has_marker_extensions = true; + } + ZExtensionStruct::TransferFeeAccount(fee_ext) => { + has_marker_extensions = true; + withheld_transfer_fee = fee_ext.withheld_amount.into(); + } + ZExtensionStruct::Compressible(compressible_ext) => { + // If compression_only flag is set, we need CompressedOnly extension + if compressible_ext.compression_only() { + has_marker_extensions = true; + } + } + _ => {} + } + } + } + + // Build TLV extensions for this output if marker extensions are present + if has_marker_extensions { + out_tlv.push(vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount, + withheld_transfer_fee, + is_frozen, + }, + )]); + } else { + out_tlv.push(vec![]); + } + // Create one output account per compression operation output_accounts.push(MultiTokenTransferOutputData { owner: idx.owner_index, amount, - delegate: 0, + delegate: idx.delegate_index, mint: idx.mint_index, version: 3, // Shaflat - has_delegate: false, + has_delegate: delegated_amount > 0, }); let compression = Compression { @@ -110,7 +170,7 @@ pub fn compress_and_close_ctoken_accounts_with_indices<'info>( pool_account_index: idx.rent_sponsor_index, pool_index: i as u8, bump: destination_index, - decimals: 0, + decimals: 1, // Used as rent_sponsor_is_signer flag (non-zero = true) }; compressions.push(compression); @@ -122,6 +182,8 @@ pub fn compress_and_close_ctoken_accounts_with_indices<'info>( .is_signer = true; // Build instruction data inline + // Only include out_tlv if any account has extensions + let has_any_tlv = out_tlv.iter().any(|v| !v.is_empty()); let instruction_data = CompressedTokenInstructionDataTransfer2 { with_transaction_hash: false, with_lamports_change_account_merkle_tree_index: false, @@ -134,7 +196,7 @@ pub fn compress_and_close_ctoken_accounts_with_indices<'info>( in_lamports: None, out_lamports: None, in_tlv: None, - out_tlv: None, + out_tlv: if has_any_tlv { Some(out_tlv) } else { None }, compressions: Some(compressions), cpi_context: None, max_top_up: 0, diff --git a/sdk-libs/client/Cargo.toml b/sdk-libs/client/Cargo.toml index 565939fd51..a82c3d9ae4 100644 --- a/sdk-libs/client/Cargo.toml +++ b/sdk-libs/client/Cargo.toml @@ -44,6 +44,7 @@ light-sdk = { workspace = true } light-hasher = { workspace = true, features = ["poseidon"] } light-compressed-account = { workspace = true, features = ["solana", "poseidon"] } light-compressed-token-sdk = { workspace = true } +light-ctoken-types = { workspace = true } light-event = { workspace = true } photon-api = { workspace = true } diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index 016e6e28cf..18ab8e08d1 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -1,3 +1,4 @@ +use borsh::BorshDeserialize; use light_compressed_account::{ compressed_account::{ CompressedAccount as ProgramCompressedAccount, CompressedAccountData, @@ -7,6 +8,7 @@ use light_compressed_account::{ TreeType, }; use light_compressed_token_sdk::compat::{AccountState, TokenData}; +use light_ctoken_types::state::ExtensionStruct; use light_indexed_merkle_tree::array::IndexedElement; use light_sdk::instruction::{ PackedAccounts, PackedAddressTreeInfo, PackedStateTreeInfo, ValidityProof, @@ -846,9 +848,13 @@ impl TryFrom<&photon_api::models::TokenAccount> for CompressedTokenAccount { .token_data .tlv .as_ref() - .map(|tlv| base64::decode_config(tlv, base64::STANDARD_NO_PAD)) - .transpose() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map(|tlv| { + let bytes = base64::decode_config(tlv, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + Vec::::deserialize(&mut bytes.as_slice()) + .map_err(|_| IndexerError::InvalidResponseData) + }) + .transpose()?, }; Ok(CompressedTokenAccount { token, account }) @@ -883,9 +889,13 @@ impl TryFrom<&photon_api::models::TokenAccountV2> for CompressedTokenAccount { .token_data .tlv .as_ref() - .map(|tlv| base64::decode_config(tlv, base64::STANDARD_NO_PAD)) - .transpose() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map(|tlv| { + let bytes = base64::decode_config(tlv, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + Vec::::deserialize(&mut bytes.as_slice()) + .map_err(|_| IndexerError::InvalidResponseData) + }) + .transpose()?, }; Ok(CompressedTokenAccount { token, account }) diff --git a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/account2.rs b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/account2.rs index 5cb87e7f42..c36de930ae 100644 --- a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/account2.rs +++ b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/account2.rs @@ -190,6 +190,7 @@ impl CTokenAccount2 { } #[profile] + #[allow(clippy::too_many_arguments)] pub fn compress_spl( &mut self, amount: u64, @@ -198,6 +199,7 @@ impl CTokenAccount2 { pool_account_index: u8, pool_index: u8, bump: u8, + decimals: u8, ) -> Result<(), TokenSdkError> { // Check if there's already a compression set if self.compression.is_some() { @@ -213,6 +215,7 @@ impl CTokenAccount2 { pool_account_index, pool_index, bump, + decimals, )); self.method_used = true; @@ -254,6 +257,7 @@ impl CTokenAccount2 { pool_account_index: u8, pool_index: u8, bump: u8, + decimals: u8, ) -> Result<(), TokenSdkError> { // Check if there's already a compression set if self.compression.is_some() { @@ -272,6 +276,7 @@ impl CTokenAccount2 { pool_account_index, pool_index, bump, + decimals, )); self.method_used = true; @@ -306,7 +311,7 @@ impl CTokenAccount2 { pool_account_index: 0, pool_index: 0, bump: 0, - decimals: 0, + decimals: 0, // Not used for ctoken compression }); self.method_used = true; @@ -332,6 +337,7 @@ impl CTokenAccount2 { self.output.amount += amount; // Use the compress_and_close method from Compression + // rent_sponsor_is_signer is always false in SDK - only registry program CPI uses true self.compression = Some(Compression::compress_and_close_ctoken( amount, self.output.mint, @@ -340,6 +346,7 @@ impl CTokenAccount2 { rent_sponsor_index, compressed_account_index, destination_index, + false, // rent_sponsor_is_signer: only true when registry program CPIs )); self.method_used = true; diff --git a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/compress_and_close.rs b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/compress_and_close.rs index d7e461bcca..811e76fcab 100644 --- a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/compress_and_close.rs +++ b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/compress_and_close.rs @@ -56,12 +56,12 @@ pub fn pack_for_compress_and_close( for extension in extensions { if let ZExtensionStruct::Compressible(e) = extension { authority_index = packed_accounts.insert_or_get_config( - Pubkey::from(e.compression_authority), + Pubkey::from(e.info.compression_authority), true, true, ); recipient_index = - packed_accounts.insert_or_get(Pubkey::from(e.rent_sponsor)); + packed_accounts.insert_or_get(Pubkey::from(e.info.rent_sponsor)); break; } @@ -77,7 +77,7 @@ pub fn pack_for_compress_and_close( for extension in extensions { if let ZExtensionStruct::Compressible(e) = extension { recipient_index = - packed_accounts.insert_or_get(Pubkey::from(e.rent_sponsor)); + packed_accounts.insert_or_get(Pubkey::from(e.info.rent_sponsor)); break; } @@ -322,8 +322,9 @@ pub fn compress_and_close_ctoken_accounts<'info>( for extension in extensions { if let ZExtensionStruct::Compressible(extension) = extension { // Check if compression_authority is set (non-zero) - if extension.compression_authority != [0u8; 32] { - compression_authority = Pubkey::from(extension.compression_authority); + if extension.info.compression_authority != [0u8; 32] { + compression_authority = + Pubkey::from(extension.info.compression_authority); } break; } @@ -344,8 +345,8 @@ pub fn compress_and_close_ctoken_accounts<'info>( for extension in extensions { if let ZExtensionStruct::Compressible(ext) = extension { // Check if rent_sponsor is set (non-zero) - if ext.rent_sponsor != [0u8; 32] { - rent_sponsor_pubkey = Some(Pubkey::from(ext.rent_sponsor)); + if ext.info.rent_sponsor != [0u8; 32] { + rent_sponsor_pubkey = Some(Pubkey::from(ext.info.rent_sponsor)); } break; } diff --git a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/decompress_full.rs b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/decompress_full.rs index 3f958ef1dc..84fe3b5ca0 100644 --- a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/decompress_full.rs +++ b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/decompress_full.rs @@ -1,6 +1,7 @@ use light_compressed_account::compressed_account::PackedMerkleContext; -use light_ctoken_types::instructions::transfer2::{ - CompressedCpiContext, MultiInputTokenDataWithContext, +use light_ctoken_types::instructions::{ + extensions::ExtensionInstructionData, + transfer2::{CompressedCpiContext, MultiInputTokenDataWithContext}, }; use light_program_profiler::profile; use light_sdk::{ @@ -18,14 +19,20 @@ use super::{ Transfer2Inputs, }, }; -use crate::{compat::TokenData, error::TokenSdkError, utils::CTokenDefaultAccounts, ValidityProof}; +use crate::{ + compat::TokenData, error::TokenSdkError, utils::CTokenDefaultAccounts, AnchorDeserialize, + AnchorSerialize, ValidityProof, +}; /// Struct to hold all the data needed for DecompressFull operation /// Contains the complete compressed account data and destination index -#[derive(Debug, Clone, crate::AnchorSerialize, crate::AnchorDeserialize)] +#[derive(Debug, Clone, AnchorDeserialize, AnchorSerialize)] pub struct DecompressFullIndices { pub source: MultiInputTokenDataWithContext, // Complete compressed account data with merkle context pub destination_index: u8, // Destination ctoken Solana account (must exist) + /// TLV extensions for this compressed account (e.g., CompressedOnly extension). + /// Used to transfer extension state during decompress. + pub tlv: Option>, } /// Decompress full balance from compressed token accounts with pre-computed indices @@ -53,6 +60,8 @@ pub fn decompress_full_ctoken_accounts_with_indices<'info>( // Process each set of indices let mut token_accounts = Vec::with_capacity(indices.len()); + let mut in_tlv_data: Vec> = Vec::with_capacity(indices.len()); + let mut has_any_tlv = false; // Convert packed_accounts to AccountMetas // TODO: we may have to add conditional delegate signers for delegate @@ -69,6 +78,14 @@ pub fn decompress_full_ctoken_accounts_with_indices<'info>( token_account.decompress_ctoken(idx.source.amount, idx.destination_index)?; token_accounts.push(token_account); + // Collect TLV data for this input + if let Some(tlv) = &idx.tlv { + has_any_tlv = true; + in_tlv_data.push(tlv.clone()); + } else { + in_tlv_data.push(Vec::new()); + } + let owner_idx = idx.source.owner as usize; if owner_idx >= signer_flags.len() { return Err(TokenSdkError::InvalidAccountData); @@ -118,6 +135,7 @@ pub fn decompress_full_ctoken_accounts_with_indices<'info>( token_accounts, transfer_config, validity_proof, + in_tlv: if has_any_tlv { Some(in_tlv_data) } else { None }, ..Default::default() }; @@ -132,6 +150,7 @@ pub fn decompress_full_ctoken_accounts_with_indices<'info>( /// * `tree_infos` - Packed tree info for each compressed account /// * `destination_indices` - Destination account indices for each decompression /// * `packed_accounts` - PackedAccounts that will be used to insert/get indices +/// * `tlv` - Optional TLV extensions for the compressed account /// /// # Returns /// Vec of DecompressFullIndices ready to use with decompress_full_ctoken_accounts_with_indices @@ -141,6 +160,7 @@ pub fn pack_for_decompress_full( tree_info: &PackedStateTreeInfo, destination: Pubkey, packed_accounts: &mut PackedAccounts, + tlv: Option>, ) -> DecompressFullIndices { let source = MultiInputTokenDataWithContext { owner: packed_accounts.insert_or_get_config(token.owner, true, false), @@ -151,7 +171,7 @@ pub fn pack_for_decompress_full( .map(|d| packed_accounts.insert_or_get(d)) .unwrap_or(0), mint: packed_accounts.insert_or_get(token.mint), - version: 2, + version: if tlv.is_some() { 3 } else { 2 }, // Version 3 required for TLV merkle_context: PackedMerkleContext { merkle_tree_pubkey_index: tree_info.merkle_tree_pubkey_index, queue_pubkey_index: tree_info.queue_pubkey_index, @@ -164,6 +184,7 @@ pub fn pack_for_decompress_full( DecompressFullIndices { source, destination_index: packed_accounts.insert_or_get(destination), + tlv, } } diff --git a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs index 4cf5cebcef..fc64ac7760 100644 --- a/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs +++ b/sdk-libs/compressed-token-sdk/src/compressed_token/v2/transfer2/instruction.rs @@ -1,6 +1,9 @@ use light_compressed_token_types::{constants::TRANSFER2, ValidityProof}; use light_ctoken_types::{ - instructions::transfer2::{CompressedCpiContext, CompressedTokenInstructionDataTransfer2}, + instructions::{ + extensions::ExtensionInstructionData, + transfer2::{CompressedCpiContext, CompressedTokenInstructionDataTransfer2}, + }, COMPRESSED_TOKEN_PROGRAM_ID, }; use light_program_profiler::profile; @@ -70,6 +73,9 @@ pub struct Transfer2Inputs { pub in_lamports: Option>, pub out_lamports: Option>, pub output_queue: u8, + /// TLV extensions for input compressed accounts (one Vec per input account). + /// Used to pass extension state (e.g., CompressedOnly) for decompress operations. + pub in_tlv: Option>>, } /// Create the instruction for compressed token multi-transfer operations @@ -83,6 +89,7 @@ pub fn create_transfer2_instruction(inputs: Transfer2Inputs) -> Result Result, pub compressible_config: Pubkey, pub rent_sponsor: Pubkey, + pub compression_only: bool, } impl Default for CompressibleParams { @@ -25,6 +26,7 @@ impl Default for CompressibleParams { lamports_per_write: Some(766), compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, } } } @@ -53,6 +55,7 @@ pub struct CompressibleParamsInfos<'info> { pub lamports_per_write: Option, pub compress_to_account_pubkey: Option, pub token_account_version: TokenDataVersion, + pub compression_only: bool, } impl<'info> CompressibleParamsInfos<'info> { @@ -70,6 +73,7 @@ impl<'info> CompressibleParamsInfos<'info> { lamports_per_write: defaults.lamports_per_write, compress_to_account_pubkey: None, token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, } } diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/create.rs b/sdk-libs/compressed-token-sdk/src/ctoken/create.rs index bc18ca3ea6..6bac4e1dc2 100644 --- a/sdk-libs/compressed-token-sdk/src/ctoken/create.rs +++ b/sdk-libs/compressed-token-sdk/src/ctoken/create.rs @@ -48,7 +48,7 @@ impl CreateCTokenAccount { } else { 0 }, - compression_only: 0, + compression_only: config.compression_only as u8, write_top_up: config.lamports_per_write.unwrap_or(0), compress_to_account_pubkey: config.compress_to_account_pubkey.clone(), }); @@ -173,6 +173,7 @@ impl<'info> From<&CreateCTokenAccountInfos<'info>> for CreateCTokenAccount { lamports_per_write: config.lamports_per_write, compress_to_account_pubkey: config.compress_to_account_pubkey.clone(), token_account_version: config.token_account_version, + compression_only: config.compression_only, }), } } diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/create_associated_token_account.rs b/sdk-libs/compressed-token-sdk/src/ctoken/create_associated_token_account.rs new file mode 100644 index 0000000000..9d2dfb082e --- /dev/null +++ b/sdk-libs/compressed-token-sdk/src/ctoken/create_associated_token_account.rs @@ -0,0 +1,495 @@ +use borsh::BorshSerialize; +use light_ctoken_types::{ + instructions::{ + create_associated_token_account::CreateAssociatedTokenAccountInstructionData, + create_associated_token_account2::CreateAssociatedTokenAccount2InstructionData, + extensions::compressible::CompressibleExtensionInstructionData, + }, + state::TokenDataVersion, +}; +use solana_account_info::AccountInfo; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; + +use crate::error::{Result, TokenSdkError}; + +/// Discriminators for create ATA instructions +const CREATE_ATA_DISCRIMINATOR: u8 = 100; +const CREATE_ATA_IDEMPOTENT_DISCRIMINATOR: u8 = 102; +const CREATE_ATA2_DISCRIMINATOR: u8 = 106; +const CREATE_ATA2_IDEMPOTENT_DISCRIMINATOR: u8 = 107; + +/// Input parameters for creating an associated token account with compressible extension +#[derive(Debug, Clone)] +pub struct CreateCompressibleAssociatedTokenAccountInputs { + /// The payer for the account creation + pub payer: Pubkey, + /// The owner of the associated token account + pub owner: Pubkey, + /// The mint for the associated token account + pub mint: Pubkey, + /// The CompressibleConfig account + pub compressible_config: Pubkey, + /// The recipient of lamports when the account is closed by rent authority (fee_payer_pda) + pub rent_sponsor: Pubkey, + /// Number of epochs of rent to prepay + pub pre_pay_num_epochs: u8, + /// Initial lamports to top up for rent payments (optional) + pub lamports_per_write: Option, + /// Version of the compressed token account when ctoken account is + /// compressed and closed. (The version specifies the hashing scheme.) + pub token_account_version: TokenDataVersion, +} + +/// Creates a compressible associated token account instruction (non-idempotent) +pub fn create_compressible_associated_token_account( + inputs: CreateCompressibleAssociatedTokenAccountInputs, +) -> Result { + create_compressible_associated_token_account_with_mode::(inputs) +} + +/// Creates a compressible associated token account instruction (idempotent) +pub fn create_compressible_associated_token_account_idempotent( + inputs: CreateCompressibleAssociatedTokenAccountInputs, +) -> Result { + create_compressible_associated_token_account_with_mode::(inputs) +} + +/// Creates a compressible associated token account instruction with compile-time idempotent mode +pub fn create_compressible_associated_token_account_with_mode( + inputs: CreateCompressibleAssociatedTokenAccountInputs, +) -> Result { + let (ata_pubkey, bump) = derive_ctoken_ata(&inputs.owner, &inputs.mint); + create_compressible_associated_token_account_with_bump_and_mode::( + inputs, ata_pubkey, bump, + ) +} + +/// Creates a compressible associated token account instruction with a specified bump (non-idempotent) +pub fn create_compressible_associated_token_account_with_bump( + inputs: CreateCompressibleAssociatedTokenAccountInputs, + ata_pubkey: Pubkey, + bump: u8, +) -> Result { + create_compressible_associated_token_account_with_bump_and_mode::( + inputs, ata_pubkey, bump, + ) +} + +/// Creates a compressible associated token account instruction with a specified bump and mode +pub fn create_compressible_associated_token_account_with_bump_and_mode( + inputs: CreateCompressibleAssociatedTokenAccountInputs, + ata_pubkey: Pubkey, + bump: u8, +) -> Result { + create_ata_instruction_unified::( + inputs.payer, + inputs.owner, + inputs.mint, + ata_pubkey, + bump, + Some(( + inputs.pre_pay_num_epochs, + inputs.lamports_per_write, + inputs.rent_sponsor, + inputs.compressible_config, + inputs.token_account_version, + )), + ) +} + +/// Creates a basic associated token account instruction (non-idempotent) +pub fn create_associated_token_account( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Result { + create_associated_token_account_with_mode::(payer, owner, mint) +} + +/// Creates a basic associated token account instruction (idempotent) +pub fn create_associated_token_account_idempotent( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Result { + create_associated_token_account_with_mode::(payer, owner, mint) +} + +/// Creates a basic associated token account instruction with compile-time idempotent mode +pub fn create_associated_token_account_with_mode( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Result { + let (ata_pubkey, bump) = derive_ctoken_ata(&owner, &mint); + create_associated_token_account_with_bump_and_mode::( + payer, owner, mint, ata_pubkey, bump, + ) +} + +/// Creates a basic associated token account instruction with a specified bump (non-idempotent) +pub fn create_associated_token_account_with_bump( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, + ata_pubkey: Pubkey, + bump: u8, +) -> Result { + create_associated_token_account_with_bump_and_mode::( + payer, owner, mint, ata_pubkey, bump, + ) +} + +/// Creates a basic associated token account instruction with specified bump and mode +pub fn create_associated_token_account_with_bump_and_mode( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, + ata_pubkey: Pubkey, + bump: u8, +) -> Result { + create_ata_instruction_unified::(payer, owner, mint, ata_pubkey, bump, None) +} + +/// Unified function to create ATA instructions with compile-time configuration +fn create_ata_instruction_unified( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, + ata_pubkey: Pubkey, + bump: u8, + compressible_config: Option<(u8, Option, Pubkey, Pubkey, TokenDataVersion)>, // (pre_pay_num_epochs, lamports_per_write, rent_sponsor, compressible_config_account, token_account_version) +) -> Result { + // Select discriminator based on idempotent mode + let discriminator = if IDEMPOTENT { + CREATE_ATA_IDEMPOTENT_DISCRIMINATOR + } else { + CREATE_ATA_DISCRIMINATOR + }; + + // Create the instruction data struct + let compressible_extension = if COMPRESSIBLE { + if let Some((pre_pay_num_epochs, lamports_per_write, _, _, token_account_version)) = + compressible_config + { + Some(CompressibleExtensionInstructionData { + token_account_version: token_account_version as u8, + rent_payment: pre_pay_num_epochs, + has_top_up: if lamports_per_write.is_some() { 1 } else { 0 }, + compression_only: 0, + write_top_up: lamports_per_write.unwrap_or(0), + compress_to_account_pubkey: None, // Not used for ATA creation + }) + } else { + return Err(TokenSdkError::InvalidAccountData); + } + } else { + None + }; + + let instruction_data = CreateAssociatedTokenAccountInstructionData { + owner: light_compressed_account::Pubkey::from(owner.to_bytes()), + mint: light_compressed_account::Pubkey::from(mint.to_bytes()), + bump, + compressible_config: compressible_extension, + }; + + // Serialize with Borsh + let mut data = Vec::new(); + data.push(discriminator); + instruction_data + .serialize(&mut data) + .map_err(|_| TokenSdkError::SerializationError)?; + + // Build accounts list based on whether it's compressible + let mut accounts = vec![ + solana_instruction::AccountMeta::new(payer, true), // fee_payer (signer) + solana_instruction::AccountMeta::new(ata_pubkey, false), // associated_token_account + solana_instruction::AccountMeta::new_readonly(Pubkey::new_from_array([0; 32]), false), // system_program + ]; + + // Add compressible-specific accounts + if COMPRESSIBLE { + if let Some((_, _, rent_sponsor, compressible_config_account, _)) = compressible_config { + accounts.push(solana_instruction::AccountMeta::new_readonly( + compressible_config_account, + false, + )); // compressible_config + accounts.push(solana_instruction::AccountMeta::new(rent_sponsor, false)); + // fee_payer_pda (rent_sponsor) + } + } + + Ok(Instruction { + program_id: Pubkey::from(light_ctoken_types::COMPRESSED_TOKEN_PROGRAM_ID), + accounts, + data, + }) +} + +pub fn derive_ctoken_ata(owner: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + owner.as_ref(), + light_ctoken_types::COMPRESSED_TOKEN_PROGRAM_ID.as_ref(), + mint.as_ref(), + ], + &Pubkey::from(light_ctoken_types::COMPRESSED_TOKEN_PROGRAM_ID), + ) +} + +// ============================================================================ +// CreateAssociatedTokenAccount2 - Owner and mint as accounts +// ============================================================================ + +/// Creates a compressible associated token account instruction v2 (non-idempotent) +/// Owner and mint are passed as account infos instead of instruction data +pub fn create_compressible_associated_token_account2( + inputs: CreateCompressibleAssociatedTokenAccountInputs, +) -> Result { + create_compressible_associated_token_account2_with_mode::(inputs) +} + +/// Creates a compressible associated token account instruction v2 (idempotent) +/// Owner and mint are passed as account infos instead of instruction data +pub fn create_compressible_associated_token_account2_idempotent( + inputs: CreateCompressibleAssociatedTokenAccountInputs, +) -> Result { + create_compressible_associated_token_account2_with_mode::(inputs) +} + +/// Creates a compressible associated token account instruction v2 with compile-time idempotent mode +fn create_compressible_associated_token_account2_with_mode( + inputs: CreateCompressibleAssociatedTokenAccountInputs, +) -> Result { + let (ata_pubkey, bump) = derive_ctoken_ata(&inputs.owner, &inputs.mint); + create_compressible_associated_token_account2_with_bump_and_mode::( + inputs, ata_pubkey, bump, + ) +} + +/// Creates a compressible associated token account instruction v2 with specified bump and mode +fn create_compressible_associated_token_account2_with_bump_and_mode( + inputs: CreateCompressibleAssociatedTokenAccountInputs, + ata_pubkey: Pubkey, + bump: u8, +) -> Result { + create_ata2_instruction_unified::( + inputs.payer, + inputs.owner, + inputs.mint, + ata_pubkey, + bump, + Some(( + inputs.pre_pay_num_epochs, + inputs.lamports_per_write, + inputs.rent_sponsor, + inputs.compressible_config, + inputs.token_account_version, + )), + ) +} + +/// Creates a basic associated token account instruction v2 (non-idempotent) +/// Owner and mint are passed as account infos instead of instruction data +pub fn create_associated_token_account2( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Result { + create_associated_token_account2_with_mode::(payer, owner, mint) +} + +/// Creates a basic associated token account instruction v2 (idempotent) +/// Owner and mint are passed as account infos instead of instruction data +pub fn create_associated_token_account2_idempotent( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Result { + create_associated_token_account2_with_mode::(payer, owner, mint) +} + +/// Creates a basic associated token account instruction v2 with compile-time idempotent mode +fn create_associated_token_account2_with_mode( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Result { + let (ata_pubkey, bump) = derive_ctoken_ata(&owner, &mint); + create_associated_token_account2_with_bump_and_mode::( + payer, owner, mint, ata_pubkey, bump, + ) +} + +/// Creates a basic associated token account instruction v2 with specified bump and mode +fn create_associated_token_account2_with_bump_and_mode( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, + ata_pubkey: Pubkey, + bump: u8, +) -> Result { + create_ata2_instruction_unified::(payer, owner, mint, ata_pubkey, bump, None) +} + +/// Unified function to create ATA2 instructions with compile-time configuration +/// Account order: [owner, mint, fee_payer, ata, system_program, ...] +fn create_ata2_instruction_unified( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, + ata_pubkey: Pubkey, + bump: u8, + compressible_config: Option<(u8, Option, Pubkey, Pubkey, TokenDataVersion)>, +) -> Result { + let discriminator = if IDEMPOTENT { + CREATE_ATA2_IDEMPOTENT_DISCRIMINATOR + } else { + CREATE_ATA2_DISCRIMINATOR + }; + + let compressible_extension = if COMPRESSIBLE { + if let Some((pre_pay_num_epochs, lamports_per_write, _, _, token_account_version)) = + compressible_config + { + Some(CompressibleExtensionInstructionData { + token_account_version: token_account_version as u8, + rent_payment: pre_pay_num_epochs, + has_top_up: if lamports_per_write.is_some() { 1 } else { 0 }, + compression_only: 0, + write_top_up: lamports_per_write.unwrap_or(0), + compress_to_account_pubkey: None, + }) + } else { + return Err(TokenSdkError::InvalidAccountData); + } + } else { + None + }; + + let instruction_data = CreateAssociatedTokenAccount2InstructionData { + bump, + compressible_config: compressible_extension, + }; + + let mut data = Vec::new(); + data.push(discriminator); + instruction_data + .serialize(&mut data) + .map_err(|_| TokenSdkError::SerializationError)?; + + let mut accounts = vec![ + solana_instruction::AccountMeta::new_readonly(owner, false), + solana_instruction::AccountMeta::new_readonly(mint, false), + solana_instruction::AccountMeta::new(payer, true), + solana_instruction::AccountMeta::new(ata_pubkey, false), + solana_instruction::AccountMeta::new_readonly(Pubkey::new_from_array([0; 32]), false), + ]; + + if COMPRESSIBLE { + if let Some((_, _, rent_sponsor, compressible_config_account, _)) = compressible_config { + accounts.push(solana_instruction::AccountMeta::new_readonly( + compressible_config_account, + false, + )); + accounts.push(solana_instruction::AccountMeta::new(rent_sponsor, false)); + } + } + + Ok(Instruction { + program_id: Pubkey::from(light_ctoken_types::COMPRESSED_TOKEN_PROGRAM_ID), + accounts, + data, + }) +} + +/// CPI wrapper to create a compressible c-token associated token account. +#[allow(clippy::too_many_arguments)] +pub fn create_associated_ctoken_account<'info>( + payer: AccountInfo<'info>, + associated_token_account: AccountInfo<'info>, + system_program: AccountInfo<'info>, + compressible_config: AccountInfo<'info>, + rent_sponsor: AccountInfo<'info>, + authority: AccountInfo<'info>, + mint: Pubkey, + bump: u8, + pre_pay_num_epochs: Option, + lamports_per_write: Option, +) -> std::result::Result<(), solana_program_error::ProgramError> { + let inputs = CreateCompressibleAssociatedTokenAccountInputs { + payer: *payer.key, + owner: *authority.key, + mint, + compressible_config: *compressible_config.key, + rent_sponsor: *rent_sponsor.key, + pre_pay_num_epochs: pre_pay_num_epochs.unwrap_or(2), + lamports_per_write, + token_account_version: TokenDataVersion::ShaFlat, + }; + + // TODO: switch to wrapper ixn using accounts instead of ixdata. + let ix = create_compressible_associated_token_account_with_bump( + inputs, + *associated_token_account.key, + bump, + )?; + + solana_cpi::invoke( + &ix, + &[ + payer, + associated_token_account, + system_program, + compressible_config, + rent_sponsor, + authority, + ], + ) +} + +/// CPI wrapper to create a compressible c-token associated token account +/// idempotently. +#[allow(clippy::too_many_arguments)] +pub fn create_associated_ctoken_account_idempotent<'info>( + payer: AccountInfo<'info>, + associated_token_account: AccountInfo<'info>, + system_program: AccountInfo<'info>, + compressible_config: AccountInfo<'info>, + rent_sponsor: AccountInfo<'info>, + authority: Pubkey, + mint: Pubkey, + bump: u8, + pre_pay_num_epochs: Option, + lamports_per_write: Option, +) -> std::result::Result<(), solana_program_error::ProgramError> { + let inputs = CreateCompressibleAssociatedTokenAccountInputs { + payer: *payer.key, + owner: authority, + mint, + compressible_config: *compressible_config.key, + rent_sponsor: *rent_sponsor.key, + pre_pay_num_epochs: pre_pay_num_epochs.unwrap_or(2), + lamports_per_write, + token_account_version: TokenDataVersion::ShaFlat, + }; + + let ix = create_compressible_associated_token_account_with_bump_and_mode::( + inputs, + *associated_token_account.key, + bump, + )?; + + solana_cpi::invoke( + &ix, + &[ + payer, + associated_token_account, + system_program, + compressible_config, + rent_sponsor, + ], + ) +} diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/create_ata.rs b/sdk-libs/compressed-token-sdk/src/ctoken/create_ata.rs index 71068391e6..0205738d8c 100644 --- a/sdk-libs/compressed-token-sdk/src/ctoken/create_ata.rs +++ b/sdk-libs/compressed-token-sdk/src/ctoken/create_ata.rs @@ -97,7 +97,7 @@ impl CreateAssociatedTokenAccount { } else { 0 }, - compression_only: 0, + compression_only: config.compression_only as u8, write_top_up: config.lamports_per_write.unwrap_or(0), compress_to_account_pubkey: None, }); @@ -243,6 +243,7 @@ impl<'info> From<&CreateAssociatedTokenAccountInfos<'info>> for CreateAssociated lamports_per_write: config.lamports_per_write, compress_to_account_pubkey: None, token_account_version: config.token_account_version, + compression_only: config.compression_only, }), idempotent: account_infos.idempotent, } @@ -319,7 +320,7 @@ impl CreateAssociatedTokenAccount2 { } else { 0 }, - compression_only: 0, + compression_only: config.compression_only as u8, write_top_up: config.lamports_per_write.unwrap_or(0), compress_to_account_pubkey: None, }); @@ -447,6 +448,7 @@ impl<'info> From<&CreateAssociatedTokenAccount2Infos<'info>> for CreateAssociate lamports_per_write: config.lamports_per_write, compress_to_account_pubkey: None, token_account_version: config.token_account_version, + compression_only: config.compression_only, }), idempotent: account_infos.idempotent, } diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_ctoken_spl.rs b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_ctoken_spl.rs index 2c39178f25..6592a94576 100644 --- a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_ctoken_spl.rs +++ b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_ctoken_spl.rs @@ -23,6 +23,7 @@ pub struct TransferCtokenToSpl { pub payer: Pubkey, pub token_pool_pda: Pubkey, pub token_pool_pda_bump: u8, + pub decimals: u8, pub spl_token_program: Pubkey, } @@ -35,6 +36,7 @@ pub struct TransferCtokenToSplAccountInfos<'info> { pub payer: AccountInfo<'info>, pub token_pool_pda: AccountInfo<'info>, pub token_pool_pda_bump: u8, + pub decimals: u8, pub spl_token_program: AccountInfo<'info>, pub compressed_token_program_authority: AccountInfo<'info>, } @@ -88,6 +90,7 @@ impl<'info> From<&TransferCtokenToSplAccountInfos<'info>> for TransferCtokenToSp payer: *account_infos.payer.key, token_pool_pda: *account_infos.token_pool_pda.key, token_pool_pda_bump: account_infos.token_pool_pda_bump, + decimals: account_infos.decimals, spl_token_program: *account_infos.spl_token_program.key, } } @@ -136,6 +139,7 @@ impl TransferCtokenToSpl { 4, // pool_account_index 0, // pool_index (TODO: make dynamic) self.token_pool_pda_bump, + self.decimals, )), delegate_is_set: false, method_used: true, @@ -152,6 +156,7 @@ impl TransferCtokenToSpl { out_lamports: None, token_accounts: vec![compress_to_pool, decompress_to_spl], output_queue: 0, // Decompressed accounts only, no output queue needed + in_tlv: None, }; create_transfer2_instruction(inputs).map_err(ProgramError::from) diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs index bd63682da5..2a2bf5c519 100644 --- a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs +++ b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_interface.rs @@ -18,38 +18,48 @@ pub struct SplInterface<'info> { pub struct TransferInterface<'info> { pub amount: u64, + pub decimals: u8, pub source_account: AccountInfo<'info>, pub destination_account: AccountInfo<'info>, pub authority: AccountInfo<'info>, pub payer: AccountInfo<'info>, pub compressed_token_program_authority: AccountInfo<'info>, pub spl_interface: Option>, + /// System program - required for compressible account lamport top-ups + pub system_program: AccountInfo<'info>, } impl<'info> TransferInterface<'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) /// * `authority` - Authority for the transfer (must be signer) /// * `payer` - Payer for the transaction /// * `compressed_token_program_authority` - Compressed token program authority + /// * `system_program` - System program (required for compressible account lamport top-ups) + #[allow(clippy::too_many_arguments)] pub fn new( amount: u64, + decimals: u8, source_account: AccountInfo<'info>, destination_account: AccountInfo<'info>, authority: AccountInfo<'info>, payer: AccountInfo<'info>, compressed_token_program_authority: AccountInfo<'info>, + system_program: AccountInfo<'info>, ) -> Self { Self { source_account, destination_account, authority, amount, + decimals, payer, compressed_token_program_authority, spl_interface: None, + system_program, } } @@ -120,6 +130,7 @@ impl<'info> TransferInterface<'info> { payer: self.payer.clone(), token_pool_pda: config.token_pool_pda.clone(), token_pool_pda_bump: config.token_pool_pda_bump, + decimals: self.decimals, spl_token_program: config.spl_token_program.clone(), compressed_token_program_authority: self .compressed_token_program_authority @@ -142,10 +153,12 @@ impl<'info> TransferInterface<'info> { payer: self.payer.clone(), token_pool_pda: config.token_pool_pda.clone(), token_pool_pda_bump: config.token_pool_pda_bump, + decimals: self.decimals, spl_token_program: config.spl_token_program.clone(), compressed_token_program_authority: self .compressed_token_program_authority .clone(), + system_program: self.system_program.clone(), } .invoke() } @@ -190,6 +203,7 @@ impl<'info> TransferInterface<'info> { payer: self.payer.clone(), token_pool_pda: config.token_pool_pda.clone(), token_pool_pda_bump: config.token_pool_pda_bump, + decimals: self.decimals, spl_token_program: config.spl_token_program.clone(), compressed_token_program_authority: self .compressed_token_program_authority @@ -212,10 +226,12 @@ impl<'info> TransferInterface<'info> { payer: self.payer.clone(), token_pool_pda: config.token_pool_pda.clone(), token_pool_pda_bump: config.token_pool_pda_bump, + decimals: self.decimals, spl_token_program: config.spl_token_program.clone(), compressed_token_program_authority: self .compressed_token_program_authority .clone(), + system_program: self.system_program.clone(), } .invoke_signed(signer_seeds) } diff --git a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_spl_ctoken.rs b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_spl_ctoken.rs index 292590f303..24dbb6fb81 100644 --- a/sdk-libs/compressed-token-sdk/src/ctoken/transfer_spl_ctoken.rs +++ b/sdk-libs/compressed-token-sdk/src/ctoken/transfer_spl_ctoken.rs @@ -16,6 +16,7 @@ use crate::compressed_token::{ pub struct TransferSplToCtoken { pub amount: u64, pub token_pool_pda_bump: u8, + pub decimals: u8, pub source_spl_token_account: Pubkey, /// Destination ctoken account (writable) pub destination_ctoken_account: Pubkey, @@ -29,6 +30,7 @@ pub struct TransferSplToCtoken { pub struct TransferSplToCtokenAccountInfos<'info> { pub amount: u64, pub token_pool_pda_bump: u8, + pub decimals: u8, pub source_spl_token_account: AccountInfo<'info>, /// Destination ctoken account (writable) pub destination_ctoken_account: AccountInfo<'info>, @@ -38,6 +40,8 @@ pub struct TransferSplToCtokenAccountInfos<'info> { pub token_pool_pda: AccountInfo<'info>, pub spl_token_program: AccountInfo<'info>, pub compressed_token_program_authority: AccountInfo<'info>, + /// System program - required for compressible account lamport top-ups + pub system_program: AccountInfo<'info>, } impl<'info> TransferSplToCtokenAccountInfos<'info> { @@ -57,6 +61,7 @@ impl<'info> TransferSplToCtokenAccountInfos<'info> { self.source_spl_token_account, // Index 3: Source SPL token account self.token_pool_pda, // Index 4: Token pool PDA self.spl_token_program, // Index 5: SPL Token program + self.system_program, // Index 6: System program ]; invoke(&instruction, &account_infos) } @@ -73,6 +78,7 @@ impl<'info> TransferSplToCtokenAccountInfos<'info> { self.source_spl_token_account, // Index 3: Source SPL token account self.token_pool_pda, // Index 4: Token pool PDA self.spl_token_program, // Index 5: SPL Token program + self.system_program, // Index 6: System program ]; invoke_signed(&instruction, &account_infos, signer_seeds) } @@ -89,6 +95,7 @@ impl<'info> From<&TransferSplToCtokenAccountInfos<'info>> for TransferSplToCtoke payer: *account_infos.payer.key, token_pool_pda: *account_infos.token_pool_pda.key, token_pool_pda_bump: account_infos.token_pool_pda_bump, + decimals: account_infos.decimals, spl_token_program: *account_infos.spl_token_program.key, } } @@ -109,6 +116,8 @@ impl TransferSplToCtoken { AccountMeta::new(self.token_pool_pda, false), // SPL Token program (index 5) - needed for CPI AccountMeta::new_readonly(self.spl_token_program, false), + // System program (index 6) - needed for compressible account lamport top-ups + AccountMeta::new_readonly(Pubkey::default(), false), ]; let wrap_spl_to_ctoken_account = CTokenAccount2 { @@ -122,6 +131,7 @@ impl TransferSplToCtoken { 4, // pool_account_index: 0, // pool_index self.token_pool_pda_bump, + self.decimals, )), delegate_is_set: false, method_used: true, @@ -146,6 +156,7 @@ impl TransferSplToCtoken { out_lamports: None, token_accounts: vec![wrap_spl_to_ctoken_account, ctoken_account], output_queue: 0, // Decompressed accounts only, no output queue needed + in_tlv: None, }; create_transfer2_instruction(inputs).map_err(ProgramError::from) diff --git a/sdk-libs/compressed-token-sdk/src/pack.rs b/sdk-libs/compressed-token-sdk/src/pack.rs index 60eeab2330..6b13dacd5e 100644 --- a/sdk-libs/compressed-token-sdk/src/pack.rs +++ b/sdk-libs/compressed-token-sdk/src/pack.rs @@ -108,8 +108,8 @@ pub mod compat { pub delegate: Option, /// The account's state pub state: AccountState, - /// Placeholder for TokenExtension tlv data (unimplemented) - pub tlv: Option>, + /// TLV extensions for compressed token accounts + pub tlv: Option>, } impl TokenData { diff --git a/sdk-libs/program-test/src/compressible.rs b/sdk-libs/program-test/src/compressible.rs index 7a9acfee53..d75bb19320 100644 --- a/sdk-libs/program-test/src/compressible.rs +++ b/sdk-libs/program-test/src/compressible.rs @@ -14,10 +14,7 @@ use light_compressible::rent::RentConfig; #[cfg(feature = "devenv")] use light_compressible::rent::SLOTS_PER_EPOCH; #[cfg(feature = "devenv")] -use light_ctoken_types::{ - state::{CToken, ExtensionStruct}, - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE, -}; +use light_ctoken_types::state::{CToken, ExtensionStruct}; #[cfg(feature = "devenv")] use light_sdk::compressible::CompressibleConfig as CpdaCompressibleConfig; #[cfg(feature = "devenv")] @@ -101,12 +98,11 @@ pub async fn claim_and_compress( for extension in extensions.iter() { if let ExtensionStruct::Compressible(e) = extension { let base_lamports = rpc - .get_minimum_balance_for_rent_exemption( - COMPRESSIBLE_TOKEN_ACCOUNT_SIZE as usize, - ) + .get_minimum_balance_for_rent_exemption(account.1.data.len()) .await .unwrap(); let last_funded_epoch = e + .info .get_last_funded_epoch( account.1.data.len() as u64, account.1.lamports, @@ -128,9 +124,6 @@ pub async fn claim_and_compress( } let current_slot = rpc.get_slot().await?; - let rent_exemption = rpc - .get_minimum_balance_for_rent_exemption(COMPRESSIBLE_TOKEN_ACCOUNT_SIZE as usize) - .await?; let mut compress_accounts = Vec::new(); let mut claim_accounts = Vec::new(); @@ -138,6 +131,9 @@ pub async fn claim_and_compress( // For each stored account, determine action using AccountRentState for (pubkey, stored_account) in stored_compressible_accounts.iter() { let account = rpc.get_account(*pubkey).await?.unwrap(); + let rent_exemption = rpc + .get_minimum_balance_for_rent_exemption(account.data.len()) + .await?; // Get compressible extension if let Some(extensions) = stored_account.account.extensions.as_ref() { @@ -150,11 +146,12 @@ pub async fn claim_and_compress( num_bytes: account.data.len() as u64, current_slot, current_lamports: account.lamports, - last_claimed_slot: comp_ext.last_claimed_slot, + last_claimed_slot: comp_ext.info.last_claimed_slot, }; // Check what action is needed - match state.calculate_claimable_rent(&comp_ext.rent_config, rent_exemption) { + match state.calculate_claimable_rent(&comp_ext.info.rent_config, rent_exemption) + { None => { // Account is compressible (has rent deficit) compress_accounts.push(*pubkey); @@ -175,18 +172,12 @@ pub async fn claim_and_compress( // Process claimable accounts in batches for token_accounts in claim_accounts.as_slice().chunks(20) { - println!( - "Claim from {} accounts: {:?}", - token_accounts.len(), - token_accounts - ); claim_forester(rpc, token_accounts, &forester_keypair, &payer).await?; } // Process compressible accounts in batches const BATCH_SIZE: usize = 10; for chunk in compress_accounts.chunks(BATCH_SIZE) { - println!("Compress and close {} accounts: {:?}", chunk.len(), chunk); compress_and_close_forester(rpc, chunk, &forester_keypair, &payer, None).await?; // Remove compressed accounts from HashMap diff --git a/sdk-libs/program-test/src/forester/compress_and_close_forester.rs b/sdk-libs/program-test/src/forester/compress_and_close_forester.rs index 5d62a1df00..96f9660655 100644 --- a/sdk-libs/program-test/src/forester/compress_and_close_forester.rs +++ b/sdk-libs/program-test/src/forester/compress_and_close_forester.rs @@ -127,14 +127,14 @@ pub async fn compress_and_close_forester( if let Some(extensions) = &ctoken_account.extensions { for extension in extensions { if let ZExtensionStruct::Compressible(e) = extension { - let current_authority = Pubkey::from(e.compression_authority); - rent_sponsor_pubkey = Pubkey::from(e.rent_sponsor); + let current_authority = Pubkey::from(e.info.compression_authority); + rent_sponsor_pubkey = Pubkey::from(e.info.rent_sponsor); if compression_authority_pubkey.is_none() { compression_authority_pubkey = Some(current_authority); } - if e.compress_to_pubkey() { + if e.info.compress_to_pubkey() { compressed_token_owner = *solana_ctoken_account_pubkey; } break; @@ -145,11 +145,20 @@ pub async fn compress_and_close_forester( let owner_index = packed_accounts.insert_or_get(compressed_token_owner); let rent_sponsor_index = packed_accounts.insert_or_get(rent_sponsor_pubkey); + // Get delegate if present + let delegate_index = if let Some(delegate_bytes) = ctoken_account.delegate.as_ref() { + let delegate_pubkey = Pubkey::from(delegate_bytes.to_bytes()); + packed_accounts.insert_or_get(delegate_pubkey) + } else { + 0 // 0 means no delegate + }; + let indices = CompressAndCloseIndices { source_index, mint_index, owner_index, rent_sponsor_index, + delegate_index, }; indices_vec.push(indices); diff --git a/sdk-libs/token-client/Cargo.toml b/sdk-libs/token-client/Cargo.toml index 7790616f5b..18c5dffe00 100644 --- a/sdk-libs/token-client/Cargo.toml +++ b/sdk-libs/token-client/Cargo.toml @@ -22,6 +22,7 @@ solana-msg = { workspace = true } solana-keypair = { workspace = true } solana-signer = { workspace = true } solana-signature = { workspace = true } +solana-system-interface = { workspace = true } spl-token-2022 = { workspace = true } spl-pod = { workspace = true } borsh = { workspace = true } diff --git a/sdk-libs/token-client/src/actions/create_compressible_token_account.rs b/sdk-libs/token-client/src/actions/create_compressible_token_account.rs index dc182db510..67967f6a8f 100644 --- a/sdk-libs/token-client/src/actions/create_compressible_token_account.rs +++ b/sdk-libs/token-client/src/actions/create_compressible_token_account.rs @@ -61,6 +61,7 @@ pub async fn create_compressible_token_account( lamports_per_write, compress_to_account_pubkey: None, token_account_version, + compression_only: true, }; let create_token_account_ix = diff --git a/sdk-libs/token-client/src/actions/transfer2/compress.rs b/sdk-libs/token-client/src/actions/transfer2/compress.rs index 5fed4b5a64..b0fcfe31ec 100644 --- a/sdk-libs/token-client/src/actions/transfer2/compress.rs +++ b/sdk-libs/token-client/src/actions/transfer2/compress.rs @@ -22,6 +22,7 @@ use crate::instructions::transfer2::{ /// * `to` - Recipient pubkey for the compressed tokens /// * `authority` - Authority that can spend from the token account /// * `payer` - Transaction fee payer +/// * `decimals` - Mint decimals for SPL transfer_checked /// /// # Returns /// `Result` - The compression instruction @@ -32,6 +33,7 @@ pub async fn compress( to: Pubkey, authority: &Keypair, payer: &Keypair, + decimals: u8, ) -> Result { // Get mint from token account let token_account_info = rpc @@ -57,6 +59,7 @@ pub async fn compress( authority: authority.pubkey(), output_queue, pool_index: None, + decimals, })], payer.pubkey(), false, diff --git a/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs b/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs index a4d72bba7e..23c8d471ad 100644 --- a/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs +++ b/sdk-libs/token-client/src/actions/transfer2/ctoken_to_spl.rs @@ -11,6 +11,7 @@ use solana_signature::Signature; use solana_signer::Signer; /// Transfer tokens from a compressed token account to an SPL token account +#[allow(clippy::too_many_arguments)] pub async fn transfer_ctoken_to_spl( rpc: &mut R, source_ctoken_account: Pubkey, @@ -19,6 +20,7 @@ pub async fn transfer_ctoken_to_spl( authority: &Keypair, mint: Pubkey, payer: &Keypair, + decimals: u8, ) -> Result { let (token_pool_pda, token_pool_pda_bump) = find_token_pool_pda_with_index(&mint, 0); @@ -32,6 +34,7 @@ pub async fn transfer_ctoken_to_spl( token_pool_pda, token_pool_pda_bump, spl_token_program: Pubkey::new_from_array(SPL_TOKEN_PROGRAM_ID), // TODO: make dynamic + decimals, } .instruction() .map_err(|e| RpcError::AssertRpcError(format!("Failed to create instruction: {:?}", e)))?; diff --git a/sdk-libs/token-client/src/actions/transfer2/decompress.rs b/sdk-libs/token-client/src/actions/transfer2/decompress.rs index 91ad01bc73..daf2503f8a 100644 --- a/sdk-libs/token-client/src/actions/transfer2/decompress.rs +++ b/sdk-libs/token-client/src/actions/transfer2/decompress.rs @@ -30,6 +30,7 @@ pub async fn decompress( solana_token_account: Pubkey, authority: &Keypair, payer: &Keypair, + decimals: u8, ) -> Result { let ix = create_generic_transfer2_instruction( rpc, @@ -39,6 +40,8 @@ pub async fn decompress( solana_token_account, amount: decompress_amount, pool_index: None, + decimals, + in_tlv: None, })], payer.pubkey(), false, diff --git a/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs b/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs index 059ac9049d..e4059fdfdb 100644 --- a/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs +++ b/sdk-libs/token-client/src/actions/transfer2/spl_to_ctoken.rs @@ -20,6 +20,7 @@ pub async fn spl_to_ctoken_transfer( amount: u64, authority: &Keypair, payer: &Keypair, + decimals: u8, ) -> Result { let token_account_info = rpc .get_account(source_spl_token_account) @@ -43,6 +44,7 @@ pub async fn spl_to_ctoken_transfer( payer: payer.pubkey(), token_pool_pda, spl_token_program: Pubkey::new_from_array(SPL_TOKEN_PROGRAM_ID), // TODO: make dynamic + decimals, } .instruction() .map_err(|e| RpcError::CustomError(e.to_string()))?; diff --git a/sdk-libs/token-client/src/instructions/transfer2.rs b/sdk-libs/token-client/src/instructions/transfer2.rs index f5758116d1..cc231212b8 100644 --- a/sdk-libs/token-client/src/instructions/transfer2.rs +++ b/sdk-libs/token-client/src/instructions/transfer2.rs @@ -14,7 +14,10 @@ use light_compressed_token_sdk::{ token_pool::find_token_pool_pda_with_index, }; use light_ctoken_types::{ - instructions::transfer2::{MultiInputTokenDataWithContext, MultiTokenTransferOutputData}, + instructions::{ + extensions::ExtensionInstructionData, + transfer2::{MultiInputTokenDataWithContext, MultiTokenTransferOutputData}, + }, state::TokenDataVersion, COMPRESSED_TOKEN_PROGRAM_ID, }; @@ -72,6 +75,7 @@ pub async fn create_decompress_instruction( decompress_amount: u64, solana_token_account: Pubkey, payer: Pubkey, + decimals: u8, ) -> Result { create_generic_transfer2_instruction( rpc, @@ -81,6 +85,8 @@ pub async fn create_decompress_instruction( solana_token_account, amount: decompress_amount, pool_index: None, + decimals, + in_tlv: None, })], payer, false, @@ -104,6 +110,9 @@ pub struct DecompressInput { pub solana_token_account: Pubkey, pub amount: u64, pub pool_index: Option, // For SPL only. None = default (0), Some(n) = specific pool + pub decimals: u8, // Mint decimals for SPL transfer_checked + /// TLV extensions for each input compressed account (required for version 3 accounts with extensions). + pub in_tlv: Option>>, } #[derive(Debug, Clone, PartialEq)] @@ -116,6 +125,7 @@ pub struct CompressInput { pub authority: Pubkey, pub output_queue: Pubkey, pub pool_index: Option, // For SPL only. None = default (0), Some(n) = specific pool + pub decimals: u8, // Mint decimals for SPL transfer_checked } #[derive(Debug, Clone, PartialEq)] @@ -215,6 +225,8 @@ pub async fn create_generic_transfer2_instruction( let mut in_lamports = Vec::new(); let mut out_lamports = Vec::new(); let mut token_accounts = Vec::new(); + let mut collected_in_tlv: Vec> = Vec::new(); + let mut has_any_tlv = false; for action in actions { match action { Transfer2InstructionType::Compress(input) => { @@ -289,6 +301,7 @@ pub async fn create_generic_transfer2_instruction( pool_account_index, pool_index, bump, + input.decimals, )?; } else { // Regular compression for compressed token accounts @@ -297,6 +310,17 @@ pub async fn create_generic_transfer2_instruction( token_accounts.push(token_account); } Transfer2InstructionType::Decompress(input) => { + // Collect in_tlv data if provided + if let Some(ref tlv_data) = input.in_tlv { + has_any_tlv = true; + collected_in_tlv.extend(tlv_data.iter().cloned()); + } else { + // Add empty TLV entries for each input (needed for proper indexing) + for _ in 0..input.compressed_token_account.len() { + collected_in_tlv.push(Vec::new()); + } + } + let token_data = input .compressed_token_account .iter() @@ -354,6 +378,7 @@ pub async fn create_generic_transfer2_instruction( pool_account_index, pool_index, bump, + input.decimals, )?; } else { // Use the new SPL-specific decompress method @@ -533,10 +558,11 @@ pub async fn create_generic_transfer2_instruction( let mut found_compress_to_pubkey = false; for extension in extensions { if let ZExtensionStruct::Compressible(compressible_ext) = extension { - found_rent_sponsor = Some(compressible_ext.rent_sponsor); + found_rent_sponsor = Some(compressible_ext.info.rent_sponsor); found_compression_authority = - Some(compressible_ext.compression_authority); - found_compress_to_pubkey = compressible_ext.compress_to_pubkey == 1; + Some(compressible_ext.info.compression_authority); + found_compress_to_pubkey = + compressible_ext.info.compress_to_pubkey == 1; break; } } @@ -634,6 +660,11 @@ pub async fn create_generic_transfer2_instruction( }, token_accounts, output_queue: shared_output_queue, + in_tlv: if has_any_tlv { + Some(collected_in_tlv) + } else { + None + }, }; println!("pre create_transfer2_instruction {:?}", inputs); create_transfer2_instruction(inputs) diff --git a/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs b/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs index 5d0c623c23..7b604dd664 100644 --- a/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs +++ b/sdk-tests/sdk-compressible-test/src/instructions/decompress_accounts_idempotent.rs @@ -236,6 +236,7 @@ pub fn decompress_accounts_idempotent<'info>( lamports_per_write: None, compress_to_account_pubkey: Some(compress_to_pubkey), token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }), } .invoke_signed(&[seeds_slice])?; @@ -257,6 +258,7 @@ pub fn decompress_accounts_idempotent<'info>( light_compressed_token_sdk::compressed_token::decompress_full::DecompressFullIndices { source, destination_index: owner_index, + tlv: None, }; token_decompress_indices.push(decompress_index); token_signers_seed_groups.push(ctoken_signer_seeds); diff --git a/sdk-tests/sdk-ctoken-test/src/transfer_interface.rs b/sdk-tests/sdk-ctoken-test/src/transfer_interface.rs index cef7cefc72..ad41d1740e 100644 --- a/sdk-tests/sdk-ctoken-test/src/transfer_interface.rs +++ b/sdk-tests/sdk-ctoken-test/src/transfer_interface.rs @@ -11,6 +11,7 @@ pub const TRANSFER_INTERFACE_AUTHORITY_SEED: &[u8] = b"transfer_interface_author #[derive(BorshSerialize, BorshDeserialize, Debug)] pub struct TransferInterfaceData { pub amount: u64, + pub decimals: u8, /// Required for SPL<->CToken transfers, None for CToken->CToken pub token_pool_pda_bump: Option, } @@ -29,33 +30,36 @@ pub struct TransferInterfaceData { /// - accounts[3]: authority (signer) /// - accounts[4]: payer (signer) /// - accounts[5]: compressed_token_program_authority +/// - accounts[6]: system_program /// For SPL bridge (optional, required for SPL<->CToken): -/// - accounts[6]: mint -/// - accounts[7]: token_pool_pda -/// - accounts[8]: spl_token_program +/// - accounts[7]: mint +/// - accounts[8]: token_pool_pda +/// - accounts[9]: spl_token_program pub fn process_transfer_interface_invoke( accounts: &[AccountInfo], data: TransferInterfaceData, ) -> Result<(), ProgramError> { - if accounts.len() < 6 { + if accounts.len() < 7 { return Err(ProgramError::NotEnoughAccountKeys); } let mut transfer = TransferInterface::new( data.amount, + data.decimals, accounts[1].clone(), // source_account accounts[2].clone(), // destination_account accounts[3].clone(), // authority accounts[4].clone(), // payer accounts[5].clone(), // compressed_token_program_authority + accounts[6].clone(), // system_program ); // Add SPL bridge config if provided - if accounts.len() >= 9 && data.token_pool_pda_bump.is_some() { + if accounts.len() >= 10 && data.token_pool_pda_bump.is_some() { transfer = transfer.with_spl_interface( - Some(accounts[6].clone()), // mint - Some(accounts[8].clone()), // spl_token_program - Some(accounts[7].clone()), // token_pool_pda + Some(accounts[7].clone()), // mint + Some(accounts[9].clone()), // spl_token_program + Some(accounts[8].clone()), // token_pool_pda data.token_pool_pda_bump, )?; } @@ -76,15 +80,16 @@ pub fn process_transfer_interface_invoke( /// - accounts[3]: authority (PDA, not signer - program signs) /// - accounts[4]: payer (signer) /// - accounts[5]: compressed_token_program_authority +/// - accounts[6]: system_program /// For SPL bridge (optional, required for SPL<->CToken): -/// - accounts[6]: mint -/// - accounts[7]: token_pool_pda -/// - accounts[8]: spl_token_program +/// - accounts[7]: mint +/// - accounts[8]: token_pool_pda +/// - accounts[9]: spl_token_program pub fn process_transfer_interface_invoke_signed( accounts: &[AccountInfo], data: TransferInterfaceData, ) -> Result<(), ProgramError> { - if accounts.len() < 6 { + if accounts.len() < 7 { return Err(ProgramError::NotEnoughAccountKeys); } @@ -99,19 +104,21 @@ pub fn process_transfer_interface_invoke_signed( let mut transfer = TransferInterface::new( data.amount, + data.decimals, accounts[1].clone(), // source_account accounts[2].clone(), // destination_account accounts[3].clone(), // authority (PDA) accounts[4].clone(), // payer accounts[5].clone(), // compressed_token_program_authority + accounts[6].clone(), // system_program ); // Add SPL bridge config if provided - if accounts.len() >= 9 && data.token_pool_pda_bump.is_some() { + if accounts.len() >= 10 && data.token_pool_pda_bump.is_some() { transfer = transfer.with_spl_interface( - Some(accounts[6].clone()), // mint - Some(accounts[8].clone()), // spl_token_program - Some(accounts[7].clone()), // token_pool_pda + Some(accounts[7].clone()), // mint + Some(accounts[9].clone()), // spl_token_program + Some(accounts[8].clone()), // token_pool_pda data.token_pool_pda_bump, )?; } diff --git a/sdk-tests/sdk-ctoken-test/src/transfer_spl_ctoken.rs b/sdk-tests/sdk-ctoken-test/src/transfer_spl_ctoken.rs index 0c914ec439..eea4cded19 100644 --- a/sdk-tests/sdk-ctoken-test/src/transfer_spl_ctoken.rs +++ b/sdk-tests/sdk-ctoken-test/src/transfer_spl_ctoken.rs @@ -14,6 +14,7 @@ pub const TRANSFER_AUTHORITY_SEED: &[u8] = b"transfer_authority"; pub struct TransferSplToCtokenData { pub amount: u64, pub token_pool_pda_bump: u8, + pub decimals: u8, } /// Instruction data for CToken to SPL transfer @@ -21,6 +22,7 @@ pub struct TransferSplToCtokenData { pub struct TransferCtokenToSplData { pub amount: u64, pub token_pool_pda_bump: u8, + pub decimals: u8, } /// Handler for transferring SPL tokens to CToken (invoke) @@ -35,11 +37,12 @@ pub struct TransferCtokenToSplData { /// - accounts[6]: token_pool_pda /// - accounts[7]: spl_token_program /// - accounts[8]: compressed_token_program_authority +/// - accounts[9]: system_program pub fn process_spl_to_ctoken_invoke( accounts: &[AccountInfo], data: TransferSplToCtokenData, ) -> Result<(), ProgramError> { - if accounts.len() < 9 { + if accounts.len() < 10 { return Err(ProgramError::NotEnoughAccountKeys); } @@ -52,8 +55,10 @@ pub fn process_spl_to_ctoken_invoke( payer: accounts[5].clone(), token_pool_pda: accounts[6].clone(), token_pool_pda_bump: data.token_pool_pda_bump, + decimals: data.decimals, spl_token_program: accounts[7].clone(), compressed_token_program_authority: accounts[8].clone(), + system_program: accounts[9].clone(), } .invoke()?; @@ -74,11 +79,12 @@ pub fn process_spl_to_ctoken_invoke( /// - accounts[6]: token_pool_pda /// - accounts[7]: spl_token_program /// - accounts[8]: compressed_token_program_authority +/// - accounts[9]: system_program pub fn process_spl_to_ctoken_invoke_signed( accounts: &[AccountInfo], data: TransferSplToCtokenData, ) -> Result<(), ProgramError> { - if accounts.len() < 9 { + if accounts.len() < 10 { return Err(ProgramError::NotEnoughAccountKeys); } @@ -100,8 +106,10 @@ pub fn process_spl_to_ctoken_invoke_signed( payer: accounts[5].clone(), token_pool_pda: accounts[6].clone(), token_pool_pda_bump: data.token_pool_pda_bump, + decimals: data.decimals, spl_token_program: accounts[7].clone(), compressed_token_program_authority: accounts[8].clone(), + system_program: accounts[9].clone(), }; // Invoke with PDA signing @@ -140,6 +148,7 @@ pub fn process_ctoken_to_spl_invoke( payer: accounts[5].clone(), token_pool_pda: accounts[6].clone(), token_pool_pda_bump: data.token_pool_pda_bump, + decimals: data.decimals, spl_token_program: accounts[7].clone(), compressed_token_program_authority: accounts[8].clone(), } @@ -188,6 +197,7 @@ pub fn process_ctoken_to_spl_invoke_signed( payer: accounts[5].clone(), token_pool_pda: accounts[6].clone(), token_pool_pda_bump: data.token_pool_pda_bump, + decimals: data.decimals, spl_token_program: accounts[7].clone(), compressed_token_program_authority: accounts[8].clone(), }; diff --git a/sdk-tests/sdk-ctoken-test/tests/test_transfer_interface.rs b/sdk-tests/sdk-ctoken-test/tests/test_transfer_interface.rs index fd1bad5077..41732e2d9a 100644 --- a/sdk-tests/sdk-ctoken-test/tests/test_transfer_interface.rs +++ b/sdk-tests/sdk-ctoken-test/tests/test_transfer_interface.rs @@ -10,7 +10,9 @@ use light_compressed_token_sdk::{ }; use light_compressed_token_types::CPI_AUTHORITY_PDA; use light_program_test::{LightProgramTest, ProgramTestConfig}; -use light_test_utils::spl::{create_mint_helper, create_token_2022_account, mint_spl_tokens}; +use light_test_utils::spl::{ + create_mint_helper, create_token_2022_account, mint_spl_tokens, CREATE_MINT_HELPER_DECIMALS, +}; use native_ctoken_examples::{TransferInterfaceData, ID, TRANSFER_INTERFACE_AUTHORITY_SEED}; use solana_sdk::{ instruction::{AccountMeta, Instruction}, @@ -84,6 +86,7 @@ async fn test_transfer_interface_spl_to_ctoken_invoke() { let data = TransferInterfaceData { amount: transfer_amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 19 = TransferInterfaceInvoke let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); @@ -95,6 +98,7 @@ async fn test_transfer_interface_spl_to_ctoken_invoke() { AccountMeta::new_readonly(sender.pubkey(), true), // authority (signer) AccountMeta::new(payer.pubkey(), true), // payer AccountMeta::new_readonly(cpi_authority_pda, false), // compressed_token_program_authority + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), // system_program AccountMeta::new_readonly(mint, false), // mint (for SPL bridge) AccountMeta::new(token_pool_pda, false), // token_pool_pda AccountMeta::new_readonly(anchor_spl::token::ID, false), // spl_token_program @@ -191,6 +195,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke() { let data = TransferInterfaceData { amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); let wrapper_accounts = vec![ @@ -200,6 +205,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke() { AccountMeta::new_readonly(owner.pubkey(), true), AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -218,6 +224,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke() { let data = TransferInterfaceData { amount: transfer_amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); @@ -228,6 +235,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke() { AccountMeta::new_readonly(owner.pubkey(), true), // authority AccountMeta::new(payer.pubkey(), true), // payer AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -331,6 +339,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke() { let data = TransferInterfaceData { amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); let wrapper_accounts = vec![ @@ -340,6 +349,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke() { AccountMeta::new_readonly(sender.pubkey(), true), AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -358,10 +368,11 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke() { let data = TransferInterfaceData { amount: transfer_amount, token_pool_pda_bump: None, // Not needed for CToken->CToken + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); - // For CToken->CToken, we only need 6 accounts (no SPL bridge) + // For CToken->CToken, we need 7 accounts (no SPL bridge, but system_program is required) let wrapper_accounts = vec![ AccountMeta::new_readonly(compressed_token_program_id, false), AccountMeta::new(sender_ctoken, false), // source (CToken) @@ -369,6 +380,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke() { AccountMeta::new_readonly(sender.pubkey(), true), // authority AccountMeta::new(payer.pubkey(), true), // payer AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), // system_program ]; let instruction = Instruction { @@ -472,6 +484,7 @@ async fn test_transfer_interface_spl_to_ctoken_invoke_signed() { let data = TransferInterfaceData { amount: transfer_amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 20 = TransferInterfaceInvokeSigned let wrapper_instruction_data = [vec![20u8], data.try_to_vec().unwrap()].concat(); @@ -483,6 +496,7 @@ async fn test_transfer_interface_spl_to_ctoken_invoke_signed() { AccountMeta::new_readonly(authority_pda, false), // authority (PDA, not signer) AccountMeta::new(payer.pubkey(), true), // payer AccountMeta::new_readonly(cpi_authority_pda, false), // compressed_token_program_authority + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), // system_program AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -597,6 +611,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke_signed() { let data = TransferInterfaceData { amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); let wrapper_accounts = vec![ @@ -606,6 +621,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke_signed() { AccountMeta::new_readonly(temp_owner.pubkey(), true), AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -624,6 +640,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke_signed() { let data = TransferInterfaceData { amount: transfer_amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 20 = TransferInterfaceInvokeSigned let wrapper_instruction_data = [vec![20u8], data.try_to_vec().unwrap()].concat(); @@ -635,6 +652,7 @@ async fn test_transfer_interface_ctoken_to_spl_invoke_signed() { AccountMeta::new_readonly(authority_pda, false), // authority (PDA) AccountMeta::new(payer.pubkey(), true), // payer AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -750,6 +768,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke_signed() { let data = TransferInterfaceData { amount, token_pool_pda_bump: Some(token_pool_pda_bump), + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![19u8], data.try_to_vec().unwrap()].concat(); let wrapper_accounts = vec![ @@ -759,6 +778,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke_signed() { AccountMeta::new_readonly(temp_owner.pubkey(), true), AccountMeta::new(payer.pubkey(), true), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), AccountMeta::new_readonly(mint, false), AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), @@ -777,6 +797,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke_signed() { let data = TransferInterfaceData { amount: transfer_amount, token_pool_pda_bump: None, // Not needed for CToken->CToken + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 20 = TransferInterfaceInvokeSigned let wrapper_instruction_data = [vec![20u8], data.try_to_vec().unwrap()].concat(); @@ -789,6 +810,7 @@ async fn test_transfer_interface_ctoken_to_ctoken_invoke_signed() { AccountMeta::new_readonly(authority_pda, false), // authority (PDA) AccountMeta::new(payer.pubkey(), true), // payer AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), ]; let instruction = Instruction { diff --git a/sdk-tests/sdk-ctoken-test/tests/test_transfer_spl_ctoken.rs b/sdk-tests/sdk-ctoken-test/tests/test_transfer_spl_ctoken.rs index 885930ee30..0402bb8b83 100644 --- a/sdk-tests/sdk-ctoken-test/tests/test_transfer_spl_ctoken.rs +++ b/sdk-tests/sdk-ctoken-test/tests/test_transfer_spl_ctoken.rs @@ -10,7 +10,9 @@ use light_compressed_token_sdk::{ }; use light_compressed_token_types::CPI_AUTHORITY_PDA; use light_program_test::{LightProgramTest, ProgramTestConfig}; -use light_test_utils::spl::{create_mint_helper, create_token_2022_account, mint_spl_tokens}; +use light_test_utils::spl::{ + create_mint_helper, create_token_2022_account, mint_spl_tokens, CREATE_MINT_HELPER_DECIMALS, +}; use native_ctoken_examples::{ TransferCtokenToSplData, TransferSplToCtokenData, ID, TRANSFER_AUTHORITY_SEED, }; @@ -95,6 +97,7 @@ async fn test_spl_to_ctoken_invoke() { let data = TransferSplToCtokenData { amount: transfer_amount, token_pool_pda_bump, + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 15 = SplToCtokenInvoke let wrapper_instruction_data = [vec![15u8], data.try_to_vec().unwrap()].concat(); @@ -109,6 +112,7 @@ async fn test_spl_to_ctoken_invoke() { // - accounts[6]: token_pool_pda // - accounts[7]: spl_token_program // - accounts[8]: compressed_token_program_authority + // - accounts[9]: system_program let wrapper_accounts = vec![ AccountMeta::new_readonly(compressed_token_program_id, false), AccountMeta::new(spl_token_account_keypair.pubkey(), false), @@ -119,6 +123,7 @@ async fn test_spl_to_ctoken_invoke() { AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), ]; let instruction = Instruction { @@ -218,6 +223,7 @@ async fn test_ctoken_to_spl_invoke() { let data = TransferSplToCtokenData { amount, token_pool_pda_bump, + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![15u8], data.try_to_vec().unwrap()].concat(); let wrapper_accounts = vec![ @@ -230,6 +236,7 @@ async fn test_ctoken_to_spl_invoke() { AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), ]; let instruction = Instruction { @@ -254,6 +261,7 @@ async fn test_ctoken_to_spl_invoke() { let data = TransferCtokenToSplData { amount: transfer_amount, token_pool_pda_bump, + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 17 = CtokenToSplInvoke let wrapper_instruction_data = [vec![17u8], data.try_to_vec().unwrap()].concat(); @@ -387,6 +395,7 @@ async fn test_spl_to_ctoken_invoke_signed() { let data = TransferSplToCtokenData { amount: transfer_amount, token_pool_pda_bump, + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 16 = SplToCtokenInvokeSigned let wrapper_instruction_data = [vec![16u8], data.try_to_vec().unwrap()].concat(); @@ -401,6 +410,7 @@ async fn test_spl_to_ctoken_invoke_signed() { AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), ]; let instruction = Instruction { @@ -525,6 +535,7 @@ async fn test_ctoken_to_spl_invoke_signed() { let data = TransferSplToCtokenData { amount, token_pool_pda_bump, + decimals: CREATE_MINT_HELPER_DECIMALS, }; let wrapper_instruction_data = [vec![15u8], data.try_to_vec().unwrap()].concat(); let wrapper_accounts = vec![ @@ -537,6 +548,7 @@ async fn test_ctoken_to_spl_invoke_signed() { AccountMeta::new(token_pool_pda, false), AccountMeta::new_readonly(anchor_spl::token::ID, false), AccountMeta::new_readonly(cpi_authority_pda, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), ]; let instruction = Instruction { @@ -561,6 +573,7 @@ async fn test_ctoken_to_spl_invoke_signed() { let data = TransferCtokenToSplData { amount: transfer_amount, token_pool_pda_bump, + decimals: CREATE_MINT_HELPER_DECIMALS, }; // Discriminator 18 = CtokenToSplInvokeSigned let wrapper_instruction_data = [vec![18u8], data.try_to_vec().unwrap()].concat(); diff --git a/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs b/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs index c07bdbf33f..30534196b1 100644 --- a/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs +++ b/sdk-tests/sdk-token-test/src/process_create_ctoken_with_compress_to_pubkey.rs @@ -27,6 +27,7 @@ pub fn process_create_ctoken_with_compress_to_pubkey<'info>( lamports_per_write: None, compress_to_account_pubkey: Some(compress_to_pubkey), token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let instruction = CreateCTokenAccount::new( diff --git a/sdk-tests/sdk-token-test/src/process_four_transfer2.rs b/sdk-tests/sdk-token-test/src/process_four_transfer2.rs index 6253580e1e..e724dbcf4f 100644 --- a/sdk-tests/sdk-token-test/src/process_four_transfer2.rs +++ b/sdk-tests/sdk-token-test/src/process_four_transfer2.rs @@ -240,6 +240,7 @@ pub fn process_four_transfer2<'info>( transfer_recipient3, ], output_queue: output_tree_index, + in_tlv: None, }; let instruction = create_transfer2_instruction(inputs).map_err(ProgramError::from)?; diff --git a/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs b/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs index 27c4445677..12bd7f06f2 100644 --- a/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs +++ b/sdk-tests/sdk-token-test/tests/decompress_full_cpi.rs @@ -83,6 +83,7 @@ async fn setup_decompress_full_test(num_inputs: usize) -> (LightProgramTest, Tes lamports_per_write: None, compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_token_account_ix = @@ -231,6 +232,7 @@ async fn test_decompress_full_cpi() { tree_info, dest_pubkey, &mut remaining_accounts, + None, // No TLV extensions ) }) .collect(); @@ -424,6 +426,7 @@ async fn test_decompress_full_cpi_with_context() { tree_info, dest_pubkey, &mut remaining_accounts, + None, // No TLV extensions ) }) .collect(); diff --git a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs index 76fafd094d..2a2d4b2b62 100644 --- a/sdk-tests/sdk-token-test/tests/pda_ctoken.rs +++ b/sdk-tests/sdk-token-test/tests/pda_ctoken.rs @@ -213,6 +213,7 @@ pub async fn create_mint( lamports_per_write: Some(1000), compress_to_account_pubkey: None, token_account_version: light_ctoken_types::state::TokenDataVersion::ShaFlat, + compression_only: false, }; let create_ata_instruction = diff --git a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs index 98c482f8bb..20698471af 100644 --- a/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs +++ b/sdk-tests/sdk-token-test/tests/test_4_transfer2.rs @@ -127,6 +127,7 @@ async fn create_compressed_mints_and_tokens( decompress_amount, token_account1_pubkey, payer.pubkey(), + 9, ) .await .unwrap(); diff --git a/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs b/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs index 05c38db9d7..12199b2858 100644 --- a/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs +++ b/sdk-tests/sdk-token-test/tests/test_compress_full_and_close.rs @@ -214,6 +214,7 @@ async fn test_compress_full_and_close() { decompress_amount, ctoken_ata_pubkey, payer.pubkey(), + 9, ) .await .unwrap();