Skip to content

refactor: relax ata decompress signer check, feat: add ata decompress idempotent#2360

Open
ananas-block wants to merge 9 commits intomainfrom
jorrit/chore-relax-ata-decompress
Open

refactor: relax ata decompress signer check, feat: add ata decompress idempotent#2360
ananas-block wants to merge 9 commits intomainfrom
jorrit/chore-relax-ata-decompress

Conversation

@ananas-block
Copy link
Copy Markdown
Contributor

@ananas-block ananas-block commented Mar 25, 2026

Motivation

Permissionless associated token account decompress

Payments need to load the associated token account balance of the sender.
Currently only the owner or delegate can decompress an associated token account.
Thus, if the recipient has an existing compressed associated token account balance the sender creates a new empty associated token account for the recipient which once compressed creates a second compressed balance.
Many transfers can create a compressed associated token account balance that cannot be decompressed within one Solana transaction.

Permissionless associated token account decompression allows the sender to decompress the balance of the recipient so that the recipient balance accumulates in one compressed balance should the associated token account be compressed.

Idempotent associated token account decompress

Permissionless decompress instructions must be idempotent so that nobody can front run a decompress transaction to fail it. Decompression can only be idempotent for one input compressed account because if a validity proof is computed over multiple inputs it will fail if we omit one.

Changes

token_input.rs -- Signer bypass

  • resolve_ata_signer returns (account, check_signer: bool) instead of just account
  • When CompressedOnly extension has is_ata=true, returns check_signer=false
  • set_input_compressed_account skips verify_owner_or_delegate_signer when check_signer=false
  • Safe because destination is a deterministic PDA (ATA derivation still validated)

processor.rs -- Idempotent decompress

  • New is_idempotent_ata_decompress(): detects exactly 1 input + 1 Decompress + is_ata=true
  • New check_ata_decompress_idempotent(): computes account hash, checks bloom filter -- if already spent, returns Ok(()) as no-op

Safety

  • compressed token accounts with is_ata=true can only be decompressed to the correctly derived associated token account pubkey. Therefore, it is safe for anyone to decompress it.
  • idempotency is a simple abort based on the nullification mechanism. In the unlikely event of a false positive in the bloom filter the user decompression does not happen, it would fail anyway because the account compression bloom filter insertion. The user needs to wait until the bloom filter is cleared. No loss of funds possible.

@ananas-block ananas-block changed the title refactor: relax ata decompress signer check, feat: add ata decompress… refactor: relax ata decompress signer check, feat: add ata decompress idempotent Mar 25, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a permissionless, idempotent ATA-decompress path to Transfer2: when decompress mode includes a CompressedOnly extension with is_ata=true and single-input/single-compression constraints, the processor computes a deterministic account-hash, checks Merkle-tree bloom/non-inclusion, and returns early as a no-op if already spent; signer verification is skipped for this ATA case.

Changes

Cohort / File(s) Summary
Documentation
programs/compressed-token/program/CLAUDE.md, programs/compressed-token/program/docs/compressed_token/TRANSFER2.md
Documented is_ata=true ATA-decompress behavior, added numeric discriminants for CompressionMode (Compress=0, Decompress=1, CompressAndClose=2), single-input/single-compression constraints, bloom-filter idempotency checks, and deterministic ATA validation notes.
Transfer2 Processor
programs/compressed-token/program/src/compressed_token/transfer2/processor.rs
Added early ATA-decompress detection in process_with_system_program_cpi: detect single-input + single-compression + CompressedOnly.is_ata, compute deterministic compressed-account account_hash, load BatchedMerkleTreeAccount, call check_input_queue_non_inclusion, and return Ok(()) if bloom indicates already spent (skips remaining decompress CPI flow).
Token Input / Signer Resolution
programs/compressed-token/program/src/shared/token_input.rs
Changed resolve_ata_signer to return (signer_account, is_ata_decompress), thread is_ata_decompress into set_input_compressed_account, and skip verify_owner_or_delegate_signer when is_ata_decompress == true.
Dependency
programs/compressed-token/program/Cargo.toml
Added workspace dependency light-batched-merkle-tree to support batched Merkle tree account operations used by the ATA-decompress idempotency check.
Client SDK account packing
sdk-libs/client/src/interface/load_accounts.rs
Changed packed owner insertion from insert_or_get_config(..., true, false) to insert_or_get(...), affecting how the wallet owner index is encoded for CompressedOnlyExtensionInstructionData owner_index.

