From 879601e63283065efb852ac3e991db23a7b22a7a Mon Sep 17 00:00:00 2001 From: acheron <98934430+acheroncrypto@users.noreply.github.com> Date: Sun, 1 Sep 2024 03:16:38 +0200 Subject: [PATCH] lang: Add `LazyAccount` (#3194) --- .github/workflows/reusable-tests.yaml | 2 + CHANGELOG.md | 1 + lang/Cargo.toml | 1 + lang/attribute/account/Cargo.toml | 1 + lang/attribute/account/src/lazy.rs | 302 ++++++++++++++++ lang/attribute/account/src/lib.rs | 16 + lang/derive/serde/Cargo.toml | 1 + lang/derive/serde/src/lazy.rs | 63 ++++ lang/derive/serde/src/lib.rs | 24 +- lang/src/accounts/lazy_account.rs | 338 ++++++++++++++++++ lang/src/accounts/mod.rs | 3 + lang/src/lazy.rs | 202 +++++++++++ lang/src/lib.rs | 18 +- lang/syn/src/codegen/accounts/constraints.rs | 11 +- lang/syn/src/codegen/accounts/exit.rs | 15 +- lang/syn/src/idl/accounts.rs | 1 + lang/syn/src/lib.rs | 34 ++ lang/syn/src/parser/accounts/constraints.rs | 11 +- lang/syn/src/parser/accounts/mod.rs | 7 + tests/lazy-account/Anchor.toml | 9 + tests/lazy-account/Cargo.toml | 14 + tests/lazy-account/package.json | 16 + .../programs/lazy-account/Cargo.toml | 19 + .../programs/lazy-account/Xargo.toml | 2 + .../programs/lazy-account/src/lib.rs | 114 ++++++ tests/lazy-account/tests/lazy-account.ts | 36 ++ tests/lazy-account/tsconfig.json | 11 + tests/package.json | 1 + 28 files changed, 1259 insertions(+), 14 deletions(-) create mode 100644 lang/attribute/account/src/lazy.rs create mode 100644 lang/derive/serde/src/lazy.rs create mode 100644 lang/src/accounts/lazy_account.rs create mode 100644 lang/src/lazy.rs create mode 100644 tests/lazy-account/Anchor.toml create mode 100644 tests/lazy-account/Cargo.toml create mode 100644 tests/lazy-account/package.json create mode 100644 tests/lazy-account/programs/lazy-account/Cargo.toml create mode 100644 tests/lazy-account/programs/lazy-account/Xargo.toml create mode 100644 tests/lazy-account/programs/lazy-account/src/lib.rs create mode 100644 tests/lazy-account/tests/lazy-account.ts create mode 100644 tests/lazy-account/tsconfig.json diff --git a/.github/workflows/reusable-tests.yaml b/.github/workflows/reusable-tests.yaml index 3a01ab8a5..b360d0bf9 100644 --- a/.github/workflows/reusable-tests.yaml +++ b/.github/workflows/reusable-tests.yaml @@ -463,6 +463,8 @@ jobs: path: tests/bench - cmd: cd tests/idl && ./test.sh path: tests/idl + - cmd: cd tests/lazy-account && anchor test + path: tests/lazy-account # TODO: Enable when `solang` becomes compatible with the new IDL spec # - cmd: cd tests/solang && anchor test # path: tests/solang diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e13a8ae0..4002405dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ The minor version will be incremented upon a breaking change and the patch versi - cli: Make `clean` command also remove the `.anchor` directory ([#3192](https://github.com/coral-xyz/anchor/pull/3192)). - lang: Deprecate `#[interface]` attribute ([#3195](https://github.com/coral-xyz/anchor/pull/3195)). - ts: Include unresolved accounts in the resolution error message ([#3207](https://github.com/coral-xyz/anchor/pull/3207)). +- lang: Add `LazyAccount` ([#3194](https://github.com/coral-xyz/anchor/pull/3194)). ### Fixes diff --git a/lang/Cargo.toml b/lang/Cargo.toml index 51b826286..14f6f7d79 100644 --- a/lang/Cargo.toml +++ b/lang/Cargo.toml @@ -36,6 +36,7 @@ idl-build = [ ] init-if-needed = ["anchor-derive-accounts/init-if-needed"] interface-instructions = ["anchor-attribute-program/interface-instructions"] +lazy-account = ["anchor-attribute-account/lazy-account", "anchor-derive-serde/lazy-account"] [dependencies] anchor-attribute-access-control = { path = "./attribute/access-control", version = "0.30.1" } diff --git a/lang/attribute/account/Cargo.toml b/lang/attribute/account/Cargo.toml index 28b9104d9..8d4f0c74f 100644 --- a/lang/attribute/account/Cargo.toml +++ b/lang/attribute/account/Cargo.toml @@ -13,6 +13,7 @@ proc-macro = true [features] anchor-debug = ["anchor-syn/anchor-debug"] idl-build = ["anchor-syn/idl-build"] +lazy-account = [] [dependencies] anchor-syn = { path = "../../syn", version = "0.30.1", features = ["hash"] } diff --git a/lang/attribute/account/src/lazy.rs b/lang/attribute/account/src/lazy.rs new file mode 100644 index 000000000..88cc97001 --- /dev/null +++ b/lang/attribute/account/src/lazy.rs @@ -0,0 +1,302 @@ +use proc_macro2::{Literal, TokenStream}; +use quote::{format_ident, quote, ToTokens}; + +pub fn gen_lazy(strct: &syn::ItemStruct) -> syn::Result { + let ident = &strct.ident; + let lazy_ident = format_ident!("Lazy{}", ident); + let load_common_ident = to_private_ident("load_common"); + let initialize_fields = to_private_ident("initialize_fields"); + let lazy_acc_ty = quote! { anchor_lang::accounts::lazy_account::LazyAccount }; + let disc_len = quote! { <#ident as anchor_lang::Discriminator>::DISCRIMINATOR.len() }; + + let load_common_docs = quote! { + /// The deserialized value is cached for future uses i.e. all subsequent calls to this + /// method do not deserialize the data again, instead, they return the cached value. + /// + /// To reload the data from the underlying account info (e.g. after a CPI call), run + /// [`LazyAccount::unload`] before running this method. + /// + /// See [`LazyAccount`]'s documentation for more information. + }; + let load_panic_docs = quote! { + /// # Panics + /// + /// If there is an existing mutable reference crated by any of the `load_mut` methods. + }; + let load_mut_panic_docs = quote! { + /// # Panics + /// + /// If there is an existing reference (mutable or not) created by any of the `load` methods. + }; + + let (loader_signatures, loader_impls) = strct + .fields + .iter() + .enumerate() + .map(|(i, field)| { + let field_ident = to_field_ident(field, i); + let load_ident = format_ident!("load_{field_ident}"); + let load_mut_ident = format_ident!("load_mut_{field_ident}"); + let load_common_ident = to_private_ident(format!("load_common_{field_ident}")); + let offset_of_ident = to_private_ident(format!("offset_of_{field_ident}")); + let size_of_ident = to_private_ident(format!("size_of_{field_ident}")); + + let offset = i.eq(&0).then(|| quote!(#disc_len)).unwrap_or_else(|| { + // Current offset is the previous field's offset + size + strct + .fields + .iter() + .nth(i - 1) + .map(|field| { + let field_ident = to_field_ident(field, i - 1); + let offset_of_ident = to_private_ident(format!("offset_of_{field_ident}")); + let size_of_ident = to_private_ident(format!("size_of_{field_ident}")); + quote! { self.#offset_of_ident() + self.#size_of_ident() } + }) + .expect("Previous field should always exist when i > 0") + }); + + let ty = &field.ty; + let size = quote! { + <#ty as anchor_lang::__private::Lazy>::size_of( + &self.__info.data.borrow()[self.#offset_of_ident()..] + ) + }; + + let signatures = quote! { + /// Load a reference to the field. + /// + #load_common_docs + /// + #load_panic_docs + fn #load_ident(&self) -> anchor_lang::Result<::core::cell::Ref<'_, #ty>>; + + /// Load a mutable reference to the field. + /// + #load_common_docs + /// + #load_mut_panic_docs + fn #load_mut_ident(&self) -> anchor_lang::Result<::core::cell::RefMut<'_, #ty>>; + + #[doc(hidden)] + fn #load_common_ident(&self, f: impl FnOnce() -> R) -> anchor_lang::Result; + + #[doc(hidden)] + fn #offset_of_ident(&self) -> usize; + + #[doc(hidden)] + fn #size_of_ident(&self) -> usize; + }; + + let impls = quote! { + fn #load_ident(&self) -> anchor_lang::Result<::core::cell::Ref<'_, #ty>> { + self.#load_common_ident(|| { + // SAFETY: The common load method makes sure the field is initialized. + ::core::cell::Ref::map(self.__account.borrow(), |acc| unsafe { + &*::core::ptr::addr_of!((*acc.as_ptr()).#field_ident) + }) + }) + } + + fn #load_mut_ident(&self) -> anchor_lang::Result<::core::cell::RefMut<'_, #ty>> { + self.#load_common_ident(|| { + // SAFETY: The common load method makes sure the field is initialized. + ::core::cell::RefMut::map(self.__account.borrow_mut(), |acc| unsafe { + &mut *::core::ptr::addr_of_mut!((*acc.as_mut_ptr()).#field_ident) + }) + }) + } + + #[inline(never)] + fn #load_common_ident(&self, f: impl FnOnce() -> R) -> anchor_lang::Result { + self.#initialize_fields(); + + // Return early if initialized + if self.__fields.borrow().as_ref().unwrap()[#i] { + return Ok(f()); + } + + // Deserialize and write + let offset = self.#offset_of_ident(); + let size = self.#size_of_ident(); + let data = self.__info.data.borrow(); + let val = anchor_lang::AnchorDeserialize::try_from_slice( + &data[offset..offset + size] + )?; + unsafe { + ::core::ptr::addr_of_mut!( + (*self.__account.borrow_mut().as_mut_ptr()).#field_ident + ).write(val) + }; + + // Set initialized + self.__fields.borrow_mut().as_mut().unwrap()[#i] = true; + + Ok(f()) + } + + // If this method gets inlined when there are >= 12 fields, compilation breaks with + // `LLVM ERROR: Branch target out of insn range` + #[inline(never)] + fn #offset_of_ident(&self) -> usize { + #offset + } + + #[inline(always)] + fn #size_of_ident(&self) -> usize { + #size + } + }; + + Ok((signatures, impls)) + }) + .collect::>>()? + .into_iter() + .unzip::<_, _, Vec<_>, Vec<_>>(); + + let load_idents = strct + .fields + .iter() + .enumerate() + .map(|(i, field)| to_field_ident(field, i)) + .map(|field| format_ident!("load_{field}")); + let total_fields = strct.fields.len(); + + Ok(quote! { + pub trait #lazy_ident { + /// Load a reference to the entire account. + /// + #load_common_docs + /// + #load_panic_docs + fn load(&self) -> anchor_lang::Result<::core::cell::Ref<'_, #ident>>; + + /// Load a mutable reference to the entire account. + /// + #load_common_docs + /// + #load_mut_panic_docs + fn load_mut(&self) -> anchor_lang::Result<::core::cell::RefMut<'_, #ident>>; + + #[doc(hidden)] + fn #load_common_ident(&self, f: impl FnOnce() -> R) -> anchor_lang::Result; + + #(#loader_signatures)* + + #[doc(hidden)] + fn #initialize_fields(&self); + + /// Run the exit routine of the account, similar to [`AccountsExit`] but implemented + /// as a regular method because we can't implement external traits for external structs. + fn exit(&self, program_id: &anchor_lang::prelude::Pubkey) -> anchor_lang::Result<()>; + } + + impl<'info> #lazy_ident for #lazy_acc_ty<'info, #ident> { + fn load(&self) -> anchor_lang::Result<::core::cell::Ref<'_, #ident>> { + self.#load_common_ident(|| { + // SAFETY: The common load method makes sure all fields are initialized. + ::core::cell::Ref::map(self.__account.borrow(), |acc| unsafe { + acc.assume_init_ref() + }) + }) + } + + fn load_mut(&self) -> anchor_lang::Result<::core::cell::RefMut<'_, #ident>> { + self.#load_common_ident(|| { + // SAFETY: The common load method makes sure all fields are initialized. + ::core::cell::RefMut::map(self.__account.borrow_mut(), |acc| unsafe { + acc.assume_init_mut() + }) + }) + } + + #[inline(never)] + fn #load_common_ident(&self, f: impl FnOnce() -> R) -> anchor_lang::Result { + self.#initialize_fields(); + + // Create a scope to drop the `__fields` borrow + let all_uninit = { + // Return early if all fields are initialized + let fields = self.__fields.borrow(); + let fields = fields.as_ref().unwrap(); + if !fields.contains(&false) { + return Ok(f()); + } + + !fields.contains(&true) + }; + + if all_uninit { + // Nothing is initialized, initialize all + let offset = #disc_len; + let mut data = self.__info.data.borrow(); + let val = anchor_lang::AnchorDeserialize::deserialize(&mut &data[offset..])?; + unsafe { self.__account.borrow_mut().as_mut_ptr().write(val) }; + + // Set fields to initialized + let mut fields = self.__fields.borrow_mut(); + let fields = fields.as_mut().unwrap(); + for field in fields { + *field = true; + } + } else { + // Only initialize uninitialized fields (`load` methods already do this). + // + // This is not exactly efficient because `load` methods have a bit of + // runtime ownership overhead. This could be optimized further, but it + // requires some refactoring and also makes the code harder to reason about. + // + // We can return back to this if benchmarks show this is a bottleneck. + #(self.#load_idents()?;)* + } + + Ok(f()) + } + + #(#loader_impls)* + + #[inline(always)] + fn #initialize_fields(&self) { + if self.__fields.borrow().is_none() { + *self.__fields.borrow_mut() = Some(vec![false; #total_fields]); + } + } + + // TODO: This method can be optimized to *only* serialize the fields that we have + // initialized rather than deserializing the whole account, and then serializing it + // back, which consumes a lot more CUs than it should for most accounts. + fn exit(&self, program_id: &anchor_lang::prelude::Pubkey) -> anchor_lang::Result<()> { + // Only persist if the owner is the current program and the account is not closed + if &<#ident as anchor_lang::Owner>::owner() == program_id + && !anchor_lang::__private::is_closed(self.__info) + { + // Make sure all fields are initialized + let acc = self.load()?; + let mut data = self.__info.try_borrow_mut_data()?; + let dst: &mut [u8] = &mut data; + let mut writer = anchor_lang::__private::BpfWriter::new(dst); + acc.try_serialize(&mut writer)?; + } + + Ok(()) + } + } + }) +} + +/// Get the field's ident and if the ident doesn't exist (e.g. for tuple structs), default to the +/// given index. +fn to_field_ident(field: &syn::Field, i: usize) -> TokenStream { + field + .ident + .as_ref() + .map(ToTokens::to_token_stream) + .unwrap_or_else(|| Literal::usize_unsuffixed(i).to_token_stream()) +} + +/// Convert to private ident. +/// +/// This is used to indicate to the users that they shouldn't use this identifier. +fn to_private_ident>(ident: S) -> syn::Ident { + format_ident!("__{}", ident.as_ref()) +} diff --git a/lang/attribute/account/src/lib.rs b/lang/attribute/account/src/lib.rs index 53a699012..94728829f 100644 --- a/lang/attribute/account/src/lib.rs +++ b/lang/attribute/account/src/lib.rs @@ -12,6 +12,9 @@ use syn::{ mod id; +#[cfg(feature = "lazy-account")] +mod lazy; + /// An attribute for a data structure representing a Solana account. /// /// `#[account]` generates trait implementations for the following traits: @@ -207,6 +210,17 @@ pub fn account( #owner_impl } } else { + let lazy = { + #[cfg(feature = "lazy-account")] + match namespace.is_empty().then(|| lazy::gen_lazy(&account_strct)) { + Some(Ok(lazy)) => lazy, + // If lazy codegen fails for whatever reason, return empty tokenstream which + // will make the account unusable with `LazyAccount` + _ => Default::default(), + } + #[cfg(not(feature = "lazy-account"))] + proc_macro2::TokenStream::default() + }; quote! { #[derive(AnchorSerialize, AnchorDeserialize, Clone)] #account_strct @@ -251,6 +265,8 @@ pub fn account( } #owner_impl + + #lazy } } }) diff --git a/lang/derive/serde/Cargo.toml b/lang/derive/serde/Cargo.toml index 6932d4bcd..43b591508 100644 --- a/lang/derive/serde/Cargo.toml +++ b/lang/derive/serde/Cargo.toml @@ -12,6 +12,7 @@ proc-macro = true [features] idl-build = ["anchor-syn/idl-build"] +lazy-account = [] [dependencies] anchor-syn = { path = "../../syn", version = "0.30.1" } diff --git a/lang/derive/serde/src/lazy.rs b/lang/derive/serde/src/lazy.rs new file mode 100644 index 000000000..ac400bc7d --- /dev/null +++ b/lang/derive/serde/src/lazy.rs @@ -0,0 +1,63 @@ +use proc_macro2::Literal; +use quote::{format_ident, quote}; +use syn::{spanned::Spanned, Fields, Item}; + +pub fn gen_lazy(input: proc_macro::TokenStream) -> syn::Result { + let item = syn::parse::(input)?; + let (name, generics, size) = match &item { + Item::Struct(strct) => (&strct.ident, &strct.generics, sum_fields(&strct.fields)), + Item::Enum(enm) => { + let arms = enm + .variants + .iter() + .map(|variant| sum_fields(&variant.fields)) + .enumerate() + .map(|(i, size)| (Literal::usize_unsuffixed(i), size)) + .map(|(i, size)| quote! { Some(#i) => { #size } }); + + ( + &enm.ident, + &enm.generics, + quote! { + 1 + match buf.first() { + #(#arms,)* + _ => unreachable!(), + } + }, + ) + } + Item::Union(_) => return Err(syn::Error::new(item.span(), "Unions are not supported")), + _ => unreachable!(), + }; + + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics anchor_lang::__private::Lazy for #name #ty_generics #where_clause { + #[inline(always)] + fn size_of(buf: &[u8]) -> usize { + #size + } + } + }) +} + +fn sum_fields(fields: &Fields) -> proc_macro2::TokenStream { + let names = fields + .iter() + .enumerate() + .map(|(i, _)| format_ident!("s{i}")) + .collect::>(); + let declarations = fields.iter().enumerate().map(|(i, field)| { + let ty = &field.ty; + let name = &names[i]; + let sum = &names[..i]; + let buf = quote! { &buf[0 #(+ #sum)*..] }; + quote! { let #name = <#ty as anchor_lang::__private::Lazy>::size_of(#buf) } + }); + + quote! { + #(#declarations;)* + 0 #(+ #names)* + } +} diff --git a/lang/derive/serde/src/lib.rs b/lang/derive/serde/src/lib.rs index 7326697b4..98614cdfb 100644 --- a/lang/derive/serde/src/lib.rs +++ b/lang/derive/serde/src/lib.rs @@ -1,5 +1,8 @@ extern crate proc_macro; +#[cfg(feature = "lazy-account")] +mod lazy; + use borsh_derive_internal::*; use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; @@ -73,5 +76,24 @@ fn gen_borsh_deserialize(input: TokenStream) -> TokenStream2 { #[proc_macro_derive(AnchorDeserialize, attributes(borsh_skip, borsh_init))] pub fn borsh_deserialize(input: TokenStream) -> TokenStream { - TokenStream::from(gen_borsh_deserialize(input)) + #[cfg(feature = "lazy-account")] + { + let deser = gen_borsh_deserialize(input.clone()); + let lazy = lazy::gen_lazy(input).unwrap_or_else(|e| e.to_compile_error()); + quote::quote! { + #deser + #lazy + } + .into() + } + #[cfg(not(feature = "lazy-account"))] + gen_borsh_deserialize(input).into() +} + +#[cfg(feature = "lazy-account")] +#[proc_macro_derive(Lazy)] +pub fn lazy(input: TokenStream) -> TokenStream { + lazy::gen_lazy(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() } diff --git a/lang/src/accounts/lazy_account.rs b/lang/src/accounts/lazy_account.rs new file mode 100644 index 000000000..1308cbd14 --- /dev/null +++ b/lang/src/accounts/lazy_account.rs @@ -0,0 +1,338 @@ +//! Like [`Account`](crate::Account), but deserializes on-demand. + +use std::{cell::RefCell, collections::BTreeSet, fmt, mem::MaybeUninit, rc::Rc}; + +use crate::{ + error::{Error, ErrorCode}, + AccountInfo, AccountMeta, AccountSerialize, Accounts, AccountsClose, Discriminator, Key, Owner, + Pubkey, Result, ToAccountInfo, ToAccountInfos, ToAccountMetas, +}; + +/// Deserialize account data lazily (on-demand). +/// +/// Anchor uses [`borsh`] deserialization by default, which can be expensive for both memory and +/// compute units usage. +/// +/// With the regular [`Account`] type, all account data gets deserialized, even the fields not used +/// by your instruction. On contrast, [`LazyAccount`] allows you to deserialize individual fields, +/// saving both memory and compute units. +/// +/// # Table of contents +/// +/// - [When to use](#when-to-use) +/// - [Features](#features) +/// - [Example](#example) +/// - [Safety](#safety) +/// - [Performance](#performance) +/// - [Memory](#memory) +/// - [Compute units](#compute-units) +/// +/// # When to use +/// +/// This is currently an experimental account type, and therefore should only be used when you're +/// running into performance issues. +/// +/// It's best to use [`LazyAccount`] when you only need to deserialize some of the fields, +/// especially if the account is read-only. +/// +/// Replacing [`Account`] (including `Box`ed) with [`LazyAccount`] *can* improve both stack memory +/// and compute unit usage. However, this is not guaranteed. For example, if you need to +/// deserialize the account fully, using [`LazyAccount`] will have additional overhead and +/// therefore use slightly more compute units. +/// +/// Currently, using the `mut` constraint eventually results in the whole account getting +/// deserialized, meaning it won't use fewer compute units compared to [`Account`]. This might get +/// optimized in the future. +/// +/// # Features +/// +/// - Can be used as a replacement for [`Account`]. +/// - Checks the account owner and its discriminator. +/// - Does **not** check the type layout matches the defined layout. +/// - All account data can be deserialized with `load` and `load_mut` methods. These methods are +/// non-inlined, meaning that they're less likely to cause stack violation errors. +/// - Each individual field can be deserialized with the generated `load_` and +/// `load_mut_` methods. +/// +/// # Example +/// +/// ``` +/// use anchor_lang::prelude::*; +/// +/// declare_id!("LazyAccount11111111111111111111111111111111"); +/// +/// #[program] +/// pub mod lazy_account { +/// use super::*; +/// +/// pub fn init(ctx: Context) -> Result<()> { +/// let mut my_account = ctx.accounts.my_account.load_mut()?; +/// my_account.authority = ctx.accounts.authority.key(); +/// +/// // Fill the dynamic data +/// for _ in 0..MAX_DATA_LEN { +/// my_account.dynamic.push(ctx.accounts.authority.key()); +/// } +/// +/// Ok(()) +/// } +/// +/// pub fn read(ctx: Context) -> Result<()> { +/// // Cached load due to the `has_one` constraint +/// let authority = ctx.accounts.my_account.load_authority()?; +/// msg!("Authority: {}", authority); +/// Ok(()) +/// } +/// +/// pub fn write(ctx: Context, new_authority: Pubkey) -> Result<()> { +/// // Cached load due to the `has_one` constraint +/// *ctx.accounts.my_account.load_mut_authority()? = new_authority; +/// Ok(()) +/// } +/// } +/// +/// #[derive(Accounts)] +/// pub struct Init<'info> { +/// #[account(mut)] +/// pub authority: Signer<'info>, +/// #[account( +/// init, +/// payer = authority, +/// space = MyAccount::DISCRIMINATOR.len() + MyAccount::INIT_SPACE +/// )] +/// pub my_account: LazyAccount<'info, MyAccount>, +/// pub system_program: Program<'info, System>, +/// } +/// +/// #[derive(Accounts)] +/// pub struct Read<'info> { +/// pub authority: Signer<'info>, +/// #[account(has_one = authority)] +/// pub my_account: LazyAccount<'info, MyAccount>, +/// } +/// +/// #[derive(Accounts)] +/// pub struct Write<'info> { +/// pub authority: Signer<'info>, +/// #[account(mut, has_one = authority)] +/// pub my_account: LazyAccount<'info, MyAccount>, +/// } +/// +/// const MAX_DATA_LEN: usize = 256; +/// +/// #[account] +/// #[derive(InitSpace)] +/// pub struct MyAccount { +/// pub authority: Pubkey, +/// pub fixed: [Pubkey; 8], +/// // Dynamic sized data also works, unlike `AccountLoader` +/// #[max_len(MAX_DATA_LEN)] +/// pub dynamic: Vec, +/// } +/// ``` +/// +/// # Safety +/// +/// The safety checks are done using the account's discriminator and the account's owner (similar +/// to [`Account`]). However, you should be extra careful when deserializing individual fields if, +/// for example, the account needs to be migrated. Make sure the previously serialized data always +/// matches the account's type identically. +/// +/// # Performance +/// +/// ## Memory +/// +/// All fields (including the inner account type) are heap allocated. It only uses 24 bytes (3x +/// pointer size) of stack memory in total. +/// +/// It's worth noting that where the account is being deserialized matters. For example, the main +/// place where Anchor programs are likely to hit stack violation errors is a generated function +/// called `try_accounts` (you might be familiar with it from the mangled build logs). This is +/// where the instruction is deserialized and constraints are run. Although having everything at the +/// same place is convenient for using constraints, this also makes it very easy to use the fixed +/// amount of stack space (4096 bytes) SVM allocates just by increasing the number of accounts the +/// instruction has. In SVM, each function has its own stack frame, meaning that it's possible to +/// deserialize more accounts simply by deserializing them inside other functions (rather than in +/// `try_accounts` which is already quite heavy). +/// +/// The mentioned stack limitation can be solved using dynamic stack frames, see [SIMD-0166]. +/// +/// ## Compute units +/// +/// Compute is harder to formulate, as it varies based on the inner account's type. That being said, +/// there are a few things you can do to optimize compute units usage when using [`LazyAccount`]: +/// +/// - Order account fields from fixed-size data (e.g. `u8`, `Pubkey`) to dynamic data (e.g. `Vec`). +/// - Order account fields based on how frequently the field is accessed (starting with the most +/// frequent). +/// - Reduce or limit dynamic fields. +/// +/// [`borsh`]: crate::prelude::borsh +/// [`Account`]: crate::prelude::Account +/// [SIMD-0166]: https://github.com/solana-foundation/solana-improvement-documents/pull/166 +pub struct LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + /// **INTERNAL FIELD DO NOT USE!** + #[doc(hidden)] + pub __info: &'info AccountInfo<'info>, + /// **INTERNAL FIELD DO NOT USE!** + #[doc(hidden)] + pub __account: Rc>>, + /// **INTERNAL FIELD DO NOT USE!** + #[doc(hidden)] + pub __fields: Rc>>>, +} + +impl<'info, T> fmt::Debug for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone + fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("LazyAccount") + .field("info", &self.__info) + .field("account", &self.__account) + .field("fields", &self.__fields) + .finish() + } +} + +impl<'info, T> LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + fn new(info: &'info AccountInfo<'info>) -> LazyAccount<'info, T> { + Self { + __info: info, + __account: Rc::new(RefCell::new(MaybeUninit::uninit())), + __fields: Rc::new(RefCell::new(None)), + } + } + + /// Check both the owner and the discriminator. + pub fn try_from(info: &'info AccountInfo<'info>) -> Result> { + let data = &info.try_borrow_data()?; + let disc = T::DISCRIMINATOR; + if data.len() < disc.len() { + return Err(ErrorCode::AccountDiscriminatorNotFound.into()); + } + + let given_disc = &data[..disc.len()]; + if given_disc != disc { + return Err(ErrorCode::AccountDiscriminatorMismatch.into()); + } + + Self::try_from_unchecked(info) + } + + /// Check the owner but **not** the discriminator. + pub fn try_from_unchecked(info: &'info AccountInfo<'info>) -> Result> { + if info.owner != &T::owner() { + return Err(Error::from(ErrorCode::AccountOwnedByWrongProgram) + .with_pubkeys((*info.owner, T::owner()))); + } + + Ok(LazyAccount::new(info)) + } + + /// Unload the deserialized account value by resetting the cache. + /// + /// This is useful when observing side-effects of CPIs. + /// + /// # Usage + /// + /// ```ignore + /// // Load the initial value + /// let initial_value = ctx.accounts.my_account.load_field()?; + /// + /// // Do CPI... + /// + /// // We still have a reference to the account from `initial_value`, drop it before `unload` + /// drop(initial_value); + /// + /// // Load the updated value + /// let updated_value = ctx.accounts.my_account.unload()?.load_field()?; + /// ``` + /// + /// # Panics + /// + /// If there is an existing reference (mutable or not) created by any of the `load` methods. + pub fn unload(&self) -> Result<&Self> { + // TODO: Should we drop the initialized fields manually? + *self.__account.borrow_mut() = MaybeUninit::uninit(); + *self.__fields.borrow_mut() = None; + Ok(self) + } +} + +impl<'info, B, T> Accounts<'info, B> for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + #[inline(never)] + fn try_accounts( + _program_id: &Pubkey, + accounts: &mut &'info [AccountInfo<'info>], + _ix_data: &[u8], + _bumps: &mut B, + _reallocs: &mut BTreeSet, + ) -> Result { + if accounts.is_empty() { + return Err(ErrorCode::AccountNotEnoughKeys.into()); + } + let account = &accounts[0]; + *accounts = &accounts[1..]; + LazyAccount::try_from(account) + } +} + +impl<'info, T> AccountsClose<'info> for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + fn close(&self, sol_destination: AccountInfo<'info>) -> Result<()> { + crate::common::close(self.to_account_info(), sol_destination) + } +} + +impl<'info, T> ToAccountMetas for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + fn to_account_metas(&self, is_signer: Option) -> Vec { + let is_signer = is_signer.unwrap_or(self.__info.is_signer); + let meta = match self.__info.is_writable { + false => AccountMeta::new_readonly(*self.__info.key, is_signer), + true => AccountMeta::new(*self.__info.key, is_signer), + }; + vec![meta] + } +} + +impl<'info, T> ToAccountInfos<'info> for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + fn to_account_infos(&self) -> Vec> { + vec![self.to_account_info()] + } +} + +impl<'info, T> AsRef> for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + fn as_ref(&self) -> &AccountInfo<'info> { + self.__info + } +} + +impl<'info, T> Key for LazyAccount<'info, T> +where + T: AccountSerialize + Discriminator + Owner + Clone, +{ + fn key(&self) -> Pubkey { + *self.__info.key + } +} diff --git a/lang/src/accounts/mod.rs b/lang/src/accounts/mod.rs index a5e55ebea..7f92e3cd1 100644 --- a/lang/src/accounts/mod.rs +++ b/lang/src/accounts/mod.rs @@ -12,3 +12,6 @@ pub mod signer; pub mod system_account; pub mod sysvar; pub mod unchecked_account; + +#[cfg(feature = "lazy-account")] +pub mod lazy_account; diff --git a/lang/src/lazy.rs b/lang/src/lazy.rs new file mode 100644 index 000000000..9a55854d8 --- /dev/null +++ b/lang/src/lazy.rs @@ -0,0 +1,202 @@ +use crate::{AnchorDeserialize, Pubkey}; + +/// A helper trait to make lazy deserialization work. +/// +/// Currently this is only implemented for [`borsh`], as it's not necessary for zero copy via +/// [`bytemuck`]. However, the functionality can be extended when we support custom serialization +/// in the future. +/// +/// # Note +/// +/// You should avoid implementing this trait manually. +/// +/// It's currently implemented automatically if you derive [`AnchorDeserialize`]: +/// +/// ```ignore +/// #[derive(AnchorDeserialize)] +/// pub struct MyStruct { +/// field: u8, +/// } +/// ``` +pub trait Lazy: AnchorDeserialize { + /// Get the serialized size of the type from the given buffer. + /// + /// For performance reasons, this method does not verify the validity of the data, and should + /// never fail. + /// + /// # Panics + /// + /// If the given buffer cannot be used to deserialize the data e.g. it's shorter than the + /// expected data. However, this doesn't mean it will panic **whenever** there is an incorrect + /// data e.g. passing **any** data for `bool::size_of` works, even when the buffer is empty. + fn size_of(buf: &[u8]) -> usize; +} + +macro_rules! impl_sized { + ($ty: ty) => { + impl Lazy for $ty { + #[inline(always)] + fn size_of(_buf: &[u8]) -> usize { + ::core::mem::size_of::<$ty>() + } + } + }; +} + +impl_sized!(bool); +impl_sized!(u8); +impl_sized!(u16); +impl_sized!(u32); +impl_sized!(u64); +impl_sized!(u128); +impl_sized!(i8); +impl_sized!(i16); +impl_sized!(i32); +impl_sized!(i64); +impl_sized!(i128); +impl_sized!(f32); +impl_sized!(f64); +impl_sized!(Pubkey); + +impl Lazy for [T; N] { + #[inline(always)] + fn size_of(buf: &[u8]) -> usize { + N * T::size_of(buf) + } +} + +impl Lazy for String { + #[inline(always)] + fn size_of(buf: &[u8]) -> usize { + LEN + get_len(buf) + } +} + +impl Lazy for Option { + #[inline(always)] + fn size_of(buf: &[u8]) -> usize { + 1 + match buf.first() { + Some(0) => 0, + Some(1) => T::size_of(&buf[1..]), + _ => unreachable!(), + } + } +} + +impl Lazy for Vec { + #[inline(always)] + fn size_of(buf: &[u8]) -> usize { + (0..get_len(buf)).fold(LEN, |acc, _| acc + T::size_of(&buf[acc..])) + } +} + +/// `borsh` length identifier of unsized types. +const LEN: usize = 4; + +#[inline(always)] +fn get_len(buf: &[u8]) -> usize { + u32::from_le_bytes((buf[..LEN].try_into()).unwrap()) + .try_into() + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AnchorSerialize; + + macro_rules! len { + ($val: expr) => { + $val.try_to_vec().unwrap().len() + }; + } + + #[test] + fn sized() { + // Sized inputs don't care about the passed data + const EMPTY: &[u8] = &[]; + assert_eq!(bool::size_of(EMPTY), len!(true)); + assert_eq!(u8::size_of(EMPTY), len!(0u8)); + assert_eq!(u16::size_of(EMPTY), len!(0u16)); + assert_eq!(u32::size_of(EMPTY), len!(0u32)); + assert_eq!(u64::size_of(EMPTY), len!(0u64)); + assert_eq!(u128::size_of(EMPTY), len!(0u128)); + assert_eq!(i8::size_of(EMPTY), len!(0i8)); + assert_eq!(i16::size_of(EMPTY), len!(0i16)); + assert_eq!(i32::size_of(EMPTY), len!(0i32)); + assert_eq!(i64::size_of(EMPTY), len!(0i64)); + assert_eq!(i128::size_of(EMPTY), len!(0i128)); + assert_eq!(f32::size_of(EMPTY), len!(0f32)); + assert_eq!(f64::size_of(EMPTY), len!(0f64)); + assert_eq!(Pubkey::size_of(EMPTY), len!(Pubkey::default())); + assert_eq!(<[i32; 4]>::size_of(EMPTY), len!([0i32; 4])); + } + + #[test] + fn r#unsized() { + assert_eq!(String::size_of(&[1, 0, 0, 0, 65]), len!(String::from("a"))); + assert_eq!(>::size_of(&[0]), len!(Option::::None)); + assert_eq!(>::size_of(&[1, 1]), len!(Some(1u8))); + assert_eq!(>::size_of(&[1, 0, 0, 0, 1]), len!(vec![1u8])); + assert_eq!( + >::size_of(&[1, 0, 0, 0, 1, 0, 0, 0, 65]), + len!(vec![String::from("a")]) + ); + assert_eq!( + >::size_of(&[2, 0, 0, 0, 1, 0, 0, 0, 65, 2, 0, 0, 0, 65, 66]), + len!(vec![String::from("a"), String::from("ab")]) + ); + } + + #[test] + fn defined() { + // Struct + #[derive(AnchorSerialize, AnchorDeserialize)] + struct MyStruct { + a: u8, + b: Vec, + c: Option, + } + + assert_eq!( + MyStruct::size_of(&[1, 2, 0, 0, 0, 1, 2, 1, 1, 0, 0, 0, 65]), + len!(MyStruct { + a: 1, + b: vec![1u8, 2], + c: Some(String::from("a")) + }) + ); + + // Enum + #[derive(AnchorSerialize, AnchorDeserialize)] + enum MyEnum { + Unit, + Named { a: u8 }, + Unnamed(i16, i16), + } + + assert_eq!(MyEnum::size_of(&[0]), len!(MyEnum::Unit)); + assert_eq!(MyEnum::size_of(&[1, 23]), len!(MyEnum::Named { a: 1 })); + assert_eq!( + MyEnum::size_of(&[2, 1, 2, 1, 2]), + len!(MyEnum::Unnamed(1, 2)) + ); + } + + #[test] + fn generic() { + #[derive(AnchorSerialize, AnchorDeserialize)] + struct GenericStruct { + t: T, + } + + assert_eq!( + GenericStruct::::size_of(&[1, 2, 3, 4, 5, 6, 7, 8]), + len!(GenericStruct { t: 1i64 }) + ); + assert_eq!( + GenericStruct::>::size_of(&[8, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8]), + len!(GenericStruct { t: vec![0u8; 8] }) + ); + } +} diff --git a/lang/src/lib.rs b/lang/src/lib.rs index 18c730e10..a27114e4d 100644 --- a/lang/src/lib.rs +++ b/lang/src/lib.rs @@ -44,8 +44,11 @@ pub mod event; #[doc(hidden)] pub mod idl; pub mod system_program; - mod vec; + +#[cfg(feature = "lazy-account")] +mod lazy; + pub use crate::bpf_upgradeable_state::*; pub use anchor_attribute_access_control::access_control; pub use anchor_attribute_account::{account, declare_id, pubkey, zero_copy}; @@ -440,19 +443,21 @@ pub mod prelude { #[cfg(feature = "interface-instructions")] pub use super::interface; + + #[cfg(feature = "lazy-account")] + pub use super::accounts::lazy_account::LazyAccount; } /// Internal module used by macros and unstable apis. #[doc(hidden)] pub mod __private { pub use anchor_attribute_account::ZeroCopyAccessor; - pub use anchor_attribute_event::EventIndex; - pub use base64; - pub use bytemuck; + pub use crate::{bpf_writer::BpfWriter, common::is_closed}; + use solana_program::pubkey::Pubkey; // Used to calculate the maximum between two expressions. @@ -478,6 +483,11 @@ pub mod __private { input.to_bytes() } } + + #[cfg(feature = "lazy-account")] + pub use crate::lazy::Lazy; + #[cfg(feature = "lazy-account")] + pub use anchor_derive_serde::Lazy; } /// Ensures a condition is true, otherwise returns with the given error. diff --git a/lang/syn/src/codegen/accounts/constraints.rs b/lang/syn/src/codegen/accounts/constraints.rs index 1683d582f..0d75c01a9 100644 --- a/lang/syn/src/codegen/accounts/constraints.rs +++ b/lang/syn/src/codegen/accounts/constraints.rs @@ -1,4 +1,4 @@ -use quote::quote; +use quote::{format_ident, quote}; use std::collections::HashSet; use crate::*; @@ -260,6 +260,13 @@ pub fn generate_constraint_has_one( Ty::AccountLoader(_) => quote! {#ident.load()?}, _ => quote! {#ident}, }; + let my_key = match &f.ty { + Ty::LazyAccount(_) => { + let load_ident = format_ident!("load_{}", target.to_token_stream().to_string()); + quote! { *#field.#load_ident()? } + } + _ => quote! { #field.#target }, + }; let error = generate_custom_error( ident, &c.error, @@ -272,7 +279,7 @@ pub fn generate_constraint_has_one( quote! { { #target_optional_check - let my_key = #field.#target; + let my_key = #my_key; let target_key = #target.key(); if my_key != target_key { return #error; diff --git a/lang/syn/src/codegen/accounts/exit.rs b/lang/syn/src/codegen/accounts/exit.rs index de732bd71..06bdea1e8 100644 --- a/lang/syn/src/codegen/accounts/exit.rs +++ b/lang/syn/src/codegen/accounts/exit.rs @@ -1,6 +1,6 @@ use crate::accounts_codegen::constraints::OptionalCheckScope; use crate::codegen::accounts::{generics, ParsedGenerics}; -use crate::{AccountField, AccountsStruct}; +use crate::{AccountField, AccountsStruct, Ty}; use quote::quote; // Generates the `Exit` trait implementation. @@ -46,9 +46,16 @@ pub fn generate(accs: &AccountsStruct) -> proc_macro2::TokenStream { } else { match f.constraints.is_mutable() { false => quote! {}, - true => quote! { - anchor_lang::AccountsExit::exit(&self.#ident, program_id) - .map_err(|e| e.with_account_name(#name_str))?; + true => match &f.ty { + // `LazyAccount` is special because it has a custom `exit` method. + Ty::LazyAccount(_) => quote! { + self.#ident.exit(program_id) + .map_err(|e| e.with_account_name(#name_str))?; + }, + _ => quote! { + anchor_lang::AccountsExit::exit(&self.#ident, program_id) + .map_err(|e| e.with_account_name(#name_str))?; + }, }, } } diff --git a/lang/syn/src/idl/accounts.rs b/lang/syn/src/idl/accounts.rs index 833036307..2eb9b5641 100644 --- a/lang/syn/src/idl/accounts.rs +++ b/lang/syn/src/idl/accounts.rs @@ -59,6 +59,7 @@ pub fn gen_idl_build_impl_accounts_struct(accounts: &AccountsStruct) -> TokenStr { Some(&ty.account_type_path) } + Ty::LazyAccount(ty) => Some(&ty.account_type_path), Ty::AccountLoader(ty) => Some(&ty.account_type_path), Ty::InterfaceAccount(ty) => Some(&ty.account_type_path), _ => None, diff --git a/lang/syn/src/lib.rs b/lang/syn/src/lib.rs index 3f87e8b4e..5392fd9fe 100644 --- a/lang/syn/src/lib.rs +++ b/lang/syn/src/lib.rs @@ -261,6 +261,7 @@ impl AccountField { let qualified_ty_name = match self { AccountField::Field(field) => match &field.ty { Ty::Account(account) => Some(parser::tts_to_string(&account.account_type_path)), + Ty::LazyAccount(account) => Some(parser::tts_to_string(&account.account_type_path)), _ => None, }, AccountField::CompositeField(field) => Some(field.symbol.clone()), @@ -404,6 +405,23 @@ impl Field { stream } } + Ty::LazyAccount(_) => { + if checked { + quote! { + match #container_ty::try_from(&#field) { + Ok(val) => val, + Err(e) => return Err(e.with_account_name(#field_str)) + } + } + } else { + quote! { + match #container_ty::try_from_unchecked(&#field) { + Ok(val) => val, + Err(e) => return Err(e.with_account_name(#field_str)) + } + } + } + } Ty::AccountLoader(_) => { if checked { quote! { @@ -446,6 +464,9 @@ impl Field { Ty::Account(_) => quote! { anchor_lang::accounts::account::Account }, + Ty::LazyAccount(_) => quote! { + anchor_lang::accounts::lazy_account::LazyAccount + }, Ty::AccountLoader(_) => quote! { anchor_lang::accounts::account_loader::AccountLoader }, @@ -487,6 +508,12 @@ impl Field { #ident } } + Ty::LazyAccount(ty) => { + let ident = &ty.account_type_path; + quote! { + #ident + } + } Ty::InterfaceAccount(ty) => { let ident = &ty.account_type_path; quote! { @@ -545,6 +572,7 @@ pub enum Ty { AccountLoader(AccountLoaderTy), Sysvar(SysvarTy), Account(AccountTy), + LazyAccount(LazyAccountTy), Program(ProgramTy), Interface(InterfaceTy), InterfaceAccount(InterfaceAccountTy), @@ -581,6 +609,12 @@ pub struct AccountTy { pub boxed: bool, } +#[derive(Debug, PartialEq, Eq)] +pub struct LazyAccountTy { + // The struct type of the account. + pub account_type_path: TypePath, +} + #[derive(Debug, PartialEq, Eq)] pub struct InterfaceAccountTy { // The struct type of the account. diff --git a/lang/syn/src/parser/accounts/constraints.rs b/lang/syn/src/parser/accounts/constraints.rs index 89fe22446..936cf2057 100644 --- a/lang/syn/src/parser/accounts/constraints.rs +++ b/lang/syn/src/parser/accounts/constraints.rs @@ -1177,7 +1177,10 @@ impl<'ty> ConstraintGroupBuilder<'ty> { // Require a known account type that implements the `Discriminator` trait so that we can // get the discriminator length dynamically - if !matches!(&self.f_ty, Some(Ty::Account(_) | Ty::AccountLoader(_))) { + if !matches!( + &self.f_ty, + Some(Ty::Account(_) | Ty::LazyAccount(_) | Ty::AccountLoader(_)) + ) { return Err(ParseError::new( c.span(), "`zero` constraint requires the type to implement the `Discriminator` trait", @@ -1189,11 +1192,12 @@ impl<'ty> ConstraintGroupBuilder<'ty> { fn add_realloc(&mut self, c: Context) -> ParseResult<()> { if !matches!(self.f_ty, Some(Ty::Account(_))) + && !matches!(self.f_ty, Some(Ty::LazyAccount(_))) && !matches!(self.f_ty, Some(Ty::AccountLoader(_))) { return Err(ParseError::new( c.span(), - "realloc must be on an Account or AccountLoader", + "realloc must be on an Account, LazyAccount or AccountLoader", )); } if self.mutable.is_none() { @@ -1239,11 +1243,12 @@ impl<'ty> ConstraintGroupBuilder<'ty> { fn add_close(&mut self, c: Context) -> ParseResult<()> { if !matches!(self.f_ty, Some(Ty::Account(_))) + && !matches!(self.f_ty, Some(Ty::LazyAccount(_))) && !matches!(self.f_ty, Some(Ty::AccountLoader(_))) { return Err(ParseError::new( c.span(), - "close must be on an Account, AccountLoader", + "close must be on an Account, LazyAccount or AccountLoader", )); } if self.mutable.is_none() { diff --git a/lang/syn/src/parser/accounts/mod.rs b/lang/syn/src/parser/accounts/mod.rs index f2958f8bc..f096f3216 100644 --- a/lang/syn/src/parser/accounts/mod.rs +++ b/lang/syn/src/parser/accounts/mod.rs @@ -334,6 +334,7 @@ fn is_field_primitive(f: &syn::Field) -> ParseResult { | "UncheckedAccount" | "AccountLoader" | "Account" + | "LazyAccount" | "Program" | "Interface" | "InterfaceAccount" @@ -352,6 +353,7 @@ fn parse_ty(f: &syn::Field) -> ParseResult<(Ty, bool)> { "UncheckedAccount" => Ty::UncheckedAccount, "AccountLoader" => Ty::AccountLoader(parse_program_account_loader(&path)?), "Account" => Ty::Account(parse_account_ty(&path)?), + "LazyAccount" => Ty::LazyAccount(parse_lazy_account_ty(&path)?), "Program" => Ty::Program(parse_program_ty(&path)?), "Interface" => Ty::Interface(parse_interface_ty(&path)?), "InterfaceAccount" => Ty::InterfaceAccount(parse_interface_account_ty(&path)?), @@ -444,6 +446,11 @@ fn parse_account_ty(path: &syn::Path) -> ParseResult { }) } +fn parse_lazy_account_ty(path: &syn::Path) -> ParseResult { + let account_type_path = parse_account(path)?; + Ok(LazyAccountTy { account_type_path }) +} + fn parse_interface_account_ty(path: &syn::Path) -> ParseResult { let account_type_path = parse_account(path)?; let boxed = parser::tts_to_string(path) diff --git a/tests/lazy-account/Anchor.toml b/tests/lazy-account/Anchor.toml new file mode 100644 index 000000000..eee11a6b5 --- /dev/null +++ b/tests/lazy-account/Anchor.toml @@ -0,0 +1,9 @@ +[programs.localnet] +lazy_account = "LazyAccount11111111111111111111111111111111" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/tests/lazy-account/Cargo.toml b/tests/lazy-account/Cargo.toml new file mode 100644 index 000000000..f39770481 --- /dev/null +++ b/tests/lazy-account/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tests/lazy-account/package.json b/tests/lazy-account/package.json new file mode 100644 index 000000000..9ac882250 --- /dev/null +++ b/tests/lazy-account/package.json @@ -0,0 +1,16 @@ +{ + "name": "lazy-account", + "version": "0.30.1", + "license": "(MIT OR Apache-2.0)", + "homepage": "https://github.com/coral-xyz/anchor#readme", + "bugs": { + "url": "https://github.com/coral-xyz/anchor/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/coral-xyz/anchor.git" + }, + "engines": { + "node": ">=17" + } +} diff --git a/tests/lazy-account/programs/lazy-account/Cargo.toml b/tests/lazy-account/programs/lazy-account/Cargo.toml new file mode 100644 index 000000000..deab4bcb6 --- /dev/null +++ b/tests/lazy-account/programs/lazy-account/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lazy-account" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "lazy_account" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = { path = "../../../../lang", features = ["lazy-account"] } diff --git a/tests/lazy-account/programs/lazy-account/Xargo.toml b/tests/lazy-account/programs/lazy-account/Xargo.toml new file mode 100644 index 000000000..1744f098a --- /dev/null +++ b/tests/lazy-account/programs/lazy-account/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/tests/lazy-account/programs/lazy-account/src/lib.rs b/tests/lazy-account/programs/lazy-account/src/lib.rs new file mode 100644 index 000000000..8947b5326 --- /dev/null +++ b/tests/lazy-account/programs/lazy-account/src/lib.rs @@ -0,0 +1,114 @@ +//! Tests demonstraing the usage of [`LazyAccount`]. +//! +//! The tests have been simplied by using a stack heavy account in order to demonstrate the usage +//! and its usefulness without adding excessive amount of accounts. +//! +//! See the individual instructions for more documentation: [`Init`], [`Read`], [`Write`]. + +use anchor_lang::prelude::*; + +declare_id!("LazyAccount11111111111111111111111111111111"); + +#[program] +pub mod lazy_account { + use super::*; + + pub fn init(ctx: Context) -> Result<()> { + let mut my_account = ctx.accounts.my_account.load_mut()?; + my_account.authority = ctx.accounts.authority.key(); + + for _ in 0..MAX_DATA_LEN { + my_account.dynamic.push(ctx.accounts.authority.key()); + } + + Ok(()) + } + + pub fn read(ctx: Context) -> Result<()> { + // Cached load due to the `has_one` constraint + let authority = ctx.accounts.my_account.load_authority()?; + msg!("Authority: {}", authority); + Ok(()) + } + + pub fn write(ctx: Context, new_authority: Pubkey) -> Result<()> { + // Cached load due to the `has_one` constraint + *ctx.accounts.my_account.load_mut_authority()? = new_authority; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct Init<'info> { + #[account(mut)] + pub authority: Signer<'info>, + #[account( + init, + payer = authority, + space = MyAccount::DISCRIMINATOR.len() + MyAccount::INIT_SPACE, + seeds = [b"my_account"], + bump + )] + pub my_account: LazyAccount<'info, MyAccount>, + /// Using `Account` instead of `LazyAccount` would either make the instruction fail due to + /// access violation errors, or worse, it would cause undefined behavior instead. + /// + /// Using `Account` with Solana v1.18.17 (`platform-tools` v1.41) results in a stack violation + /// error (without a compiler error/warning on build). + #[account( + init, + payer = authority, + space = StackHeavyAccount::DISCRIMINATOR.len() + StackHeavyAccount::INIT_SPACE, + seeds = [b"stack_heavy_account"], + bump + )] + pub stack_heavy_account: LazyAccount<'info, StackHeavyAccount>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Read<'info> { + pub authority: Signer<'info>, + /// Using `Account` or `Box` instead of `LazyAccount` would increase the compute + /// units usage by ~90k units due to the unnecessary deserialization of the unused fields. + #[account(seeds = [b"my_account"], bump, has_one = authority)] + pub my_account: LazyAccount<'info, MyAccount>, + /// This account imitates heavy stack usage in more complex programs + #[account(seeds = [b"stack_heavy_account"], bump)] + pub stack_heavy_account: Account<'info, StackHeavyAccount>, +} + +#[derive(Accounts)] +pub struct Write<'info> { + pub authority: Signer<'info>, + /// Using `Account` instead of `LazyAccount` would either make the instruction fail due to stack + /// violation errors, or worse, it would cause undefined behavior instead. + /// + /// Using `Account` with Solana v1.18.17 (`platform-tools` v1.41) results in undefined behavior + /// in this instruction, and the authority field gets corrupted when writing. + #[account(mut, seeds = [b"my_account"], bump, has_one = authority)] + pub my_account: LazyAccount<'info, MyAccount>, + /// This account imitates heavy stack usage in more complex programs + #[account(seeds = [b"stack_heavy_account"], bump)] + pub stack_heavy_account: Account<'info, StackHeavyAccount>, +} + +const MAX_DATA_LEN: usize = 256; + +#[account] +#[derive(InitSpace)] +pub struct MyAccount { + pub authority: Pubkey, + /// Fixed size data + pub fixed: [Pubkey; 8], + /// Dynamic sized data also works, unlike `AccountLoader` + #[max_len(MAX_DATA_LEN)] + pub dynamic: Vec, +} + +/// Stack heavy filler account that imitates heavy stack usage caused my many accounts +#[account] +#[derive(InitSpace)] +pub struct StackHeavyAccount { + pub data: [u8; 1600], +} diff --git a/tests/lazy-account/tests/lazy-account.ts b/tests/lazy-account/tests/lazy-account.ts new file mode 100644 index 000000000..6f1020088 --- /dev/null +++ b/tests/lazy-account/tests/lazy-account.ts @@ -0,0 +1,36 @@ +import * as anchor from "@coral-xyz/anchor"; +import assert from "assert"; + +import type { LazyAccount } from "../target/types/lazy_account"; + +describe("lazy-account", () => { + anchor.setProvider(anchor.AnchorProvider.env()); + const program: anchor.Program = anchor.workspace.lazyAccount; + + it("Can init", async () => { + const { pubkeys, signature } = await program.methods.init().rpcAndKeys(); + await program.provider.connection.confirmTransaction( + signature, + "confirmed" + ); + const myAccount = await program.account.myAccount.fetch(pubkeys.myAccount); + assert(myAccount.authority.equals(program.provider.publicKey!)); + }); + + it("Can read", async () => { + await program.methods.read().rpc(); + }); + + it("Can write", async () => { + const newAuthority = anchor.web3.PublicKey.default; + const { pubkeys, signature } = await program.methods + .write(newAuthority) + .rpcAndKeys(); + await program.provider.connection.confirmTransaction( + signature, + "confirmed" + ); + const myAccount = await program.account.myAccount.fetch(pubkeys.myAccount); + assert(myAccount.authority.equals(newAuthority)); + }); +}); diff --git a/tests/lazy-account/tsconfig.json b/tests/lazy-account/tsconfig.json new file mode 100644 index 000000000..8c893f2d5 --- /dev/null +++ b/tests/lazy-account/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/tests/package.json b/tests/package.json index 7c782d276..55d0e6ba6 100644 --- a/tests/package.json +++ b/tests/package.json @@ -25,6 +25,7 @@ "idl", "ido-pool", "interface", + "lazy-account", "lockup", "misc", "multisig",