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/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..e6305ac57d 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,10 @@ impl ZCompressionMode { } pub fn is_decompress(&self) -> bool { - matches!(self, ZCompressionMode::Decompress) + matches!( + self, + ZCompressionMode::Decompress | ZCompressionMode::DecompressIdempotent + ) } pub fn is_compress_and_close(&self) -> bool { @@ -35,6 +42,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 +212,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 +234,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 +249,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..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 @@ -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,873 @@ 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. +/// 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(); +} + +/// 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" + ); +} + +/// 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 1ff92eeda9..f91ffae9e1 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) @@ -333,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; @@ -346,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) @@ -363,19 +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 as signer and get its index - let wallet_owner_index = - packed_tree_accounts.insert_or_get_config(wallet_owner, true, false); - // Update the owner_index in collected_in_tlv for CompressedOnly extensions + 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 { @@ -405,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, @@ -442,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)?; } diff --git a/program-tests/utils/src/actions/legacy/transfer2/decompress.rs b/program-tests/utils/src/actions/legacy/transfer2/decompress.rs index 55bba47b5e..5f9ec98feb 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(), + 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 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/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/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/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 c049ff7b21..6117cb23f9 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() { @@ -257,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, 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,