Skip to content

Commit 4b38472

Browse files
committed
refactor: extract into functions
1 parent a86c1cd commit 4b38472

4 files changed

Lines changed: 244 additions & 61 deletions

File tree

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

Lines changed: 80 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use anchor_compressed_token::ErrorCode;
22
use anchor_lang::prelude::ProgramError;
33
use light_array_map::ArrayMap;
4-
use light_compressed_account::instruction_data::with_readonly::InstructionDataInvokeCpiWithReadOnly;
4+
use light_compressed_account::instruction_data::with_readonly::{
5+
InstructionDataInvokeCpiWithReadOnly, ZInstructionDataInvokeCpiWithReadOnlyMut,
6+
};
57
use light_program_profiler::profile;
68
use light_token_interface::{
79
hash_cache::HashCache,
@@ -256,59 +258,10 @@ fn process_with_system_program_cpi<'a>(
256258
accounts,
257259
mint_cache,
258260
)?;
259-
260-
// ATA decompress is permissionless and idempotent.
261-
// Detect from: exactly 1 input, 1 Decompress compression, CompressedOnly with is_ata=true.
262-
// Multi-input batches (including mixed ATA + non-ATA) are not idempotent.
263-
let is_ata_decompress = inputs.in_token_data.len() == 1
264-
&& inputs
265-
.compressions
266-
.as_ref()
267-
.is_some_and(|c| c.len() == 1 && c.iter().any(|c| c.mode.is_decompress()))
268-
&& inputs.in_tlv.as_ref().is_some_and(|tlvs| {
269-
tlvs.iter().flatten().any(|ext| {
270-
matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata())
271-
})
272-
});
273-
274-
if is_ata_decompress {
275-
let input_data = &inputs.in_token_data[0];
276-
let merkle_context = &input_data.merkle_context;
277-
let input_account = cpi_instruction_struct
278-
.input_compressed_accounts
279-
.first()
280-
.ok_or(ProgramError::InvalidAccountData)?;
281-
282-
let owner_hashed = light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(
283-
&crate::LIGHT_CPI_SIGNER.program_id,
284-
);
285-
let tree_account = validated_accounts
286-
.packed_accounts
287-
.get_u8(merkle_context.merkle_tree_pubkey_index, "idempotent: tree")?;
288-
let merkle_tree_hashed =
289-
light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(tree_account.key());
290-
291-
let lamports: u64 = (*input_account.lamports).into();
292-
let account_hash = light_compressed_account::compressed_account::hash_with_hashed_values(
293-
&lamports,
294-
input_account.address.as_ref().map(|x| x.as_slice()),
295-
Some((
296-
input_account.discriminator.as_slice(),
297-
input_account.data_hash.as_slice(),
298-
)),
299-
&owner_hashed,
300-
&merkle_tree_hashed,
301-
&merkle_context.leaf_index.get(),
302-
true,
303-
)
304-
.map_err(ProgramError::from)?;
305-
306-
let mut tree =
307-
light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount::state_from_account_info(tree_account)
308-
.map_err(ProgramError::from)?;
309-
310-
if tree.check_input_queue_non_inclusion(&account_hash).is_err() {
311-
// Account is in bloom filter -- already spent. Idempotent no-op.
261+
let is_idempotent_ata_decompress = is_idempotent_ata_decompress(inputs);
262+
#[allow(clippy::collapsible_if)]
263+
if is_idempotent_ata_decompress {
264+
if check_ata_decompress_idempotent(inputs, &cpi_instruction_struct, validated_accounts)? {
312265
return Ok(());
313266
}
314267
}
@@ -389,3 +342,76 @@ fn process_with_system_program_cpi<'a>(
389342
}
390343
Ok(())
391344
}
345+
346+
/// Detect idempotent associated token account decompress:
347+
/// - exactly 1 input compressed token account with CompressedOnly extension is_ata=true
348+
/// - 1 Decompress compression.
349+
///
350+
/// Multi-input batches (including mixed ATA + non-ATA) are not idempotent.
351+
#[inline(always)]
352+
pub fn is_idempotent_ata_decompress(inputs: &ZCompressedTokenInstructionDataTransfer2) -> bool {
353+
inputs.in_token_data.len() == 1
354+
&& inputs
355+
.compressions
356+
.as_ref()
357+
.is_some_and(|c| c.len() == 1 && c.iter().any(|c| c.mode.is_decompress()))
358+
&& inputs.in_tlv.as_ref().is_some_and(|tlvs| {
359+
tlvs.iter().flatten().any(|ext| {
360+
matches!(ext, ZExtensionInstructionData::CompressedOnly(data) if data.is_ata())
361+
})
362+
})
363+
}
364+
365+
/// Computes the compressed account hash and checks whether the hash exists in the input queue bloom filters.
366+
/// The account compression program inserts spent compressed accounts into the respective input queue.
367+
///
368+
/// if exists in bloom filter -> exit the ata was already decompressed
369+
/// else decompress
370+
#[cold]
371+
fn check_ata_decompress_idempotent(
372+
inputs: &ZCompressedTokenInstructionDataTransfer2,
373+
cpi_instruction_struct: &ZInstructionDataInvokeCpiWithReadOnlyMut<'_>,
374+
validated_accounts: &Transfer2Accounts,
375+
) -> Result<bool, ProgramError> {
376+
let input_data = inputs
377+
.in_token_data
378+
.first()
379+
.ok_or(ProgramError::InvalidAccountData)?;
380+
let merkle_context = &input_data.merkle_context;
381+
let input_account = cpi_instruction_struct
382+
.input_compressed_accounts
383+
.first()
384+
.ok_or(ProgramError::InvalidAccountData)?;
385+
386+
let owner_hashed = light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(
387+
&crate::LIGHT_CPI_SIGNER.program_id,
388+
);
389+
let tree_account = validated_accounts
390+
.packed_accounts
391+
.get_u8(merkle_context.merkle_tree_pubkey_index, "idempotent: tree")?;
392+
let merkle_tree_hashed =
393+
light_hasher::hash_to_field_size::hash_to_bn254_field_size_be(tree_account.key());
394+
395+
let lamports: u64 = input_account.lamports.get();
396+
let account_hash = light_compressed_account::compressed_account::hash_with_hashed_values(
397+
&lamports,
398+
input_account.address.as_ref().map(|x| x.as_slice()),
399+
Some((
400+
input_account.discriminator.as_slice(),
401+
input_account.data_hash.as_slice(),
402+
)),
403+
&owner_hashed,
404+
&merkle_tree_hashed,
405+
&merkle_context.leaf_index.get(),
406+
true,
407+
)
408+
.map_err(ProgramError::from)?;
409+
410+
let mut tree =
411+
light_batched_merkle_tree::merkle_tree::BatchedMerkleTreeAccount::state_from_account_info(
412+
tree_account,
413+
)
414+
.map_err(ProgramError::from)?;
415+
416+
Ok(tree.check_input_queue_non_inclusion(&account_hash).is_err())
417+
}

