diff --git a/program-tests/compressed-token-test/tests/compress_only/delegated.rs b/program-tests/compressed-token-test/tests/compress_only/delegated.rs index 4042c00d49..b6434e1d6d 100644 --- a/program-tests/compressed-token-test/tests/compress_only/delegated.rs +++ b/program-tests/compressed-token-test/tests/compress_only/delegated.rs @@ -129,3 +129,547 @@ async fn test_orphan_delegate_can_decompress() { .await .unwrap(); } + +/// Test that decompressing to an existing account with the same delegate +/// accumulates the delegated_amount. +/// +/// Scenario: +/// 1. Create destination ctoken with delegate D and delegated_amount = 300 +/// 2. Create compressed token with delegate D and delegated_amount = 200 +/// 3. Decompress to existing destination +/// 4. Verify destination delegated_amount = 500 (300 + 200) +#[tokio::test] +#[serial] +async fn test_decompress_accumulates_delegated_amount() { + use borsh::BorshDeserialize; + use light_client::indexer::Indexer; + use light_compressed_token_sdk::spl_interface::find_spl_interface_pda_with_index; + use light_program_test::program_test::TestRpc; + use light_test_utils::{ + actions::legacy::instructions::transfer2::{ + create_generic_transfer2_instruction, DecompressInput, Transfer2InstructionType, + }, + mint_2022::{create_token_22_account, mint_spl_tokens_22, RESTRICTED_EXTENSIONS}, + Rpc, + }; + use light_token::instruction::{CompressibleParams, CreateTokenAccount, TransferFromSpl}; + use light_token_interface::{ + instructions::extensions::{ + CompressedOnlyExtensionInstructionData, ExtensionInstructionData, + }, + state::{Token, TokenDataVersion}, + }; + use solana_sdk::signer::Signer; + + use super::shared::{set_ctoken_account_state, setup_extensions_test}; + + let mut context = setup_extensions_test(ALL_EXTENSIONS).await.unwrap(); + let has_restricted_extensions = ALL_EXTENSIONS + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // Create delegate keypair + let delegate = Keypair::new(); + let existing_delegated_amount = 300_000_000u64; + let compressed_delegated_amount = 200_000_000u64; + let expected_total_delegated_amount = existing_delegated_amount + compressed_delegated_amount; + + // 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 Light Token account to be compressed (source) + let owner = Keypair::new(); + let source_keypair = Keypair::new(); + let source_account = source_keypair.pubkey(); + + let create_source_ix = + CreateTokenAccount::new(payer.pubkey(), source_account, mint_pubkey, owner.pubkey()) + .with_compressible(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 + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: has_restricted_extensions, + }) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_source_ix], + &payer.pubkey(), + &[&payer, &source_keypair], + ) + .await + .unwrap(); + + // 3. Transfer tokens to source Light Token + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted_extensions); + let transfer_ix = TransferFromSpl { + amount: mint_amount, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination: source_account, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // 4. Set delegate on source account before compression + set_ctoken_account_state( + &mut context.rpc, + source_account, + Some(delegate.pubkey()), + compressed_delegated_amount, + false, // not frozen + ) + .await + .unwrap(); + + // 5. Create DESTINATION Light Token account that already has a delegate + let dest_keypair = Keypair::new(); + let dest_account = dest_keypair.pubkey(); + + let create_dest_ix = + CreateTokenAccount::new(payer.pubkey(), dest_account, mint_pubkey, owner.pubkey()) + .with_compressible(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: has_restricted_extensions, + }) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[create_dest_ix], &payer.pubkey(), &[&payer, &dest_keypair]) + .await + .unwrap(); + + // Give destination 10 SOL so it won't be compressed by forester + context + .rpc + .airdrop_lamports(&dest_account, 10_000_000_000) + .await + .unwrap(); + + // 6. Set the SAME delegate on destination with existing delegated_amount + set_ctoken_account_state( + &mut context.rpc, + dest_account, + Some(delegate.pubkey()), + existing_delegated_amount, + false, // not frozen + ) + .await + .unwrap(); + + // 7. Warp epoch to trigger forester compression of source account + context.rpc.warp_epoch_forward(30).await.unwrap(); + + // 8. Verify source account is compressed + let source_after = context.rpc.get_account(source_account).await.unwrap(); + assert!( + source_after.is_none() || source_after.unwrap().lamports == 0, + "Source account should be closed after compression" + ); + + // 9. Get the compressed token account + 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" + ); + + // 10. Decompress to existing destination with matching delegate + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: compressed_delegated_amount, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }, + )]]; + + 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: 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(); + + // 11. Verify delegated_amount was accumulated + let dest_account_data = context + .rpc + .get_account(dest_account) + .await + .unwrap() + .expect("Destination account should exist"); + + let dest_ctoken = + Token::deserialize(&mut &dest_account_data.data[..]).expect("Failed to deserialize Token"); + + assert_eq!( + dest_ctoken.delegate, + Some(delegate.pubkey().to_bytes().into()), + "Delegate should be preserved" + ); + + assert_eq!( + dest_ctoken.delegated_amount, expected_total_delegated_amount, + "Delegated amount should be accumulated: {} + {} = {}", + existing_delegated_amount, compressed_delegated_amount, expected_total_delegated_amount + ); + + println!( + "Successfully accumulated delegated_amount: {} + {} = {}", + existing_delegated_amount, compressed_delegated_amount, dest_ctoken.delegated_amount + ); +} + +/// Test that decompressing to an existing account with a DIFFERENT delegate +/// does not accumulate the delegated_amount (delegates must match). +/// +/// Scenario: +/// 1. Create destination ctoken with delegate D1 and delegated_amount = 300 +/// 2. Create compressed token with delegate D2 and delegated_amount = 200 +/// 3. Decompress to existing destination +/// 4. Verify destination delegated_amount remains 300 (no accumulation) +#[tokio::test] +#[serial] +async fn test_decompress_skips_accumulation_when_delegate_mismatch() { + use borsh::BorshDeserialize; + use light_client::indexer::Indexer; + use light_compressed_token_sdk::spl_interface::find_spl_interface_pda_with_index; + use light_program_test::program_test::TestRpc; + use light_test_utils::{ + actions::legacy::instructions::transfer2::{ + create_generic_transfer2_instruction, DecompressInput, Transfer2InstructionType, + }, + mint_2022::{create_token_22_account, mint_spl_tokens_22, RESTRICTED_EXTENSIONS}, + Rpc, + }; + use light_token::instruction::{CompressibleParams, CreateTokenAccount, TransferFromSpl}; + use light_token_interface::{ + instructions::extensions::{ + CompressedOnlyExtensionInstructionData, ExtensionInstructionData, + }, + state::{Token, TokenDataVersion}, + }; + use solana_sdk::signer::Signer; + + use super::shared::{set_ctoken_account_state, setup_extensions_test}; + + let mut context = setup_extensions_test(ALL_EXTENSIONS).await.unwrap(); + let has_restricted_extensions = ALL_EXTENSIONS + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let payer = context.payer.insecure_clone(); + let mint_pubkey = context.mint_pubkey; + + // Create TWO DIFFERENT delegate keypairs + let delegate_compressed = Keypair::new(); // delegate in compressed token + let delegate_destination = Keypair::new(); // delegate in destination account + let existing_delegated_amount = 300_000_000u64; + let compressed_delegated_amount = 200_000_000u64; + + // 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 Light Token account to be compressed (source) + let owner = Keypair::new(); + let source_keypair = Keypair::new(); + let source_account = source_keypair.pubkey(); + + let create_source_ix = + CreateTokenAccount::new(payer.pubkey(), source_account, mint_pubkey, owner.pubkey()) + .with_compressible(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 + lamports_per_write: Some(100), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: has_restricted_extensions, + }) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_source_ix], + &payer.pubkey(), + &[&payer, &source_keypair], + ) + .await + .unwrap(); + + // 3. Transfer tokens to source Light Token + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted_extensions); + let transfer_ix = TransferFromSpl { + amount: mint_amount, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination: source_account, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // 4. Set delegate on source account before compression (delegate_compressed) + set_ctoken_account_state( + &mut context.rpc, + source_account, + Some(delegate_compressed.pubkey()), + compressed_delegated_amount, + false, // not frozen + ) + .await + .unwrap(); + + // 5. Create DESTINATION Light Token account with a DIFFERENT delegate + let dest_keypair = Keypair::new(); + let dest_account = dest_keypair.pubkey(); + + let create_dest_ix = + CreateTokenAccount::new(payer.pubkey(), dest_account, mint_pubkey, owner.pubkey()) + .with_compressible(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: has_restricted_extensions, + }) + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction(&[create_dest_ix], &payer.pubkey(), &[&payer, &dest_keypair]) + .await + .unwrap(); + + // Give destination 10 SOL so it won't be compressed by forester + context + .rpc + .airdrop_lamports(&dest_account, 10_000_000_000) + .await + .unwrap(); + + // 6. Set a DIFFERENT delegate on destination + set_ctoken_account_state( + &mut context.rpc, + dest_account, + Some(delegate_destination.pubkey()), + existing_delegated_amount, + false, // not frozen + ) + .await + .unwrap(); + + // 7. Warp epoch to trigger forester compression of source account + context.rpc.warp_epoch_forward(30).await.unwrap(); + + // 8. Verify source account is compressed + let source_after = context.rpc.get_account(source_account).await.unwrap(); + assert!( + source_after.is_none() || source_after.unwrap().lamports == 0, + "Source account should be closed after compression" + ); + + // 9. Get the compressed token account + 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" + ); + + // 10. Decompress to existing destination with DIFFERENT delegate + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: compressed_delegated_amount, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }, + )]]; + + 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: 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(); + + // 11. Verify delegated_amount was NOT accumulated (delegates don't match) + let dest_account_data = context + .rpc + .get_account(dest_account) + .await + .unwrap() + .expect("Destination account should exist"); + + let dest_ctoken = + Token::deserialize(&mut &dest_account_data.data[..]).expect("Failed to deserialize Token"); + + // Delegate should remain as the destination's original delegate + assert_eq!( + dest_ctoken.delegate, + Some(delegate_destination.pubkey().to_bytes().into()), + "Delegate should remain as destination's original delegate" + ); + + // Delegated amount should NOT be accumulated (delegates don't match) + assert_eq!( + dest_ctoken.delegated_amount, existing_delegated_amount, + "Delegated amount should NOT be accumulated when delegates don't match: expected {}, got {}", + existing_delegated_amount, dest_ctoken.delegated_amount + ); + + println!( + "Successfully skipped accumulation when delegates don't match: destination delegated_amount remains {}", + dest_ctoken.delegated_amount + ); +} diff --git a/programs/compressed-token/program/docs/EXTENSIONS.md b/programs/compressed-token/program/docs/EXTENSIONS.md index 392d06726e..894aeaeb1b 100644 --- a/programs/compressed-token/program/docs/EXTENSIONS.md +++ b/programs/compressed-token/program/docs/EXTENSIONS.md @@ -292,15 +292,18 @@ ctoken.base.set_initialized(); **Trigger:** Decompressing a compressed token that has CompressedOnly extension. -**State Restoration (`validate_and_apply_compressed_only` function, lines 15-70):** +**State Restoration (`validate_and_apply_compressed_only` function, lines 15-64):** 1. Return early if no decompress inputs or no CompressedOnly extension (lines 23-29) -2. Validate amount matches for ATA or compress_to_pubkey decompress (lines 31-46) -3. Validate destination ownership via `validate_destination` (lines 48-56) -4. Restore delegate pubkey and delegated_amount via `apply_delegate` (lines 58-59) -5. Restore `withheld_transfer_fee` via `apply_withheld_fee` (lines 61-62) -6. Restore frozen state via `ctoken.base.set_frozen()` (lines 64-67) - -**Validation (`validate_destination`, lines 77-106):** +2. Validate amount matches compression amount (lines 31-40) +3. Validate destination ownership via `validate_destination` (lines 42-50) +4. Apply delegate state via `apply_delegate` (line 53): + - If no existing delegate: set delegate and accumulate delegated_amount + - If existing delegate matches input: accumulate delegated_amount + - If existing delegate differs: skip (no accumulation, silent ignore) +5. Restore `withheld_transfer_fee` via `apply_withheld_fee` (line 56) +6. Restore frozen state via `ctoken.base.set_frozen()` (lines 58-61) + +**Validation (`validate_destination`, lines 66-100):** - For non-ATA: CToken owner must match input owner - For ATA: destination address must match input owner (ATA pubkey), and CToken owner must match wallet owner @@ -308,13 +311,13 @@ ctoken.base.set_initialized(); | Field | Preserved (C&C) | Restored (Decompress) | Notes | |-------|-----------------|----------------------|-------| -| delegated_amount | ✅ | ✅ | Stored in extension | -| withheld_transfer_fee | ✅ | ✅ | Restored to TransferFeeAccount | -| is_frozen | ✅ | ✅ | Restored via `set_frozen()` | -| is_ata | ✅ | ✅ | Used to validate ATA derivation on decompress | -| delegate pubkey | Validated | From input | Passed as instruction account | -| amount | ❌ (set to 0) | From compression | New amount from compressed token | -| close_authority | ❌ | ❌ | Not preserved | +| delegated_amount | Yes | Yes | Accumulated when delegates match | +| withheld_transfer_fee | Yes | Yes | Restored to TransferFeeAccount | +| is_frozen | Yes | Yes | Restored via `set_frozen()` | +| is_ata | Yes | Yes | Used to validate ATA derivation on decompress | +| delegate pubkey | Validated | Set or matched | Set if no existing; accumulation requires match | +| amount | No (set to 0) | From compression | New amount from compressed token | +| close_authority | No | No | Not preserved | ### Error Codes @@ -490,7 +493,7 @@ MintExtensionChecks { ## Open Questions -### 1. ~~Should DefaultAccountState be a restricted extension?~~ ✅ IMPLEMENTED +### 1. ~~Should DefaultAccountState be a restricted extension?~~ IMPLEMENTED **Status:** Implemented. `DefaultAccountState` is now in `RESTRICTED_EXTENSION_TYPES`. @@ -499,7 +502,7 @@ When a mint has the `DefaultAccountState` extension (regardless of current state 2. Once compressed, we don't re-check the mint's DefaultAccountState when creating outputs 3. CToken accounts still respect the current frozen state for proper initialization -### 2. ~~How to enforce restricted extensions in anchor instructions?~~ ✅ IMPLEMENTED +### 2. ~~How to enforce restricted extensions in anchor instructions?~~ IMPLEMENTED **Status:** Implemented via different pool PDA derivation for restricted mints. diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs index 55ab3dec25..6e109d8262 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/decompress.rs @@ -107,35 +107,34 @@ fn apply_delegate( inputs: &DecompressCompressOnlyInputs, packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, ) -> Result<(), ProgramError> { - // Skip if destination already has delegate - if ctoken.delegate().is_some() { - return Ok(()); - } - let delegated_amount: u64 = ext_data.delegated_amount.into(); - // Resolve delegate only when needed let input_delegate = if inputs.input_token_data.has_delegate() { Some(packed_accounts.get_u8(inputs.input_token_data.delegate, "delegate")?) } else { None }; - if let Some(delegate_acc) = input_delegate { + let Some(delegate_acc) = input_delegate else { + return Ok(()); + }; + + let delegate_is_set = if let Some(existing_delegate) = ctoken.delegate() { + pubkey_eq(existing_delegate.array_ref(), delegate_acc.key()) + } else { ctoken .base .set_delegate(Some(Pubkey::from(*delegate_acc.key())))?; - if delegated_amount > 0 { - let current = ctoken.base.delegated_amount.get(); - ctoken.base.delegated_amount.set( - current - .checked_add(delegated_amount) - .ok_or(ProgramError::ArithmeticOverflow)?, - ); - } - } else if delegated_amount > 0 { - msg!("Decompress: delegated_amount > 0 but no delegate"); - return Err(TokenError::DecompressDelegatedAmountWithoutDelegate.into()); + true + }; + + if delegate_is_set && delegated_amount > 0 { + let current = ctoken.base.delegated_amount.get(); + ctoken.base.delegated_amount.set( + current + .checked_add(delegated_amount) + .ok_or(ProgramError::ArithmeticOverflow)?, + ); } Ok(())