Skip to content

Clarify ATA transfer recipient contract after the LEZ update #53

@3esmit

Description

@3esmit

Summary

The LEZ update changed the ATA transfer surface on main.

ATA::Transfer now accepts a generic recipient token holding account instead of encoding extra recipient-side ATA structure in the instruction surface. That may be the right direction, but the current contract still needs to be made explicit for integrators and locked down in tests.

The core ATA instruction docs now define Transfer as a 3-account flow whose destination is a generic recipient token holding.

/// Transfer tokens FROM owner's ATA to a recipient token holding account.
/// Uses ATA PDA seeds to authorize the chained Token::Transfer call.
///
/// Required accounts (3):
/// - Owner account (authorized)
/// - Sender ATA (owner's token holding)
/// - Recipient token holding (must be initialized)
///
/// `token_program_id` is derived from `sender_ata.account.program_owner`.
Transfer {
ata_program_id: ProgramId,
amount: u128,
},

The guest wrapper then narrows that description by saying the recipient holding must already be initialized.

/// Transfer tokens FROM owner's ATA to a recipient token holding account.
/// The recipient holding account must already be initialized.
#[instruction]
pub fn transfer(
owner: AccountWithMetadata,
sender_ata: AccountWithMetadata,
recipient: AccountWithMetadata,
ata_program_id: ProgramId,
amount: u128,
) -> SpelResult {
let (post_states, chained_calls) =
ata_program::transfer::transfer_from_associated_token_account(
owner,
sender_ata,
recipient,
ata_program_id,
amount,
);
Ok(SpelOutput::with_chained_calls(post_states, chained_calls))

That overall contract is still not crisp enough, because the ATA implementation delegates recipient behavior to the token layer, where default-account handling is still influenced by LEZ claim semantics.

Problem

After the LEZ update:

  • ata_core::Instruction::Transfer documents a 3-account flow:
    • authorized owner
    • sender ATA
    • recipient token holding
  • the guest wrapper repeats that the recipient holding must already be initialized
  • ata_program::transfer verifies the sender ATA and delegates the recipient behavior to the downstream token transfer
  • token::Transfer still has default-recipient claim behavior through Claim::Authorized

The current ATA transfer implementation verifies the sender ATA, marks only that sender as authorized for the chained call, and forwards the recipient account as-is into token::Transfer.

pub fn transfer_from_associated_token_account(
owner: AccountWithMetadata,
sender_ata: AccountWithMetadata,
recipient: AccountWithMetadata,
ata_program_id: ProgramId,
amount: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
let token_program_id = sender_ata.account.program_owner;
assert!(owner.is_authorized, "Owner authorization is missing");
let definition_id = TokenHolding::try_from(&sender_ata.account.data)
.expect("Sender ATA must hold a valid token")
.definition_id();
let sender_seed =
ata_core::verify_ata_and_get_seed(&sender_ata, &owner, definition_id, ata_program_id);
let post_states = vec![
AccountPostState::new(owner.account.clone()),
AccountPostState::new(sender_ata.account.clone()),
AccountPostState::new(recipient.account.clone()),
];
let mut sender_ata_auth = sender_ata.clone();
sender_ata_auth.is_authorized = true;
let chained_call = ChainedCall::new(
token_program_id,
vec![sender_ata_auth, recipient],
&token_core::Instruction::Transfer {
amount_to_transfer: amount,
},
)
.with_pda_seeds(vec![sender_seed]);
(post_states, vec![chained_call])

The downstream token transfer still treats a default recipient as materializable by cloning a zeroized holding shape from the sender.

let mut recipient_holding = if recipient.account == Account::default() {
TokenHolding::zeroized_clone_from(&sender_holding)
} else {
TokenHolding::try_from(&recipient.account.data).expect("Invalid recipient data")
};

That same token path still emits Claim::Authorized for a default recipient post-state.

vec![
AccountPostState::new(sender_post),
AccountPostState::new_claimed_if_default(recipient_post, Claim::Authorized),
]

This leaves an open contract question for the ATA layer:

  • is the intended rule truly "recipient must already be initialized"
  • or is the intended rule "recipient may be initialized, or may be default if the downstream authorization model allows it"

The current implementation may be acceptable either way, but the intended rule should be stated and tested directly.

Why It Matters

  • ATA is now exposing a broader recipient model after the LEZ update, so client code needs a precise behavioral contract.
  • If the rule is "initialized recipient only", callers should get a clear ATA-level failure model instead of having to infer behavior from token/runtime claim handling.
  • If broader recipient cases are intentionally supported, that support should be documented and tested explicitly.
  • Without sharper coverage, future refactors could change recipient behavior without making that change obvious in review.

The current ATA integration coverage only locks down the initialized-recipient happy path.

fn ata_transfer() {
let mut state = state_for_ata_tests_with_precreated_recipient_ata();
let instruction = ata_core::Instruction::Transfer {
ata_program_id: Ids::ata_program(),
amount: 400_000_u128,
};
let message = public_transaction::Message::try_new(
Ids::ata_program(),
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
assert_eq!(
state.get_account_by_id(Ids::owner_ata()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 600_000_u128,
}),
nonce: Nonce(0),
}
);
assert_eq!(
state.get_account_by_id(Ids::recipient_ata()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 400_000_u128,
}),
nonce: Nonce(0),
}
);
}

That means the repository still does not make the intended behavior obvious for default recipients, malformed token holdings, or other recipient-shape boundary cases.

Proposed Scope

  • Define the intended recipient contract for the current post-LEZ ATA::Transfer API.
  • Align the core docs, guest wrapper docs, and IDL with that contract.
  • Decide whether ATA should:
    • enforce initialized-recipient requirements directly
    • or explicitly rely on downstream token/runtime claim semantics for some recipient shapes
  • Add boundary coverage for the recipient cases the project wants to support or reject.

Acceptance Criteria

  • The repository documents one clear recipient contract for ATA::Transfer on the current LEZ-based surface.
  • Core instruction comments, guest wrapper comments, and emitted IDL all describe the same contract.
  • Tests cover the intended supported path for an initialized recipient holding.
  • Tests also cover the intended behavior for unsupported or special recipient states, such as:
    • default recipient account
    • malformed recipient token data
    • recipient with mismatched definition
  • The final behavior does not require integrators to reverse-engineer ATA semantics from token internals.

Validation Hints

  • Run ATA unit tests and ATA integration tests after tightening the contract.
  • Add explicit negative tests for recipient-shape failures rather than relying only on the happy path.
  • Verify that any instruction docs or generated artifacts exposed to clients match the final behavior.

Notes

  • This issue is about clarifying and locking the ATA semantics that exist after the LEZ update.
  • It should not assume that the older ATA transfer shape or older recipient expectations need to be restored.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: documentationImprovements or additions to documentation

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions