diff --git a/program-tests/compressed-token-test/tests/light_token.rs b/program-tests/compressed-token-test/tests/light_token.rs index 678ee70d04..8f1579519c 100644 --- a/program-tests/compressed-token-test/tests/light_token.rs +++ b/program-tests/compressed-token-test/tests/light_token.rs @@ -49,3 +49,6 @@ mod burn; #[path = "light_token/extensions_failing.rs"] mod extensions_failing; + +#[path = "light_token/delegate_compress.rs"] +mod delegate_compress; diff --git a/program-tests/compressed-token-test/tests/light_token/delegate_compress.rs b/program-tests/compressed-token-test/tests/light_token/delegate_compress.rs new file mode 100644 index 0000000000..537bce3a83 --- /dev/null +++ b/program-tests/compressed-token-test/tests/light_token/delegate_compress.rs @@ -0,0 +1,229 @@ +use anchor_spl::token_2022::spl_token_2022; +use light_program_test::utils::assert::assert_rpc_error; +use solana_sdk::program_pack::Pack; + +use super::shared::*; + +/// Test delegate compress on CToken accounts. +/// +/// Scenarios: +/// 1. Partial compress (300 of 500 delegated) — delegate and delegated_amount updated +/// 2. Exact remaining compress (200) — delegate cleared +/// 3. Delegate cannot compress after being cleared — OwnerMismatch error +/// 4. Owner can still compress normally after delegate is cleared +#[tokio::test] +#[serial] +async fn test_delegate_compress() -> Result<(), RpcError> { + // Setup: CToken account with compressible extension + let mut context = setup_account_test_with_created_account(Some((0, false))).await?; + let payer = context.payer.insecure_clone(); + let owner = context.owner_keypair.insecure_clone(); + let token_account_pubkey = context.token_account_keypair.pubkey(); + let mint_pubkey = context.mint_pubkey; + + // Fund owner for transaction fees and compressible top-up + context + .rpc + .airdrop_lamports(&owner.pubkey(), 1_000_000_000) + .await?; + + // Set CToken balance to 1000 via set_account + { + let mut token_account = context + .rpc + .get_account(token_account_pubkey) + .await? + .unwrap(); + + let mut spl_account = + spl_token_2022::state::Account::unpack_unchecked(&token_account.data[..165]).unwrap(); + spl_account.amount = 1000; + spl_token_2022::state::Account::pack(spl_account, &mut token_account.data[..165]).unwrap(); + context.rpc.set_account(token_account_pubkey, token_account); + } + + // Approve delegate for 500 + let delegate = Keypair::new(); + approve_and_assert(&mut context, delegate.pubkey(), 500, "approve_delegate_500").await; + + // Warp slot so compressible top-up assertion works + context.rpc.warp_to_slot(4).unwrap(); + + let output_queue = context + .rpc + .get_random_state_tree_info() + .unwrap() + .get_output_pubkey() + .unwrap(); + + // ========================================================================= + // Scenario 1: Partial compress (300 of 500 delegated) + // ========================================================================= + { + compress( + &mut context.rpc, + token_account_pubkey, + 300, + owner.pubkey(), + &delegate, + &payer, + 9, + ) + .await + .unwrap(); + + let compress_input = CompressInput { + compressed_token_account: None, + solana_token_account: token_account_pubkey, + to: owner.pubkey(), + mint: mint_pubkey, + amount: 300, + authority: delegate.pubkey(), + output_queue, + pool_index: None, + decimals: 9, + version: None, + }; + assert_transfer2_compress(&mut context.rpc, compress_input).await; + + // Verify: amount == 700, delegated_amount == 200, delegate still set + let account_data = context + .rpc + .get_account(token_account_pubkey) + .await? + .unwrap(); + let spl_account = + spl_token_2022::state::Account::unpack(&account_data.data[..165]).unwrap(); + assert_eq!( + spl_account.amount, 700, + "Balance should be 700 after compressing 300" + ); + assert_eq!( + spl_account.delegated_amount, 200, + "Delegated amount should be 200 after compressing 300 of 500" + ); + assert_eq!( + spl_account.delegate, + spl_token_2022::solana_program::program_option::COption::Some(delegate.pubkey()), + "Delegate should still be set" + ); + } + + // ========================================================================= + // Scenario 2: Exact remaining amount (200), delegate cleared + // ========================================================================= + { + compress( + &mut context.rpc, + token_account_pubkey, + 200, + owner.pubkey(), + &delegate, + &payer, + 9, + ) + .await + .unwrap(); + + let compress_input = CompressInput { + compressed_token_account: None, + solana_token_account: token_account_pubkey, + to: owner.pubkey(), + mint: mint_pubkey, + amount: 200, + authority: delegate.pubkey(), + output_queue, + pool_index: None, + decimals: 9, + version: None, + }; + assert_transfer2_compress(&mut context.rpc, compress_input).await; + + // Verify: amount == 500, delegated_amount == 0, delegate cleared + let account_data = context + .rpc + .get_account(token_account_pubkey) + .await? + .unwrap(); + let spl_account = + spl_token_2022::state::Account::unpack(&account_data.data[..165]).unwrap(); + assert_eq!( + spl_account.amount, 500, + "Balance should be 500 after compressing 200 more" + ); + assert_eq!( + spl_account.delegated_amount, 0, + "Delegated amount should be 0 after compressing all delegated tokens" + ); + assert_eq!( + spl_account.delegate, + spl_token_2022::solana_program::program_option::COption::None, + "Delegate should be cleared when delegated_amount reaches 0" + ); + } + + // ========================================================================= + // Scenario 3: Delegate cannot compress after being cleared + // ========================================================================= + { + let result = compress( + &mut context.rpc, + token_account_pubkey, + 1, + owner.pubkey(), + &delegate, + &payer, + 9, + ) + .await; + + // OwnerMismatch = 6075 + assert_rpc_error(result, 0, 6075).unwrap(); + } + + // ========================================================================= + // Scenario 4: Owner can still compress normally + // ========================================================================= + { + compress( + &mut context.rpc, + token_account_pubkey, + 100, + owner.pubkey(), + &owner, + &payer, + 9, + ) + .await + .unwrap(); + + let compress_input = CompressInput { + compressed_token_account: None, + solana_token_account: token_account_pubkey, + to: owner.pubkey(), + mint: mint_pubkey, + amount: 100, + authority: owner.pubkey(), + output_queue, + pool_index: None, + decimals: 9, + version: None, + }; + assert_transfer2_compress(&mut context.rpc, compress_input).await; + + // Verify: amount == 400 + let account_data = context + .rpc + .get_account(token_account_pubkey) + .await? + .unwrap(); + let spl_account = + spl_token_2022::state::Account::unpack(&account_data.data[..165]).unwrap(); + assert_eq!( + spl_account.amount, 400, + "Balance should be 400 after owner compresses 100" + ); + } + + Ok(()) +} diff --git a/program-tests/utils/src/assert_transfer2.rs b/program-tests/utils/src/assert_transfer2.rs index 76c8a8073a..cc3aa57676 100644 --- a/program-tests/utils/src/assert_transfer2.rs +++ b/program-tests/utils/src/assert_transfer2.rs @@ -45,6 +45,23 @@ pub async fn assert_transfer2_with_delegate( // Decrement balance for compress expected_spl_accounts.get_mut(&pubkey).unwrap().amount -= compress_input.amount; + + // Handle delegate amount decrement when delegate is compressing + let expected = expected_spl_accounts.get_mut(&pubkey).unwrap(); + if expected.delegate + == spl_token_2022::solana_program::program_option::COption::Some( + compress_input.authority, + ) + { + expected.delegated_amount = expected + .delegated_amount + .checked_sub(compress_input.amount) + .expect("Delegate compress amount exceeds delegated_amount"); + if expected.delegated_amount == 0 { + expected.delegate = + spl_token_2022::solana_program::program_option::COption::None; + } + } } Transfer2InstructionType::Decompress(decompress_input) => { let pubkey = decompress_input.solana_token_account; diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs index 81503fccfb..1e06388ca7 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs @@ -57,13 +57,27 @@ pub fn compress_or_decompress_ctokens( ZCompressionMode::Compress => { // Verify authority for compression operations let authority_account = authority.ok_or(ErrorCode::InvalidCompressAuthority)?; - check_ctoken_owner(&mut ctoken, authority_account, mint_checks.as_ref())?; + let is_delegate = + check_ctoken_owner(&mut ctoken, authority_account, mint_checks.as_ref())?; if !ctoken.is_initialized() { return Err(TokenError::InvalidAccountState.into()); } + // Delegate: enforce and decrement delegated_amount + if is_delegate { + let new_delegated = ctoken + .base + .delegated_amount + .get() + .checked_sub(amount) + .ok_or(ProgramError::InsufficientFunds)?; + ctoken.base.delegated_amount.set(new_delegated); + if new_delegated == 0 { + ctoken.base.set_delegate(None)?; + } + } + // Compress: subtract from solana account - // Update the balance in the ctoken solana account ctoken.base.amount.set( current_balance .checked_sub(amount) diff --git a/programs/compressed-token/program/src/shared/owner_validation.rs b/programs/compressed-token/program/src/shared/owner_validation.rs index e5c307367f..5f0518bacc 100644 --- a/programs/compressed-token/program/src/shared/owner_validation.rs +++ b/programs/compressed-token/program/src/shared/owner_validation.rs @@ -79,12 +79,13 @@ pub fn verify_owner_or_delegate_signer<'a>( /// 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. +/// Returns `is_delegate`: true if the signer is the account-level delegate. #[profile] pub fn check_ctoken_owner( compressed_token: &mut ZTokenMut, authority_account: &AccountInfo, mint_checks: Option<&MintExtensionChecks>, -) -> Result<(), ProgramError> { +) -> Result { // Verify authority is signer check_signer(authority_account).map_err(|e| { anchor_lang::solana_program::msg!("Authority signer check failed: {:?}", e); @@ -96,18 +97,25 @@ pub fn check_ctoken_owner( // Check if authority is the owner if pubkey_eq(authority_key, owner_key) { - return Ok(()); // Owner can always compress + return Ok(false); } // 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 pubkey_eq(authority_key, permanent_delegate) { - return Ok(()); // Permanent delegate can (de)compress any account of this mint + return Ok(false); } } } - // Authority is neither owner nor permanent delegate + // Check if authority is the account-level delegate (approved via CTokenApprove) + if let Some(delegate) = compressed_token.delegate() { + if pubkey_eq(authority_key, &delegate.to_bytes()) { + return Ok(true); + } + } + + // Authority is neither owner, permanent delegate, nor account delegate Err(ErrorCode::OwnerMismatch.into()) }