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-tests/compressed-token-test/tests/compress_only/ata_decompress.rs b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs index b48517e4de..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 { @@ -193,7 +195,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 +205,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 +1286,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 +1533,477 @@ 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 regular Decompress 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 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] +#[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 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_ata_decompress_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::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, + ) + .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(), + "ATA decompress 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/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: diff --git a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs index 1ff92eeda9..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, @@ -333,7 +348,8 @@ pub async fn create_generic_transfer2_instruction( } token_accounts.push(token_account); } - Transfer2InstructionType::Decompress(input) => { + 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,9 +360,9 @@ 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 - // 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,28 +379,18 @@ 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 - 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 wallet_owner_index = packed_tree_accounts.insert_or_get(wallet_owner); + set_owner_index_for_ata_tlvs( + &mut collected_in_tlv[tlv_start..tlv_end], + wallet_owner_index, + ); } } @@ -405,13 +411,13 @@ 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::>(); @@ -419,20 +425,13 @@ pub async fn create_generic_transfer2_instruction( let mut token_account = CTokenAccount2::new(token_data)?; if recipient_account_owner.to_bytes() != LIGHT_TOKEN_PROGRAM_ID { - // For SPL decompression, get mint first 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)?; } @@ -694,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)); + } +} diff --git a/programs/compressed-token/program/CLAUDE.md b/programs/compressed-token/program/CLAUDE.md index 36f65b535f..a6ea701b71 100644 --- a/programs/compressed-token/program/CLAUDE.md +++ b/programs/compressed-token/program/CLAUDE.md @@ -69,6 +69,7 @@ Every instruction description must include the sections: 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 + - 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/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..e19203b978 100644 --- a/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md +++ b/programs/compressed-token/program/docs/compressed_token/TRANSFER2.md @@ -26,9 +26,11 @@ - 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**) + + **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) @@ -59,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, Decompress, CompressAndClose) + - `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/processor.rs b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs index c049ff7b21..3d27eb54f7 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,10 @@ 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; +use light_compressed_account::instruction_data::with_readonly::{ + InstructionDataInvokeCpiWithReadOnly, ZInstructionDataInvokeCpiWithReadOnlyMut, +}; use light_program_profiler::profile; use light_token_interface::{ hash_cache::HashCache, @@ -246,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( @@ -255,7 +259,14 @@ fn process_with_system_program_cpi<'a>( &validated_accounts.packed_accounts, accounts, mint_cache, + is_idempotent_ata_decompress, )?; + #[allow(clippy::collapsible_if)] + if is_idempotent_ata_decompress { + if check_ata_decompress_idempotent(inputs, &cpi_instruction_struct, validated_accounts)? { + return Ok(()); + } + } // Process output compressed accounts. set_output_compressed_accounts( @@ -333,3 +344,80 @@ 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)?; + + match tree.check_input_queue_non_inclusion(&account_hash) { + Ok(()) => Ok(false), + Err(BatchedMerkleTreeError::NonInclusionCheckFailed) => Ok(true), + Err(e) => Err(ProgramError::from(e)), + } +} 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 274cd57eb7..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 @@ -80,18 +81,28 @@ 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 { - resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)? + let (signer_account, check_signer) = if let Some(exts) = tlv_data { + resolve_ata_signer( + exts, + packed_accounts, + mint_account, + owner_account, + allow_permissionless_ata_decompress, + )? } else { - owner_account + (owner_account, true) }; - 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 check_signer { + 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 +204,8 @@ fn resolve_ata_signer<'a>( packed_accounts: &'a [AccountInfo], mint_account: &AccountInfo, owner_account: &'a AccountInfo, -) -> Result<&'a AccountInfo, ProgramError> { + allow_permissionless_ata_decompress: bool, +) -> Result<(&'a AccountInfo, bool), ProgramError> { for ext in exts.iter() { if let ZExtensionInstructionData::CompressedOnly(data) = ext { if data.is_ata() { @@ -228,13 +240,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); + // Only the strict idempotent ATA decompress flow is permissionless. + return Ok((wallet_owner, !allow_permissionless_ata_decompress)); } } } - Ok(owner_account) + 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 061ad5074b..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. /// @@ -364,7 +366,7 @@ fn build_transfer2( }, )?; - let owner_idx = packed.insert_or_get_config(ctx.wallet_owner, true, false); + 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); 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..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, &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/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");