programs/compressed-token/program/src/shared/token_input.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,15 @@ pub fn set_input_compressed_account<'a>(
8080
// For ATA decompress (is_ata=true), verify the wallet owner from owner_index instead
8181
// of the compressed account owner (which is the ATA pubkey that can't sign).
8282
// Also verify that owner_account (the ATA) matches the derived ATA from wallet_owner + mint + bump.
83-
let (signer_account, is_ata_decompress) = if let Some(exts) = tlv_data {
83+
let (signer_account, check_signer) = if let Some(exts) = tlv_data {
8484
resolve_ata_signer(exts, packed_accounts, mint_account, owner_account)?
8585
} else {
86-
(owner_account, false)
86+
(owner_account, true)
8787
};
8888

8989
// ATA decompress is permissionless -- the destination is a deterministic PDA,
9090
// so there is no griefing vector. ATA derivation is still validated above.
91-
if !is_ata_decompress {
91+
if check_signer {
9292
verify_owner_or_delegate_signer(
9393
signer_account,
9494
delegate_account,
@@ -232,13 +232,13 @@ fn resolve_ata_signer<'a>(
232232
if !pinocchio::pubkey::pubkey_eq(owner_account.key(), &derived_ata) {
233233
return Err(TokenError::InvalidAtaDerivation.into());
234234
}
235-
236-
return Ok((wallet_owner, true));
235+
// Do not check signer the recipient token account is the correct ata.
236+
return Ok((wallet_owner, false));
237237
}
238238
}
239239
}
240240

241-
Ok((owner_account, false))
241+
Ok((owner_account, true))
242242
}
243243

