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
3 changes: 3 additions & 0 deletions program-tests/compressed-token-test/tests/light_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ mod burn;

#[path = "light_token/extensions_failing.rs"]
mod extensions_failing;

#[path = "light_token/delegate_compress.rs"]
mod delegate_compress;
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
use anchor_spl::token_2022::spl_token_2022;
use light_program_test::utils::assert::assert_rpc_error;
use solana_sdk::program_pack::Pack;

use super::shared::*;

/// Test delegate compress on CToken accounts.
///
/// Scenarios:
/// 1. Partial compress (300 of 500 delegated) — delegate and delegated_amount updated
/// 2. Exact remaining compress (200) — delegate cleared
/// 3. Delegate cannot compress after being cleared — OwnerMismatch error
/// 4. Owner can still compress normally after delegate is cleared
#[tokio::test]
#[serial]
async fn test_delegate_compress() -> Result<(), RpcError> {
// Setup: CToken account with compressible extension
let mut context = setup_account_test_with_created_account(Some((0, false))).await?;
let payer = context.payer.insecure_clone();
let owner = context.owner_keypair.insecure_clone();
let token_account_pubkey = context.token_account_keypair.pubkey();
let mint_pubkey = context.mint_pubkey;

// Fund owner for transaction fees and compressible top-up
context
.rpc
.airdrop_lamports(&owner.pubkey(), 1_000_000_000)
.await?;

// Set CToken balance to 1000 via set_account
{
let mut token_account = context
.rpc
.get_account(token_account_pubkey)
.await?
.unwrap();

let mut spl_account =
spl_token_2022::state::Account::unpack_unchecked(&token_account.data[..165]).unwrap();
spl_account.amount = 1000;
spl_token_2022::state::Account::pack(spl_account, &mut token_account.data[..165]).unwrap();
context.rpc.set_account(token_account_pubkey, token_account);
}

// Approve delegate for 500
let delegate = Keypair::new();
approve_and_assert(&mut context, delegate.pubkey(), 500, "approve_delegate_500").await;

// Warp slot so compressible top-up assertion works
context.rpc.warp_to_slot(4).unwrap();

let output_queue = context
.rpc
.get_random_state_tree_info()
.unwrap()
.get_output_pubkey()
.unwrap();

// =========================================================================
// Scenario 1: Partial compress (300 of 500 delegated)
// =========================================================================
{
compress(
&mut context.rpc,
token_account_pubkey,
300,
owner.pubkey(),
&delegate,
&payer,
9,
)
.await
.unwrap();

let compress_input = CompressInput {
compressed_token_account: None,
solana_token_account: token_account_pubkey,
to: owner.pubkey(),
mint: mint_pubkey,
amount: 300,
authority: delegate.pubkey(),
output_queue,
pool_index: None,
decimals: 9,
version: None,
};
assert_transfer2_compress(&mut context.rpc, compress_input).await;

// Verify: amount == 700, delegated_amount == 200, delegate still set
let account_data = context
.rpc
.get_account(token_account_pubkey)
.await?
.unwrap();
let spl_account =
spl_token_2022::state::Account::unpack(&account_data.data[..165]).unwrap();
assert_eq!(
spl_account.amount, 700,
"Balance should be 700 after compressing 300"
);
assert_eq!(
spl_account.delegated_amount, 200,
"Delegated amount should be 200 after compressing 300 of 500"
);
assert_eq!(
spl_account.delegate,
spl_token_2022::solana_program::program_option::COption::Some(delegate.pubkey()),
"Delegate should still be set"
);
}

// =========================================================================
// Scenario 2: Exact remaining amount (200), delegate cleared
// =========================================================================
{
compress(
&mut context.rpc,
token_account_pubkey,
200,
owner.pubkey(),
&delegate,
&payer,
9,
)
.await
.unwrap();

let compress_input = CompressInput {
compressed_token_account: None,
solana_token_account: token_account_pubkey,
to: owner.pubkey(),
mint: mint_pubkey,
amount: 200,
authority: delegate.pubkey(),
output_queue,
pool_index: None,
decimals: 9,
version: None,
};
assert_transfer2_compress(&mut context.rpc, compress_input).await;

// Verify: amount == 500, delegated_amount == 0, delegate cleared
let account_data = context
.rpc
.get_account(token_account_pubkey)
.await?
.unwrap();
let spl_account =
spl_token_2022::state::Account::unpack(&account_data.data[..165]).unwrap();
assert_eq!(
spl_account.amount, 500,
"Balance should be 500 after compressing 200 more"
);
assert_eq!(
spl_account.delegated_amount, 0,
"Delegated amount should be 0 after compressing all delegated tokens"
);
assert_eq!(
spl_account.delegate,
spl_token_2022::solana_program::program_option::COption::None,
"Delegate should be cleared when delegated_amount reaches 0"
);
}

// =========================================================================
// Scenario 3: Delegate cannot compress after being cleared
// =========================================================================
{
let result = compress(
&mut context.rpc,
token_account_pubkey,
1,
owner.pubkey(),
&delegate,
&payer,
9,
)
.await;

// OwnerMismatch = 6075
assert_rpc_error(result, 0, 6075).unwrap();
}

// =========================================================================
// Scenario 4: Owner can still compress normally
// =========================================================================
{
compress(
&mut context.rpc,
token_account_pubkey,
100,
owner.pubkey(),
&owner,
&payer,
9,
)
.await
.unwrap();

let compress_input = CompressInput {
compressed_token_account: None,
solana_token_account: token_account_pubkey,
to: owner.pubkey(),
mint: mint_pubkey,
amount: 100,
authority: owner.pubkey(),
output_queue,
pool_index: None,
decimals: 9,
version: None,
};
assert_transfer2_compress(&mut context.rpc, compress_input).await;

// Verify: amount == 400
let account_data = context
.rpc
.get_account(token_account_pubkey)
.await?
.unwrap();
let spl_account =
spl_token_2022::state::Account::unpack(&account_data.data[..165]).unwrap();
assert_eq!(
spl_account.amount, 400,
"Balance should be 400 after owner compresses 100"
);
}

Ok(())
}
17 changes: 17 additions & 0 deletions program-tests/utils/src/assert_transfer2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ pub async fn assert_transfer2_with_delegate(

// Decrement balance for compress
expected_spl_accounts.get_mut(&pubkey).unwrap().amount -= compress_input.amount;

// Handle delegate amount decrement when delegate is compressing
let expected = expected_spl_accounts.get_mut(&pubkey).unwrap();
if expected.delegate
== spl_token_2022::solana_program::program_option::COption::Some(
compress_input.authority,
)
{
expected.delegated_amount = expected
.delegated_amount
.checked_sub(compress_input.amount)
.expect("Delegate compress amount exceeds delegated_amount");
if expected.delegated_amount == 0 {
expected.delegate =
spl_token_2022::solana_program::program_option::COption::None;
}
}
}
Transfer2InstructionType::Decompress(decompress_input) => {
let pubkey = decompress_input.solana_token_account;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,27 @@ pub fn compress_or_decompress_ctokens(
ZCompressionMode::Compress => {
// Verify authority for compression operations
let authority_account = authority.ok_or(ErrorCode::InvalidCompressAuthority)?;
check_ctoken_owner(&mut ctoken, authority_account, mint_checks.as_ref())?;
let is_delegate =
check_ctoken_owner(&mut ctoken, authority_account, mint_checks.as_ref())?;
if !ctoken.is_initialized() {
return Err(TokenError::InvalidAccountState.into());
}

// Delegate: enforce and decrement delegated_amount
if is_delegate {
let new_delegated = ctoken
.base
.delegated_amount
.get()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
ctoken.base.delegated_amount.set(new_delegated);
if new_delegated == 0 {
ctoken.base.set_delegate(None)?;
}
}

// Compress: subtract from solana account
// Update the balance in the ctoken solana account
ctoken.base.amount.set(
current_balance
.checked_sub(amount)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,13 @@ pub fn verify_owner_or_delegate_signer<'a>(

/// Verify and update token account authority using zero-copy compressed token format.
/// Allows owner, account delegate, or permanent delegate (from mint) to authorize compression operations.
/// Returns `is_delegate`: true if the signer is the account-level delegate.
#[profile]
pub fn check_ctoken_owner(
compressed_token: &mut ZTokenMut,
authority_account: &AccountInfo,
mint_checks: Option<&MintExtensionChecks>,
) -> Result<(), ProgramError> {
) -> Result<bool, ProgramError> {
// Verify authority is signer
check_signer(authority_account).map_err(|e| {
anchor_lang::solana_program::msg!("Authority signer check failed: {:?}", e);
Expand All @@ -96,18 +97,25 @@ pub fn check_ctoken_owner(

// Check if authority is the owner
if pubkey_eq(authority_key, owner_key) {
return Ok(()); // Owner can always compress
return Ok(false);
}

// Check if authority is the permanent delegate from the mint
if let Some(checks) = mint_checks {
if let Some(permanent_delegate) = &checks.permanent_delegate {
if pubkey_eq(authority_key, permanent_delegate) {
return Ok(()); // Permanent delegate can (de)compress any account of this mint
return Ok(false);
}
}
}

// Authority is neither owner nor permanent delegate
// Check if authority is the account-level delegate (approved via CTokenApprove)
if let Some(delegate) = compressed_token.delegate() {
if pubkey_eq(authority_key, &delegate.to_bytes()) {
return Ok(true);
}
}

// Authority is neither owner, permanent delegate, nor account delegate
Err(ErrorCode::OwnerMismatch.into())
Comment on lines +119 to 120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Error message could be more specific now that there are three authorization paths.

The OwnerMismatch error is returned when authority is neither owner, permanent delegate, nor account delegate. The error name OwnerMismatch is a bit misleading now that delegates are valid authorizers too. This is pre-existing, so just a minor observation — no action needed unless you want to improve diagnostics.

🤖 Prompt for AI Agents
In `@programs/compressed-token/program/src/shared/owner_validation.rs` around
lines 118 - 119, The returned Err(ErrorCode::OwnerMismatch.into()) is too
generic now that authority can be owner, permanent delegate, or account
delegate; add a clearer error variant (e.g., ErrorCode::InvalidAuthorizer or
ErrorCode::AuthorityNotAuthorized) in the ErrorCode enum and replace the usage
in owner_validation.rs (the site that currently returns OwnerMismatch) with the
new variant, or alternatively add distinct variants (OwnerNotMatched,
DelegateNotMatched) and return the most appropriate one; update any
error-to-string mappings/tests that reference OwnerMismatch accordingly.

}
Loading