Sequence Diagram

sequenceDiagram
    participant Client as Client/Instruction
    participant Processor as Transfer2 Processor
    participant TokenInput as Token Input Resolver
    participant MerkleTree as BatchedMerkleTreeAccount

    Client->>Processor: Transfer2 (Decompress, CompressedOnly is_ata=true)
    Processor->>Processor: Validate single-input & single-compression
    Processor->>Processor: Compute deterministic account_hash
    Processor->>MerkleTree: Load tree & check_input_queue_non_inclusion(account_hash)
    alt Bloom indicates already spent
        MerkleTree-->>Processor: Bloom hit -> spent
        Processor-->>Client: Return Ok() (no-op)
    else Not present
        MerkleTree-->>Processor: Not found
        Processor->>TokenInput: set_input_compressed_account(is_ata_decompress=true)
        TokenInput->>TokenInput: Skip verify_owner_or_delegate_signer
        TokenInput-->>Processor: Signer resolved (permissionless flow)
        Processor->>Processor: Continue normal decompress CPI processing
        Processor-->>Client: Return Ok() (decompressed)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

ai-review

Suggested reviewers

  • sergeytimoshin
  • SwenSchaeferjohann
  • tilo-14

Poem

🌱 Bloom checks once, the second time is spare,

ATA sighs softly—no signer to bear.
Merkle nods, the duplicate skips by,
Decompress idempotent beneath the sky. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title references both a refactor (relax signer check) and a feature (idempotent ATA decompress) that are clearly present in the changeset, matching the core objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 70.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jorrit/chore-relax-ata-decompress

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs (1)

97-129: ⚠️ Potential issue | 🟠 Major

DecompressIdempotent does not validate that the ATA is pre-initialized as required by the specification.

The TRANSFER2.md documentation states "ATA must be pre-created" for DecompressIdempotent, but the implementation passes the same validation path to both Decompress and DecompressIdempotent without mode awareness. The validate_and_apply_compressed_only() helper does not receive the compression mode, so it cannot enforce mode-specific initialization requirements. This means DecompressIdempotent has no way to verify that the destination ATA already exists and is initialized, as the spec requires.

Pass the mode to the helper and add a mode-aware check:

  • Decompress: destination may be fresh or pre-existing (current behavior)
  • DecompressIdempotent: destination must be pre-initialized (currently unenforced)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs`
around lines 97 - 129, The DecompressIdempotent branch doesn't enforce the
TRANSFER2.md requirement that the destination ATA be pre-initialized; update the
Decompress/DecompressIdempotent handling to pass the current ZCompressionMode
into validate_and_apply_compressed_only (or add an extra parameter) and
implement a mode-aware check inside validate_and_apply_compressed_only that
asserts the destination ATA is already created/initialized when mode ==
ZCompressionMode::DecompressIdempotent but allows fresh destinations for
ZCompressionMode::Decompress; reference validate_and_apply_compressed_only and
ZCompressionMode::DecompressIdempotent when adding the validation and adjust
callers accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@programs/compressed-token/program/CLAUDE.md`:
- Around line 71-73: The doc statement about permissionless ATA decompress is
too broad; update the line referencing Decompress and DecompressIdempotent to
specify that the permissionless path applies only to CToken-associated token
accounts (CToken-ATA) rather than all SPL token accounts — mention the specific
symbols Decompress, DecompressIdempotent (mode 3), and the is_ata=true flag and
align wording with the implementation in transfer2/compression/spl.rs which
rejects DecompressIdempotent for regular SPL token accounts.

In
`@programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs`:
- Around line 81-84: The match arm for ZCompressionMode::DecompressIdempotent
currently returns ProgramError::InvalidInstructionData which hides the real
cause; change it to return a named token error (e.g.,
Err(TokenError::InvalidCompressionMode.into()) or a new TokenError variant
dedicated to unsupported DecompressIdempotent) so callers can distinguish
malformed payloads from unsupported compression modes; update imports/usages in
transfer2::compression::spl.rs to bring TokenError into scope and add the new
TokenError variant if it doesn't exist, ensuring the error maps to ProgramError
via .into().