244244
#[cold]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use anchor_lang::AnchorSerialize;
2+
use light_compressed_token::compressed_token::transfer2::processor::is_idempotent_ata_decompress;
3+
use light_token_interface::instructions::{
4+
extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData},
5+
transfer2::{
6+
CompressedTokenInstructionDataTransfer2, Compression, CompressionMode,
7+
MultiInputTokenDataWithContext,
8+
},
9+
};
10+
use light_zero_copy::traits::ZeroCopyAt;
11+
use rand::{rngs::StdRng, Rng, SeedableRng};
12+
13+
fn serialize(data: &CompressedTokenInstructionDataTransfer2) -> Vec<u8> {
14+
let mut buf = Vec::new();
15+
AnchorSerialize::serialize(data, &mut buf).unwrap();
16+
buf
17+
}
18+
19+
fn base_input() -> MultiInputTokenDataWithContext {
20+
MultiInputTokenDataWithContext::default()
21+
}
22+
23+
fn base_data() -> CompressedTokenInstructionDataTransfer2 {
24+
CompressedTokenInstructionDataTransfer2 {
25+
with_transaction_hash: false,
26+
with_lamports_change_account_merkle_tree_index: false,
27+
lamports_change_account_merkle_tree_index: 0,
28+
lamports_change_account_owner_index: 0,
29+
output_queue: 0,
30+
max_top_up: 0,
31+
cpi_context: None,
32+
compressions: None,
33+
proof: None,
34+
in_token_data: vec![],
35+
out_token_data: vec![],
36+
in_lamports: None,
37+
out_lamports: None,
38+
in_tlv: None,
39+
out_tlv: None,
40+
}
41+
}
42+
43+
fn check(data: &CompressedTokenInstructionDataTransfer2) -> bool {
44+
let buf = serialize(data);
45+
let (z, _) = CompressedTokenInstructionDataTransfer2::zero_copy_at(&buf).unwrap();
46+
is_idempotent_ata_decompress(&z)
47+
}
48+
49+
#[test]
50+
fn test_is_idempotent_ata_decompress_empty() {
51+
assert!(!check(&base_data()));
52+
}
53+
54+
#[test]
55+
fn test_is_idempotent_ata_decompress_no_compressions() {
56+
let mut data = base_data();
57+
data.in_token_data = vec![base_input()];
58+
data.in_tlv = Some(vec![vec![ExtensionInstructionData::CompressedOnly(
59+
CompressedOnlyExtensionInstructionData {
60+
delegated_amount: 0,
61+
withheld_transfer_fee: 0,
62+
is_frozen: false,
63+
compression_index: 0,
64+
is_ata: true,
65+
bump: 0,
66+
owner_index: 0,
67+
},
68+
)]]);
69+
assert!(!check(&data));
70+
}
71+
72+
#[test]
73+
fn test_is_idempotent_ata_decompress_multiple_inputs() {
74+
let mut data = base_data();
75+
data.in_token_data = vec![base_input(), base_input()];
76+
data.compressions = Some(vec![Compression::decompress(100, 0, 0)]);
77+
assert!(!check(&data));
78+
}
79+
80+
#[test]
81+
fn test_is_idempotent_ata_decompress_compress_mode() {
82+
let mut data = base_data();
83+
data.in_token_data = vec![base_input()];
84+
data.compressions = Some(vec![Compression::compress(100, 0, 0, 0)]);
85+
data.in_tlv = Some(vec![vec![ExtensionInstructionData::CompressedOnly(
86+
CompressedOnlyExtensionInstructionData {
87+
delegated_amount: 0,
88+
withheld_transfer_fee: 0,
89+
is_frozen: false,
90+
compression_index: 0,
91+
is_ata: true,
92+
bump: 0,
93+
owner_index: 0,
94+
},
95+
)]]);
96+
assert!(!check(&data));
97+
}
98+
99+
#[test]
100+
fn test_is_idempotent_ata_decompress_not_ata() {
101+
let mut data = base_data();
102+
data.in_token_data = vec![base_input()];
103+
data.compressions = Some(vec![Compression::decompress(100, 0, 0)]);
104+
data.in_tlv = Some(vec![vec![ExtensionInstructionData::CompressedOnly(
105+
CompressedOnlyExtensionInstructionData {
106+
delegated_amount: 0,
107+
withheld_transfer_fee: 0,
108+
is_frozen: false,
109+
compression_index: 0,
110+
is_ata: false,
111+
bump: 0,
112+
owner_index: 0,
113+
},
114+
)]]);
115+
assert!(!check(&data));
116+
}
117+
118+
#[test]
119+
fn test_is_idempotent_ata_decompress_random_always_false() {
120+
let mut rng = StdRng::seed_from_u64(42);
121+
for _ in 0..1000 {
122+
let mut data = base_data();
123+
let num_inputs = rng.gen_range(0..5);
124+
data.in_token_data = (0..num_inputs).map(|_| base_input()).collect();
125+
126+
// Random compressions -- never Decompress mode so result is always false.
127+
if rng.gen_bool(0.5) {
128+
let num_compressions = rng.gen_range(0..4);
129+
data.compressions = Some(
130+
(0..num_compressions)
131+
.map(|_| {
132+
if rng.gen_bool(0.5) {
133+
Compression::compress(rng.gen(), 0, 0, 0)
134+
} else {
135+
Compression {
136+
mode: CompressionMode::CompressAndClose,
137+
amount: rng.gen(),
138+
mint: 0,
139+
source_or_recipient: 0,
140+
authority: 0,
141+
pool_account_index: 0,
142+
pool_index: 0,
143+
bump: 0,
144+
decimals: 0,
145+
}
146+
}
147+
})
148+
.collect(),
149+
);
150+
}
151+
152+
assert!(
153+
!check(&data),
154+
"Expected false for random input with {num_inputs} inputs"
155+
);
156+
}
157+
}

sdk-libs/client/src/interface/load_accounts.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ fn build_transfer2(
364364
},
365365
)?;
366366

367-
let owner_idx = packed.insert_or_get(ctx.wallet_owner);
367+
let owner_idx = packed.insert_or_get_read_only(ctx.wallet_owner);
368368
let ata_idx = packed.insert_or_get(derive_token_ata(&ctx.wallet_owner, &ctx.mint));
369369
let mint_idx = packed.insert_or_get(token.mint);
370370
let delegate_idx = token.delegate.map(|d| packed.insert_or_get(d)).unwrap_or(0);

0 commit comments

Comments
 (0)