-
Notifications
You must be signed in to change notification settings - Fork 87
feat: light mint AccountLoader #2207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughThis PR introduces an Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~28 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@program-libs/token-interface/src/state/mint/anchor_wrapper.rs`:
- Around line 49-55: Add the LightZeroCopy trait bound to the generic
AccountLoader<'info, T> so T: LightZeroCopy (and update every impl block for
AccountLoader<'info, T>) — this ensures only types marked by the LightZeroCopy
marker (e.g., Mint) can be used with AccountLoader and keeps the API
self-documenting; update the struct/generic declaration and all impl blocks
referencing AccountLoader<'info, T> to include the T: LightZeroCopy bound.
- Around line 288-469: Add two unit tests: one that verifies
AccountLoader::load_mut successfully returns a mutable Mint when account owner
and data indicate a valid, initialized Mint (set IS_INITIALIZED_OFFSET = 1 and
populate any required fields) and that modifications through the returned &mut
Mint persist in the wrapped AccountInfo data; and another that calls
AccountLoader::load() twice on the same AccountLoader instance (or two fresh
AccountLoader::new(info) views if necessary) to assert both calls succeed and
return equivalent Mint values, ensuring repeated reads are supported. Locate
tests near the existing test module and reference AccountLoader::load_mut and
AccountLoader::load (and IS_INITIALIZED_OFFSET, Mint) when creating the mock
AccountInfo.
In `@sdk-libs/macros/src/light_pdas/accounts/builder.rs`:
- Around line 404-427: The IDL generator (__anchor_private_gen_idl_accounts)
currently hardcodes writable: false and signer: false for every field; update
the code that builds the account list to collect per-field metadata (e.g., use
the ParsedField values: ParsedField.is_mut and ParsedField.is_signer alongside
field_names) and emit each IdlInstructionAccount with writable set to is_mut and
signer set to is_signer; locate the vector construction that iterates over
`#field_names` and change it to iterate over a collected (name, is_mut, is_signer)
tuple so each IdlInstructionAccount reflects the real mutability and signer
flags.
In `@sdk-libs/macros/src/light_pdas/accounts/parse.rs`:
- Around line 204-220: The current has_mut_attribute uses
token_str.contains("mut") which can produce false positives; instead parse the
attribute's Meta::List and iterate its list.nested items, and treat the
attribute as mut only when you find a NestedMeta that is exactly the identifier
"mut" (e.g., match NestedMeta::Meta(syn::Meta::Path(path)) and
path.is_ident("mut")). Update has_mut_attribute to ignore other nested meta
forms (like key = value or function calls) and only return true for a standalone
mut NestedMeta so you don't match substrings inside other tokens.
| /// 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 {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider adding LightZeroCopy trait bound to AccountLoader<T>.
The LightZeroCopy marker trait exists to mark types compatible with AccountLoader, but the generic AccountLoader<'info, T> struct doesn't require T: LightZeroCopy. Currently, someone could construct AccountLoader<'info, SomeOtherType> even though only Mint has the specialized load() methods.
This isn't a bug (there's no generic load() they could call), but adding the bound would make the API more self-documenting.
🔧 Optional: Add trait bound
-pub struct AccountLoader<'info, T> {
+pub struct AccountLoader<'info, T: LightZeroCopy> {
info: AccountInfo<'info>,
_phantom: PhantomData<T>,
}Note: This would require adding the bound to all impl blocks as well.
Also applies to: 82-85
🤖 Prompt for AI Agents
In `@program-libs/token-interface/src/state/mint/anchor_wrapper.rs` around lines
49 - 55, Add the LightZeroCopy trait bound to the generic AccountLoader<'info,
T> so T: LightZeroCopy (and update every impl block for AccountLoader<'info, T>)
— this ensures only types marked by the LightZeroCopy marker (e.g., Mint) can be
used with AccountLoader and keeps the API self-documenting; update the
struct/generic declaration and all impl blocks referencing AccountLoader<'info,
T> to include the T: LightZeroCopy bound.
| #[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()); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Good test coverage!
The tests cover construction, trait implementations, error cases, and the is_initialized quick check. Consider adding a test for load_mut() success path and a test verifying that calling load() twice works (since each creates a fresh view).
🤖 Prompt for AI Agents
In `@program-libs/token-interface/src/state/mint/anchor_wrapper.rs` around lines
288 - 469, Add two unit tests: one that verifies AccountLoader::load_mut
successfully returns a mutable Mint when account owner and data indicate a
valid, initialized Mint (set IS_INITIALIZED_OFFSET = 1 and populate any required
fields) and that modifications through the returned &mut Mint persist in the
wrapped AccountInfo data; and another that calls AccountLoader::load() twice on
the same AccountLoader instance (or two fresh AccountLoader::new(info) views if
necessary) to assert both calls succeed and return equivalent Mint values,
ensuring repeated reads are supported. Locate tests near the existing test
module and reference AccountLoader::load_mut and AccountLoader::load (and
IS_INITIALIZED_OFFSET, Mint) when creating the mock AccountInfo.
| /// 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<String, anchor_lang::idl::types::IdlAccount>, | ||
| _types: &mut std::collections::BTreeMap<String, anchor_lang::idl::types::IdlTypeDef>, | ||
| ) -> Vec<anchor_lang::idl::types::IdlInstructionAccountItem> { | ||
| 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![], | ||
| } | ||
| ) | ||
| ),* | ||
| ] | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IDL metadata loses field mutability and signer information.
The generated IdlInstructionAccount hardcodes writable: false and signer: false for all fields, but you have this metadata available in ParsedField.is_mut and ParsedField.is_signer. Clients relying on IDL to construct transactions may incorrectly mark accounts as read-only.
🔧 Proposed fix to use actual field metadata
- 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![],
- }
- )
+ anchor_lang::idl::types::IdlInstructionAccountItem::Single(
+ anchor_lang::idl::types::IdlInstructionAccount {
+ name: stringify!(`#field_names`).into(),
+ docs: vec![],
+ writable: `#field_is_mut`,
+ signer: `#field_is_signer`,
+ optional: false,
+ address: None,
+ pda: None,
+ relations: vec![],
+ }
+ )You'll need to collect the is_mut and is_signer values alongside field_names:
let field_metadata: Vec<_> = fields.iter().map(|f| {
let name = &f.ident;
let is_mut = f.is_mut;
let is_signer = f.is_signer;
(name, is_mut, is_signer)
}).collect();🤖 Prompt for AI Agents
In `@sdk-libs/macros/src/light_pdas/accounts/builder.rs` around lines 404 - 427,
The IDL generator (__anchor_private_gen_idl_accounts) currently hardcodes
writable: false and signer: false for every field; update the code that builds
the account list to collect per-field metadata (e.g., use the ParsedField
values: ParsedField.is_mut and ParsedField.is_signer alongside field_names) and
emit each IdlInstructionAccount with writable set to is_mut and signer set to
is_signer; locate the vector construction that iterates over `#field_names` and
change it to iterate over a collected (name, is_mut, is_signer) tuple so each
IdlInstructionAccount reflects the real mutability and signer flags.
| /// 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
String-based mut detection may produce false positives.
The token_str.contains("mut") check at line 214 will match any occurrence of "mut" in the attribute content, including:
#[account(seeds = [b"immutable"])]#[account(constraint = compute_mutable(...))]
Consider checking for mut as a standalone token or at word boundaries.
🔧 Proposed fix using word boundary matching
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;
- }
+ // Check for `mut` as a standalone token (not part of another word)
+ // Handles: mut, mut), (mut, etc.
+ for token in token_str.split(|c: char| !c.is_alphanumeric() && c != '_') {
+ if token == "mut" {
+ return true;
+ }
+ }
}
}
false
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// 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 | |
| } | |
| /// 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(); | |
| // Check for `mut` as a standalone token (not part of another word) | |
| // Handles: mut, mut), (mut, etc. | |
| for token in token_str.split(|c: char| !c.is_alphanumeric() && c != '_') { | |
| if token == "mut" { | |
| return true; | |
| } | |
| } | |
| } | |
| } | |
| false | |
| } |
🤖 Prompt for AI Agents
In `@sdk-libs/macros/src/light_pdas/accounts/parse.rs` around lines 204 - 220, The
current has_mut_attribute uses token_str.contains("mut") which can produce false
positives; instead parse the attribute's Meta::List and iterate its list.nested
items, and treat the attribute as mut only when you find a NestedMeta that is
exactly the identifier "mut" (e.g., match
NestedMeta::Meta(syn::Meta::Path(path)) and path.is_ident("mut")). Update
has_mut_attribute to ignore other nested meta forms (like key = value or
function calls) and only return true for a standalone mut NestedMeta so you
don't match substrings inside other tokens.
Notes:
UncheckedAccountorAccountInfoorAccountLoader.LightMintor a customAccountLoader<Mint>only by replacing anchorsAccountsSummary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.