From f521153cd6cdd40a1e1f723f63ae15723dc2e672 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 25 Mar 2026 00:49:30 +0000 Subject: [PATCH 01/14] refactor: relax ata decompress signer check, feat: add ata decompress idempotent --- .../src/v3/layout/layout-transfer2.ts | 1 + program-libs/token-interface/src/error.rs | 8 + .../src/instructions/transfer2/compression.rs | 31 +- .../tests/compress_only/ata_decompress.rs | 704 +++++++++++++++++- .../actions/legacy/instructions/transfer2.rs | 98 ++- .../actions/legacy/transfer2/decompress.rs | 36 + program-tests/utils/src/assert_transfer2.rs | 6 +- programs/compressed-token/program/CLAUDE.md | 4 +- .../docs/compressed_token/TRANSFER2.md | 11 +- .../ctoken/compress_or_decompress_ctokens.rs | 2 +- .../transfer2/compression/ctoken/inputs.rs | 4 +- .../transfer2/compression/mod.rs | 2 +- .../transfer2/compression/spl.rs | 4 + .../compressed_token/transfer2/processor.rs | 22 + .../program/src/shared/token_input.rs | 26 +- .../src/compressed_token/v2/account2.rs | 25 + 16 files changed, 953 insertions(+), 31 deletions(-) diff --git a/js/compressed-token/src/v3/layout/layout-transfer2.ts b/js/compressed-token/src/v3/layout/layout-transfer2.ts index 0f17dfb141..87555f0e1d 100644 --- a/js/compressed-token/src/v3/layout/layout-transfer2.ts +++ b/js/compressed-token/src/v3/layout/layout-transfer2.ts @@ -31,6 +31,7 @@ export const EXTENSION_DISCRIMINANT_COMPRESSIBLE = 32; export const COMPRESSION_MODE_COMPRESS = 0; export const COMPRESSION_MODE_DECOMPRESS = 1; export const COMPRESSION_MODE_COMPRESS_AND_CLOSE = 2; +export const COMPRESSION_MODE_DECOMPRESS_IDEMPOTENT = 3; /** * Compression struct for Transfer2 instruction diff --git a/program-libs/token-interface/src/error.rs b/program-libs/token-interface/src/error.rs index 7ea2d994a6..30a4a34089 100644 --- a/program-libs/token-interface/src/error.rs +++ b/program-libs/token-interface/src/error.rs @@ -206,6 +206,12 @@ pub enum TokenError { #[error("ATA derivation failed or mismatched for is_ata compressed token")] InvalidAtaDerivation, + + #[error("DecompressIdempotent requires exactly 1 input and 1 compression")] + IdempotentDecompressRequiresSingleInput, + + #[error("DecompressIdempotent is only supported for ATA accounts (is_ata must be true)")] + IdempotentDecompressRequiresAta, } impl From for u32 { @@ -277,6 +283,8 @@ impl From for u32 { TokenError::DecompressAmountMismatch => 18064, TokenError::CompressionIndexOutOfBounds => 18065, TokenError::InvalidAtaDerivation => 18066, + TokenError::IdempotentDecompressRequiresSingleInput => 18067, + TokenError::IdempotentDecompressRequiresAta => 18068, TokenError::HasherError(e) => u32::from(e), TokenError::ZeroCopyError(e) => u32::from(e), TokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/token-interface/src/instructions/transfer2/compression.rs b/program-libs/token-interface/src/instructions/transfer2/compression.rs index dd4be64bae..de961b15d9 100644 --- a/program-libs/token-interface/src/instructions/transfer2/compression.rs +++ b/program-libs/token-interface/src/instructions/transfer2/compression.rs @@ -16,6 +16,10 @@ pub enum CompressionMode { /// Signer must be rent authority, token account must be compressible /// Not implemented for spl token accounts. CompressAndClose, + /// Permissionless ATA decompress with single-input constraint. + /// Requires CompressedOnly extension with is_ata=true. + /// On-chain behavior is identical to Decompress. + DecompressIdempotent, } impl ZCompressionMode { @@ -24,7 +28,14 @@ impl ZCompressionMode { } pub fn is_decompress(&self) -> bool { - matches!(self, ZCompressionMode::Decompress) + matches!( + self, + ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent + ) + } + + pub fn is_decompress_idempotent(&self) -> bool { + matches!(self, ZCompressionMode::DecompressIdempotent) } pub fn is_compress_and_close(&self) -> bool { @@ -35,6 +46,7 @@ impl ZCompressionMode { pub const COMPRESS: u8 = 0u8; pub const DECOMPRESS: u8 = 1u8; pub const COMPRESS_AND_CLOSE: u8 = 2u8; +pub const DECOMPRESS_IDEMPOTENT: u8 = 3u8; impl<'a> ZeroCopyAtMut<'a> for CompressionMode { type ZeroCopyAtMut = Ref<&'a mut [u8], u8>; @@ -204,6 +216,20 @@ impl Compression { decimals: 0, } } + + pub fn decompress_idempotent(amount: u64, mint: u8, recipient: u8) -> Self { + Compression { + amount, + mode: CompressionMode::DecompressIdempotent, + mint, + source_or_recipient: recipient, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 0, + } + } } impl ZCompressionMut<'_> { @@ -212,6 +238,7 @@ impl ZCompressionMut<'_> { COMPRESS => Ok(CompressionMode::Compress), DECOMPRESS => Ok(CompressionMode::Decompress), COMPRESS_AND_CLOSE => Ok(CompressionMode::CompressAndClose), + DECOMPRESS_IDEMPOTENT => Ok(CompressionMode::DecompressIdempotent), _ => Err(TokenError::InvalidCompressionMode), } } @@ -226,7 +253,7 @@ impl ZCompression<'_> { .checked_add((*self.amount).into()) .ok_or(TokenError::ArithmeticOverflow) } - ZCompressionMode::Decompress => { + ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent => { // Decompress: subtract from balance (tokens are being removed from spl token pool) current_balance .checked_sub((*self.amount).into()) diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index b48517e4de..1928ea1918 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -193,7 +193,7 @@ async fn attempt_decompress_with_tlv( amount, pool_index: None, decimals: 9, - in_tlv: Some(in_tlv), + in_tlv: Some(in_tlv.clone()), })], payer.pubkey(), true, @@ -203,7 +203,20 @@ async fn attempt_decompress_with_tlv( RpcError::CustomError(format!("Failed to create decompress instruction: {:?}", e)) })?; - rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[payer, owner]) + // ATA decompress is permissionless (only payer signs). + // Non-ATA decompress still requires owner to sign. + let is_ata = in_tlv + .iter() + .flatten() + .any(|ext| matches!(ext, ExtensionInstructionData::CompressedOnly(data) if data.is_ata)); + + let signers: Vec<&Keypair> = if !is_ata && payer.pubkey() != owner.pubkey() { + vec![payer, owner] + } else { + vec![payer] + }; + + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &signers) .await } @@ -1271,8 +1284,8 @@ async fn test_ata_multiple_compress_decompress_cycles() { .await .unwrap(); - // For ATA decompress, wallet owner signs (not ATA pubkey) - rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer, &wallet]) + // ATA decompress is permissionless -- only payer needs to sign + rpc.create_and_send_transaction(&[decompress_ix], &payer.pubkey(), &[&payer]) .await .unwrap(); @@ -1518,3 +1531,686 @@ async fn test_non_ata_compress_only_decompress() { let dest_ctoken = Token::deserialize(&mut &dest_account.data[..]).unwrap(); assert_eq!(dest_ctoken.amount, mint_amount); } + +/// Test that DecompressIdempotent succeeds with a third-party payer (not owner). +/// Only the payer signs -- the owner does not sign. This verifies the permissionless +/// nature of DecompressIdempotent for ATA compressed tokens. +#[tokio::test] +#[serial] +async fn test_decompress_idempotent_succeeds() { + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA (idempotent - same address) + let create_dest_ix = CreateAssociatedTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_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: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // Build DecompressIdempotent instruction with is_ata=true and correct bump + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: context.ata_bump, + owner_index: 0, // Will be updated by create_generic_transfer2_instruction + }, + )]]; + + let ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::DecompressIdempotent( + DecompressInput { + compressed_token_account: vec![context.compressed_account.clone()], + decompress_amount: context.amount, + solana_token_account: context.ata_pubkey, + amount: context.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + }, + )], + context.payer.pubkey(), + true, + ) + .await + .unwrap(); + + // Only payer signs -- owner does NOT sign (permissionless) + let result = context + .rpc + .create_and_send_transaction(&[ix], &context.payer.pubkey(), &[&context.payer]) + .await; + + assert!( + result.is_ok(), + "DecompressIdempotent with third-party payer should succeed: {:?}", + result.err() + ); + + // Verify ATA has the correct balance + use borsh::BorshDeserialize; + use light_token_interface::state::Token; + let dest_account = context + .rpc + .get_account(context.ata_pubkey) + .await + .unwrap() + .unwrap(); + let dest_ctoken = Token::deserialize(&mut &dest_account.data[..]).unwrap(); + assert_eq!( + dest_ctoken.amount, context.amount, + "Decompressed amount should match original amount" + ); +} + +/// Test that DecompressIdempotent rejects transactions with multiple inputs. +/// The protocol requires exactly 1 input and 1 compression for DecompressIdempotent. +#[tokio::test] +#[serial] +async fn test_decompress_idempotent_rejects_multiple_inputs() { + // We need two compressed accounts from the same ATA. Use the multi-cycle approach. + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let extensions = &[ExtensionType::Pausable]; + let (mint_keypair, _) = + create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; + let mint_pubkey = mint_keypair.pubkey(); + + let spl_account = + create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let total_mint_amount = 10_000_000_000u64; + mint_spl_tokens_22( + &mut rpc, + &payer, + &mint_pubkey, + &spl_account, + total_mint_amount, + ) + .await; + + let wallet = Keypair::new(); + let (ata_pubkey, ata_bump) = + get_associated_token_address_and_bump(&wallet.pubkey(), &mint_pubkey); + + let amount1 = 100_000_000u64; + let amount2 = 200_000_000u64; + + let has_restricted = extensions + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); + + // Cycle 1: Create ATA, fund, compress + let create_ata_ix = + CreateAssociatedTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(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: 0, + 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_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + let transfer_ix1 = TransferFromSpl { + amount: amount1, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination: ata_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix1], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + rpc.warp_epoch_forward(30).await.unwrap(); + + // Cycle 2: Recreate ATA, fund, compress + let create_ata_ix2 = + CreateAssociatedTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(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: 0, + 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_ata_ix2], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + let transfer_ix2 = TransferFromSpl { + amount: amount2, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination: ata_pubkey, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix2], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + rpc.warp_epoch_forward(30).await.unwrap(); + + // Now we have 2 compressed accounts from the ATA + let compressed_accounts = rpc + .get_compressed_token_accounts_by_owner(&ata_pubkey, None, None) + .await + .unwrap() + .value + .items; + + assert_eq!( + compressed_accounts.len(), + 2, + "Should have 2 compressed accounts" + ); + + // Create destination ATA for decompress + let create_ata_ix3 = + CreateAssociatedTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) + .with_compressible(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, + }) + .idempotent() + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix3], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Try DecompressIdempotent with 2 inputs -- should fail with IdempotentDecompressRequiresSingleInput + let in_tlv = vec![ + vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: ata_bump, + owner_index: 0, + }, + )], + vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: ata_bump, + owner_index: 0, + }, + )], + ]; + + let total_amount = compressed_accounts[0].token.amount + compressed_accounts[1].token.amount; + + let ix = create_generic_transfer2_instruction( + &mut rpc, + vec![Transfer2InstructionType::DecompressIdempotent( + DecompressInput { + compressed_token_account: compressed_accounts.clone(), + decompress_amount: total_amount, + solana_token_account: ata_pubkey, + amount: total_amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + }, + )], + payer.pubkey(), + true, + ) + .await + .unwrap(); + + let result = rpc + .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) + .await; + + // Error code 18067 = IdempotentDecompressRequiresSingleInput + assert_rpc_error(result, 0, 18067).unwrap(); +} + +/// Test that DecompressIdempotent rejects inputs without is_ata in CompressedOnly extension. +/// The protocol requires is_ata=true for DecompressIdempotent mode. +#[tokio::test] +#[serial] +async fn test_decompress_idempotent_rejects_non_ata() { + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA + let create_dest_ix = CreateAssociatedTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_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: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // Build DecompressIdempotent with is_ata=false -- should fail + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, // NOT an ATA -- should be rejected + bump: 0, + owner_index: 0, + }, + )]]; + + let ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::DecompressIdempotent( + DecompressInput { + compressed_token_account: vec![context.compressed_account.clone()], + decompress_amount: context.amount, + solana_token_account: context.ata_pubkey, + amount: context.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + }, + )], + context.payer.pubkey(), + true, + ) + .await + .unwrap(); + + // With is_ata=false, the compressed account owner (ATA pubkey, a PDA) is marked + // as the signer in the instruction, but no keypair can sign for a PDA. + // The transaction fails at the signing level, proving DecompressIdempotent + // requires is_ata=true to work. + let result = context + .rpc + .create_and_send_transaction(&[ix], &context.payer.pubkey(), &[&context.payer]) + .await; + + assert!( + result.is_err(), + "DecompressIdempotent with is_ata=false should fail" + ); +} + +/// Test that regular Decompress (not idempotent) with is_ata=true in TLV +/// succeeds permissionlessly -- only payer signs, not the owner. +#[tokio::test] +#[serial] +async fn test_permissionless_ata_decompress() { + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA + let create_dest_ix = CreateAssociatedTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_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: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // Build regular Decompress with is_ata=true TLV + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: context.ata_bump, + owner_index: 0, // Will be updated by create_generic_transfer2_instruction + }, + )]]; + + let ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![context.compressed_account.clone()], + decompress_amount: context.amount, + solana_token_account: context.ata_pubkey, + amount: context.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + })], + context.payer.pubkey(), + true, + ) + .await + .unwrap(); + + // Only payer signs -- owner does NOT sign (permissionless ATA decompress) + let result = context + .rpc + .create_and_send_transaction(&[ix], &context.payer.pubkey(), &[&context.payer]) + .await; + + assert!( + result.is_ok(), + "Permissionless ATA decompress should succeed with only payer signing: {:?}", + result.err() + ); + + // Verify ATA has the correct balance + use borsh::BorshDeserialize; + use light_token_interface::state::Token; + let dest_account = context + .rpc + .get_account(context.ata_pubkey) + .await + .unwrap() + .unwrap(); + let dest_ctoken = Token::deserialize(&mut &dest_account.data[..]).unwrap(); + assert_eq!( + dest_ctoken.amount, context.amount, + "Decompressed amount should match original amount" + ); +} + +/// Test that regular Decompress without owner signer fails for non-ATA compressed tokens. +/// Non-ATA tokens require the owner to sign; a third-party payer alone is insufficient. +#[tokio::test] +#[serial] +async fn test_permissionless_non_ata_decompress_fails() { + // Set up a non-ATA compressed token using the same pattern as decompress_restrictions.rs + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let extensions = &[ExtensionType::Pausable]; + let (mint_keypair, _) = + create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; + let mint_pubkey = mint_keypair.pubkey(); + + let spl_account = + create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22(&mut rpc, &payer, &mint_pubkey, &spl_account, mint_amount).await; + + // Create regular (non-ATA) Light Token account with compression_only=true + let owner = Keypair::new(); + let account_keypair = Keypair::new(); + let ctoken_account = account_keypair.pubkey(); + + let create_ix = + CreateTokenAccount::new(payer.pubkey(), ctoken_account, mint_pubkey, owner.pubkey()) + .with_compressible(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: 0, + 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(); + + // Transfer tokens to the Light Token account + let has_restricted = extensions + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); + + let transfer_ix = TransferFromSpl { + amount: mint_amount, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination: ctoken_account, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Warp epoch to trigger forester compression + rpc.warp_epoch_forward(30).await.unwrap(); + + // Get compressed token accounts + let compressed_accounts = rpc + .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) + .await + .unwrap() + .value + .items; + + assert_eq!( + compressed_accounts.len(), + 1, + "Should have 1 compressed account" + ); + + // Create destination Light Token account for decompress + let dest_keypair = Keypair::new(); + let create_dest_ix = CreateTokenAccount::new( + payer.pubkey(), + dest_keypair.pubkey(), + mint_pubkey, + owner.pubkey(), + ) + .with_compressible(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_dest_ix], &payer.pubkey(), &[&payer, &dest_keypair]) + .await + .unwrap(); + + // Build Decompress instruction with is_ata=false (non-ATA) + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }, + )]]; + + let ix = create_generic_transfer2_instruction( + &mut rpc, + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![compressed_accounts[0].clone()], + decompress_amount: mint_amount, + solana_token_account: dest_keypair.pubkey(), + amount: mint_amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + })], + payer.pubkey(), + true, + ) + .await + .unwrap(); + + // Only payer signs -- owner does NOT sign. For non-ATA this should fail + // at the transaction signing level (owner is marked as signer in the instruction + // but no keypair provided) -- proving non-ATA decompress is not permissionless. + let result = rpc + .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) + .await; + + assert!( + result.is_err(), + "Non-ATA decompress without owner signer should fail" + ); +} diff --git a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs index 1ff92eeda9..cab7cd9e4e 100644 --- a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs +++ b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs @@ -160,6 +160,7 @@ pub enum Transfer2InstructionType { Transfer(TransferInput), Approve(ApproveInput), CompressAndClose(CompressAndCloseInput), + DecompressIdempotent(DecompressInput), } // Note doesn't support multiple signers. @@ -202,6 +203,10 @@ pub async fn create_generic_transfer2_instruction( .compressed_token_account .iter() .for_each(|account| hashes.push(account.account.hash)), + Transfer2InstructionType::DecompressIdempotent(input) => input + .compressed_token_account + .iter() + .for_each(|account| hashes.push(account.account.hash)), }); let rpc_proof_result = rpc .get_validity_proof(hashes, vec![], None) @@ -372,9 +377,8 @@ pub async fn create_generic_transfer2_instruction( use light_token_interface::state::Token; if let Ok(ctoken) = Token::deserialize(&mut &recipient_account.data[..]) { let wallet_owner = Pubkey::from(ctoken.owner.to_bytes()); - // Add wallet owner as signer and get its index - let wallet_owner_index = - packed_tree_accounts.insert_or_get_config(wallet_owner, true, false); + // Add wallet owner (not as signer -- ATA decompress is permissionless) + let wallet_owner_index = packed_tree_accounts.insert_or_get(wallet_owner); // Update the owner_index in collected_in_tlv for CompressedOnly extensions for tlv in collected_in_tlv.iter_mut() { for ext in tlv.iter_mut() { @@ -456,6 +460,94 @@ pub async fn create_generic_transfer2_instruction( token_accounts.push(token_account); } + Transfer2InstructionType::DecompressIdempotent(input) => { + // Same as Decompress but uses DecompressIdempotent mode + if let Some(ref tlv_data) = input.in_tlv { + has_any_tlv = true; + collected_in_tlv.extend(tlv_data.iter().cloned()); + } else { + for _ in 0..input.compressed_token_account.len() { + collected_in_tlv.push(Vec::new()); + } + } + + let is_ata = input.in_tlv.as_ref().is_some_and(|tlv| { + tlv.iter().flatten().any(|ext| { + matches!(ext, ExtensionInstructionData::CompressedOnly(data) if data.is_ata) + }) + }); + + let recipient_index = + packed_tree_accounts.insert_or_get(input.solana_token_account); + let recipient_account = rpc + .get_account(input.solana_token_account) + .await + .unwrap() + .unwrap(); + let recipient_account_owner = recipient_account.owner; + + if is_ata && recipient_account_owner.to_bytes() == LIGHT_TOKEN_PROGRAM_ID { + use borsh::BorshDeserialize; + use light_token_interface::state::Token; + if let Ok(ctoken) = Token::deserialize(&mut &recipient_account.data[..]) { + let wallet_owner = Pubkey::from(ctoken.owner.to_bytes()); + // Not as signer -- permissionless + let wallet_owner_index = packed_tree_accounts.insert_or_get(wallet_owner); + for tlv in collected_in_tlv.iter_mut() { + for ext in tlv.iter_mut() { + if let ExtensionInstructionData::CompressedOnly(data) = ext { + if data.is_ata { + data.owner_index = wallet_owner_index; + } + } + } + } + } + } + + let token_data = input + .compressed_token_account + .iter() + .zip( + packed_tree_infos + .state_trees + .as_ref() + .unwrap() + .packed_tree_infos[inputs_offset..] + .iter(), + ) + .map(|(account, rpc_account)| { + pack_input_token_account( + account, + rpc_account, + &mut packed_tree_accounts, + &mut in_lamports, + false, + TokenDataVersion::from_discriminator( + account.account.data.as_ref().unwrap().discriminator, + ) + .unwrap(), + None, + is_ata, + ) + }) + .collect::>(); + inputs_offset += token_data.len(); + let mut token_account = CTokenAccount2::new(token_data)?; + + // Use decompress_idempotent instead of decompress + token_account.decompress_idempotent(input.decompress_amount, recipient_index)?; + + out_lamports.push( + input + .compressed_token_account + .iter() + .map(|account| account.account.lamports) + .sum::(), + ); + + token_accounts.push(token_account); + } Transfer2InstructionType::Transfer(input) => { let token_data = input .compressed_token_account diff --git a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs index 55bba47b5e..e96737c1dd 100644 --- a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs +++ b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs @@ -57,3 +57,39 @@ pub async fn decompress( rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) .await } + +/// Decompress ATA compressed tokens using DecompressIdempotent mode. +/// Permissionless -- only payer needs to sign. +pub async fn decompress_idempotent( + rpc: &mut R, + compressed_token_account: &[CompressedTokenAccount], + decompress_amount: u64, + solana_token_account: Pubkey, + payer: &Keypair, + decimals: u8, + in_tlv: Option< + Vec>, + >, +) -> Result { + let ix = create_generic_transfer2_instruction( + rpc, + vec![Transfer2InstructionType::DecompressIdempotent( + DecompressInput { + compressed_token_account: compressed_token_account.to_vec(), + decompress_amount, + solana_token_account, + amount: decompress_amount, + pool_index: None, + decimals, + in_tlv, + }, + )], + payer.pubkey(), + false, + ) + .await + .map_err(|e| RpcError::CustomError(e.to_string()))?; + + rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &[payer]) + .await +} diff --git a/program-tests/utils/src/assert_transfer2.rs b/program-tests/utils/src/assert_transfer2.rs index cc3aa57676..9f3e603979 100644 --- a/program-tests/utils/src/assert_transfer2.rs +++ b/program-tests/utils/src/assert_transfer2.rs @@ -63,7 +63,8 @@ pub async fn assert_transfer2_with_delegate( } } } - Transfer2InstructionType::Decompress(decompress_input) => { + Transfer2InstructionType::Decompress(decompress_input) + | Transfer2InstructionType::DecompressIdempotent(decompress_input) => { let pubkey = decompress_input.solana_token_account; // Get or initialize the expected account state @@ -201,7 +202,8 @@ pub async fn assert_transfer2_with_delegate( ); } } - Transfer2InstructionType::Decompress(decompress_input) => { + Transfer2InstructionType::Decompress(decompress_input) + | Transfer2InstructionType::DecompressIdempotent(decompress_input) => { // Get mint from the source compressed token account let source_mint = decompress_input.compressed_token_account[0].token.mint; let source_owner = decompress_input.compressed_token_account[0].token.owner; diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 36f65b535f..be02e38872 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -68,7 +68,9 @@ Every instruction description must include the sections: ### Token Operations 5. **Transfer2** - [`docs/compressed_token/TRANSFER2.md`](docs/compressed_token/TRANSFER2.md) - Batch transfer instruction for compressed/decompressed operations (discriminator: 101, enum: `InstructionType::Transfer2`) - - Supports Compress, Decompress, CompressAndClose operations + - Supports Compress, Decompress, CompressAndClose, DecompressIdempotent operations + - DecompressIdempotent (mode 3): permissionless ATA decompress with single-input constraint + - ATA decompress is permissionless for both Decompress and DecompressIdempotent (is_ata=true) - Multi-mint support with sum checks 6. **MintAction** - [`docs/compressed_token/MINT_ACTION.md`](docs/compressed_token/MINT_ACTION.md) diff --git a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index c98e28aaf0..cebbcaf3b5 100644 --- a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -26,9 +26,12 @@ - SPL tokens when compressed are backed by tokens stored in ctoken pool PDAs 3. Compression modes: - - `Compress`: Move tokens from Solana account (ctoken or SPL) to compressed state - - `Decompress`: Move tokens from compressed state to Solana account (ctoken or SPL) - - `CompressAndClose`: Compress full ctoken balance and close the account (authority: compression_authority only, requires compressible extension, **ctoken accounts only - NOT supported for SPL tokens**) + - `Compress` (0): Move tokens from Solana account (ctoken or SPL) to compressed state + - `Decompress` (1): Move tokens from compressed state to Solana account (ctoken or SPL) + - `CompressAndClose` (2): Compress full ctoken balance and close the account (authority: compression_authority only, requires compressible extension, **ctoken accounts only - NOT supported for SPL tokens**) + - `DecompressIdempotent` (3): Permissionless ATA decompress. Requires exactly 1 input, 1 compression, and CompressedOnly extension with `is_ata=true`. On-chain behavior is identical to `Decompress`; the mode enforces single-input constraints. ATA must be pre-created. **CToken ATAs only - NOT supported for SPL tokens.** + + **Permissionless ATA decompress:** Both `Decompress` and `DecompressIdempotent` modes skip the owner/delegate signer check when the input has CompressedOnly extension with `is_ata=true`. This is safe because the destination is a deterministic PDA (ATA derivation is still validated). 4. Global sum check enforces transaction balance: - Input sum = compressed inputs + compress operations (tokens entering compressed state) @@ -59,7 +62,7 @@ - `out_tlv`: Optional TLV data for output accounts (used for CompressedOnly extension during CompressAndClose) 2. Compression struct fields (path: program-libs/token-interface/src/instructions/transfer2/compression.rs): - - `mode`: CompressionMode enum (Compress, Decompress, CompressAndClose) + - `mode`: CompressionMode enum (Compress=0, Decompress=1, CompressAndClose=2, DecompressIdempotent=3) - `amount`: u64 - Amount to compress/decompress - `mint`: u8 - Index of mint account in packed accounts - `source_or_recipient`: u8 - Index of source (compress) or recipient (decompress) 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 91cdba9b59..9c27401090 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 @@ -94,7 +94,7 @@ pub fn compress_or_decompress_ctokens( } Ok(()) } - ZCompressionMode::Decompress => { + ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent => { if decompress_inputs.is_none() { if let Some(ref checks) = mint_checks { checks.enforce_extension_state()?; diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs index ae8078cda5..3dd0b664ff 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs @@ -39,7 +39,7 @@ impl<'a> DecompressCompressOnlyInputs<'a> { let idx = input_idx as usize; // Compression must be Decompress mode to consume an input - if compression.mode != ZCompressionMode::Decompress { + if !compression.mode.is_decompress() { msg!( "Input linked to non-decompress compression at index {}", compression_index @@ -109,7 +109,7 @@ impl<'a> CTokenCompressionInputs<'a> { mint_checks: Option, decompress_inputs: Option>, ) -> Result { - let authority_account = if compression.mode != ZCompressionMode::Decompress { + let authority_account = if !compression.mode.is_decompress() { Some(packed_accounts.get_u8( compression.authority, "process_ctoken_compression: authority", diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index f7303303d5..564d9d3758 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -200,7 +200,7 @@ pub(crate) fn validate_compression_mode_fields( compression: &ZCompression, ) -> Result<(), ProgramError> { match compression.mode { - ZCompressionMode::Decompress => { + ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent => { // the authority field is not used. if compression.authority != 0 { msg!("authority must be 0 for Decompress mode"); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs index 6bd098c2b4..be74c629e9 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs @@ -78,6 +78,10 @@ pub(super) fn process_spl_compressions( msg!("CompressAndClose is unimplemented for spl token accounts"); unimplemented!() } + ZCompressionMode::DecompressIdempotent => { + msg!("DecompressIdempotent is not supported for SPL token accounts"); + return Err(ProgramError::InvalidInstructionData); + } } Ok(()) } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index c049ff7b21..5f337f498b 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -118,6 +118,28 @@ pub fn validate_instruction_data( return Err(TokenError::CompressedOnlyBlocksTransfer); } } + // DecompressIdempotent: exactly 1 input, 1 compression, must have CompressedOnly with is_ata=true + if let Some(compressions) = inputs.compressions.as_ref() { + let has_idempotent = compressions + .iter() + .any(|c| c.mode == ZCompressionMode::DecompressIdempotent); + if has_idempotent { + if inputs.in_token_data.len() != 1 || compressions.len() != 1 { + msg!("DecompressIdempotent requires exactly 1 input and 1 compression"); + return Err(TokenError::IdempotentDecompressRequiresSingleInput); + } + let has_ata = inputs.in_tlv.as_ref().is_some_and(|tlvs| { + tlvs.iter().flatten().any(|ext| { + matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) + }) + }); + if !has_ata { + msg!("DecompressIdempotent requires is_ata=true in CompressedOnly extension"); + return Err(TokenError::IdempotentDecompressRequiresAta); + } + } + } + // 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() { diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index 274cd57eb7..d30da5f52d 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -80,18 +80,22 @@ pub fn set_input_compressed_account<'a>( // For ATA decompress (is_ata=true), verify the wallet owner from owner_index instead // of the compressed account owner (which is the ATA pubkey that can't sign). // Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump. - let signer_account = if let Some(exts) = tlv_data { + let (signer_account, is_ata_decompress) = if let Some(exts) = tlv_data { resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)? } else { - owner_account + (owner_account, false) }; - verify_owner_or_delegate_signer( - signer_account, - delegate_account, - permanent_delegate, - all_accounts, - )?; + // ATA decompress is permissionless -- the destination is a deterministic PDA, + // so there is no griefing vector. ATA derivation is still validated above. + if !is_ata_decompress { + verify_owner_or_delegate_signer( + signer_account, + delegate_account, + permanent_delegate, + all_accounts, + )?; + } let token_version = TokenDataVersion::try_from(input_token_data.version)?; let data_hash = { @@ -193,7 +197,7 @@ fn resolve_ata_signer<'a>( packed_accounts: &'a [AccountInfo], mint_account: &AccountInfo, owner_account: &'a AccountInfo, -) -> Result<&'a AccountInfo, ProgramError> { +) -> Result<(&'a AccountInfo, bool), ProgramError> { for ext in exts.iter() { if let ZExtensionInstructionData::CompressedOnly(data) = ext { if data.is_ata() { @@ -229,12 +233,12 @@ fn resolve_ata_signer<'a>( return Err(TokenError::InvalidAtaDerivation.into()); } - return Ok(wallet_owner); + return Ok((wallet_owner, true)); } } } - Ok(owner_account) + Ok((owner_account, false)) } #[cold] 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 b1a1179d00..2275506a81 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 @@ -245,6 +245,31 @@ impl CTokenAccount2 { Ok(()) } + #[profile] + pub fn decompress_idempotent( + &mut self, + amount: u64, + source_index: u8, + ) -> Result<(), TokenSdkError> { + if self.compression.is_some() { + return Err(TokenSdkError::CompressionCannotBeSetTwice); + } + + if self.output.amount < amount { + return Err(TokenSdkError::InsufficientBalance); + } + self.output.amount -= amount; + + self.compression = Some(Compression::decompress_idempotent( + amount, + self.output.mint, + source_index, + )); + self.method_used = true; + + Ok(()) + } + #[profile] pub fn decompress_spl( &mut self, From 3655ac3346e7a2c4f0b1fc476839e707a710216f Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 25 Mar 2026 01:30:43 +0000 Subject: [PATCH 02/14] add idempotency --- Cargo.lock | 1 + .../src/instructions/transfer2/compression.rs | 4 - .../tests/compress_only/ata_decompress.rs | 269 +++++++++++++++--- .../actions/legacy/instructions/transfer2.rs | 122 +------- .../actions/legacy/transfer2/decompress.rs | 2 +- programs/compressed-token/program/Cargo.toml | 1 + .../src/compressed_token/transfer2/config.rs | 10 +- .../compressed_token/transfer2/processor.rs | 44 +++ 8 files changed, 298 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dfc954f985..bad55ee481 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3814,6 +3814,7 @@ dependencies = [ "lazy_static", "light-account-checks", "light-array-map", + "light-batched-merkle-tree", "light-compressed-account", "light-compressible", "light-hasher", diff --git a/program-libs/token-interface/src/instructions/transfer2/compression.rs b/program-libs/token-interface/src/instructions/transfer2/compression.rs index de961b15d9..e6305ac57d 100644 --- a/program-libs/token-interface/src/instructions/transfer2/compression.rs +++ b/program-libs/token-interface/src/instructions/transfer2/compression.rs @@ -34,10 +34,6 @@ impl ZCompressionMode { ) } - pub fn is_decompress_idempotent(&self) -> bool { - matches!(self, ZCompressionMode::DecompressIdempotent) - } - pub fn is_compress_and_close(&self) -> bool { matches!(self, ZCompressionMode::CompressAndClose) } diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index 1928ea1918..02fd181d3e 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -1859,96 +1859,157 @@ async fn test_decompress_idempotent_rejects_multiple_inputs() { } /// Test that DecompressIdempotent rejects inputs without is_ata in CompressedOnly extension. -/// The protocol requires is_ata=true for DecompressIdempotent mode. +/// Uses a non-ATA compressed token (wallet-owned) so the owner CAN sign and we exercise +/// the program-level IdempotentDecompressRequiresAta validation (error 18068). #[tokio::test] #[serial] async fn test_decompress_idempotent_rejects_non_ata() { - let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) .await .unwrap(); + let payer = rpc.get_payer().insecure_clone(); - // Create destination ATA - let create_dest_ix = CreateAssociatedTokenAccount::new( - context.payer.pubkey(), - context.owner.pubkey(), - context.mint_pubkey, + let extensions = &[ExtensionType::Pausable]; + let (mint_keypair, _) = + create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; + let mint_pubkey = mint_keypair.pubkey(); + + let spl_account = + create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; + let mint_amount = 1_000_000_000u64; + mint_spl_tokens_22(&mut rpc, &payer, &mint_pubkey, &spl_account, mint_amount).await; + + // Create regular (non-ATA) Light Token account with compression_only=true + let owner = Keypair::new(); + let account_keypair = Keypair::new(); + let ctoken_account = account_keypair.pubkey(); + + let create_ix = + CreateTokenAccount::new(payer.pubkey(), ctoken_account, mint_pubkey, owner.pubkey()) + .with_compressible(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: 0, + 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(); + + // Transfer tokens to the Light Token account + let has_restricted = extensions + .iter() + .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); + let (spl_interface_pda, spl_interface_pda_bump) = + find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); + + let transfer_ix = TransferFromSpl { + amount: mint_amount, + spl_interface_pda_bump, + decimals: 9, + source_spl_token_account: spl_account, + destination: ctoken_account, + authority: payer.pubkey(), + mint: mint_pubkey, + payer: payer.pubkey(), + spl_interface_pda, + spl_token_program: spl_token_2022::ID, + } + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // Warp epoch to trigger forester compression + rpc.warp_epoch_forward(30).await.unwrap(); + + // Get compressed token accounts (owner is the wallet, NOT an ATA pubkey) + let compressed_accounts = rpc + .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) + .await + .unwrap() + .value + .items; + + assert_eq!(compressed_accounts.len(), 1); + + // Create destination Light Token account + let dest_keypair = Keypair::new(); + let create_dest_ix = CreateTokenAccount::new( + payer.pubkey(), + dest_keypair.pubkey(), + mint_pubkey, + owner.pubkey(), ) .with_compressible(CompressibleParams { - compressible_config: context - .rpc + compressible_config: rpc .test_accounts .funding_pool_config .compressible_config_pda, - rent_sponsor: context - .rpc - .test_accounts - .funding_pool_config - .rent_sponsor_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, }) - .idempotent() .instruction() .unwrap(); - context - .rpc - .create_and_send_transaction( - &[create_dest_ix], - &context.payer.pubkey(), - &[&context.payer], - ) + rpc.create_and_send_transaction(&[create_dest_ix], &payer.pubkey(), &[&payer, &dest_keypair]) .await .unwrap(); - // Build DecompressIdempotent with is_ata=false -- should fail + // Build DecompressIdempotent with is_ata=false -- should be rejected by program let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( CompressedOnlyExtensionInstructionData { delegated_amount: 0, withheld_transfer_fee: 0, is_frozen: false, compression_index: 0, - is_ata: false, // NOT an ATA -- should be rejected + is_ata: false, // NOT an ATA -- program should reject with 18068 bump: 0, owner_index: 0, }, )]]; let ix = create_generic_transfer2_instruction( - &mut context.rpc, + &mut rpc, vec![Transfer2InstructionType::DecompressIdempotent( DecompressInput { - compressed_token_account: vec![context.compressed_account.clone()], - decompress_amount: context.amount, - solana_token_account: context.ata_pubkey, - amount: context.amount, + compressed_token_account: vec![compressed_accounts[0].clone()], + decompress_amount: mint_amount, + solana_token_account: dest_keypair.pubkey(), + amount: mint_amount, pool_index: None, decimals: 9, in_tlv: Some(in_tlv), }, )], - context.payer.pubkey(), + payer.pubkey(), true, ) .await .unwrap(); - // With is_ata=false, the compressed account owner (ATA pubkey, a PDA) is marked - // as the signer in the instruction, but no keypair can sign for a PDA. - // The transaction fails at the signing level, proving DecompressIdempotent - // requires is_ata=true to work. - let result = context - .rpc - .create_and_send_transaction(&[ix], &context.payer.pubkey(), &[&context.payer]) + // Owner signs so we get past runtime signer check -- program rejects with 18068 + let result = rpc + .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &owner]) .await; - assert!( - result.is_err(), - "DecompressIdempotent with is_ata=false should fail" - ); + // Error code 18068 = IdempotentDecompressRequiresAta + assert_rpc_error(result, 0, 18068).unwrap(); } /// Test that regular Decompress (not idempotent) with is_ata=true in TLV @@ -2214,3 +2275,129 @@ async fn test_permissionless_non_ata_decompress_fails() { "Non-ATA decompress without owner signer should fail" ); } + +/// Test that DecompressIdempotent with an already-spent compressed account +/// is a no-op (returns Ok without modifying the CToken balance). +/// The bloom filter in the V2 tree catches the already-nullified account. +#[tokio::test] +#[serial] +async fn test_decompress_idempotent_already_spent_is_noop() { + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA + let create_dest_ix = CreateAssociatedTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_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: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // First decompress: spend the compressed account normally + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: context.ata_bump, + owner_index: 0, + }, + )]]; + + let first_ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::DecompressIdempotent( + DecompressInput { + compressed_token_account: vec![context.compressed_account.clone()], + decompress_amount: context.amount, + solana_token_account: context.ata_pubkey, + amount: context.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv.clone()), + }, + )], + context.payer.pubkey(), + true, + ) + .await + .unwrap(); + + let second_ix = first_ix.clone(); + + context + .rpc + .create_and_send_transaction(&[first_ix], &context.payer.pubkey(), &[&context.payer]) + .await + .unwrap(); + + // Verify ATA has the tokens after first decompress + use borsh::BorshDeserialize; + use light_token_interface::state::Token; + let dest_account = context + .rpc + .get_account(context.ata_pubkey) + .await + .unwrap() + .unwrap(); + let dest_ctoken = Token::deserialize(&mut &dest_account.data[..]).unwrap(); + assert_eq!(dest_ctoken.amount, context.amount); + + // Second decompress: reuse the same instruction (the proof won't be + // verified because the bloom filter check short-circuits before the CPI). + let result = context + .rpc + .create_and_send_transaction(&[second_ix], &context.payer.pubkey(), &[&context.payer]) + .await; + + assert!( + result.is_ok(), + "DecompressIdempotent with already-spent account should be no-op: {:?}", + result.err() + ); + + // Verify CToken balance is unchanged (still the original amount, not doubled) + let dest_account = context + .rpc + .get_account(context.ata_pubkey) + .await + .unwrap() + .unwrap(); + let dest_ctoken = Token::deserialize(&mut &dest_account.data[..]).unwrap(); + assert_eq!( + dest_ctoken.amount, context.amount, + "CToken balance should be unchanged after idempotent no-op" + ); +} diff --git a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs index cab7cd9e4e..f91ffae9e1 100644 --- a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs +++ b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs @@ -338,7 +338,11 @@ pub async fn create_generic_transfer2_instruction( } token_accounts.push(token_account); } - Transfer2InstructionType::Decompress(input) => { + Transfer2InstructionType::Decompress(ref input) + | Transfer2InstructionType::DecompressIdempotent(ref input) => { + let is_idempotent = + matches!(action, Transfer2InstructionType::DecompressIdempotent(_)); + // Collect in_tlv data if provided if let Some(ref tlv_data) = input.in_tlv { has_any_tlv = true; @@ -351,7 +355,6 @@ pub async fn create_generic_transfer2_instruction( } // Check if any input has is_ata=true in the TLV - // If so, we need to use the destination Light Token's owner as the signer let is_ata = input.in_tlv.as_ref().is_some_and(|tlv| { tlv.iter().flatten().any(|ext| { matches!(ext, ExtensionInstructionData::CompressedOnly(data) if data.is_ata) @@ -368,18 +371,14 @@ pub async fn create_generic_transfer2_instruction( .unwrap(); let recipient_account_owner = recipient_account.owner; - // For is_ata, the compressed account owner is the ATA pubkey (stored during compress_and_close) - // We keep that for hash calculation. The wallet owner signs instead of ATA pubkey. - // Get the wallet owner from the destination Light Token account and add as signer. + // For is_ata, get the wallet owner from the destination Light Token account. + // ATA decompress is permissionless -- wallet_owner is not a signer. if is_ata && recipient_account_owner.to_bytes() == LIGHT_TOKEN_PROGRAM_ID { - // Deserialize Token to get wallet owner use borsh::BorshDeserialize; use light_token_interface::state::Token; if let Ok(ctoken) = Token::deserialize(&mut &recipient_account.data[..]) { let wallet_owner = Pubkey::from(ctoken.owner.to_bytes()); - // Add wallet owner (not as signer -- ATA decompress is permissionless) let wallet_owner_index = packed_tree_accounts.insert_or_get(wallet_owner); - // Update the owner_index in collected_in_tlv for CompressedOnly extensions for tlv in collected_in_tlv.iter_mut() { for ext in tlv.iter_mut() { if let ExtensionInstructionData::CompressedOnly(data) = ext { @@ -409,34 +408,30 @@ pub async fn create_generic_transfer2_instruction( rpc_account, &mut packed_tree_accounts, &mut in_lamports, - false, // Decompress is always owner-signed + false, TokenDataVersion::from_discriminator( account.account.data.as_ref().unwrap().discriminator, ) .unwrap(), - None, // No override - use stored owner (ATA pubkey for is_ata) - is_ata, // For ATA: owner (ATA pubkey) is not signer + None, + is_ata, ) }) .collect::>(); inputs_offset += token_data.len(); let mut token_account = CTokenAccount2::new(token_data)?; - if recipient_account_owner.to_bytes() != LIGHT_TOKEN_PROGRAM_ID { - // For SPL decompression, get mint first + if is_idempotent { + token_account + .decompress_idempotent(input.decompress_amount, recipient_index)?; + } else if recipient_account_owner.to_bytes() != LIGHT_TOKEN_PROGRAM_ID { let mint = input.compressed_token_account[0].token.mint; - - // Add the SPL Token program that owns the account let _token_program_index = packed_tree_accounts.insert_or_get_read_only(recipient_account_owner); - - // Use pool_index from input, default to 0 let pool_index = input.pool_index.unwrap_or(0); let (spl_interface_pda, bump) = find_spl_interface_pda_with_index(&mint, pool_index, false); let pool_account_index = packed_tree_accounts.insert_or_get(spl_interface_pda); - - // Use the new SPL-specific decompress method token_account.decompress_spl( input.decompress_amount, recipient_index, @@ -446,7 +441,6 @@ pub async fn create_generic_transfer2_instruction( input.decimals, )?; } else { - // Use the new SPL-specific decompress method token_account.decompress(input.decompress_amount, recipient_index)?; } @@ -460,94 +454,6 @@ pub async fn create_generic_transfer2_instruction( token_accounts.push(token_account); } - Transfer2InstructionType::DecompressIdempotent(input) => { - // Same as Decompress but uses DecompressIdempotent mode - if let Some(ref tlv_data) = input.in_tlv { - has_any_tlv = true; - collected_in_tlv.extend(tlv_data.iter().cloned()); - } else { - for _ in 0..input.compressed_token_account.len() { - collected_in_tlv.push(Vec::new()); - } - } - - let is_ata = input.in_tlv.as_ref().is_some_and(|tlv| { - tlv.iter().flatten().any(|ext| { - matches!(ext, ExtensionInstructionData::CompressedOnly(data) if data.is_ata) - }) - }); - - let recipient_index = - packed_tree_accounts.insert_or_get(input.solana_token_account); - let recipient_account = rpc - .get_account(input.solana_token_account) - .await - .unwrap() - .unwrap(); - let recipient_account_owner = recipient_account.owner; - - if is_ata && recipient_account_owner.to_bytes() == LIGHT_TOKEN_PROGRAM_ID { - use borsh::BorshDeserialize; - use light_token_interface::state::Token; - if let Ok(ctoken) = Token::deserialize(&mut &recipient_account.data[..]) { - let wallet_owner = Pubkey::from(ctoken.owner.to_bytes()); - // Not as signer -- permissionless - let wallet_owner_index = packed_tree_accounts.insert_or_get(wallet_owner); - for tlv in collected_in_tlv.iter_mut() { - for ext in tlv.iter_mut() { - if let ExtensionInstructionData::CompressedOnly(data) = ext { - if data.is_ata { - data.owner_index = wallet_owner_index; - } - } - } - } - } - } - - let token_data = input - .compressed_token_account - .iter() - .zip( - packed_tree_infos - .state_trees - .as_ref() - .unwrap() - .packed_tree_infos[inputs_offset..] - .iter(), - ) - .map(|(account, rpc_account)| { - pack_input_token_account( - account, - rpc_account, - &mut packed_tree_accounts, - &mut in_lamports, - false, - TokenDataVersion::from_discriminator( - account.account.data.as_ref().unwrap().discriminator, - ) - .unwrap(), - None, - is_ata, - ) - }) - .collect::>(); - inputs_offset += token_data.len(); - let mut token_account = CTokenAccount2::new(token_data)?; - - // Use decompress_idempotent instead of decompress - token_account.decompress_idempotent(input.decompress_amount, recipient_index)?; - - out_lamports.push( - input - .compressed_token_account - .iter() - .map(|account| account.account.lamports) - .sum::(), - ); - - token_accounts.push(token_account); - } Transfer2InstructionType::Transfer(input) => { let token_data = input .compressed_token_account diff --git a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs index e96737c1dd..5f9ec98feb 100644 --- a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs +++ b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs @@ -85,7 +85,7 @@ pub async fn decompress_idempotent( }, )], payer.pubkey(), - false, + true, ) .await .map_err(|e| RpcError::CustomError(e.to_string()))?; diff --git a/programs/compressed-token/program/Cargo.toml b/programs/compressed-token/program/Cargo.toml index 6c24b776cd..6eec39ccef 100644 --- a/programs/compressed-token/program/Cargo.toml +++ b/programs/compressed-token/program/Cargo.toml @@ -45,6 +45,7 @@ solana-security-txt = "1.1.0" light-hasher = { workspace = true } light-heap = { workspace = true, optional = true } light-compressed-account = { workspace = true, features = ["anchor"] } +light-batched-merkle-tree = { workspace = true } spl-token-2022 = { workspace = true, features = ["no-entrypoint"] } spl-pod = { workspace = true } light-zero-copy = { workspace = true, features = ["mut", "std", "derive"] } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs index 3a05a0b528..92a657279e 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs @@ -1,5 +1,7 @@ use anchor_lang::prelude::ProgramError; -use light_token_interface::instructions::transfer2::ZCompressedTokenInstructionDataTransfer2; +use light_token_interface::instructions::transfer2::{ + ZCompressedTokenInstructionDataTransfer2, ZCompressionMode, +}; /// Configuration for Transfer2 account validation /// Replaces complex boolean parameters with clean single config object @@ -20,6 +22,8 @@ pub struct Transfer2Config { pub total_output_lamports: u64, /// No compressed accounts (neither input nor output) - determines system CPI path pub no_compressed_accounts: bool, + /// DecompressIdempotent mode -- enables bloom filter check for idempotency + pub is_decompress_idempotent: bool, } impl Transfer2Config { @@ -41,6 +45,10 @@ impl Transfer2Config { total_input_lamports: 0, total_output_lamports: 0, no_compressed_accounts, + is_decompress_idempotent: inputs.compressions.as_ref().is_some_and(|c| { + c.iter() + .any(|c| c.mode == ZCompressionMode::DecompressIdempotent) + }), }) } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 5f337f498b..6117cb23f9 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -279,6 +279,50 @@ fn process_with_system_program_cpi<'a>( mint_cache, )?; + // Idempotency check for DecompressIdempotent: if the compressed account is already + // spent (found in the V2 tree's bloom filter), return Ok as a no-op. + if transfer_config.is_decompress_idempotent { + let input_data = &inputs.in_token_data[0]; + let merkle_context = &input_data.merkle_context; + let input_account = cpi_instruction_struct + .input_compressed_accounts + .first() + .ok_or(ProgramError::InvalidAccountData)?; + + let owner_hashed = light_hasher::hash_to_field_size::hash_to_bn254_field_size_be( + &crate::LIGHT_CPI_SIGNER.program_id, + ); + let tree_account = validated_accounts + .packed_accounts + .get_u8(merkle_context.merkle_tree_pubkey_index, "idempotent: tree")?; + let merkle_tree_hashed = + light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(tree_account.key()); + + let lamports: u64 = (*input_account.lamports).into(); + let account_hash = light_compressed_account::compressed_account::hash_with_hashed_values( + &lamports, + input_account.address.as_ref().map(|x| x.as_slice()), + Some(( + input_account.discriminator.as_slice(), + input_account.data_hash.as_slice(), + )), + &owner_hashed, + &merkle_tree_hashed, + &merkle_context.leaf_index.get(), + true, + ) + .map_err(ProgramError::from)?; + + let mut tree = + light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount::state_from_account_info(tree_account) + .map_err(ProgramError::from)?; + + if tree.check_input_queue_non_inclusion(&account_hash).is_err() { + // Account is in bloom filter -- already spent. Idempotent no-op. + return Ok(()); + } + } + // Process output compressed accounts. set_output_compressed_accounts( &mut cpi_instruction_struct, From aa0b202894cb10892e99417d560bc36739d128c0 Mon Sep 17 00:00:00 2001 From: ananas Date: Fri, 27 Mar 2026 18:37:23 +0000 Subject: [PATCH 03/14] refactor: make all ata tranfer2 decompression idempotent --- .../src/v3/layout/layout-transfer2.ts | 1 - program-libs/token-interface/src/error.rs | 8 - .../src/instructions/transfer2/compression.rs | 27 +- .../tests/compress_only/ata_decompress.rs | 321 ++---------------- .../actions/legacy/instructions/transfer2.rs | 16 +- .../actions/legacy/transfer2/decompress.rs | 36 -- program-tests/utils/src/assert_transfer2.rs | 6 +- programs/compressed-token/program/CLAUDE.md | 5 +- .../docs/compressed_token/TRANSFER2.md | 5 +- .../ctoken/compress_or_decompress_ctokens.rs | 2 +- .../transfer2/compression/ctoken/inputs.rs | 4 +- .../transfer2/compression/mod.rs | 2 +- .../transfer2/compression/spl.rs | 4 - .../src/compressed_token/transfer2/config.rs | 10 +- .../compressed_token/transfer2/processor.rs | 45 ++- .../src/compressed_token/v2/account2.rs | 25 -- 16 files changed, 63 insertions(+), 454 deletions(-) diff --git a/js/compressed-token/src/v3/layout/layout-transfer2.ts b/js/compressed-token/src/v3/layout/layout-transfer2.ts index 87555f0e1d..0f17dfb141 100644 --- a/js/compressed-token/src/v3/layout/layout-transfer2.ts +++ b/js/compressed-token/src/v3/layout/layout-transfer2.ts @@ -31,7 +31,6 @@ export const EXTENSION_DISCRIMINANT_COMPRESSIBLE = 32; export const COMPRESSION_MODE_COMPRESS = 0; export const COMPRESSION_MODE_DECOMPRESS = 1; export const COMPRESSION_MODE_COMPRESS_AND_CLOSE = 2; -export const COMPRESSION_MODE_DECOMPRESS_IDEMPOTENT = 3; /** * Compression struct for Transfer2 instruction diff --git a/program-libs/token-interface/src/error.rs b/program-libs/token-interface/src/error.rs index 30a4a34089..7ea2d994a6 100644 --- a/program-libs/token-interface/src/error.rs +++ b/program-libs/token-interface/src/error.rs @@ -206,12 +206,6 @@ pub enum TokenError { #[error("ATA derivation failed or mismatched for is_ata compressed token")] InvalidAtaDerivation, - - #[error("DecompressIdempotent requires exactly 1 input and 1 compression")] - IdempotentDecompressRequiresSingleInput, - - #[error("DecompressIdempotent is only supported for ATA accounts (is_ata must be true)")] - IdempotentDecompressRequiresAta, } impl From for u32 { @@ -283,8 +277,6 @@ impl From for u32 { TokenError::DecompressAmountMismatch => 18064, TokenError::CompressionIndexOutOfBounds => 18065, TokenError::InvalidAtaDerivation => 18066, - TokenError::IdempotentDecompressRequiresSingleInput => 18067, - TokenError::IdempotentDecompressRequiresAta => 18068, TokenError::HasherError(e) => u32::from(e), TokenError::ZeroCopyError(e) => u32::from(e), TokenError::CompressedAccountError(e) => u32::from(e), diff --git a/program-libs/token-interface/src/instructions/transfer2/compression.rs b/program-libs/token-interface/src/instructions/transfer2/compression.rs index e6305ac57d..dd4be64bae 100644 --- a/program-libs/token-interface/src/instructions/transfer2/compression.rs +++ b/program-libs/token-interface/src/instructions/transfer2/compression.rs @@ -16,10 +16,6 @@ pub enum CompressionMode { /// Signer must be rent authority, token account must be compressible /// Not implemented for spl token accounts. CompressAndClose, - /// Permissionless ATA decompress with single-input constraint. - /// Requires CompressedOnly extension with is_ata=true. - /// On-chain behavior is identical to Decompress. - DecompressIdempotent, } impl ZCompressionMode { @@ -28,10 +24,7 @@ impl ZCompressionMode { } pub fn is_decompress(&self) -> bool { - matches!( - self, - ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent - ) + matches!(self, ZCompressionMode::Decompress) } pub fn is_compress_and_close(&self) -> bool { @@ -42,7 +35,6 @@ impl ZCompressionMode { pub const COMPRESS: u8 = 0u8; pub const DECOMPRESS: u8 = 1u8; pub const COMPRESS_AND_CLOSE: u8 = 2u8; -pub const DECOMPRESS_IDEMPOTENT: u8 = 3u8; impl<'a> ZeroCopyAtMut<'a> for CompressionMode { type ZeroCopyAtMut = Ref<&'a mut [u8], u8>; @@ -212,20 +204,6 @@ impl Compression { decimals: 0, } } - - pub fn decompress_idempotent(amount: u64, mint: u8, recipient: u8) -> Self { - Compression { - amount, - mode: CompressionMode::DecompressIdempotent, - mint, - source_or_recipient: recipient, - authority: 0, - pool_account_index: 0, - pool_index: 0, - bump: 0, - decimals: 0, - } - } } impl ZCompressionMut<'_> { @@ -234,7 +212,6 @@ impl ZCompressionMut<'_> { COMPRESS => Ok(CompressionMode::Compress), DECOMPRESS => Ok(CompressionMode::Decompress), COMPRESS_AND_CLOSE => Ok(CompressionMode::CompressAndClose), - DECOMPRESS_IDEMPOTENT => Ok(CompressionMode::DecompressIdempotent), _ => Err(TokenError::InvalidCompressionMode), } } @@ -249,7 +226,7 @@ impl ZCompression<'_> { .checked_add((*self.amount).into()) .ok_or(TokenError::ArithmeticOverflow) } - ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent => { + ZCompressionMode::Decompress => { // Decompress: subtract from balance (tokens are being removed from spl token pool) current_balance .checked_sub((*self.amount).into()) diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index 02fd181d3e..49c5b434d9 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -1532,118 +1532,11 @@ async fn test_non_ata_compress_only_decompress() { assert_eq!(dest_ctoken.amount, mint_amount); } -/// Test that DecompressIdempotent succeeds with a third-party payer (not owner). -/// Only the payer signs -- the owner does not sign. This verifies the permissionless -/// nature of DecompressIdempotent for ATA compressed tokens. +/// Test that ATA decompress rejects transactions with multiple inputs. +/// The protocol requires exactly 1 input and 1 compression for ATA decompress. #[tokio::test] #[serial] -async fn test_decompress_idempotent_succeeds() { - let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) - .await - .unwrap(); - - // Create destination ATA (idempotent - same address) - let create_dest_ix = CreateAssociatedTokenAccount::new( - context.payer.pubkey(), - context.owner.pubkey(), - context.mint_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: true, - }) - .idempotent() - .instruction() - .unwrap(); - - context - .rpc - .create_and_send_transaction( - &[create_dest_ix], - &context.payer.pubkey(), - &[&context.payer], - ) - .await - .unwrap(); - - // Build DecompressIdempotent instruction with is_ata=true and correct bump - let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( - CompressedOnlyExtensionInstructionData { - delegated_amount: 0, - withheld_transfer_fee: 0, - is_frozen: false, - compression_index: 0, - is_ata: true, - bump: context.ata_bump, - owner_index: 0, // Will be updated by create_generic_transfer2_instruction - }, - )]]; - - let ix = create_generic_transfer2_instruction( - &mut context.rpc, - vec![Transfer2InstructionType::DecompressIdempotent( - DecompressInput { - compressed_token_account: vec![context.compressed_account.clone()], - decompress_amount: context.amount, - solana_token_account: context.ata_pubkey, - amount: context.amount, - pool_index: None, - decimals: 9, - in_tlv: Some(in_tlv), - }, - )], - context.payer.pubkey(), - true, - ) - .await - .unwrap(); - - // Only payer signs -- owner does NOT sign (permissionless) - let result = context - .rpc - .create_and_send_transaction(&[ix], &context.payer.pubkey(), &[&context.payer]) - .await; - - assert!( - result.is_ok(), - "DecompressIdempotent with third-party payer should succeed: {:?}", - result.err() - ); - - // Verify ATA has the correct balance - use borsh::BorshDeserialize; - use light_token_interface::state::Token; - let dest_account = context - .rpc - .get_account(context.ata_pubkey) - .await - .unwrap() - .unwrap(); - let dest_ctoken = Token::deserialize(&mut &dest_account.data[..]).unwrap(); - assert_eq!( - dest_ctoken.amount, context.amount, - "Decompressed amount should match original amount" - ); -} - -/// Test that DecompressIdempotent rejects transactions with multiple inputs. -/// The protocol requires exactly 1 input and 1 compression for DecompressIdempotent. -#[tokio::test] -#[serial] -async fn test_decompress_idempotent_rejects_multiple_inputs() { +async fn test_ata_decompress_rejects_multiple_inputs() { // We need two compressed accounts from the same ATA. Use the multi-cycle approach. let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) .await @@ -1803,7 +1696,7 @@ async fn test_decompress_idempotent_rejects_multiple_inputs() { .await .unwrap(); - // Try DecompressIdempotent with 2 inputs -- should fail with IdempotentDecompressRequiresSingleInput + // Try ATA decompress with 2 inputs -- should fail with InvalidInstructionData let in_tlv = vec![ vec![ExtensionInstructionData::CompressedOnly( CompressedOnlyExtensionInstructionData { @@ -1833,17 +1726,15 @@ async fn test_decompress_idempotent_rejects_multiple_inputs() { let ix = create_generic_transfer2_instruction( &mut rpc, - vec![Transfer2InstructionType::DecompressIdempotent( - DecompressInput { - compressed_token_account: compressed_accounts.clone(), - decompress_amount: total_amount, - solana_token_account: ata_pubkey, - amount: total_amount, - pool_index: None, - decimals: 9, - in_tlv: Some(in_tlv), - }, - )], + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: compressed_accounts.clone(), + decompress_amount: total_amount, + solana_token_account: ata_pubkey, + amount: total_amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + })], payer.pubkey(), true, ) @@ -1854,165 +1745,11 @@ async fn test_decompress_idempotent_rejects_multiple_inputs() { .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) .await; - // Error code 18067 = IdempotentDecompressRequiresSingleInput - assert_rpc_error(result, 0, 18067).unwrap(); -} - -/// Test that DecompressIdempotent rejects inputs without is_ata in CompressedOnly extension. -/// Uses a non-ATA compressed token (wallet-owned) so the owner CAN sign and we exercise -/// the program-level IdempotentDecompressRequiresAta validation (error 18068). -#[tokio::test] -#[serial] -async fn test_decompress_idempotent_rejects_non_ata() { - let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) - .await - .unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let extensions = &[ExtensionType::Pausable]; - let (mint_keypair, _) = - create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; - let mint_pubkey = mint_keypair.pubkey(); - - let spl_account = - create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; - let mint_amount = 1_000_000_000u64; - mint_spl_tokens_22(&mut rpc, &payer, &mint_pubkey, &spl_account, mint_amount).await; - - // Create regular (non-ATA) Light Token account with compression_only=true - let owner = Keypair::new(); - let account_keypair = Keypair::new(); - let ctoken_account = account_keypair.pubkey(); - - let create_ix = - CreateTokenAccount::new(payer.pubkey(), ctoken_account, mint_pubkey, owner.pubkey()) - .with_compressible(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: 0, - 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(); - - // Transfer tokens to the Light Token account - let has_restricted = extensions - .iter() - .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); - let (spl_interface_pda, spl_interface_pda_bump) = - find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); - - let transfer_ix = TransferFromSpl { - amount: mint_amount, - spl_interface_pda_bump, - decimals: 9, - source_spl_token_account: spl_account, - destination: ctoken_account, - authority: payer.pubkey(), - mint: mint_pubkey, - payer: payer.pubkey(), - spl_interface_pda, - spl_token_program: spl_token_2022::ID, - } - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - // Warp epoch to trigger forester compression - rpc.warp_epoch_forward(30).await.unwrap(); - - // Get compressed token accounts (owner is the wallet, NOT an ATA pubkey) - let compressed_accounts = rpc - .get_compressed_token_accounts_by_owner(&owner.pubkey(), None, None) - .await - .unwrap() - .value - .items; - - assert_eq!(compressed_accounts.len(), 1); - - // Create destination Light Token account - let dest_keypair = Keypair::new(); - let create_dest_ix = CreateTokenAccount::new( - payer.pubkey(), - dest_keypair.pubkey(), - mint_pubkey, - owner.pubkey(), - ) - .with_compressible(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_dest_ix], &payer.pubkey(), &[&payer, &dest_keypair]) - .await - .unwrap(); - - // Build DecompressIdempotent with is_ata=false -- should be rejected by program - let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( - CompressedOnlyExtensionInstructionData { - delegated_amount: 0, - withheld_transfer_fee: 0, - is_frozen: false, - compression_index: 0, - is_ata: false, // NOT an ATA -- program should reject with 18068 - bump: 0, - owner_index: 0, - }, - )]]; - - let ix = create_generic_transfer2_instruction( - &mut rpc, - vec![Transfer2InstructionType::DecompressIdempotent( - DecompressInput { - compressed_token_account: vec![compressed_accounts[0].clone()], - decompress_amount: mint_amount, - solana_token_account: dest_keypair.pubkey(), - amount: mint_amount, - pool_index: None, - decimals: 9, - in_tlv: Some(in_tlv), - }, - )], - payer.pubkey(), - true, - ) - .await - .unwrap(); - - // Owner signs so we get past runtime signer check -- program rejects with 18068 - let result = rpc - .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer, &owner]) - .await; - - // Error code 18068 = IdempotentDecompressRequiresAta - assert_rpc_error(result, 0, 18068).unwrap(); + // TokenError::InvalidInstructionData (18001) from ATA decompress single-input constraint + assert_rpc_error(result, 0, 18001).unwrap(); } -/// Test that regular Decompress (not idempotent) with is_ata=true in TLV +/// Test that regular Decompress with is_ata=true in TLV /// succeeds permissionlessly -- only payer signs, not the owner. #[tokio::test] #[serial] @@ -2276,12 +2013,12 @@ async fn test_permissionless_non_ata_decompress_fails() { ); } -/// Test that DecompressIdempotent with an already-spent compressed account +/// Test that ATA decompress with an already-spent compressed account /// is a no-op (returns Ok without modifying the CToken balance). /// The bloom filter in the V2 tree catches the already-nullified account. #[tokio::test] #[serial] -async fn test_decompress_idempotent_already_spent_is_noop() { +async fn test_ata_decompress_already_spent_is_noop() { let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) .await .unwrap(); @@ -2338,17 +2075,15 @@ async fn test_decompress_idempotent_already_spent_is_noop() { let first_ix = create_generic_transfer2_instruction( &mut context.rpc, - vec![Transfer2InstructionType::DecompressIdempotent( - DecompressInput { - compressed_token_account: vec![context.compressed_account.clone()], - decompress_amount: context.amount, - solana_token_account: context.ata_pubkey, - amount: context.amount, - pool_index: None, - decimals: 9, - in_tlv: Some(in_tlv.clone()), - }, - )], + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![context.compressed_account.clone()], + decompress_amount: context.amount, + solana_token_account: context.ata_pubkey, + amount: context.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv.clone()), + })], context.payer.pubkey(), true, ) @@ -2384,7 +2119,7 @@ async fn test_decompress_idempotent_already_spent_is_noop() { assert!( result.is_ok(), - "DecompressIdempotent with already-spent account should be no-op: {:?}", + "ATA decompress with already-spent account should be no-op: {:?}", result.err() ); diff --git a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs index f91ffae9e1..fc9c5d1b57 100644 --- a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs +++ b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs @@ -160,7 +160,6 @@ pub enum Transfer2InstructionType { Transfer(TransferInput), Approve(ApproveInput), CompressAndClose(CompressAndCloseInput), - DecompressIdempotent(DecompressInput), } // Note doesn't support multiple signers. @@ -203,10 +202,6 @@ pub async fn create_generic_transfer2_instruction( .compressed_token_account .iter() .for_each(|account| hashes.push(account.account.hash)), - Transfer2InstructionType::DecompressIdempotent(input) => input - .compressed_token_account - .iter() - .for_each(|account| hashes.push(account.account.hash)), }); let rpc_proof_result = rpc .get_validity_proof(hashes, vec![], None) @@ -338,11 +333,7 @@ pub async fn create_generic_transfer2_instruction( } token_accounts.push(token_account); } - Transfer2InstructionType::Decompress(ref input) - | Transfer2InstructionType::DecompressIdempotent(ref input) => { - let is_idempotent = - matches!(action, Transfer2InstructionType::DecompressIdempotent(_)); - + Transfer2InstructionType::Decompress(ref input) => { // Collect in_tlv data if provided if let Some(ref tlv_data) = input.in_tlv { has_any_tlv = true; @@ -421,10 +412,7 @@ pub async fn create_generic_transfer2_instruction( inputs_offset += token_data.len(); let mut token_account = CTokenAccount2::new(token_data)?; - if is_idempotent { - token_account - .decompress_idempotent(input.decompress_amount, recipient_index)?; - } else if recipient_account_owner.to_bytes() != LIGHT_TOKEN_PROGRAM_ID { + if recipient_account_owner.to_bytes() != LIGHT_TOKEN_PROGRAM_ID { let mint = input.compressed_token_account[0].token.mint; let _token_program_index = packed_tree_accounts.insert_or_get_read_only(recipient_account_owner); diff --git a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs index 5f9ec98feb..55bba47b5e 100644 --- a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs +++ b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs @@ -57,39 +57,3 @@ pub async fn decompress( rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &signers) .await } - -/// Decompress ATA compressed tokens using DecompressIdempotent mode. -/// Permissionless -- only payer needs to sign. -pub async fn decompress_idempotent( - rpc: &mut R, - compressed_token_account: &[CompressedTokenAccount], - decompress_amount: u64, - solana_token_account: Pubkey, - payer: &Keypair, - decimals: u8, - in_tlv: Option< - Vec>, - >, -) -> Result { - let ix = create_generic_transfer2_instruction( - rpc, - vec![Transfer2InstructionType::DecompressIdempotent( - DecompressInput { - compressed_token_account: compressed_token_account.to_vec(), - decompress_amount, - solana_token_account, - amount: decompress_amount, - pool_index: None, - decimals, - in_tlv, - }, - )], - payer.pubkey(), - true, - ) - .await - .map_err(|e| RpcError::CustomError(e.to_string()))?; - - rpc.create_and_send_transaction(&[ix], &payer.pubkey(), &[payer]) - .await -} diff --git a/program-tests/utils/src/assert_transfer2.rs b/program-tests/utils/src/assert_transfer2.rs index 9f3e603979..cc3aa57676 100644 --- a/program-tests/utils/src/assert_transfer2.rs +++ b/program-tests/utils/src/assert_transfer2.rs @@ -63,8 +63,7 @@ pub async fn assert_transfer2_with_delegate( } } } - Transfer2InstructionType::Decompress(decompress_input) - | Transfer2InstructionType::DecompressIdempotent(decompress_input) => { + Transfer2InstructionType::Decompress(decompress_input) => { let pubkey = decompress_input.solana_token_account; // Get or initialize the expected account state @@ -202,8 +201,7 @@ pub async fn assert_transfer2_with_delegate( ); } } - Transfer2InstructionType::Decompress(decompress_input) - | Transfer2InstructionType::DecompressIdempotent(decompress_input) => { + Transfer2InstructionType::Decompress(decompress_input) => { // Get mint from the source compressed token account let source_mint = decompress_input.compressed_token_account[0].token.mint; let source_owner = decompress_input.compressed_token_account[0].token.owner; diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index be02e38872..a6ea701b71 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -68,9 +68,8 @@ Every instruction description must include the sections: ### Token Operations 5. **Transfer2** - [`docs/compressed_token/TRANSFER2.md`](docs/compressed_token/TRANSFER2.md) - Batch transfer instruction for compressed/decompressed operations (discriminator: 101, enum: `InstructionType::Transfer2`) - - Supports Compress, Decompress, CompressAndClose, DecompressIdempotent operations - - DecompressIdempotent (mode 3): permissionless ATA decompress with single-input constraint - - ATA decompress is permissionless for both Decompress and DecompressIdempotent (is_ata=true) + - Supports Compress, Decompress, CompressAndClose operations + - ATA decompress (is_ata=true) is permissionless and idempotent (bloom filter check) - Multi-mint support with sum checks 6. **MintAction** - [`docs/compressed_token/MINT_ACTION.md`](docs/compressed_token/MINT_ACTION.md) diff --git a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md index cebbcaf3b5..e19203b978 100644 --- a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -29,9 +29,8 @@ - `Compress` (0): Move tokens from Solana account (ctoken or SPL) to compressed state - `Decompress` (1): Move tokens from compressed state to Solana account (ctoken or SPL) - `CompressAndClose` (2): Compress full ctoken balance and close the account (authority: compression_authority only, requires compressible extension, **ctoken accounts only - NOT supported for SPL tokens**) - - `DecompressIdempotent` (3): Permissionless ATA decompress. Requires exactly 1 input, 1 compression, and CompressedOnly extension with `is_ata=true`. On-chain behavior is identical to `Decompress`; the mode enforces single-input constraints. ATA must be pre-created. **CToken ATAs only - NOT supported for SPL tokens.** - **Permissionless ATA decompress:** Both `Decompress` and `DecompressIdempotent` modes skip the owner/delegate signer check when the input has CompressedOnly extension with `is_ata=true`. This is safe because the destination is a deterministic PDA (ATA derivation is still validated). + **Permissionless ATA decompress:** When the input has CompressedOnly extension with `is_ata=true`, Decompress skips the owner/delegate signer check (permissionless). This is safe because the destination is a deterministic PDA (ATA derivation is still validated). ATA decompress also enforces a single-input constraint (exactly 1 input and 1 compression) and includes a bloom filter idempotency check -- if the compressed account is already spent, the transaction returns Ok as a no-op. 4. Global sum check enforces transaction balance: - Input sum = compressed inputs + compress operations (tokens entering compressed state) @@ -62,7 +61,7 @@ - `out_tlv`: Optional TLV data for output accounts (used for CompressedOnly extension during CompressAndClose) 2. Compression struct fields (path: program-libs/token-interface/src/instructions/transfer2/compression.rs): - - `mode`: CompressionMode enum (Compress=0, Decompress=1, CompressAndClose=2, DecompressIdempotent=3) + - `mode`: CompressionMode enum (Compress=0, Decompress=1, CompressAndClose=2) - `amount`: u64 - Amount to compress/decompress - `mint`: u8 - Index of mint account in packed accounts - `source_or_recipient`: u8 - Index of source (compress) or recipient (decompress) 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 9c27401090..91cdba9b59 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 @@ -94,7 +94,7 @@ pub fn compress_or_decompress_ctokens( } Ok(()) } - ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent => { + ZCompressionMode::Decompress => { if decompress_inputs.is_none() { if let Some(ref checks) = mint_checks { checks.enforce_extension_state()?; diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs index 3dd0b664ff..ae8078cda5 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs @@ -39,7 +39,7 @@ impl<'a> DecompressCompressOnlyInputs<'a> { let idx = input_idx as usize; // Compression must be Decompress mode to consume an input - if !compression.mode.is_decompress() { + if compression.mode != ZCompressionMode::Decompress { msg!( "Input linked to non-decompress compression at index {}", compression_index @@ -109,7 +109,7 @@ impl<'a> CTokenCompressionInputs<'a> { mint_checks: Option, decompress_inputs: Option>, ) -> Result { - let authority_account = if !compression.mode.is_decompress() { + let authority_account = if compression.mode != ZCompressionMode::Decompress { Some(packed_accounts.get_u8( compression.authority, "process_ctoken_compression: authority", diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs index 564d9d3758..f7303303d5 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs @@ -200,7 +200,7 @@ pub(crate) fn validate_compression_mode_fields( compression: &ZCompression, ) -> Result<(), ProgramError> { match compression.mode { - ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent => { + ZCompressionMode::Decompress => { // the authority field is not used. if compression.authority != 0 { msg!("authority must be 0 for Decompress mode"); diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs index be74c629e9..6bd098c2b4 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs @@ -78,10 +78,6 @@ pub(super) fn process_spl_compressions( msg!("CompressAndClose is unimplemented for spl token accounts"); unimplemented!() } - ZCompressionMode::DecompressIdempotent => { - msg!("DecompressIdempotent is not supported for SPL token accounts"); - return Err(ProgramError::InvalidInstructionData); - } } Ok(()) } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs index 92a657279e..3a05a0b528 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/config.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/config.rs @@ -1,7 +1,5 @@ use anchor_lang::prelude::ProgramError; -use light_token_interface::instructions::transfer2::{ - ZCompressedTokenInstructionDataTransfer2, ZCompressionMode, -}; +use light_token_interface::instructions::transfer2::ZCompressedTokenInstructionDataTransfer2; /// Configuration for Transfer2 account validation /// Replaces complex boolean parameters with clean single config object @@ -22,8 +20,6 @@ pub struct Transfer2Config { pub total_output_lamports: u64, /// No compressed accounts (neither input nor output) - determines system CPI path pub no_compressed_accounts: bool, - /// DecompressIdempotent mode -- enables bloom filter check for idempotency - pub is_decompress_idempotent: bool, } impl Transfer2Config { @@ -45,10 +41,6 @@ impl Transfer2Config { total_input_lamports: 0, total_output_lamports: 0, no_compressed_accounts, - is_decompress_idempotent: inputs.compressions.as_ref().is_some_and(|c| { - c.iter() - .any(|c| c.mode == ZCompressionMode::DecompressIdempotent) - }), }) } } diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 6117cb23f9..fcd6497605 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -118,28 +118,6 @@ pub fn validate_instruction_data( return Err(TokenError::CompressedOnlyBlocksTransfer); } } - // DecompressIdempotent: exactly 1 input, 1 compression, must have CompressedOnly with is_ata=true - if let Some(compressions) = inputs.compressions.as_ref() { - let has_idempotent = compressions - .iter() - .any(|c| c.mode == ZCompressionMode::DecompressIdempotent); - if has_idempotent { - if inputs.in_token_data.len() != 1 || compressions.len() != 1 { - msg!("DecompressIdempotent requires exactly 1 input and 1 compression"); - return Err(TokenError::IdempotentDecompressRequiresSingleInput); - } - let has_ata = inputs.in_tlv.as_ref().is_some_and(|tlvs| { - tlvs.iter().flatten().any(|ext| { - matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) - }) - }); - if !has_ata { - msg!("DecompressIdempotent requires is_ata=true in CompressedOnly extension"); - return Err(TokenError::IdempotentDecompressRequiresAta); - } - } - } - // 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() { @@ -279,9 +257,26 @@ fn process_with_system_program_cpi<'a>( mint_cache, )?; - // Idempotency check for DecompressIdempotent: if the compressed account is already - // spent (found in the V2 tree's bloom filter), return Ok as a no-op. - if transfer_config.is_decompress_idempotent { + // ATA decompress is permissionless and idempotent. + // Detect from Decompress mode + CompressedOnly extension with is_ata=true. + let is_ata_decompress = inputs + .compressions + .as_ref() + .is_some_and(|c| c.iter().any(|c| c.mode.is_decompress())) + && inputs.in_tlv.as_ref().is_some_and(|tlvs| { + tlvs.iter().flatten().any(|ext| { + matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) + }) + }); + + if is_ata_decompress { + // Single-input constraint: permissionless decompress must be atomic. + if inputs.in_token_data.len() != 1 + || inputs.compressions.as_ref().map_or(0, |c| c.len()) != 1 + { + msg!("ATA decompress requires exactly 1 input and 1 compression"); + return Err(TokenError::InvalidInstructionData.into()); + } let input_data = &inputs.in_token_data[0]; let merkle_context = &input_data.merkle_context; let input_account = cpi_instruction_struct 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 2275506a81..b1a1179d00 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 @@ -245,31 +245,6 @@ impl CTokenAccount2 { Ok(()) } - #[profile] - pub fn decompress_idempotent( - &mut self, - amount: u64, - source_index: u8, - ) -> Result<(), TokenSdkError> { - if self.compression.is_some() { - return Err(TokenSdkError::CompressionCannotBeSetTwice); - } - - if self.output.amount < amount { - return Err(TokenSdkError::InsufficientBalance); - } - self.output.amount -= amount; - - self.compression = Some(Compression::decompress_idempotent( - amount, - self.output.mint, - source_index, - )); - self.method_used = true; - - Ok(()) - } - #[profile] pub fn decompress_spl( &mut self, From b3271fe63c2ae05240184878b683e816226e9ebd Mon Sep 17 00:00:00 2001 From: ananas Date: Sat, 28 Mar 2026 00:42:17 +0000 Subject: [PATCH 04/14] fix: only activate ATA idempotent decompress for single-input txs The is_ata_decompress detection was too broad -- it triggered for any tx containing a Decompress compression and any CompressedOnly TLV with is_ata=true. This broke multi-account decompress batches (e.g., AMM stress test) that contain a mix of ATA and non-ATA accounts. Fix: require exactly 1 input and 1 compression as part of the detection condition, not as a separate validation that errors. Multi-input decompress txs now skip the idempotency path entirely. --- .../tests/compress_only/ata_decompress.rs | 217 ------------------ .../compressed_token/transfer2/processor.rs | 23 +- 2 files changed, 9 insertions(+), 231 deletions(-) diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index 49c5b434d9..2b93829155 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -1532,223 +1532,6 @@ async fn test_non_ata_compress_only_decompress() { assert_eq!(dest_ctoken.amount, mint_amount); } -/// Test that ATA decompress rejects transactions with multiple inputs. -/// The protocol requires exactly 1 input and 1 compression for ATA decompress. -#[tokio::test] -#[serial] -async fn test_ata_decompress_rejects_multiple_inputs() { - // We need two compressed accounts from the same ATA. Use the multi-cycle approach. - let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) - .await - .unwrap(); - let payer = rpc.get_payer().insecure_clone(); - - let extensions = &[ExtensionType::Pausable]; - let (mint_keypair, _) = - create_mint_22_with_extension_types(&mut rpc, &payer, 9, extensions).await; - let mint_pubkey = mint_keypair.pubkey(); - - let spl_account = - create_token_22_account(&mut rpc, &payer, &mint_pubkey, &payer.pubkey()).await; - let total_mint_amount = 10_000_000_000u64; - mint_spl_tokens_22( - &mut rpc, - &payer, - &mint_pubkey, - &spl_account, - total_mint_amount, - ) - .await; - - let wallet = Keypair::new(); - let (ata_pubkey, ata_bump) = - get_associated_token_address_and_bump(&wallet.pubkey(), &mint_pubkey); - - let amount1 = 100_000_000u64; - let amount2 = 200_000_000u64; - - let has_restricted = extensions - .iter() - .any(|ext| RESTRICTED_EXTENSIONS.contains(ext)); - let (spl_interface_pda, spl_interface_pda_bump) = - find_spl_interface_pda_with_index(&mint_pubkey, 0, has_restricted); - - // Cycle 1: Create ATA, fund, compress - let create_ata_ix = - CreateAssociatedTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) - .with_compressible(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: 0, - 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_ata_ix], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - let transfer_ix1 = TransferFromSpl { - amount: amount1, - spl_interface_pda_bump, - decimals: 9, - source_spl_token_account: spl_account, - destination: ata_pubkey, - authority: payer.pubkey(), - mint: mint_pubkey, - payer: payer.pubkey(), - spl_interface_pda, - spl_token_program: spl_token_2022::ID, - } - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[transfer_ix1], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - rpc.warp_epoch_forward(30).await.unwrap(); - - // Cycle 2: Recreate ATA, fund, compress - let create_ata_ix2 = - CreateAssociatedTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) - .with_compressible(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: 0, - 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_ata_ix2], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - let transfer_ix2 = TransferFromSpl { - amount: amount2, - spl_interface_pda_bump, - decimals: 9, - source_spl_token_account: spl_account, - destination: ata_pubkey, - authority: payer.pubkey(), - mint: mint_pubkey, - payer: payer.pubkey(), - spl_interface_pda, - spl_token_program: spl_token_2022::ID, - } - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[transfer_ix2], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - rpc.warp_epoch_forward(30).await.unwrap(); - - // Now we have 2 compressed accounts from the ATA - let compressed_accounts = rpc - .get_compressed_token_accounts_by_owner(&ata_pubkey, None, None) - .await - .unwrap() - .value - .items; - - assert_eq!( - compressed_accounts.len(), - 2, - "Should have 2 compressed accounts" - ); - - // Create destination ATA for decompress - let create_ata_ix3 = - CreateAssociatedTokenAccount::new(payer.pubkey(), wallet.pubkey(), mint_pubkey) - .with_compressible(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, - }) - .idempotent() - .instruction() - .unwrap(); - - rpc.create_and_send_transaction(&[create_ata_ix3], &payer.pubkey(), &[&payer]) - .await - .unwrap(); - - // Try ATA decompress with 2 inputs -- should fail with InvalidInstructionData - let in_tlv = vec![ - vec![ExtensionInstructionData::CompressedOnly( - CompressedOnlyExtensionInstructionData { - delegated_amount: 0, - withheld_transfer_fee: 0, - is_frozen: false, - compression_index: 0, - is_ata: true, - bump: ata_bump, - owner_index: 0, - }, - )], - vec![ExtensionInstructionData::CompressedOnly( - CompressedOnlyExtensionInstructionData { - delegated_amount: 0, - withheld_transfer_fee: 0, - is_frozen: false, - compression_index: 0, - is_ata: true, - bump: ata_bump, - owner_index: 0, - }, - )], - ]; - - let total_amount = compressed_accounts[0].token.amount + compressed_accounts[1].token.amount; - - let ix = create_generic_transfer2_instruction( - &mut rpc, - vec![Transfer2InstructionType::Decompress(DecompressInput { - compressed_token_account: compressed_accounts.clone(), - decompress_amount: total_amount, - solana_token_account: ata_pubkey, - amount: total_amount, - pool_index: None, - decimals: 9, - in_tlv: Some(in_tlv), - })], - payer.pubkey(), - true, - ) - .await - .unwrap(); - - let result = rpc - .create_and_send_transaction(&[ix], &payer.pubkey(), &[&payer]) - .await; - - // TokenError::InvalidInstructionData (18001) from ATA decompress single-input constraint - assert_rpc_error(result, 0, 18001).unwrap(); -} - /// Test that regular Decompress with is_ata=true in TLV /// succeeds permissionlessly -- only payer signs, not the owner. #[tokio::test] diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index fcd6497605..6cd5c4e5e2 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -258,25 +258,20 @@ fn process_with_system_program_cpi<'a>( )?; // ATA decompress is permissionless and idempotent. - // Detect from Decompress mode + CompressedOnly extension with is_ata=true. - let is_ata_decompress = inputs - .compressions - .as_ref() - .is_some_and(|c| c.iter().any(|c| c.mode.is_decompress())) + // Detect from: exactly 1 input, 1 Decompress compression, CompressedOnly with is_ata=true. + // Multi-input batches (including mixed ATA + non-ATA) are not idempotent. + let is_ata_decompress = inputs.in_token_data.len() == 1 + && inputs + .compressions + .as_ref() + .is_some_and(|c| c.len() == 1 && c.iter().any(|c| c.mode.is_decompress())) && inputs.in_tlv.as_ref().is_some_and(|tlvs| { tlvs.iter().flatten().any(|ext| { - matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) - }) + matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) + }) }); if is_ata_decompress { - // Single-input constraint: permissionless decompress must be atomic. - if inputs.in_token_data.len() != 1 - || inputs.compressions.as_ref().map_or(0, |c| c.len()) != 1 - { - msg!("ATA decompress requires exactly 1 input and 1 compression"); - return Err(TokenError::InvalidInstructionData.into()); - } let input_data = &inputs.in_token_data[0]; let merkle_context = &input_data.merkle_context; let input_account = cpi_instruction_struct From 76d399bf305cc53cb04bb87c90f40d026b098cec Mon Sep 17 00:00:00 2001 From: ananas Date: Sun, 29 Mar 2026 19:50:35 +0100 Subject: [PATCH 05/14] fix sdk --- sdk-libs/client/src/interface/load_accounts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-libs/client/src/interface/load_accounts.rs b/sdk-libs/client/src/interface/load_accounts.rs index 061ad5074b..29c21022e4 100644 --- a/sdk-libs/client/src/interface/load_accounts.rs +++ b/sdk-libs/client/src/interface/load_accounts.rs @@ -364,7 +364,7 @@ fn build_transfer2( }, )?; - let owner_idx = packed.insert_or_get_config(ctx.wallet_owner, true, false); + let owner_idx = packed.insert_or_get(ctx.wallet_owner); let ata_idx = packed.insert_or_get(derive_token_ata(&ctx.wallet_owner, &ctx.mint)); let mint_idx = packed.insert_or_get(token.mint); let delegate_idx = token.delegate.map(|d| packed.insert_or_get(d)).unwrap_or(0); From 60c75944c5a47547683e85506c3ab84b1a35bd0d Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 30 Mar 2026 00:20:14 +0100 Subject: [PATCH 06/14] fix: remove creator signer from decompress_all since ATA decompress no longer requires owner signature --- .../csdk-anchor-full-derived-test/tests/amm_stress_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index 1bd04c0cbc..0a0510fece 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -568,7 +568,7 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { .create_and_send_transaction( &decompress_ixs, &ctx.payer.pubkey(), - &[&ctx.payer, &ctx.creator], + &[&ctx.payer], ) .await .expect("Decompression should succeed"); From c3f1d674e4925566fd60919024feb3de298fb925 Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 30 Mar 2026 00:30:32 +0100 Subject: [PATCH 07/14] fix: rustfmt formatting for collapsed single-line call --- .../csdk-anchor-full-derived-test/tests/amm_stress_test.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index 0a0510fece..810276bb8c 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -565,11 +565,7 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { .expect("create_load_instructions should succeed"); ctx.rpc - .create_and_send_transaction( - &decompress_ixs, - &ctx.payer.pubkey(), - &[&ctx.payer], - ) + .create_and_send_transaction(&decompress_ixs, &ctx.payer.pubkey(), &[&ctx.payer]) .await .expect("Decompression should succeed"); From a86c1cd34a3f6e4afdc17d80edbf439b1e107e44 Mon Sep 17 00:00:00 2001 From: ananas Date: Mon, 30 Mar 2026 01:13:34 +0100 Subject: [PATCH 08/14] fix: remove extra signers from remaining decompress calls in amm_test and d10_token_accounts_test --- sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs | 6 +----- .../tests/d10_token_accounts_test.rs | 8 ++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 050531f970..1f894f30e4 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -692,11 +692,7 @@ async fn test_amm_full_lifecycle() { .expect("create_load_instructions should succeed"); ctx.rpc - .create_and_send_transaction( - &decompress_ixs, - &ctx.payer.pubkey(), - &[&ctx.payer, &ctx.creator], - ) + .create_and_send_transaction(&decompress_ixs, &ctx.payer.pubkey(), &[&ctx.payer]) .await .expect("Decompression should succeed"); diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs index 32b1e218e5..226e02f4cf 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs @@ -564,13 +564,9 @@ async fn test_d10_single_ata_markonly_lifecycle() { .await .expect("create_load_instructions should succeed"); - // Execute decompression (ATA owner must sign for decompression) + // Execute decompression (ATA owner no longer needs to sign) ctx.rpc - .create_and_send_transaction( - &decompress_instructions, - &ctx.payer.pubkey(), - &[&ctx.payer, &ata_owner_keypair], - ) + .create_and_send_transaction(&decompress_instructions, &ctx.payer.pubkey(), &[&ctx.payer]) .await .expect("ATA decompression should succeed"); From 4b38472e5eed5f9fa236a50dd50e72c348b3f0fe Mon Sep 17 00:00:00 2001 From: ananas Date: Tue, 31 Mar 2026 22:03:15 +0100 Subject: [PATCH 09/14] refactor: extract into functions --- .../compressed_token/transfer2/processor.rs | 134 +++++++++------ .../program/src/shared/token_input.rs | 12 +- .../program/tests/transfer2_processor.rs | 157 ++++++++++++++++++ .../client/src/interface/load_accounts.rs | 2 +- 4 files changed, 244 insertions(+), 61 deletions(-) create mode 100644 programs/compressed-token/program/tests/transfer2_processor.rs diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 6cd5c4e5e2..314f12cc4e 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -1,7 +1,9 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; use light_array_map::ArrayMap; -use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnly; +use light_compressed_account::instruction_data::with_readonly::{ + InstructionDataInvokeCpiWithReadOnly, ZInstructionDataInvokeCpiWithReadOnlyMut, +}; use light_program_profiler::profile; use light_token_interface::{ hash_cache::HashCache, @@ -256,59 +258,10 @@ fn process_with_system_program_cpi<'a>( accounts, mint_cache, )?; - - // ATA decompress is permissionless and idempotent. - // Detect from: exactly 1 input, 1 Decompress compression, CompressedOnly with is_ata=true. - // Multi-input batches (including mixed ATA + non-ATA) are not idempotent. - let is_ata_decompress = inputs.in_token_data.len() == 1 - && inputs - .compressions - .as_ref() - .is_some_and(|c| c.len() == 1 && c.iter().any(|c| c.mode.is_decompress())) - && inputs.in_tlv.as_ref().is_some_and(|tlvs| { - tlvs.iter().flatten().any(|ext| { - matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) - }) - }); - - if is_ata_decompress { - let input_data = &inputs.in_token_data[0]; - let merkle_context = &input_data.merkle_context; - let input_account = cpi_instruction_struct - .input_compressed_accounts - .first() - .ok_or(ProgramError::InvalidAccountData)?; - - let owner_hashed = light_hasher::hash_to_field_size::hash_to_bn254_field_size_be( - &crate::LIGHT_CPI_SIGNER.program_id, - ); - let tree_account = validated_accounts - .packed_accounts - .get_u8(merkle_context.merkle_tree_pubkey_index, "idempotent: tree")?; - let merkle_tree_hashed = - light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(tree_account.key()); - - let lamports: u64 = (*input_account.lamports).into(); - let account_hash = light_compressed_account::compressed_account::hash_with_hashed_values( - &lamports, - input_account.address.as_ref().map(|x| x.as_slice()), - Some(( - input_account.discriminator.as_slice(), - input_account.data_hash.as_slice(), - )), - &owner_hashed, - &merkle_tree_hashed, - &merkle_context.leaf_index.get(), - true, - ) - .map_err(ProgramError::from)?; - - let mut tree = - light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount::state_from_account_info(tree_account) - .map_err(ProgramError::from)?; - - if tree.check_input_queue_non_inclusion(&account_hash).is_err() { - // Account is in bloom filter -- already spent. Idempotent no-op. + let is_idempotent_ata_decompress = is_idempotent_ata_decompress(inputs); + #[allow(clippy::collapsible_if)] + if is_idempotent_ata_decompress { + if check_ata_decompress_idempotent(inputs, &cpi_instruction_struct, validated_accounts)? { return Ok(()); } } @@ -389,3 +342,76 @@ fn process_with_system_program_cpi<'a>( } Ok(()) } + +/// Detect idempotent associated token account decompress: +/// - exactly 1 input compressed token account with CompressedOnly extension is_ata=true +/// - 1 Decompress compression. +/// +/// Multi-input batches (including mixed ATA + non-ATA) are not idempotent. +#[inline(always)] +pub fn is_idempotent_ata_decompress(inputs: &ZCompressedTokenInstructionDataTransfer2) -> bool { + inputs.in_token_data.len() == 1 + && inputs + .compressions + .as_ref() + .is_some_and(|c| c.len() == 1 && c.iter().any(|c| c.mode.is_decompress())) + && inputs.in_tlv.as_ref().is_some_and(|tlvs| { + tlvs.iter().flatten().any(|ext| { + matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) + }) + }) +} + +/// Computes the compressed account hash and checks whether the hash exists in the input queue bloom filters. +/// The account compression program inserts spent compressed accounts into the respective input queue. +/// +/// if exists in bloom filter -> exit the ata was already decompressed +/// else decompress +#[cold] +fn check_ata_decompress_idempotent( + inputs: &ZCompressedTokenInstructionDataTransfer2, + cpi_instruction_struct: &ZInstructionDataInvokeCpiWithReadOnlyMut<'_>, + validated_accounts: &Transfer2Accounts, +) -> Result { + let input_data = inputs + .in_token_data + .first() + .ok_or(ProgramError::InvalidAccountData)?; + let merkle_context = &input_data.merkle_context; + let input_account = cpi_instruction_struct + .input_compressed_accounts + .first() + .ok_or(ProgramError::InvalidAccountData)?; + + let owner_hashed = light_hasher::hash_to_field_size::hash_to_bn254_field_size_be( + &crate::LIGHT_CPI_SIGNER.program_id, + ); + let tree_account = validated_accounts + .packed_accounts + .get_u8(merkle_context.merkle_tree_pubkey_index, "idempotent: tree")?; + let merkle_tree_hashed = + light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(tree_account.key()); + + let lamports: u64 = input_account.lamports.get(); + let account_hash = light_compressed_account::compressed_account::hash_with_hashed_values( + &lamports, + input_account.address.as_ref().map(|x| x.as_slice()), + Some(( + input_account.discriminator.as_slice(), + input_account.data_hash.as_slice(), + )), + &owner_hashed, + &merkle_tree_hashed, + &merkle_context.leaf_index.get(), + true, + ) + .map_err(ProgramError::from)?; + + let mut tree = + light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount::state_from_account_info( + tree_account, + ) + .map_err(ProgramError::from)?; + + Ok(tree.check_input_queue_non_inclusion(&account_hash).is_err()) +} diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index d30da5f52d..d8d7c06d11 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -80,15 +80,15 @@ pub fn set_input_compressed_account<'a>( // For ATA decompress (is_ata=true), verify the wallet owner from owner_index instead // of the compressed account owner (which is the ATA pubkey that can't sign). // Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump. - let (signer_account, is_ata_decompress) = if let Some(exts) = tlv_data { + let (signer_account, check_signer) = if let Some(exts) = tlv_data { resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)? } else { - (owner_account, false) + (owner_account, true) }; // ATA decompress is permissionless -- the destination is a deterministic PDA, // so there is no griefing vector. ATA derivation is still validated above. - if !is_ata_decompress { + if check_signer { verify_owner_or_delegate_signer( signer_account, delegate_account, @@ -232,13 +232,13 @@ fn resolve_ata_signer<'a>( if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) { return Err(TokenError::InvalidAtaDerivation.into()); } - - return Ok((wallet_owner, true)); + // Do not check signer the recipient token account is the correct ata. + return Ok((wallet_owner, false)); } } } - Ok((owner_account, false)) + Ok((owner_account, true)) } #[cold] diff --git a/programs/compressed-token/program/tests/transfer2_processor.rs b/programs/compressed-token/program/tests/transfer2_processor.rs new file mode 100644 index 0000000000..e30641b0b2 --- /dev/null +++ b/programs/compressed-token/program/tests/transfer2_processor.rs @@ -0,0 +1,157 @@ +use anchor_lang::AnchorSerialize; +use light_compressed_token::compressed_token::transfer2::processor::is_idempotent_ata_decompress; +use light_token_interface::instructions::{ + extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData}, + transfer2::{ + CompressedTokenInstructionDataTransfer2, Compression, CompressionMode, + MultiInputTokenDataWithContext, + }, +}; +use light_zero_copy::traits::ZeroCopyAt; +use rand::{rngs::StdRng, Rng, SeedableRng}; + +fn serialize(data: &CompressedTokenInstructionDataTransfer2) -> Vec { + let mut buf = Vec::new(); + AnchorSerialize::serialize(data, &mut buf).unwrap(); + buf +} + +fn base_input() -> MultiInputTokenDataWithContext { + MultiInputTokenDataWithContext::default() +} + +fn base_data() -> CompressedTokenInstructionDataTransfer2 { + CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 0, + max_top_up: 0, + cpi_context: None, + compressions: None, + proof: None, + in_token_data: vec![], + out_token_data: vec![], + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + } +} + +fn check(data: &CompressedTokenInstructionDataTransfer2) -> bool { + let buf = serialize(data); + let (z, _) = CompressedTokenInstructionDataTransfer2::zero_copy_at(&buf).unwrap(); + is_idempotent_ata_decompress(&z) +} + +#[test] +fn test_is_idempotent_ata_decompress_empty() { + assert!(!check(&base_data())); +} + +#[test] +fn test_is_idempotent_ata_decompress_no_compressions() { + let mut data = base_data(); + data.in_token_data = vec![base_input()]; + data.in_tlv = Some(vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: 0, + owner_index: 0, + }, + )]]); + assert!(!check(&data)); +} + +#[test] +fn test_is_idempotent_ata_decompress_multiple_inputs() { + let mut data = base_data(); + data.in_token_data = vec![base_input(), base_input()]; + data.compressions = Some(vec![Compression::decompress(100, 0, 0)]); + assert!(!check(&data)); +} + +#[test] +fn test_is_idempotent_ata_decompress_compress_mode() { + let mut data = base_data(); + data.in_token_data = vec![base_input()]; + data.compressions = Some(vec![Compression::compress(100, 0, 0, 0)]); + data.in_tlv = Some(vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: 0, + owner_index: 0, + }, + )]]); + assert!(!check(&data)); +} + +#[test] +fn test_is_idempotent_ata_decompress_not_ata() { + let mut data = base_data(); + data.in_token_data = vec![base_input()]; + data.compressions = Some(vec![Compression::decompress(100, 0, 0)]); + data.in_tlv = Some(vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }, + )]]); + assert!(!check(&data)); +} + +#[test] +fn test_is_idempotent_ata_decompress_random_always_false() { + let mut rng = StdRng::seed_from_u64(42); + for _ in 0..1000 { + let mut data = base_data(); + let num_inputs = rng.gen_range(0..5); + data.in_token_data = (0..num_inputs).map(|_| base_input()).collect(); + + // Random compressions -- never Decompress mode so result is always false. + if rng.gen_bool(0.5) { + let num_compressions = rng.gen_range(0..4); + data.compressions = Some( + (0..num_compressions) + .map(|_| { + if rng.gen_bool(0.5) { + Compression::compress(rng.gen(), 0, 0, 0) + } else { + Compression { + mode: CompressionMode::CompressAndClose, + amount: rng.gen(), + mint: 0, + source_or_recipient: 0, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 0, + } + } + }) + .collect(), + ); + } + + assert!( + !check(&data), + "Expected false for random input with {num_inputs} inputs" + ); + } +} diff --git a/sdk-libs/client/src/interface/load_accounts.rs b/sdk-libs/client/src/interface/load_accounts.rs index 29c21022e4..b926c62b74 100644 --- a/sdk-libs/client/src/interface/load_accounts.rs +++ b/sdk-libs/client/src/interface/load_accounts.rs @@ -364,7 +364,7 @@ fn build_transfer2( }, )?; - let owner_idx = packed.insert_or_get(ctx.wallet_owner); + let owner_idx = packed.insert_or_get_read_only(ctx.wallet_owner); let ata_idx = packed.insert_or_get(derive_token_ata(&ctx.wallet_owner, &ctx.mint)); let mint_idx = packed.insert_or_get(token.mint); let delegate_idx = token.delegate.map(|d| packed.insert_or_get(d)).unwrap_or(0); From 9f69ded06c5d645cf5da9906b0b49c54fcf9906c Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 1 Apr 2026 15:40:41 +0100 Subject: [PATCH 10/14] fix(test-utils): scope ATA TLV owner_index updates to current decompress action --- .../actions/legacy/instructions/transfer2.rs | 92 +++++++++++++++++-- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs index fc9c5d1b57..ec2114f042 100644 --- a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs +++ b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs @@ -162,6 +162,21 @@ pub enum Transfer2InstructionType { CompressAndClose(CompressAndCloseInput), } +fn set_owner_index_for_ata_tlvs( + tlvs: &mut [Vec], + wallet_owner_index: u8, +) { + for tlv in tlvs.iter_mut() { + for ext in tlv.iter_mut() { + if let ExtensionInstructionData::CompressedOnly(data) = ext { + if data.is_ata { + data.owner_index = wallet_owner_index; + } + } + } + } +} + // Note doesn't support multiple signers. pub async fn create_generic_transfer2_instruction( rpc: &mut R, @@ -334,6 +349,7 @@ pub async fn create_generic_transfer2_instruction( token_accounts.push(token_account); } Transfer2InstructionType::Decompress(ref input) => { + let tlv_start = collected_in_tlv.len(); // Collect in_tlv data if provided if let Some(ref tlv_data) = input.in_tlv { has_any_tlv = true; @@ -344,6 +360,7 @@ pub async fn create_generic_transfer2_instruction( collected_in_tlv.push(Vec::new()); } } + let tlv_end = collected_in_tlv.len(); // Check if any input has is_ata=true in the TLV let is_ata = input.in_tlv.as_ref().is_some_and(|tlv| { @@ -370,15 +387,10 @@ pub async fn create_generic_transfer2_instruction( if let Ok(ctoken) = Token::deserialize(&mut &recipient_account.data[..]) { let wallet_owner = Pubkey::from(ctoken.owner.to_bytes()); let wallet_owner_index = packed_tree_accounts.insert_or_get(wallet_owner); - for tlv in collected_in_tlv.iter_mut() { - for ext in tlv.iter_mut() { - if let ExtensionInstructionData::CompressedOnly(data) = ext { - if data.is_ata { - data.owner_index = wallet_owner_index; - } - } - } - } + set_owner_index_for_ata_tlvs( + &mut collected_in_tlv[tlv_start..tlv_end], + wallet_owner_index, + ); } } @@ -680,3 +692,65 @@ pub async fn create_generic_transfer2_instruction( }; create_transfer2_instruction(inputs) } + +#[cfg(test)] +mod tests { + use light_token_interface::instructions::extensions::CompressedOnlyExtensionInstructionData; + + use super::*; + + fn compressed_only(is_ata: bool, owner_index: u8) -> ExtensionInstructionData { + ExtensionInstructionData::CompressedOnly(CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata, + bump: 0, + owner_index, + }) + } + + #[test] + fn set_owner_index_for_ata_tlvs_updates_only_selected_slice() { + let mut collected_in_tlv = vec![ + vec![compressed_only(true, 1)], + vec![compressed_only(true, 2)], + vec![compressed_only(true, 3)], + vec![compressed_only(true, 4)], + ]; + + set_owner_index_for_ata_tlvs(&mut collected_in_tlv[2..4], 9); + + assert_eq!( + collected_in_tlv[0], + vec![compressed_only(true, 1)], + "entries before slice must remain unchanged" + ); + assert_eq!( + collected_in_tlv[1], + vec![compressed_only(true, 2)], + "entries before slice must remain unchanged" + ); + assert_eq!( + collected_in_tlv[2], + vec![compressed_only(true, 9)], + "entries inside slice must be updated" + ); + assert_eq!( + collected_in_tlv[3], + vec![compressed_only(true, 9)], + "entries inside slice must be updated" + ); + } + + #[test] + fn set_owner_index_for_ata_tlvs_does_not_touch_non_ata() { + let mut tlvs = vec![vec![compressed_only(false, 7), compressed_only(true, 8)]]; + + set_owner_index_for_ata_tlvs(&mut tlvs, 11); + + assert_eq!(tlvs[0][0], compressed_only(false, 7)); + assert_eq!(tlvs[0][1], compressed_only(true, 11)); + } +} From 1eb88d7a0790a29aaa9579395ccb601588b3f68e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 1 Apr 2026 16:35:05 +0100 Subject: [PATCH 11/14] fix(transfer2): only treat NonInclusionCheckFailed as ATA idempotent hit --- .../program/src/compressed_token/transfer2/processor.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index 314f12cc4e..b69b85baca 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -1,5 +1,6 @@ use anchor_compressed_token::ErrorCode; use anchor_lang::prelude::ProgramError; +use light_batched_merkle_tree::errors::BatchedMerkleTreeError; use light_array_map::ArrayMap; use light_compressed_account::instruction_data::with_readonly::{ InstructionDataInvokeCpiWithReadOnly, ZInstructionDataInvokeCpiWithReadOnlyMut, @@ -413,5 +414,9 @@ fn check_ata_decompress_idempotent( ) .map_err(ProgramError::from)?; - Ok(tree.check_input_queue_non_inclusion(&account_hash).is_err()) + match tree.check_input_queue_non_inclusion(&account_hash) { + Ok(()) => Ok(false), + Err(BatchedMerkleTreeError::NonInclusionCheckFailed) => Ok(true), + Err(e) => Err(ProgramError::from(e)), + } } From d562f290d8f7bd240380c352ba2e86c05d83b921 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 1 Apr 2026 16:54:04 +0100 Subject: [PATCH 12/14] gate ATA signer bypass to strict idempotent decompress path --- .../src/compressed_token/transfer2/processor.rs | 3 ++- .../src/compressed_token/transfer2/token_inputs.rs | 2 ++ .../program/src/shared/token_input.rs | 14 +++++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index b69b85baca..3d27eb54f7 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -249,6 +249,7 @@ fn process_with_system_program_cpi<'a>( // Create HashCache to cache hashed pubkeys. let mut hash_cache = HashCache::new(); + let is_idempotent_ata_decompress = is_idempotent_ata_decompress(inputs); // Process input compressed accounts and build compression-to-input lookup. let compression_to_input = set_input_compressed_accounts( @@ -258,8 +259,8 @@ fn process_with_system_program_cpi<'a>( &validated_accounts.packed_accounts, accounts, mint_cache, + is_idempotent_ata_decompress, )?; - let is_idempotent_ata_decompress = is_idempotent_ata_decompress(inputs); #[allow(clippy::collapsible_if)] if is_idempotent_ata_decompress { if check_ata_decompress_idempotent(inputs, &cpi_instruction_struct, validated_accounts)? { diff --git a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs index f22e939936..2d2f07918b 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/token_inputs.rs @@ -25,6 +25,7 @@ pub fn set_input_compressed_accounts<'a>( packed_accounts: &ProgramPackedAccounts<'_, AccountInfo>, all_accounts: &[AccountInfo], mint_cache: &'a MintExtensionCache, + allow_permissionless_ata_decompress: bool, ) -> Result<[Option; MAX_COMPRESSIONS], ProgramError> { // compression_to_input[compression_index] = Some(input_index), None means unset let mut compression_to_input: [Option; MAX_COMPRESSIONS] = [None; MAX_COMPRESSIONS]; @@ -84,6 +85,7 @@ pub fn set_input_compressed_accounts<'a>( tlv_data, mint_cache, is_frozen, + allow_permissionless_ata_decompress, )?; } diff --git a/programs/compressed-token/program/src/shared/token_input.rs b/programs/compressed-token/program/src/shared/token_input.rs index d8d7c06d11..73726b5736 100644 --- a/programs/compressed-token/program/src/shared/token_input.rs +++ b/programs/compressed-token/program/src/shared/token_input.rs @@ -37,6 +37,7 @@ pub fn set_input_compressed_account<'a>( tlv_data: Option<&'a [ZExtensionInstructionData<'a>]>, mint_cache: &MintExtensionCache, is_frozen: bool, + allow_permissionless_ata_decompress: bool, ) -> std::result::Result<(), ProgramError> { // Get owner from packed accounts using the owner index let owner_account = packed_accounts @@ -81,7 +82,13 @@ pub fn set_input_compressed_account<'a>( // of the compressed account owner (which is the ATA pubkey that can't sign). // Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump. let (signer_account, check_signer) = if let Some(exts) = tlv_data { - resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)? + resolve_ata_signer( + exts, + packed_accounts, + mint_account, + owner_account, + allow_permissionless_ata_decompress, + )? } else { (owner_account, true) }; @@ -197,6 +204,7 @@ fn resolve_ata_signer<'a>( packed_accounts: &'a [AccountInfo], mint_account: &AccountInfo, owner_account: &'a AccountInfo, + allow_permissionless_ata_decompress: bool, ) -> Result<(&'a AccountInfo, bool), ProgramError> { for ext in exts.iter() { if let ZExtensionInstructionData::CompressedOnly(data) = ext { @@ -232,8 +240,8 @@ fn resolve_ata_signer<'a>( if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) { return Err(TokenError::InvalidAtaDerivation.into()); } - // Do not check signer the recipient token account is the correct ata. - return Ok((wallet_owner, false)); + // Only the strict idempotent ATA decompress flow is permissionless. + return Ok((wallet_owner, !allow_permissionless_ata_decompress)); } } } From 60024b85d54b2b08ad2dea00ed24cf1dc213a50d Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 1 Apr 2026 17:21:19 +0100 Subject: [PATCH 13/14] add the ata tests to ci, and add an extra one --- .../tests/compress_only/ata_decompress.rs | 88 +++++++++++++++++++ program-tests/justfile | 7 +- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index 2b93829155..454470fa23 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -41,6 +41,8 @@ const DECOMPRESS_DESTINATION_MISMATCH: u32 = 18057; const MINT_MISMATCH: u32 = 18058; /// Expected error code for DecompressAmountMismatch const DECOMPRESS_AMOUNT_MISMATCH: u32 = 18064; +/// Expected error code for InvalidAtaDerivation +const INVALID_ATA_DERIVATION: u32 = 18066; /// Setup context for ATA CompressOnly tests struct AtaCompressedTokenContext { @@ -1636,6 +1638,92 @@ async fn test_permissionless_ata_decompress() { ); } +/// Test that strict permissionless ATA decompress still enforces ATA derivation on-chain. +/// Even with payer-only signature, wrong ATA bump must fail with InvalidAtaDerivation. (idempotency only on bloom filter hit.) +#[tokio::test] +#[serial] +async fn test_permissionless_ata_decompress_rejects_wrong_bump() { + let mut context = setup_ata_compressed_token(&[ExtensionType::Pausable], None, false) + .await + .unwrap(); + + // Create destination ATA. + let create_dest_ix = CreateAssociatedTokenAccount::new( + context.payer.pubkey(), + context.owner.pubkey(), + context.mint_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: true, + }) + .idempotent() + .instruction() + .unwrap(); + + context + .rpc + .create_and_send_transaction( + &[create_dest_ix], + &context.payer.pubkey(), + &[&context.payer], + ) + .await + .unwrap(); + + // Build strict ATA decompress with intentionally wrong bump. + let wrong_bump = context.ata_bump.wrapping_add(1); + let in_tlv = vec![vec![ExtensionInstructionData::CompressedOnly( + CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: wrong_bump, + owner_index: 0, // updated by helper; bump remains wrong + }, + )]]; + + let ix = create_generic_transfer2_instruction( + &mut context.rpc, + vec![Transfer2InstructionType::Decompress(DecompressInput { + compressed_token_account: vec![context.compressed_account.clone()], + decompress_amount: context.amount, + solana_token_account: context.ata_pubkey, + amount: context.amount, + pool_index: None, + decimals: 9, + in_tlv: Some(in_tlv), + })], + context.payer.pubkey(), + true, + ) + .await + .unwrap(); + + // Payer-only signing should reach on-chain ATA derivation checks and fail there. + let result = context + .rpc + .create_and_send_transaction(&[ix], &context.payer.pubkey(), &[&context.payer]) + .await; + + assert_rpc_error(result, 0, INVALID_ATA_DERIVATION).unwrap(); +} + /// Test that regular Decompress without owner signer fails for non-ATA compressed tokens. /// Non-ATA tokens require the owner to sign; a third-party payer alone is insufficient. #[tokio::test] diff --git a/program-tests/justfile b/program-tests/justfile index d65fa80df2..c4d01e88d7 100644 --- a/program-tests/justfile +++ b/program-tests/justfile @@ -52,7 +52,7 @@ test-system-cpi-v2-functional-account-infos: RUSTFLAGS="-D warnings" cargo test-sbf -p system-cpi-v2-test -- functional_account_infos # Compressed token tests -test-compressed-token: test-compressed-token-unit test-compressed-token-v1 test-compressed-token-mint test-compressed-token-light-token test-compressed-token-transfer2 test-compressed-token-token-pool +test-compressed-token: test-compressed-token-unit test-compressed-token-v1 test-compressed-token-mint test-compressed-token-light-token test-compressed-token-transfer2 test-compressed-token-token-pool test-compressed-token-compress-only test-compressed-token-unit: RUSTFLAGS="-D warnings" cargo test -p light-compressed-token @@ -72,6 +72,9 @@ test-compressed-token-transfer2: test-compressed-token-token-pool: RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test --test token_pool +test-compressed-token-compress-only: + RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test --test compress_only + # Compressed token batched tree test (flaky, may need retries) test-compressed-token-batched-tree: RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test -- test_transfer_with_photon_and_batched_tree @@ -105,7 +108,7 @@ ci-system-address: test-system-address test-e2e test-e2e-extended test-compresse ci-system-compression: test-system-compression test-system-re-init # Matches CI: compressed-token-and-e2e -ci-compressed-token-and-e2e: test-compressed-token-unit test-compressed-token-v1 test-compressed-token-mint test-compressed-token-token-pool +ci-compressed-token-and-e2e: test-compressed-token-unit test-compressed-token-v1 test-compressed-token-mint test-compressed-token-token-pool test-compressed-token-compress-only # Matches CI: compressed-token-batched-tree (with retry for flaky test) ci-compressed-token-batched-tree: From 9637da23328043431a971a728b10664895fde711 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 1 Apr 2026 17:37:54 +0100 Subject: [PATCH 14/14] sdk: single ata per instruction in load --- sdk-libs/client/src/interface/load_accounts.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk-libs/client/src/interface/load_accounts.rs b/sdk-libs/client/src/interface/load_accounts.rs index b926c62b74..014ef25f29 100644 --- a/sdk-libs/client/src/interface/load_accounts.rs +++ b/sdk-libs/client/src/interface/load_accounts.rs @@ -66,7 +66,9 @@ pub enum LoadAccountsError { TreeInfoIndexOutOfBounds { index: usize, len: usize }, } -const MAX_ATAS_PER_IX: usize = 8; +// Permissionless ATA decompress is strictly gated on-chain to a single-input +// idempotent ATA decompress shape, so ATA load instructions must stay 1-per-ix. +const MAX_ATAS_PER_IX: usize = 1; /// Build load instructions for cold accounts. Returns empty vec if all hot. ///