diff --git a/program-libs/token-interface/src/state/mint/anchor_wrapper.rs b/program-libs/token-interface/src/state/mint/anchor_wrapper.rs new file mode 100644 index 0000000000..215f9bc76b --- /dev/null +++ b/program-libs/token-interface/src/state/mint/anchor_wrapper.rs @@ -0,0 +1,470 @@ +//! Anchor wrapper for Light Protocol mint accounts. +//! +//! Provides `AccountLoader<'info, T>` - a type-safe wrapper for Light Protocol accounts that +//! provides zero-copy access. Named `AccountLoader` for API familiarity with Anchor users. +//! +//! # Key Insight: Why This Works with Anchor +//! +//! Anchor's codegen for ALL field types calls `Accounts::try_accounts()`. Our `AccountLoader` +//! implements this trait with no-op validation, allowing the account to be uninitialized +//! before CPI. Validation happens lazily when `load()` is called. +//! +//! # Usage +//! +//! Use `AccountLoader<'info, Mint>` in Anchor accounts structs: +//! +//! ```ignore +//! use light_token_interface::state::mint::{AccountLoader, Mint}; +//! +//! #[derive(Accounts, LightAccounts)] // Both derives work together! +//! #[instruction(params: CreateMintParams)] +//! pub struct CreateMint<'info> { +//! #[account(mut)] +//! #[light_account(init, mint::signer = mint_signer, ...)] +//! pub mint: AccountLoader<'info, Mint>, +//! } +//! +//! pub fn handler(ctx: Context) -> Result<()> { +//! // After CPI completes, access mint data via zero-copy: +//! let mint_data = ctx.accounts.mint.load()?; +//! msg!("Decimals: {}", mint_data.decimals); +//! Ok(()) +//! } +//! ``` +//! +//! # Zero-Copy Pattern +//! +//! `load()` returns a `ZMint<'info>` zero-copy view that reads directly from account +//! data without allocation. `load_mut()` returns a `ZMintMut<'info>` that can write +//! directly to account data. Since zero-copy writes directly, `AccountsExit::exit()` +//! is a no-op. + +use std::{marker::PhantomData, ops::Deref}; + +use anchor_lang::prelude::*; + +use super::{Mint, ZMint, ZMintMut, IS_INITIALIZED_OFFSET}; +use crate::{TokenError, LIGHT_TOKEN_PROGRAM_ID}; + +/// Marker trait for types that can be loaded via AccountLoader. +/// +/// This trait marks types that have zero-copy serialization support +/// and can be accessed through the AccountLoader pattern. +pub trait LightZeroCopy {} + +impl LightZeroCopy for Mint {} + +/// Zero-copy account loader for Light Protocol accounts. +/// +/// Named `AccountLoader` for API familiarity with Anchor users. +/// Unlike Anchor's AccountLoader, this performs NO validation in `try_accounts` +/// - validation happens lazily when `load()` is called. +/// +/// # Type Parameter +/// +/// - `T`: The account type to load (e.g., `Mint`). Must implement `LightZeroCopy`. +/// +/// # Anchor Integration +/// +/// `AccountLoader` implements all required Anchor traits, allowing it to be used +/// in `#[derive(Accounts)]` structs. During account deserialization +/// (`try_accounts`), no validation is performed - this allows the account to +/// be uninitialized before CPI. Validation happens when `load()` is called. +/// +/// # Zero-Copy Behavior +/// +/// Unlike borsh deserialization which allocates memory, zero-copy views read +/// and write directly from/to account data. This means: +/// - No memory allocation overhead +/// - Writes via `load_mut()` are immediately reflected in account data +/// - `AccountsExit::exit()` is a no-op since there's nothing to serialize +#[derive(Debug, Clone)] +pub struct AccountLoader<'info, T> { + info: AccountInfo<'info>, + _phantom: PhantomData, +} + +impl<'info, T> AccountLoader<'info, T> { + /// Creates a new `AccountLoader` wrapper from an `AccountInfo`. + /// + /// This does not perform any validation - the account may be uninitialized. + /// Validation occurs when `load()` is called. + /// + /// # Example + /// + /// ```ignore + /// // In an Anchor instruction handler, after CPI: + /// let loader = AccountLoader::::new(ctx.accounts.mint.to_account_info()); + /// let mint_data = loader.load()?; + /// assert!(mint_data.is_initialized()); + /// ``` + pub fn new(info: AccountInfo<'info>) -> Self { + Self { + info, + _phantom: PhantomData, + } + } + + /// Returns a clone of the underlying `AccountInfo`. + /// + /// This is required for macro-generated code that calls `.to_account_info()` + /// on account fields in `#[derive(LightAccounts)]` structs. + pub fn to_account_info(&self) -> AccountInfo<'info> { + self.info.clone() + } + + /// Returns a reference to the account's public key. + pub fn key(&self) -> &Pubkey { + self.info.key + } +} + +// ============================================================================= +// Mint-specific methods +// ============================================================================= + +impl<'info> AccountLoader<'info, Mint> { + /// Loads and validates the mint data, returning an immutable zero-copy view. + /// + /// This method: + /// 1. Validates the account is owned by the Light Token Program + /// 2. Creates a zero-copy view into the account data + /// 3. Validates the mint is initialized and account type is correct + /// + /// Each call creates a fresh view - there is no caching. + /// + /// # Errors + /// + /// Returns `TokenError::InvalidMintOwner` if the account owner is not the Light Token Program. + /// Returns `TokenError::MintNotInitialized` if the mint is not initialized. + /// Returns `TokenError::InvalidAccountType` if the account type is not a mint. + /// Returns `TokenError::MintBorrowFailed` if the account data cannot be borrowed. + /// Returns `TokenError::MintDeserializationFailed` if zero-copy parsing fails. + pub fn load(&self) -> std::result::Result, TokenError> { + // Validate owner + if self.info.owner != &LIGHT_TOKEN_PROGRAM_ID.into() { + return Err(TokenError::InvalidMintOwner); + } + + let data = self + .info + .try_borrow_data() + .map_err(|_| TokenError::MintBorrowFailed)?; + + // Extend lifetime - safe because account data lives for transaction duration. + // This matches the pattern used in Token::from_account_info_checked. + let data_slice: &'info [u8] = + unsafe { core::slice::from_raw_parts(data.as_ptr(), data.len()) }; + + let (mint, _) = Mint::zero_copy_at_checked(data_slice)?; + Ok(mint) + } + + /// Loads and validates the mint data, returning a mutable zero-copy view. + /// + /// This method behaves like `load()` but returns a mutable view, + /// allowing modifications to the mint data. Since zero-copy writes directly + /// to account data, changes are immediately persisted. + /// + /// # Errors + /// + /// Same as `load()`. + pub fn load_mut(&self) -> std::result::Result, TokenError> { + // Validate owner + if self.info.owner != &LIGHT_TOKEN_PROGRAM_ID.into() { + return Err(TokenError::InvalidMintOwner); + } + + let mut data = self + .info + .try_borrow_mut_data() + .map_err(|_| TokenError::MintBorrowFailed)?; + + // Extend lifetime - safe because account data lives for transaction duration. + let data_slice: &'info mut [u8] = + unsafe { core::slice::from_raw_parts_mut(data.as_mut_ptr(), data.len()) }; + + let (mint, _) = Mint::zero_copy_at_mut_checked(data_slice)?; + Ok(mint) + } + + /// Returns true if the mint account appears to be initialized. + /// + /// This performs a quick check without fully parsing the account. + /// It checks: + /// 1. Account is owned by the Light Token Program + /// 2. Account has sufficient data length + /// 3. The is_initialized byte is non-zero + pub fn is_initialized(&self) -> bool { + // Check owner + if self.info.owner != &LIGHT_TOKEN_PROGRAM_ID.into() { + return false; + } + + let data = match self.info.try_borrow_data() { + Ok(d) => d, + Err(_) => return false, + }; + + if data.len() <= IS_INITIALIZED_OFFSET { + return false; + } + + data[IS_INITIALIZED_OFFSET] != 0 + } +} + +// ============================================================================= +// Deref, AsRef, and ToAccountInfo implementations +// ============================================================================= + +impl<'info, T> Deref for AccountLoader<'info, T> { + type Target = AccountInfo<'info>; + + fn deref(&self) -> &Self::Target { + &self.info + } +} + +impl<'info, T> AsRef> for AccountLoader<'info, T> { + fn as_ref(&self) -> &AccountInfo<'info> { + &self.info + } +} + +// ============================================================================= +// Anchor trait implementations +// ============================================================================= + +impl<'info, T, B> Accounts<'info, B> for AccountLoader<'info, T> { + fn try_accounts( + _program_id: &Pubkey, + accounts: &mut &'info [AccountInfo<'info>], + _ix_data: &[u8], + _bumps: &mut B, + _reallocs: &mut std::collections::BTreeSet, + ) -> Result { + // NO validation - just grab AccountInfo + // This allows the account to be uninitialized before CPI + if accounts.is_empty() { + return Err(ErrorCode::AccountNotEnoughKeys.into()); + } + let account = accounts[0].clone(); + *accounts = &accounts[1..]; + Ok(AccountLoader::new(account)) + } +} + +impl<'info, T> AccountsExit<'info> for AccountLoader<'info, T> { + fn exit(&self, _program_id: &Pubkey) -> Result<()> { + // No-op: zero-copy writes directly to account data + Ok(()) + } +} + +impl ToAccountMetas for AccountLoader<'_, T> { + fn to_account_metas(&self, is_signer: Option) -> Vec { + let is_signer = is_signer.unwrap_or(self.info.is_signer); + if self.info.is_writable { + vec![AccountMeta::new(*self.info.key, is_signer)] + } else { + vec![AccountMeta::new_readonly(*self.info.key, is_signer)] + } + } +} + +impl<'info, T> ToAccountInfos<'info> for AccountLoader<'info, T> { + fn to_account_infos(&self) -> Vec> { + vec![self.info.clone()] + } +} + +impl anchor_lang::Key for AccountLoader<'_, T> { + fn key(&self) -> Pubkey { + *self.info.key + } +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell as StdRefCell, rc::Rc}; + + use solana_pubkey::Pubkey as SolanaPubkey; + + use super::*; + + /// Helper to create a mock AccountInfo for testing + fn create_mock_account_info<'a>( + key: &'a SolanaPubkey, + owner: &'a SolanaPubkey, + lamports: &'a mut u64, + data: &'a mut [u8], + is_writable: bool, + is_signer: bool, + ) -> AccountInfo<'a> { + AccountInfo { + key, + lamports: Rc::new(StdRefCell::new(lamports)), + data: Rc::new(StdRefCell::new(data)), + owner, + rent_epoch: 0, + is_signer, + is_writable, + executable: false, + } + } + + #[test] + fn test_account_loader_new() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + assert_eq!(*loader.key(), key); + } + + #[test] + fn test_deref_provides_account_info_access() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + + // Deref should provide access to AccountInfo fields + assert!(loader.is_writable); + assert!(!loader.is_signer); + } + + #[test] + fn test_load_fails_for_wrong_owner() { + let key = SolanaPubkey::new_unique(); + let wrong_owner = SolanaPubkey::new_unique(); // Not Light Token Program + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + + let info = + create_mock_account_info(&key, &wrong_owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + + let result = loader.load(); + assert!(matches!(result, Err(TokenError::InvalidMintOwner))); + } + + #[test] + fn test_load_fails_for_uninitialized() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + // Create data with is_initialized = 0 (uninitialized) + let mut data = vec![0u8; 256]; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + + let result = loader.load(); + // Will fail during validation + assert!(result.is_err()); + } + + #[test] + fn test_to_account_metas_writable() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + + let metas = loader.to_account_metas(None); + assert_eq!(metas.len(), 1); + assert_eq!(metas[0].pubkey, key); + assert!(metas[0].is_writable); + assert!(!metas[0].is_signer); + } + + #[test] + fn test_to_account_metas_readonly() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, false, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + + let metas = loader.to_account_metas(None); + assert_eq!(metas.len(), 1); + assert!(!metas[0].is_writable); + } + + #[test] + fn test_key_trait() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + assert_eq!(anchor_lang::Key::key(&loader), key); + } + + #[test] + fn test_is_initialized_false_for_wrong_owner() { + let key = SolanaPubkey::new_unique(); + let wrong_owner = SolanaPubkey::new_unique(); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + // Set is_initialized byte to 1 + data[IS_INITIALIZED_OFFSET] = 1; + + let info = + create_mock_account_info(&key, &wrong_owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + assert!(!loader.is_initialized()); + } + + #[test] + fn test_is_initialized_false_for_zero_byte() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + // is_initialized byte is 0 + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + assert!(!loader.is_initialized()); + } + + #[test] + fn test_is_initialized_true() { + let key = SolanaPubkey::new_unique(); + let owner = SolanaPubkey::new_from_array(LIGHT_TOKEN_PROGRAM_ID); + let mut lamports = 1_000_000u64; + let mut data = vec![0u8; 256]; + // Set is_initialized byte to 1 + data[IS_INITIALIZED_OFFSET] = 1; + + let info = create_mock_account_info(&key, &owner, &mut lamports, &mut data, true, false); + + let loader: AccountLoader<'_, Mint> = AccountLoader::new(info); + assert!(loader.is_initialized()); + } +} diff --git a/program-libs/token-interface/src/state/mint/compressed_mint.rs b/program-libs/token-interface/src/state/mint/compressed_mint.rs index f1668c810f..5f6ba7ae8b 100644 --- a/program-libs/token-interface/src/state/mint/compressed_mint.rs +++ b/program-libs/token-interface/src/state/mint/compressed_mint.rs @@ -15,6 +15,10 @@ use crate::{ /// AccountType::Mint discriminator value pub const ACCOUNT_TYPE_MINT: u8 = 1; +/// Byte offset of `is_initialized` field in Mint account data. +/// Layout: 4 (COption prefix) + 32 (pubkey) + 8 (supply) + 1 (decimals) = 45 +pub const IS_INITIALIZED_OFFSET: usize = 45; + #[repr(C)] #[derive(Debug, PartialEq, Eq, Clone, BorshSerialize, BorshDeserialize)] pub struct Mint { diff --git a/program-libs/token-interface/src/state/mint/mod.rs b/program-libs/token-interface/src/state/mint/mod.rs index 63484b75f5..4b8be8f765 100644 --- a/program-libs/token-interface/src/state/mint/mod.rs +++ b/program-libs/token-interface/src/state/mint/mod.rs @@ -3,6 +3,11 @@ mod compressed_mint; mod top_up; mod zero_copy; +#[cfg(feature = "anchor")] +mod anchor_wrapper; + +#[cfg(feature = "anchor")] +pub use anchor_wrapper::{AccountLoader, LightZeroCopy}; pub use compressed_mint::*; pub use top_up::*; pub use zero_copy::*; diff --git a/program-libs/token-interface/src/state/mint/zero_copy.rs b/program-libs/token-interface/src/state/mint/zero_copy.rs index f62ae35d1e..462f4cc78b 100644 --- a/program-libs/token-interface/src/state/mint/zero_copy.rs +++ b/program-libs/token-interface/src/state/mint/zero_copy.rs @@ -10,7 +10,7 @@ use light_zero_copy::{ }; use spl_pod::solana_msg::msg; -use super::compressed_mint::{MintMetadata, ACCOUNT_TYPE_MINT}; +use super::compressed_mint::{MintMetadata, ACCOUNT_TYPE_MINT, IS_INITIALIZED_OFFSET}; use crate::{ instructions::mint_action::MintInstructionData, state::{ @@ -107,8 +107,7 @@ impl<'a> ZeroCopyNew<'a> for Mint { bytes: &'a mut [u8], config: Self::ZeroCopyConfig, ) -> Result<(Self::Output, &'a mut [u8]), light_zero_copy::errors::ZeroCopyError> { - // Check that the account is not already initialized (is_initialized byte at offset 45) - const IS_INITIALIZED_OFFSET: usize = 45; // 4 + 32 + 8 + 1 = 45 + // Check that the account is not already initialized if bytes.len() > IS_INITIALIZED_OFFSET && bytes[IS_INITIALIZED_OFFSET] != 0 { return Err(light_zero_copy::errors::ZeroCopyError::MemoryNotZeroed); } diff --git a/sdk-libs/macros/src/lib.rs b/sdk-libs/macros/src/lib.rs index 427b8dc614..329d36b9e4 100644 --- a/sdk-libs/macros/src/lib.rs +++ b/sdk-libs/macros/src/lib.rs @@ -447,7 +447,10 @@ pub fn derive_light_rent_sponsor(input: TokenStream) -> TokenStream { /// - `ID`: Program ID (from declare_id!) /// /// The struct should have fields named `fee_payer` (or `payer`) and `compression_config`. -#[proc_macro_derive(LightAccounts, attributes(light_account, instruction))] +/// When using `AccountLoader<'info, Mint>` directly in a struct, use ONLY `#[derive(LightAccounts)]` +/// (not `#[derive(Accounts, LightAccounts)]`), as Anchor's derive doesn't know about AccountLoader. +/// The LightAccounts macro will generate all necessary Anchor trait implementations. +#[proc_macro_derive(LightAccounts, attributes(light_account, instruction, account))] pub fn light_accounts_derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); into_token_stream(light_pdas::accounts::derive_light_accounts(input)) diff --git a/sdk-libs/macros/src/light_pdas/accounts/builder.rs b/sdk-libs/macros/src/light_pdas/accounts/builder.rs index 548962587f..848ce9e653 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/builder.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/builder.rs @@ -227,6 +227,250 @@ impl LightAccountsBuilder { self.parsed.instruction_args.is_some() } + /// Query: any field with `AccountLoader<'info, Mint>` type? + /// + /// When true, we need to generate Anchor trait implementations ourselves + /// because Anchor's `#[derive(Accounts)]` doesn't know about this type. + pub fn has_light_loader_fields(&self) -> bool { + self.parsed.has_light_loader_fields + } + + /// Generate Anchor trait implementations for structs with AccountLoader<'info, Mint> fields. + /// + /// When a struct contains these fields, Anchor's `#[derive(Accounts)]` fails + /// because the type isn't in Anchor's hardcoded primitive type whitelist. + /// This method generates the necessary trait implementations manually. + /// + /// Generated traits: + /// - `Accounts<'info, B>` - Account deserialization + /// - `AccountsExit<'info>` - Account serialization on exit + /// - `ToAccountInfos<'info>` - Convert to account info list + /// - `ToAccountMetas` - Convert to account meta list + /// - `Bumps` - Anchor Bumps trait for Context compatibility + pub fn generate_anchor_accounts_impl(&self) -> Result { + let struct_name = &self.parsed.struct_name; + let (impl_generics, ty_generics, where_clause) = self.parsed.generics.split_for_impl(); + let fields = &self.parsed.all_fields; + + // Generate field assignments for try_accounts + let field_assignments: Vec = fields + .iter() + .map(|f| { + let field_ident = &f.ident; + let field_ty = &f.ty; + quote! { + let #field_ident: #field_ty = anchor_lang::Accounts::try_accounts( + __program_id, + __accounts, + __ix_data, + __bumps, + __reallocs, + )?; + } + }) + .collect(); + + let field_names: Vec<&syn::Ident> = fields.iter().map(|f| &f.ident).collect(); + + // Generate exit calls - only for mutable fields + let exit_calls: Vec = fields + .iter() + .filter(|f| f.is_mut) + .map(|f| { + let field_ident = &f.ident; + quote! { + anchor_lang::AccountsExit::exit(&self.#field_ident, program_id)?; + } + }) + .collect(); + + // Generate to_account_infos calls + let account_info_calls: Vec = fields + .iter() + .map(|f| { + let field_ident = &f.ident; + quote! { + account_infos.extend(anchor_lang::ToAccountInfos::to_account_infos(&self.#field_ident)); + } + }) + .collect(); + + // Generate to_account_metas calls + let account_meta_calls: Vec = fields + .iter() + .map(|f| { + let field_ident = &f.ident; + quote! { + account_metas.extend(anchor_lang::ToAccountMetas::to_account_metas(&self.#field_ident, None)); + } + }) + .collect(); + + let field_count = fields.len(); + + // Generate the Bumps struct for Anchor compatibility + let bumps_struct_name = + syn::Ident::new(&format!("{}Bumps", struct_name), struct_name.span()); + + // Generate client accounts module name (snake_case of struct name) + // This is required because Anchor's #[program] macro references it. + let struct_name_str = struct_name.to_string(); + let client_module_name = syn::Ident::new( + &format!( + "__client_accounts_{}", + crate::utils::to_snake_case(&struct_name_str) + ), + struct_name.span(), + ); + + // Generate fields for the client accounts struct + let client_struct_fields: Vec = fields + .iter() + .map(|f| { + let field_ident = &f.ident; + quote! { + pub #field_ident: anchor_lang::prelude::Pubkey, + } + }) + .collect(); + + // Generate ToAccountMetas for client struct + let client_to_account_metas: Vec = fields + .iter() + .map(|f| { + let field_ident = &f.ident; + if f.is_mut { + if f.is_signer { + quote! { + account_metas.push(anchor_lang::prelude::AccountMeta::new(self.#field_ident, true)); + } + } else { + quote! { + account_metas.push(anchor_lang::prelude::AccountMeta::new(self.#field_ident, false)); + } + } + } else if f.is_signer { + quote! { + account_metas.push(anchor_lang::prelude::AccountMeta::new_readonly(self.#field_ident, true)); + } + } else { + quote! { + account_metas.push(anchor_lang::prelude::AccountMeta::new_readonly(self.#field_ident, false)); + } + } + }) + .collect(); + + Ok(quote! { + /// Auto-generated client accounts module for Anchor compatibility. + /// Required by Anchor's #[program] macro. + pub mod #client_module_name { + use super::*; + + /// Client-side representation of the accounts struct. + #[derive(Clone)] + pub struct #struct_name { + #(#client_struct_fields)* + } + + impl anchor_lang::ToAccountMetas for #struct_name { + fn to_account_metas(&self, _is_signer: Option) -> Vec { + let mut account_metas = Vec::with_capacity(#field_count); + #(#client_to_account_metas)* + account_metas + } + } + } + + /// Auto-generated Bumps struct for Anchor compatibility. + #[derive(Default, Debug, Clone)] + pub struct #bumps_struct_name { + // Empty - bumps are handled separately for AccountLoader fields + } + + impl #bumps_struct_name { + /// Get a bump by name (returns None for AccountLoader-based structs). + pub fn get(&self, _name: &str) -> Option { + None + } + } + + /// Anchor Bumps trait implementation for Context compatibility. + #[automatically_derived] + impl #impl_generics anchor_lang::Bumps for #struct_name #ty_generics #where_clause { + type Bumps = #bumps_struct_name; + } + + /// IDL generation method required by Anchor's #[program] macro. + impl #impl_generics #struct_name #ty_generics #where_clause { + pub fn __anchor_private_gen_idl_accounts( + _accounts: &mut std::collections::BTreeMap, + _types: &mut std::collections::BTreeMap, + ) -> Vec { + vec![ + #( + anchor_lang::idl::types::IdlInstructionAccountItem::Single( + anchor_lang::idl::types::IdlInstructionAccount { + name: stringify!(#field_names).into(), + docs: vec![], + writable: false, + signer: false, + optional: false, + address: None, + pda: None, + relations: vec![], + } + ) + ),* + ] + } + } + + #[automatically_derived] + impl #impl_generics anchor_lang::Accounts<'info, #bumps_struct_name> for #struct_name #ty_generics #where_clause { + fn try_accounts( + __program_id: &anchor_lang::prelude::Pubkey, + __accounts: &mut &'info [anchor_lang::prelude::AccountInfo<'info>], + __ix_data: &[u8], + __bumps: &mut #bumps_struct_name, + __reallocs: &mut std::collections::BTreeSet, + ) -> anchor_lang::Result { + #(#field_assignments)* + + Ok(Self { + #(#field_names),* + }) + } + } + + #[automatically_derived] + impl #impl_generics anchor_lang::AccountsExit<'info> for #struct_name #ty_generics #where_clause { + fn exit(&self, program_id: &anchor_lang::prelude::Pubkey) -> anchor_lang::Result<()> { + #(#exit_calls)* + Ok(()) + } + } + + #[automatically_derived] + impl #impl_generics anchor_lang::ToAccountInfos<'info> for #struct_name #ty_generics #where_clause { + fn to_account_infos(&self) -> Vec> { + let mut account_infos = Vec::with_capacity(#field_count); + #(#account_info_calls)* + account_infos + } + } + + #[automatically_derived] + impl #impl_generics anchor_lang::ToAccountMetas for #struct_name #ty_generics #where_clause { + fn to_account_metas(&self, _is_signer: Option) -> Vec { + let mut account_metas = Vec::with_capacity(#field_count); + #(#account_meta_calls)* + account_metas + } + } + }) + } + /// Generate no-op trait impls (for backwards compatibility). pub fn generate_noop_impls(&self) -> Result { let struct_name = &self.parsed.struct_name; diff --git a/sdk-libs/macros/src/light_pdas/accounts/derive.rs b/sdk-libs/macros/src/light_pdas/accounts/derive.rs index ad5bbbe5f9..61fc1994c6 100644 --- a/sdk-libs/macros/src/light_pdas/accounts/derive.rs +++ b/sdk-libs/macros/src/light_pdas/accounts/derive.rs @@ -30,13 +30,33 @@ use syn::DeriveInput; use super::builder::LightAccountsBuilder; /// Main orchestration - shows the high-level flow clearly. +/// +/// When structs contain `AccountLoader<'info, Mint>` fields, +/// we must generate Anchor trait implementations ourselves because Anchor's +/// `#[derive(Accounts)]` doesn't know about this type. +/// +/// Users should use ONLY `#[derive(LightAccounts)]` (not both Accounts and LightAccounts) +/// when they have these fields. pub(super) fn derive_light_accounts(input: &DeriveInput) -> Result { let builder = LightAccountsBuilder::parse(input)?; builder.validate()?; + // Check if struct has AccountLoader<'info, Mint> type fields. + // When present, we must generate Anchor trait implementations ourselves + // because Anchor's #[derive(Accounts)] doesn't know about this type. + let anchor_impls = if builder.has_light_loader_fields() { + builder.generate_anchor_accounts_impl()? + } else { + quote! {} + }; + // No instruction args = no-op impls (backwards compatibility) if !builder.has_instruction_args() { - return builder.generate_noop_impls(); + let noop_impls = builder.generate_noop_impls()?; + return Ok(quote! { + #anchor_impls + #noop_impls + }); } // Generate pre_init body for ALL account types (PDAs, mints, token accounts, ATAs) @@ -51,6 +71,7 @@ pub(super) fn derive_light_accounts(input: &DeriveInput) -> Result` from light_token_interface. +/// +/// Note: This checks if it's our custom AccountLoader, not Anchor's. The heuristic is: +/// - `AccountLoader<'info, Mint>` is ours (Anchor's requires ZeroCopy which Mint doesn't have) +fn is_light_loader_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + // AccountLoader with Mint type parameter is ours + if segment.ident == "AccountLoader" { + // Check if the type parameter is Mint + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + for arg in &args.args { + if let syn::GenericArgument::Type(Type::Path(inner_path)) = arg { + if let Some(inner_seg) = inner_path.path.segments.last() { + if inner_seg.ident == "Mint" { + return true; + } + } + } + } + } + } + } + } + false +} + +/// Check if a type is `Signer<'info>`. +fn is_signer_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + return segment.ident == "Signer"; + } + } + false +} + +/// Check if field has `#[account(mut)]` attribute. +fn has_mut_attribute(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("account") { + // Parse the attribute content to look for `mut` + let tokens = match &attr.meta { + syn::Meta::List(list) => list.tokens.clone(), + _ => continue, + }; + let token_str = tokens.to_string(); + if token_str.contains("mut") { + return true; + } + } + } + false +} + /// Parsed representation of a struct with rentfree and light_mint fields. pub(super) struct ParsedLightAccountsStruct { pub struct_name: Ident, @@ -167,6 +233,12 @@ pub(super) struct ParsedLightAccountsStruct { /// If CreateAccountsProof type is passed as a direct instruction arg, stores arg name. /// Matched by TYPE, not by name - allows any argument name (e.g., `proof`, `create_proof`). pub direct_proof_arg: Option, + /// All fields in the struct (for generating Accounts trait when LightAccountLoader fields present). + pub all_fields: Vec, + /// True if any field has `AccountLoader<'info, Mint>` type. + /// When true, LightAccounts must generate the Accounts trait implementation + /// because Anchor's derive doesn't know about this type. + pub has_light_loader_fields: bool, } /// A field marked with #[light_account(init)] @@ -286,6 +358,8 @@ pub(super) fn parse_light_accounts_struct( let mut token_account_fields = Vec::new(); let mut ata_fields = Vec::new(); let mut infra_fields = InfraFields::default(); + let mut all_fields = Vec::new(); + let mut has_light_loader_fields = false; for field in fields { let field_ident = field @@ -300,6 +374,19 @@ pub(super) fn parse_light_accounts_struct( infra_fields.set(field_type, field_ident.clone())?; } + // Check if field type is AccountLoader<'info, Mint> + if is_light_loader_type(&field.ty) { + has_light_loader_fields = true; + } + + // Track all fields for Accounts trait generation + all_fields.push(ParsedField { + ident: field_ident.clone(), + ty: field.ty.clone(), + is_mut: has_mut_attribute(&field.attrs), + is_signer: is_signer_type(&field.ty), + }); + // Check for #[light_account(...)] - the unified syntax if let Some(light_account_field) = parse_light_account_attr(field, &field_ident, &direct_proof_arg)? @@ -337,5 +424,7 @@ pub(super) fn parse_light_accounts_struct( instruction_args, infra_fields, direct_proof_arg, + all_fields, + has_light_loader_fields, }) } diff --git a/sdk-libs/macros/src/light_pdas/program/instructions.rs b/sdk-libs/macros/src/light_pdas/program/instructions.rs index 79f2682c7e..214592912f 100644 --- a/sdk-libs/macros/src/light_pdas/program/instructions.rs +++ b/sdk-libs/macros/src/light_pdas/program/instructions.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Item, ItemMod, Result, Type}; +use syn::{Item, ItemMod, ItemStruct, Result, Type}; // Re-export types from parsing for external use pub use super::parsing::{ @@ -591,14 +591,30 @@ pub fn light_program_impl(_args: TokenStream, mut module: ItemMod) -> Result fields + // (Anchor's #[derive(Accounts)] doesn't recognize AccountLoader, so LightAccounts generates all Anchor traits) let mut pda_specs: Vec = Vec::new(); let mut token_specs: Vec = Vec::new(); let mut rentfree_struct_names = std::collections::HashSet::new(); let mut has_any_mint_fields = false; let mut has_any_ata_fields = false; - for item_struct in crate_ctx.structs_with_derive("Accounts") { + // Collect structs with either derive + let accounts_structs = crate_ctx.structs_with_derive("Accounts"); + let light_accounts_structs = crate_ctx.structs_with_derive("LightAccounts"); + + // Combine and deduplicate (some structs may have both derives) + let mut all_structs: Vec<&ItemStruct> = accounts_structs; + for s in light_accounts_structs { + if !all_structs.iter().any(|existing| existing.ident == s.ident) { + all_structs.push(s); + } + } + + for item_struct in all_structs { // Parse #[instruction(...)] attribute to get instruction arg names let instruction_args = parse_instruction_arg_names(&item_struct.attrs)?; diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs index 02ef7bfe52..656020582c 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/instruction_accounts.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; use light_compressible::CreateAccountsProof; use light_sdk_macros::LightAccounts; +use light_token_interface::state::mint::{AccountLoader, Mint}; use crate::state::*; @@ -133,7 +134,7 @@ pub struct CreateTwoMintsParams { } /// Test instruction with 2 #[light_account(init)] fields to verify multi-mint support. -#[derive(Accounts, LightAccounts)] +#[derive(LightAccounts)] #[instruction(params: CreateTwoMintsParams)] pub struct CreateTwoMints<'info> { #[account(mut)] @@ -155,7 +156,7 @@ pub struct CreateTwoMints<'info> { )] pub mint_signer_b: UncheckedAccount<'info>, - /// CHECK: Initialized by mint_action - first mint + /// First mint with zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer_a, @@ -164,9 +165,9 @@ pub struct CreateTwoMints<'info> { mint::seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_a_bump )] - pub cmint_a: UncheckedAccount<'info>, + pub cmint_a: AccountLoader<'info, Mint>, - /// CHECK: Initialized by mint_action - second mint + /// Second mint with zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer_b, @@ -175,7 +176,7 @@ pub struct CreateTwoMints<'info> { mint::seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_b_bump )] - pub cmint_b: UncheckedAccount<'info>, + pub cmint_b: AccountLoader<'info, Mint>, /// CHECK: Compression config pub compression_config: AccountInfo<'info>, @@ -211,7 +212,7 @@ pub struct CreateThreeMintsParams { } /// Test instruction with 3 #[light_account(init)] fields to verify multi-mint support. -#[derive(Accounts, LightAccounts)] +#[derive(LightAccounts)] #[instruction(params: CreateThreeMintsParams)] pub struct CreateThreeMints<'info> { #[account(mut)] @@ -240,7 +241,7 @@ pub struct CreateThreeMints<'info> { )] pub mint_signer_c: UncheckedAccount<'info>, - /// CHECK: Initialized by light_mint CPI + /// First mint with zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer_a, @@ -249,9 +250,9 @@ pub struct CreateThreeMints<'info> { mint::seeds = &[MINT_SIGNER_A_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_a_bump )] - pub cmint_a: UncheckedAccount<'info>, + pub cmint_a: AccountLoader<'info, Mint>, - /// CHECK: Initialized by light_mint CPI + /// Second mint with zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer_b, @@ -260,9 +261,9 @@ pub struct CreateThreeMints<'info> { mint::seeds = &[MINT_SIGNER_B_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_b_bump )] - pub cmint_b: UncheckedAccount<'info>, + pub cmint_b: AccountLoader<'info, Mint>, - /// CHECK: Initialized by light_mint CPI + /// Third mint with zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer_c, @@ -271,7 +272,7 @@ pub struct CreateThreeMints<'info> { mint::seeds = &[MINT_SIGNER_C_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_c_bump )] - pub cmint_c: UncheckedAccount<'info>, + pub cmint_c: AccountLoader<'info, Mint>, /// CHECK: Compression config pub compression_config: AccountInfo<'info>, @@ -310,7 +311,7 @@ pub struct CreateMintWithMetadataParams { /// Test instruction with #[light_account(init)] with metadata fields. /// Tests the metadata support in the RentFree macro. -#[derive(Accounts, LightAccounts)] +#[derive(LightAccounts)] #[instruction(params: CreateMintWithMetadataParams)] pub struct CreateMintWithMetadata<'info> { #[account(mut)] @@ -325,7 +326,7 @@ pub struct CreateMintWithMetadata<'info> { )] pub mint_signer: UncheckedAccount<'info>, - /// CHECK: Initialized by light_mint CPI with metadata + /// Mint with metadata and zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer, @@ -339,7 +340,72 @@ pub struct CreateMintWithMetadata<'info> { mint::update_authority = authority, mint::additional_metadata = params.additional_metadata.clone() )] - pub cmint: UncheckedAccount<'info>, + pub cmint: AccountLoader<'info, Mint>, + + /// CHECK: Compression config + pub compression_config: AccountInfo<'info>, + + /// CHECK: CToken config + pub light_token_compressible_config: AccountInfo<'info>, + + /// CHECK: CToken rent sponsor + #[account(mut)] + pub rent_sponsor: AccountInfo<'info>, + + /// CHECK: CToken program + pub light_token_program: AccountInfo<'info>, + + /// CHECK: CToken CPI authority + pub light_token_cpi_authority: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +// ============================================================================= +// AccountLoader Wrapper Test +// ============================================================================= + +pub const LIGHT_MINT_TEST_SIGNER_SEED: &[u8] = b"light_mint_test_signer"; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct CreateMintWithAccountLoaderParams { + pub create_accounts_proof: CreateAccountsProof, + pub mint_signer_bump: u8, +} + +/// Test instruction to verify AccountLoader wrapper works correctly. +/// Uses `AccountLoader<'info, Mint>` directly in the Accounts struct to demonstrate +/// type-safe access to mint data after CPI initialization. +/// +/// Note: Uses `#[derive(LightAccounts)]` alone (not with `#[derive(Accounts)]`) +/// because Anchor's derive doesn't know about `AccountLoader` type. +/// The `LightAccounts` macro generates the necessary Anchor trait implementations. +#[derive(LightAccounts)] +#[instruction(params: CreateMintWithAccountLoaderParams)] +pub struct CreateMintWithAccountLoader<'info> { + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub authority: Signer<'info>, + + /// CHECK: PDA derived from authority for mint signer + #[account( + seeds = [LIGHT_MINT_TEST_SIGNER_SEED, authority.key().as_ref()], + bump, + )] + pub mint_signer: UncheckedAccount<'info>, + + /// Mint account with zero-copy access after CPI initialization. + /// AccountLoader provides type-safe access to mint data without manual wrapping. + #[account(mut)] + #[light_account(init, + mint::signer = mint_signer, + mint::authority = fee_payer, + mint::decimals = 6, + mint::seeds = &[LIGHT_MINT_TEST_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], + mint::bump = params.mint_signer_bump + )] + pub cmint: AccountLoader<'info, Mint>, /// CHECK: Compression config pub compression_config: AccountInfo<'info>, diff --git a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs index 826aefb20c..90c2de697a 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/src/lib.rs @@ -265,8 +265,9 @@ pub mod csdk_anchor_full_derived_test { D9U64Params, }, instruction_accounts::{ - CreateMintWithMetadata, CreateMintWithMetadataParams, CreatePdasAndMintAuto, - CreateThreeMints, CreateThreeMintsParams, CreateTwoMints, CreateTwoMintsParams, + CreateMintWithAccountLoader, CreateMintWithAccountLoaderParams, CreateMintWithMetadata, + CreateMintWithMetadataParams, CreatePdasAndMintAuto, CreateThreeMints, + CreateThreeMintsParams, CreateTwoMints, CreateTwoMintsParams, }, instructions::d10_token_accounts::{ D10SingleAta, D10SingleAtaParams, D10SingleVault, D10SingleVaultParams, @@ -411,6 +412,33 @@ pub mod csdk_anchor_full_derived_test { Ok(()) } + /// Test instruction demonstrating type-safe mint access after CPI initialization. + /// Uses AccountLoader<'info, Mint> directly in the Accounts struct for zero-copy access. + pub fn create_mint_with_account_loader<'info>( + ctx: Context<'_, '_, '_, 'info, CreateMintWithAccountLoader<'info>>, + _params: CreateMintWithAccountLoaderParams, + ) -> Result<()> { + // Direct zero-copy access via AccountLoader field + // No wrapping needed - cmint is already AccountLoader<'info, Mint> + let mint_data = ctx.accounts.cmint.load().map_err(|e| { + solana_msg::msg!("Failed to load mint: {:?}", e); + anchor_lang::error::ErrorCode::AccountDidNotDeserialize + })?; + + // Verify the mint was initialized correctly using zero-copy accessors + solana_msg::msg!("Mint zero-copy load succeeded!"); + solana_msg::msg!(" decimals: {}", mint_data.decimals); + solana_msg::msg!(" is_initialized: {}", mint_data.is_initialized()); + solana_msg::msg!(" supply: {}", u64::from(mint_data.supply)); + + // Verify expected values from the #[light_account(init)] attributes + assert!(mint_data.is_initialized(), "Mint should be initialized"); + assert_eq!(mint_data.decimals, 6, "Decimals should be 6"); + assert_eq!(u64::from(mint_data.supply), 0, "Initial supply should be 0"); + + Ok(()) + } + /// AMM initialize instruction with all light account markers. /// Tests: 2x #[light_account(init)], 2x #[light_account(token)], 1x #[light_account(init)], /// CreateTokenAccountCpi.rent_free(), CreateTokenAtaCpi.rent_free(), MintToCpi diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/light_mint_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/light_mint_test.rs new file mode 100644 index 0000000000..eaf1ad8a7b --- /dev/null +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/light_mint_test.rs @@ -0,0 +1,137 @@ +//! Integration test for AccountLoader wrapper functionality. +//! +//! Tests that mint data can be accessed after CPI initialization using +//! type-safe deserialization patterns. + +use anchor_lang::{InstructionData, ToAccountMetas}; +use csdk_anchor_full_derived_test::instruction_accounts::{ + CreateMintWithAccountLoaderParams, LIGHT_MINT_TEST_SIGNER_SEED, +}; +use light_client::interface::{get_create_accounts_proof, CreateAccountsProofInput}; +use light_program_test::{ + program_test::{setup_mock_program_data, LightProgramTest}, + ProgramTestConfig, Rpc, +}; +use light_sdk_types::LIGHT_TOKEN_PROGRAM_ID; +use light_token::instruction::{COMPRESSIBLE_CONFIG_V1, RENT_SPONSOR}; +use solana_instruction::Instruction; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signer::Signer; + +/// Test creating a mint and verifying the handler can access mint data after CPI. +/// This demonstrates the pattern for accessing type-safe mint data after initialization. +#[tokio::test] +async fn test_create_mint_with_account_loader_wrapper() { + use light_token::instruction::find_mint_address as find_cmint_address; + + let program_id = csdk_anchor_full_derived_test::ID; + let mut config = ProgramTestConfig::new_v2( + true, + Some(vec![("csdk_anchor_full_derived_test", program_id)]), + ); + config = config.with_light_protocol_events(); + + let mut rpc = LightProgramTest::new(config).await.unwrap(); + let payer = rpc.get_payer().insecure_clone(); + + let program_data_pda = setup_mock_program_data(&mut rpc, &payer, &program_id); + + // Initialize rent-free config for the test program + let (init_config_ix, config_pda) = light_client::interface::InitializeRentFreeConfig::new( + &program_id, + &payer.pubkey(), + &program_data_pda, + RENT_SPONSOR, + payer.pubkey(), + ) + .build(); + + rpc.create_and_send_transaction(&[init_config_ix], &payer.pubkey(), &[&payer]) + .await + .expect("Initialize config should succeed"); + + let authority = Keypair::new(); + + // Derive PDA for mint signer + let (mint_signer_pda, mint_signer_bump) = Pubkey::find_program_address( + &[LIGHT_MINT_TEST_SIGNER_SEED, authority.pubkey().as_ref()], + &program_id, + ); + + // Derive mint PDA + let (cmint_pda, _) = find_cmint_address(&mint_signer_pda); + + // Get proof for the mint + let proof_result = get_create_accounts_proof( + &rpc, + &program_id, + vec![CreateAccountsProofInput::mint(mint_signer_pda)], + ) + .await + .unwrap(); + + // Build the instruction + let accounts = csdk_anchor_full_derived_test::accounts::CreateMintWithAccountLoader { + fee_payer: payer.pubkey(), + authority: authority.pubkey(), + mint_signer: mint_signer_pda, + cmint: cmint_pda, + compression_config: config_pda, + light_token_compressible_config: COMPRESSIBLE_CONFIG_V1, + rent_sponsor: RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID.into(), + light_token_cpi_authority: light_token_types::CPI_AUTHORITY_PDA.into(), + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = + csdk_anchor_full_derived_test::instruction::CreateMintWithAccountLoader { + _params: CreateMintWithAccountLoaderParams { + create_accounts_proof: proof_result.create_accounts_proof, + mint_signer_bump, + }, + }; + + let instruction = Instruction { + program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: instruction_data.data(), + }; + + // Execute the transaction - this will: + // 1. Create the mint via CPI + // 2. In the handler, access and verify the mint data + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer, &authority]) + .await + .expect("CreateMintWithAccountLoader should succeed"); + + // Verify mint exists on-chain with expected data + let cmint_account = rpc + .get_account(cmint_pda) + .await + .unwrap() + .expect("Mint should exist on-chain"); + + // Deserialize and verify + use borsh::BorshDeserialize; + use light_token_interface::state::Mint; + let mint: Mint = + Mint::try_from_slice(&cmint_account.data[..]).expect("Failed to deserialize Mint"); + + // Verify values match what was specified in #[light_account(init)] attributes + assert_eq!(mint.base.decimals, 6, "Mint should have 6 decimals"); + assert!(mint.base.is_initialized, "Mint should be initialized"); + assert_eq!(mint.base.supply, 0, "Initial supply should be 0"); + + // Verify mint authority is the fee_payer (as specified in mint::authority = fee_payer) + assert_eq!( + mint.base.mint_authority, + Some(payer.pubkey().to_bytes().into()), + "Mint authority should be fee_payer" + ); +} diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/mod.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/mod.rs index 765f1880d3..69fcb6b4da 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/mod.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/mod.rs @@ -1 +1,2 @@ +mod light_mint_test; mod metadata_test; diff --git a/sdk-tests/single-mint-test/Cargo.toml b/sdk-tests/single-mint-test/Cargo.toml index 618df66e5b..6684882df2 100644 --- a/sdk-tests/single-mint-test/Cargo.toml +++ b/sdk-tests/single-mint-test/Cargo.toml @@ -32,6 +32,7 @@ light-anchor-spl = { workspace = true, features = ["metadata", "idl-build"] } light-token = { workspace = true, features = ["anchor"] } light-token-types = { workspace = true, features = ["anchor"] } light-compressible = { workspace = true, features = ["anchor"] } +light-token-interface = { workspace = true, features = ["anchor"] } solana-program = { workspace = true } solana-pubkey = { workspace = true } solana-msg = { workspace = true } diff --git a/sdk-tests/single-mint-test/src/lib.rs b/sdk-tests/single-mint-test/src/lib.rs index 334688be63..3e2c0635bc 100644 --- a/sdk-tests/single-mint-test/src/lib.rs +++ b/sdk-tests/single-mint-test/src/lib.rs @@ -10,6 +10,7 @@ use light_compressible::CreateAccountsProof; use light_sdk::derive_light_cpi_signer; use light_sdk_macros::{light_program, LightAccounts}; use light_sdk_types::CpiSigner; +use light_token_interface::state::mint::{AccountLoader, Mint}; declare_id!("Mint111111111111111111111111111111111111111"); @@ -25,7 +26,9 @@ pub struct CreateMintParams { } /// Minimal accounts struct for testing single mint creation. -#[derive(Accounts, LightAccounts)] +/// Uses only #[derive(LightAccounts)] because it contains AccountLoader<'info, Mint> field. +/// The LightAccounts macro generates all necessary Anchor trait implementations. +#[derive(LightAccounts)] #[instruction(params: CreateMintParams)] pub struct CreateMint<'info> { #[account(mut)] @@ -40,7 +43,7 @@ pub struct CreateMint<'info> { )] pub mint_signer: UncheckedAccount<'info>, - /// CHECK: Initialized by light_mint CPI + /// Mint account with zero-copy access after CPI initialization. #[account(mut)] #[light_account(init, mint::signer = mint_signer, @@ -49,7 +52,7 @@ pub struct CreateMint<'info> { mint::seeds = &[MINT_SIGNER_SEED, self.authority.to_account_info().key.as_ref()], mint::bump = params.mint_signer_bump )] - pub mint: UncheckedAccount<'info>, + pub mint: AccountLoader<'info, Mint>, /// CHECK: Compression config pub compression_config: AccountInfo<'info>,