In `@sdk-libs/compressed-token-sdk/src/compressed_token/v2/account2.rs`:
- Around line 248-271: is_decompress() currently only matches
CompressionMode::Decompress and thus returns false for idempotent flows; update
the is_decompress() implementation to also treat
CompressionMode::DecompressIdempotent as a decompress case so that calls like
decompress_idempotent (which sets Compression::decompress_idempotent) are
recognized as decompress operations; locate is_decompress() and add
DecompressIdempotent to the match/conditional alongside Decompress to return
true.

---

Outside diff comments:
In
`@programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs`:
- Around line 97-129: The DecompressIdempotent branch doesn't enforce the
TRANSFER2.md requirement that the destination ATA be pre-initialized; update the
Decompress/DecompressIdempotent handling to pass the current ZCompressionMode
into validate_and_apply_compressed_only (or add an extra parameter) and
implement a mode-aware check inside validate_and_apply_compressed_only that
asserts the destination ATA is already created/initialized when mode ==
ZCompressionMode::DecompressIdempotent but allows fresh destinations for
ZCompressionMode::Decompress; reference validate_and_apply_compressed_only and
ZCompressionMode::DecompressIdempotent when adding the validation and adjust
callers accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44c31050-3598-4586-ac6f-b42f507cb049

📥 Commits

Reviewing files that changed from the base of the PR and between 2809be4 and f521153.

⛔ Files ignored due to path filters (5)
  • js/compressed-token/src/v3/layout/layout-transfer2.ts is excluded by none and included by none
  • program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs is excluded by none and included by none
  • program-tests/utils/src/actions/legacy/instructions/transfer2.rs is excluded by none and included by none
  • program-tests/utils/src/actions/legacy/transfer2/decompress.rs is excluded by none and included by none
  • program-tests/utils/src/assert_transfer2.rs is excluded by none and included by none
