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..2b93829155 100644 --- a/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs +++ b/program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs @@ -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,391 @@ 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 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/utils/src/actions/legacy/instructions/transfer2.rs b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs index 1ff92eeda9..fc9c5d1b57 100644 --- a/program-tests/utils/src/actions/legacy/instructions/transfer2.rs +++ b/program-tests/utils/src/actions/legacy/instructions/transfer2.rs @@ -333,7 +333,7 @@ pub async fn create_generic_transfer2_instruction( } token_accounts.push(token_account); } - Transfer2InstructionType::Decompress(input) => { + Transfer2InstructionType::Decompress(ref input) => { // Collect in_tlv data if provided if let Some(ref tlv_data) = input.in_tlv { has_any_tlv = true; @@ -346,7 +346,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 +362,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,13 +399,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 +413,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 +429,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/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..6cd5c4e5e2 100644 --- a/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs +++ b/programs/compressed-token/program/src/compressed_token/transfer2/processor.rs @@ -257,6 +257,62 @@ fn process_with_system_program_cpi<'a>( mint_cache, )?; + // ATA decompress is permissionless and idempotent. + // Detect from: exactly 1 input, 1 Decompress compression, CompressedOnly with is_ata=true. + // Multi-input batches (including mixed ATA + non-ATA) are not idempotent. + let is_ata_decompress = inputs.in_token_data.len() == 1 + && inputs + .compressions + .as_ref() + .is_some_and(|c| c.len() == 1 && c.iter().any(|c| c.mode.is_decompress())) + && inputs.in_tlv.as_ref().is_some_and(|tlvs| { + tlvs.iter().flatten().any(|ext| { + matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata()) + }) + }); + + if is_ata_decompress { + let input_data = &inputs.in_token_data[0]; + let merkle_context = &input_data.merkle_context; + let input_account = cpi_instruction_struct + .input_compressed_accounts + .first() + .ok_or(ProgramError::InvalidAccountData)?; + + let owner_hashed = light_hasher::hash_to_field_size::hash_to_bn254_field_size_be( + &crate::LIGHT_CPI_SIGNER.program_id, + ); + let tree_account = validated_accounts + .packed_accounts + .get_u8(merkle_context.merkle_tree_pubkey_index, "idempotent: tree")?; + let merkle_tree_hashed = + light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(tree_account.key()); + + let lamports: u64 = (*input_account.lamports).into(); + let account_hash = light_compressed_account::compressed_account::hash_with_hashed_values( + &lamports, + input_account.address.as_ref().map(|x| x.as_slice()), + Some(( + input_account.discriminator.as_slice(), + input_account.data_hash.as_slice(), + )), + &owner_hashed, + &merkle_tree_hashed, + &merkle_context.leaf_index.get(), + true, + ) + .map_err(ProgramError::from)?; + + let mut tree = + light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount::state_from_account_info(tree_account) + .map_err(ProgramError::from)?; + + if tree.check_input_queue_non_inclusion(&account_hash).is_err() { + // Account is in bloom filter -- already spent. Idempotent no-op. + 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]