Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@

use anchor_lang::{system_program, InstructionData, ToAccountMetas};
use light_client::indexer::Indexer;
use light_compressed_account::instruction_data::compressed_proof::ValidityProof;
use light_compressed_token_sdk::{
compressed_token::{
transfer2::{
create_transfer2_instruction, Transfer2AccountsMetaConfig, Transfer2Config,
Transfer2Inputs,
},
CTokenAccount2,
},
constants::CPI_AUTHORITY_PDA,
spl_interface::find_spl_interface_pda_with_index as sdk_find_spl_interface_pda,
};
Expand All @@ -23,19 +31,28 @@ use light_test_utils::{
create_generic_transfer2_instruction, DecompressInput, Transfer2InstructionType,
},
mint_2022::{
create_token_22_account, mint_spl_tokens_22, set_mint_transfer_fee, set_mint_transfer_hook,
create_token_22_account, mint_spl_tokens_22, pause_mint, set_mint_transfer_fee,
set_mint_transfer_hook,
},
};
use light_token::instruction::{
CompressibleParams, CreateTokenAccount, TransferFromSpl, TransferToSpl,
};
use light_token_interface::{
find_spl_interface_pda_with_index,
instructions::extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData},
instructions::{
extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData},
transfer2::{Compression, MultiTokenTransferOutputData},
},
state::TokenDataVersion,
};
use serial_test::serial;
use solana_sdk::{instruction::Instruction, pubkey::Pubkey, signature::Keypair, signer::Signer};
use solana_sdk::{
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
signature::Keypair,
signer::Signer,
};
use spl_token_2022::{
extension::{
transfer_fee::instruction::initialize_transfer_fee_config,
Expand All @@ -47,6 +64,9 @@ use spl_token_2022::{

use super::shared::{setup_extensions_test, ExtensionsTestContext};

/// Expected error code for MintPaused
const MINT_PAUSED: u32 = 6127;

/// Expected error code for NonZeroTransferFeeNotSupported
const NON_ZERO_TRANSFER_FEE_NOT_SUPPORTED: u32 = 6129;

Expand Down Expand Up @@ -694,3 +714,208 @@ async fn test_decompress_bypasses_non_nil_hook() {

println!("Decompress bypassed non-nil transfer hook check");
}

// ============================================================================
// cToken-to-cToken Blocking Tests
//
// These tests verify that cToken-to-cToken transfers (Compress from cToken A +
// Decompress to cToken B with no compressed accounts) are BLOCKED when
// extension state is invalid (non-zero fees, paused, non-nil hook).
// ============================================================================

/// Build a cToken-to-cToken transfer instruction.
///
/// This constructs a transfer2 instruction with:
/// - Compress from source cToken (subtract tokens)
/// - Decompress to destination cToken (add tokens)
/// - No compressed accounts in either direction (hot path)
fn create_ctoken_to_ctoken_instruction(
payer: Pubkey,
source_ctoken: Pubkey,
dest_ctoken: Pubkey,
authority: Pubkey,
mint: Pubkey,
amount: u64,
) -> Instruction {
let packed_accounts = vec![
// Mint (index 0)
AccountMeta::new_readonly(mint, false),
// Source ctoken account (index 1) - writable
AccountMeta::new(source_ctoken, false),
// Authority for compression (index 2) - signer
AccountMeta::new_readonly(authority, true),
// Destination ctoken account (index 3) - writable
AccountMeta::new(dest_ctoken, false),
// System program (index 4) - needed for compressible account lamport top-ups
AccountMeta::new_readonly(Pubkey::default(), false),
];

let compress_from_source = CTokenAccount2 {
inputs: vec![],
output: MultiTokenTransferOutputData::default(),
compression: Some(Compression::compress(
amount, 0, // mint index
1, // source ctoken index
2, // authority index
)),
delegate_is_set: false,
method_used: true,
};

let decompress_to_dest = CTokenAccount2 {
inputs: vec![],
output: MultiTokenTransferOutputData::default(),
compression: Some(Compression::decompress(
amount, 0, // mint index
3, // destination ctoken index
)),
delegate_is_set: false,
method_used: true,
};

let inputs = Transfer2Inputs {
validity_proof: ValidityProof::new(None),
transfer_config: Transfer2Config::default().filter_zero_amount_outputs(),
meta_config: Transfer2AccountsMetaConfig::new_decompressed_accounts_only(
payer,
packed_accounts,
),
in_lamports: None,
out_lamports: None,
token_accounts: vec![compress_from_source, decompress_to_dest],
output_queue: 0,
in_tlv: None,
};

create_transfer2_instruction(inputs).unwrap()
}

/// Helper: Set up source cToken (with tokens) and an empty destination cToken for
/// cToken-to-cToken transfer tests. Extension state is still valid at this point.
/// Returns (context, source_ctoken, dest_ctoken, owner).
async fn setup_ctoken_to_ctoken_test(
extensions: &[ExtensionType],
) -> (ExtensionsTestContext, Pubkey, Pubkey, Keypair) {
let mut context = setup_extensions_test(extensions).await.unwrap();
let payer = context.payer.insecure_clone();
let mint_pubkey = context.mint_pubkey;

let (source_ctoken, _spl_source, owner, _) = setup_ctoken_for_bypass_test(&mut context).await;

let dest_keypair = Keypair::new();
let dest_ctoken = dest_keypair.pubkey();
let create_dest_ix =
CreateTokenAccount::new(payer.pubkey(), dest_ctoken, mint_pubkey, owner.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,
})
.instruction()
.unwrap();

context
.rpc
.create_and_send_transaction(&[create_dest_ix], &payer.pubkey(), &[&payer, &dest_keypair])
.await
.unwrap();

(context, source_ctoken, dest_ctoken, owner)
}

/// Test that cToken-to-cToken transfer is blocked when the mint has non-zero transfer fees.
#[tokio::test]
#[serial]
async fn test_ctoken_to_ctoken_blocked_by_non_zero_fee() {
let (mut context, source_ctoken, dest_ctoken, owner) =
setup_ctoken_to_ctoken_test(&[ExtensionType::TransferFeeConfig]).await;
let payer = context.payer.insecure_clone();
let mint_pubkey = context.mint_pubkey;

set_mint_transfer_fee(&mut context.rpc, &mint_pubkey, 100, 1000).await;

let transfer_ix = create_ctoken_to_ctoken_instruction(
payer.pubkey(),
source_ctoken,
dest_ctoken,
owner.pubkey(),
mint_pubkey,
100_000_000,
);

let result = context
.rpc
.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer, &owner])
.await;

assert_rpc_error(result, 0, NON_ZERO_TRANSFER_FEE_NOT_SUPPORTED).unwrap();
}