📒 Files selected for processing (11)
  • program-libs/token-interface/src/error.rs
  • program-libs/token-interface/src/instructions/transfer2/compression.rs
  • programs/compressed-token/program/CLAUDE.md
  • programs/compressed-token/program/docs/compressed_token/TRANSFER2.md
  • programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/compress_or_decompress_ctokens.rs
  • programs/compressed-token/program/src/compressed_token/transfer2/compression/ctoken/inputs.rs
  • programs/compressed-token/program/src/compressed_token/transfer2/compression/mod.rs
  • programs/compressed-token/program/src/compressed_token/transfer2/compression/spl.rs
  • programs/compressed-token/program/src/compressed_token/transfer2/processor.rs
  • programs/compressed-token/program/src/shared/token_input.rs
  • sdk-libs/compressed-token-sdk/src/compressed_token/v2/account2.rs

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@programs/compressed-token/program/src/compressed_token/transfer2/processor.rs`:
- Around line 316-318: Before deserializing the tree with
BatchedMerkleTreeAccount::state_from_account_info, add an explicit validation
that the provided tree_account refers to a V2 tree by checking the merkle tree
metadata type against STATE_MERKLE_TREE_TYPE_V2; if the metadata type is not V2,
return an error. Concretely, read the merkle-tree metadata/header from
tree_account (using the merkle tree metadata helper or by checking the metadata
type field exposed by the merkle_tree module), compare it to
merkle_tree::STATE_MERKLE_TREE_TYPE_V2, and only then call
BatchedMerkleTreeAccount::state_from_account_info; this mirrors the defensive
pattern around DecompressIdempotent/is_ata and makes the version requirement
explicit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d04d31c7-2503-4d70-a43a-6a7b5b577369

📥 Commits

Reviewing files that changed from the base of the PR and between f521153 and 3655ac3.

⛔ Files ignored due to path filters (4)
  • Cargo.lock is excluded by !**/*.lock and included by none
  • program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs is excluded by none and included by none
  • program-tests/utils/src/actions/legacy/instructions/transfer2.rs is excluded by none and included by none
  • program-tests/utils/src/actions/legacy/transfer2/decompress.rs is excluded by none and included by none
📒 Files selected for processing (4)
  • program-libs/token-interface/src/instructions/transfer2/compression.rs
  • programs/compressed-token/program/Cargo.toml
  • programs/compressed-token/program/src/compressed_token/transfer2/config.rs
  • programs/compressed-token/program/src/compressed_token/transfer2/processor.rs

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
programs/compressed-token/program/CLAUDE.md (1)

72-72: 🧹 Nitpick | 🔵 Trivial

The permissionless ATA decompress documentation may need scope clarification.

This accurately documents the new feature, but per the earlier review discussion, consider clarifying that this permissionless path applies specifically to CToken-associated token accounts (where is_ata=true in the CompressedOnly extension), not to standard SPL token accounts. The implementation in transfer2/compression/spl.rs handles SPL token accounts differently.

The referenced docs/compressed_token/TRANSFER2.md provides the detailed breakdown, so you could tighten this line to:

-   - ATA decompress (is_ata=true) is permissionless and idempotent (bloom filter check)
+   - CToken ATA decompress (is_ata=true) is permissionless and idempotent (bloom filter check)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@programs/compressed-token/program/CLAUDE.md` at line 72, The sentence about
"ATA decompress (is_ata=true) is permissionless and idempotent (bloom filter
check)" is ambiguous—update the doc to state that the permissionless ATA
decompress path applies only to CToken-associated token accounts that have the
CompressedOnly extension with is_ata=true (i.e., CToken-associated ATAs), and
not to regular SPL token accounts; reference the implementation in
transfer2/compression/spl.rs which treats SPL accounts differently and ensure
the wording explicitly distinguishes "CToken-associated token accounts
(CompressedOnly.is_ata=true)" from "standard SPL token accounts."
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@programs/compressed-token/program/CLAUDE.md`:
- Line 72: The sentence about "ATA decompress (is_ata=true) is permissionless
and idempotent (bloom filter check)" is ambiguous—update the doc to state that
the permissionless ATA decompress path applies only to CToken-associated token
accounts that have the CompressedOnly extension with is_ata=true (i.e.,
CToken-associated ATAs), and not to regular SPL token accounts; reference the
implementation in transfer2/compression/spl.rs which treats SPL accounts
differently and ensure the wording explicitly distinguishes "CToken-associated
token accounts (CompressedOnly.is_ata=true)" from "standard SPL token accounts."

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 43685556-5eb4-446d-829c-c9268d5b531a

📥 Commits

Reviewing files that changed from the base of the PR and between 3655ac3 and aa0b202.

⛔ Files ignored due to path filters (2)
  • program-tests/compressed-token-test/tests/compress_only/ata_decompress.rs is excluded by none and included by none
  • program-tests/utils/src/actions/legacy/instructions/transfer2.rs is excluded by none and included by none
📒 Files selected for processing (3)
  • programs/compressed-token/program/CLAUDE.md
  • programs/compressed-token/program/docs/compressed_token/TRANSFER2.md
  • programs/compressed-token/program/src/compressed_token/transfer2/processor.rs

The is_ata_decompress detection was too broad -- it triggered for any
tx containing a Decompress compression and any CompressedOnly TLV with
is_ata=true. This broke multi-account decompress batches (e.g., AMM
stress test) that contain a mix of ATA and non-ATA accounts.

Fix: require exactly 1 input and 1 compression as part of the detection
condition, not as a separate validation that errors. Multi-input
decompress txs now skip the idempotency path entirely.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@sdk-libs/client/src/interface/load_accounts.rs`:
- Line 367: The code currently uses packed.insert_or_get(ctx.wallet_owner) which
marks the wallet_owner account writable; replace that call with
packed.insert_or_get_read_only(ctx.wallet_owner) so wallet_owner remains
read-only (since it’s only used to derive/verify the ATA and never mutated),
preserving permissionless behavior and improving transaction parallelism—update
the call site referencing insert_or_get in the load_accounts logic (where
wallet_owner is inserted and later used to verify the ATA).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c6d232d4-fd0c-4aaf-aef1-fe3bdd0f9e03

📥 Commits

Reviewing files that changed from the base of the PR and between b3271fe and 76d399b.

📒 Files selected for processing (1)
  • sdk-libs/client/src/interface/load_accounts.rs

@ananas-block ananas-block force-pushed the jorrit/chore-relax-ata-decompress branch from 54c9c52 to 4b38472 Compare March 31, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant