Skip to content

Commit e378d77

Browse files
committed
fix(compressed-token): validate stale ATA decompress replays
When the input bloom filter detects an already-spent compressed ATA input, the idempotent path returned Ok(()) without re-checking the compression entry. A wallet owner could replay the spent input under a different mint or destination and have it succeed as a no-op, leaving indexers with a fake decompress event. Add validate_idempotent_ata_decompress_replay, called only on the bloom-filter-hit branch, which enforces: - compression amount equals input amount - compression mint pubkey equals input mint pubkey - destination is the canonical ATA derived from (recorded wallet, program id, mint, recorded bump) - destination CToken's stored mint equals the compression mint (loaded via Token::from_account_info_checked) A failing replay now returns the matching TokenError variant instead of a silent Ok(()). Honest replays with identical fields still succeed.
1 parent f020d02 commit e378d77

1 file changed

Lines changed: 83 additions & 1 deletion

File tree

  • programs/compressed-token/program/src/compressed_token/transfer2

programs/compressed-token/program/src/compressed_token/transfer2/processor.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ use light_token_interface::{
1515
ZCompressionMode,
1616
},
1717
},
18+
state::Token,
1819
TokenError,
1920
};
2021
use light_zero_copy::{traits::ZeroCopyAt, ZeroCopyNew};
21-
use pinocchio::account_info::AccountInfo;
22+
use pinocchio::{account_info::AccountInfo, pubkey::pubkey_eq};
2223
use spl_pod::solana_msg::msg;
2324

2425
use super::check_extensions::{build_mint_extension_cache, MintExtensionCache};
@@ -263,6 +264,7 @@ fn process_with_system_program_cpi<'a>(
263264
#[allow(clippy::collapsible_if)]
264265
if is_idempotent_ata_decompress {
265266
if check_ata_decompress_idempotent(inputs, &cpi_instruction_struct, validated_accounts)? {
267+
validate_idempotent_ata_decompress_replay(inputs, validated_accounts)?;
266268
return Ok(());
267269
}
268270
}
@@ -420,3 +422,83 @@ fn check_ata_decompress_idempotent(
420422
Err(e) => Err(ProgramError::from(e)),
421423
}
422424
}
425+
426+
#[cold]
427+
fn validate_idempotent_ata_decompress_replay(
428+
inputs: &ZCompressedTokenInstructionDataTransfer2,
429+
validated_accounts: &Transfer2Accounts,
430+
) -> Result<(), ProgramError> {
431+
let input_token_data = inputs
432+
.in_token_data
433+
.first()
434+
.ok_or(ProgramError::InvalidAccountData)?;
435+
let compression = inputs
436+
.compressions
437+
.as_ref()
438+
.and_then(|c| c.first())
439+
.ok_or(ProgramError::InvalidAccountData)?;
440+
let ext_data = inputs
441+
.in_tlv
442+
.as_ref()
443+
.and_then(|tlvs| tlvs.first())
444+
.and_then(|tlv| {
445+
tlv.iter().find_map(|ext| match ext {
446+
ZExtensionInstructionData::CompressedOnly(data) if data.is_ata() => Some(data),
447+
_ => None,
448+
})
449+
})
450+
.ok_or(ProgramError::InvalidAccountData)?;
451+
452+
let compression_amount: u64 = compression.amount.get();
453+
let input_amount: u64 = input_token_data.amount.get();
454+
if compression_amount != input_amount {
455+
msg!(
456+
"Idempotent ATA decompress: amount mismatch (compression {}, input {})",
457+
compression_amount,
458+
input_amount,
459+
);
460+
return Err(TokenError::DecompressAmountMismatch.into());
461+
}
462+
463+
let wallet = validated_accounts
464+
.packed_accounts
465+
.get_u8(ext_data.owner_index, "idempotent: wallet")?;
466+
let compression_mint = validated_accounts
467+
.packed_accounts
468+
.get_u8(compression.mint, "idempotent: compression mint")?;
469+
let input_mint = validated_accounts
470+
.packed_accounts
471+
.get_u8(input_token_data.mint, "idempotent: input mint")?;
472+
let destination = validated_accounts
473+
.packed_accounts
474+
.get_u8(compression.source_or_recipient, "idempotent: destination")?;
475+
476+
if !pubkey_eq(compression_mint.key(), input_mint.key()) {
477+
msg!("Idempotent ATA decompress: compression mint does not match input mint");
478+
return Err(TokenError::MintMismatch.into());
479+
}
480+
481+
let bump_seed = [ext_data.bump];
482+
let ata_seeds: [&[u8]; 4] = [
483+
wallet.key().as_ref(),
484+
crate::LIGHT_CPI_SIGNER.program_id.as_ref(),
485+
compression_mint.key().as_ref(),
486+
bump_seed.as_ref(),
487+
];
488+
let derived_ata =
489+
pinocchio::pubkey::create_program_address(&ata_seeds, &crate::LIGHT_CPI_SIGNER.program_id)
490+
.map_err(|_| TokenError::InvalidAtaDerivation)?;
491+
if !pubkey_eq(destination.key(), &derived_ata) {
492+
msg!("Idempotent ATA decompress: destination is not the canonical ATA of the recorded wallet");
493+
return Err(TokenError::DecompressDestinationMismatch.into());
494+
}
495+
496+
let destination_ctoken =
497+
Token::from_account_info_checked(destination).map_err(ProgramError::from)?;
498+
if !pubkey_eq(destination_ctoken.mint.array_ref(), compression_mint.key()) {
499+
msg!("Idempotent ATA decompress: destination mint does not match compression mint");
500+
return Err(TokenError::MintMismatch.into());
501+
}
502+
503+
Ok(())
504+
}

0 commit comments

Comments
 (0)