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.
Summary
The LEZ update changed the ATA transfer surface on
main.ATA::Transfernow 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
Transferas a 3-account flow whose destination is a generic recipient token holding.lez-programs/ata/core/src/lib.rs
Lines 21 to 33 in 471abef
The guest wrapper then narrows that description by saying the recipient holding must already be initialized.
lez-programs/ata/methods/guest/src/bin/ata.rs
Lines 31 to 49 in 471abef
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::Transferdocuments a 3-account flow:ata_program::transferverifies the sender ATA and delegates the recipient behavior to the downstream token transfertoken::Transferstill has default-recipient claim behavior throughClaim::AuthorizedThe 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.lez-programs/ata/src/transfer.rs
Lines 7 to 38 in 471abef
The downstream token transfer still treats a default recipient as materializable by cloning a zeroized holding shape from the sender.
lez-programs/token/src/transfer.rs
Lines 17 to 21 in 471abef
That same token path still emits
Claim::Authorizedfor a default recipient post-state.lez-programs/token/src/transfer.rs
Lines 106 to 109 in 471abef
This leaves an open contract question for the ATA layer:
The current implementation may be acceptable either way, but the intended rule should be stated and tested directly.
Why It Matters
The current ATA integration coverage only locks down the initialized-recipient happy path.
lez-programs/integration_tests/tests/ata.rs
Lines 215 to 261 in 471abef
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
ATA::TransferAPI.Acceptance Criteria
ATA::Transferon the current LEZ-based surface.Validation Hints
Notes