/// Test that cToken-to-cToken transfer is blocked when the mint is paused.
#[tokio::test]
#[serial]
async fn test_ctoken_to_ctoken_blocked_by_pause() {
let (mut context, source_ctoken, dest_ctoken, owner) =
setup_ctoken_to_ctoken_test(&[ExtensionType::Pausable]).await;
let payer = context.payer.insecure_clone();
let mint_pubkey = context.mint_pubkey;

pause_mint(&mut context.rpc, &mint_pubkey).await;

let transfer_ix = create_ctoken_to_ctoken_instruction(
payer.pubkey(),
source_ctoken,
dest_ctoken,
owner.pubkey(),
mint_pubkey,
100_000_000,
);

let result = context
.rpc
.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer, &owner])
.await;

assert_rpc_error(result, 0, MINT_PAUSED).unwrap();
}

/// Test that cToken-to-cToken transfer is blocked when the mint has a non-nil transfer hook.
#[tokio::test]
#[serial]
async fn test_ctoken_to_ctoken_blocked_by_non_nil_hook() {
let (mut context, source_ctoken, dest_ctoken, owner) =
setup_ctoken_to_ctoken_test(&[ExtensionType::TransferHook]).await;
let payer = context.payer.insecure_clone();
let mint_pubkey = context.mint_pubkey;

let dummy_hook_program = Pubkey::new_unique();
set_mint_transfer_hook(&mut context.rpc, &mint_pubkey, dummy_hook_program).await;

let transfer_ix = create_ctoken_to_ctoken_instruction(
payer.pubkey(),
source_ctoken,
dest_ctoken,
owner.pubkey(),
mint_pubkey,
100_000_000,
);

let result = context
.rpc
.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer, &owner])
.await;

assert_rpc_error(result, 0, TRANSFER_HOOK_NOT_SUPPORTED).unwrap();
}
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,10 @@ async fn test_spl_to_ctoken_fails_with_non_zero_transfer_fee() {
.instruction()
.unwrap();

context
let result = context
.rpc
.create_and_send_transaction(&[transfer_ix], &payer.pubkey(), &[&payer])
.await
.unwrap();
.await;
assert_rpc_error(result, 0, NON_ZERO_TRANSFER_FEE_NOT_SUPPORTED).unwrap();
println!("Correctly rejected SPL→Light Token with non-zero transfer fees");
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ pub fn compress_or_decompress_ctokens(
Ok(())
}
ZCompressionMode::Decompress => {
if decompress_inputs.is_none() {
if let Some(ref checks) = mint_checks {
checks.enforce_extension_state()?;
}
}

validate_and_apply_compressed_only(
token_account_info,
&mut ctoken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ pub struct MintExtensionChecks {
pub has_non_nil_transfer_hook: bool,
}

impl MintExtensionChecks {
/// Enforce extension state restrictions (paused, non-zero fees, non-nil hook).
/// Returns an error if any restricted extension state is active.
pub fn enforce_extension_state(&self) -> Result<(), ProgramError> {
if self.is_paused {
return Err(ErrorCode::MintPaused.into());
}
if self.has_non_zero_transfer_fee {
return Err(ErrorCode::NonZeroTransferFeeNotSupported.into());
}
if self.has_non_nil_transfer_hook {
return Err(ErrorCode::TransferHookNotSupported.into());
}
Ok(())
}
}

/// Parse mint extensions in a single pass with zero-copy deserialization.
/// This function deserializes the mint once and extracts extension information.
/// It does NOT throw errors for invalid extension states (paused, non-zero fees, non-nil hook).
Expand Down Expand Up @@ -143,15 +160,7 @@ pub fn check_mint_extensions(
}

// Check for invalid extension states - throw specific errors for each
if checks.is_paused {
return Err(ErrorCode::MintPaused.into());
}
if checks.has_non_zero_transfer_fee {
return Err(ErrorCode::NonZeroTransferFeeNotSupported.into());
}
if checks.has_non_nil_transfer_hook {
return Err(ErrorCode::TransferHookNotSupported.into());
}
checks.enforce_extension_state()?;

Ok(checks)
}
Expand Down