Skip to content

Commit 7360dbb

Browse files
authored
feat(lang): Allow Generic type programs in Accounts Struct (#3878)
* feat(lang): Enhance Program type with generic validation support * feat(tests): Add generic program tests * refactor: Replace realloc with resize * feat(tests): Add test for custom program address validation * bench: update * chore: Improve formatting * Revert "refactor: Replace realloc with resize" This reverts commit 61cc66d. * feat(tests): Add custom program tests * chore(tests): Rename to custom-program * chore: Update CHANGELOG.md * chore: Update benchmarks
1 parent 219023e commit 7360dbb

File tree

17 files changed

+348
-19
lines changed

17 files changed

+348
-19
lines changed

.github/workflows/reusable-tests.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,8 @@ jobs:
414414
path: tests/declare-id
415415
- cmd: cd tests/declare-program && anchor test --skip-lint
416416
path: tests/declare-program
417+
- cmd: cd tests/custom-program && anchor test --skip-lint
418+
path: tests/custom-program
417419
- cmd: cd tests/typescript && anchor test --skip-lint && npx tsc --noEmit
418420
path: tests/typescript
419421
# zero-copy tests cause `/usr/bin/ld: final link failed: No space left on device`

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The minor version will be incremented upon a breaking change and the patch versi
3737
- cli: Replace `anchor verify` to use `solana-verify` under the hood, adding automatic installation via AVM, local path support, and future-proof argument passing ([#3768](https://github.com/solana-foundation/anchor/pull/3768)).
3838
- lang: Replace `solana-program` crate with smaller crates ([#3819](https://github.com/solana-foundation/anchor/pull/3819)).
3939
- cli: Make `anchor deploy` to upload the IDL to the cluster by default unless `--no-idl` is passed ([#3863](https://github.com/solana-foundation/anchor/pull/3863)).
40+
- lang: Add generic program validation support to `Program` type allowing `Program<'info>` for executable-only validation ([#3878](https://github.com/solana-foundation/anchor/pull/3878)).
4041
- lang: Use `solana-invoke` instead of `solana_cpi::invoke` ([#3900](https://github.com/solana-foundation/anchor/pull/3900)).
4142
- client: remove `solana-client` from `anchor-client` and `cli` ([#3877](https://github.com/solana-foundation/anchor/pull/3877)).
4243
- idl: Build IDL on stable Rustc ([#3842](https://github.com/solana-foundation/anchor/pull/3842)).

bench/COMPUTE_UNITS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,9 @@ Solana version: 2.1.0
286286
| interface4 | 1,189 | - |
287287
| interface8 | 1,748 | - |
288288
| program1 | 779 | - |
289-
| program2 | 920 | - |
290-
| program4 | 1,193 | - |
291-
| program8 | 1,744 | - |
289+
| program2 | 934 | 🔴 **+14 (1.52%)** |
290+
| program4 | 1,221 | 🔴 **+28 (2.35%)** |
291+
| program8 | 1,800 | 🔴 **+56 (3.21%)** |
292292
| signer1 | 774 | - |
293293
| signer2 | 1,064 | - |
294294
| signer4 | 1,637 | - |

lang/src/accounts/program.rs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,24 @@ use std::ops::Deref;
2121
///
2222
/// # Table of Contents
2323
/// - [Basic Functionality](#basic-functionality)
24+
/// - [Generic Program Validation](#generic-program-validation)
2425
/// - [Out of the Box Types](#out-of-the-box-types)
2526
///
2627
/// # Basic Functionality
2728
///
29+
/// For `Program<'info, T>` where T implements Id:
2830
/// Checks:
2931
///
3032
/// - `account_info.key == expected_program`
3133
/// - `account_info.executable == true`
3234
///
35+
/// # Generic Program Validation
36+
///
37+
/// For `Program<'info>` (without type parameter):
38+
/// - Only checks: `account_info.executable == true`
39+
/// - Use this when you only need to verify that an address is executable,
40+
/// without validating against a specific program ID.
41+
///
3342
/// # Example
3443
/// ```ignore
3544
/// #[program]
@@ -65,6 +74,16 @@ use std::ops::Deref;
6574
/// - `program_data`'s constraint checks that its upgrade authority is the `authority` account.
6675
/// - Finally, `authority` needs to sign the transaction.
6776
///
77+
/// ## Generic Program Example
78+
/// ```ignore
79+
/// #[derive(Accounts)]
80+
/// pub struct ValidateExecutableProgram<'info> {
81+
/// // Only validates that the provided account is executable
82+
/// pub any_program: Program<'info>,
83+
/// pub authority: Signer<'info>,
84+
/// }
85+
/// ```
86+
///
6887
/// # Out of the Box Types
6988
///
7089
/// Between the [`anchor_lang`](https://docs.rs/anchor-lang/latest/anchor_lang) and [`anchor_spl`](https://docs.rs/anchor_spl/latest/anchor_spl) crates,
@@ -75,7 +94,7 @@ use std::ops::Deref;
7594
/// - [`Token`](https://docs.rs/anchor-spl/latest/anchor_spl/token/struct.Token.html)
7695
///
7796
#[derive(Clone)]
78-
pub struct Program<'info, T> {
97+
pub struct Program<'info, T = ()> {
7998
info: &'info AccountInfo<'info>,
8099
_phantom: PhantomData<T>,
81100
}
@@ -128,13 +147,15 @@ impl<'a, T: Id> TryFrom<&'a AccountInfo<'a>> for Program<'a, T> {
128147
type Error = Error;
129148
/// Deserializes the given `info` into a `Program`.
130149
fn try_from(info: &'a AccountInfo<'a>) -> Result<Self> {
131-
if info.key != &T::id() {
150+
// Special handling for unit type () - only check executable, not program ID
151+
let is_unit_type = T::id() == Pubkey::default();
152+
153+
if !is_unit_type && info.key != &T::id() {
132154
return Err(Error::from(ErrorCode::InvalidProgramId).with_pubkeys((*info.key, T::id())));
133155
}
134156
if !info.executable {
135157
return Err(ErrorCode::InvalidProgramExecutable.into());
136158
}
137-
138159
Ok(Program::new(info))
139160
}
140161
}
@@ -195,3 +216,13 @@ impl<T: AccountDeserialize> Key for Program<'_, T> {
195216
*self.info.key
196217
}
197218
}
219+
220+
// Implement Id trait for unit type to support Program<'info> without type parameter
221+
impl crate::Id for () {
222+
fn id() -> Pubkey {
223+
// For generic programs, this should never be called since they don't validate specific program IDs.
224+
// However, we need to implement it to satisfy the trait bounds.
225+
// Using a special marker value that indicates "any program"
226+
Pubkey::default()
227+
}
228+
}

lang/syn/src/idl/accounts.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,17 @@ fn get_address(acc: &Field) -> TokenStream {
146146
match &acc.ty {
147147
Ty::Program(_) | Ty::Sysvar(_) => {
148148
let ty = acc.account_ty();
149-
let id_trait = matches!(acc.ty, Ty::Program(_))
150-
.then(|| quote!(anchor_lang::Id))
151-
.unwrap_or_else(|| quote!(anchor_lang::solana_program::sysvar::SysvarId));
152-
quote! { Some(<#ty as #id_trait>::id().to_string()) }
149+
// Check if this is the unit type marker (for generic Program<'info>)
150+
let ty_str = quote!(#ty).to_string();
151+
if ty_str == "" || ty_str == "__SolanaProgramUnitType" {
152+
// For generic programs, we don't have a specific address
153+
quote! { None }
154+
} else {
155+
let id_trait = matches!(acc.ty, Ty::Program(_))
156+
.then(|| quote!(anchor_lang::Id))
157+
.unwrap_or_else(|| quote!(anchor_lang::solana_program::sysvar::SysvarId));
158+
quote! { Some(<#ty as #id_trait>::id().to_string()) }
159+
}
153160
}
154161
_ => acc
155162
.constraints

lang/syn/src/lib.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,20 @@ impl Field {
344344
Sysvar<#account>
345345
}
346346
}
347+
Ty::Program(ty) => {
348+
let program = &ty.account_type_path;
349+
// Check if this is the generic Program<'info> (unit type)
350+
let program_str = quote!(#program).to_string();
351+
if program_str == "__SolanaProgramUnitType" {
352+
quote! {
353+
#container_ty<'info>
354+
}
355+
} else {
356+
quote! {
357+
#container_ty<'info, #program>
358+
}
359+
}
360+
}
347361
_ => quote! {
348362
#container_ty<#account_ty>
349363
},
@@ -543,8 +557,14 @@ impl Field {
543557
},
544558
Ty::Program(ty) => {
545559
let program = &ty.account_type_path;
546-
quote! {
547-
#program
560+
// Check if this is the special marker for generic Program<'info> (unit type)
561+
let program_str = quote!(#program).to_string();
562+
if program_str == "__SolanaProgramUnitType" {
563+
quote! {}
564+
} else {
565+
quote! {
566+
#program
567+
}
548568
}
549569
}
550570
Ty::Interface(ty) => {

lang/syn/src/parser/accounts/mod.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ fn parse_interface_account_ty(path: &syn::Path) -> ParseResult<InterfaceAccountT
477477
}
478478

479479
fn parse_program_ty(path: &syn::Path) -> ParseResult<ProgramTy> {
480-
let account_type_path = parse_account(path)?;
480+
let account_type_path = parse_program_account(path)?;
481481
Ok(ProgramTy { account_type_path })
482482
}
483483

@@ -486,6 +486,52 @@ fn parse_interface_ty(path: &syn::Path) -> ParseResult<InterfaceTy> {
486486
Ok(InterfaceTy { account_type_path })
487487
}
488488

489+
// Special parsing function for Program that handles both Program<'info> and Program<'info, T>
490+
fn parse_program_account(path: &syn::Path) -> ParseResult<syn::TypePath> {
491+
let segments = &path.segments[0];
492+
match &segments.arguments {
493+
syn::PathArguments::AngleBracketed(args) => {
494+
match args.args.len() {
495+
// Program<'info> - only lifetime, no type parameter
496+
1 => {
497+
// Create a special marker for unit type that gets handled later
498+
use syn::{Path, PathSegment, PathArguments};
499+
let path_segment = PathSegment {
500+
ident: syn::Ident::new("__SolanaProgramUnitType", proc_macro2::Span::call_site()),
501+
arguments: PathArguments::None,
502+
};
503+
504+
Ok(syn::TypePath {
505+
qself: None,
506+
path: Path {
507+
leading_colon: None,
508+
segments: std::iter::once(path_segment).collect(),
509+
},
510+
})
511+
}
512+
// Program<'info, T> - lifetime and type
513+
2 => {
514+
match &args.args[1] {
515+
syn::GenericArgument::Type(syn::Type::Path(ty_path)) => Ok(ty_path.clone()),
516+
_ => Err(ParseError::new(
517+
args.args[1].span(),
518+
"second bracket argument must be a type",
519+
)),
520+
}
521+
}
522+
_ => Err(ParseError::new(
523+
args.args.span(),
524+
"Program must have either just a lifetime (Program<'info>) or a lifetime and type (Program<'info, T>)",
525+
)),
526+
}
527+
}
528+
_ => Err(ParseError::new(
529+
segments.arguments.span(),
530+
"expected angle brackets with lifetime or lifetime and type",
531+
)),
532+
}
533+
}
534+
489535
// TODO: this whole method is a hack. Do something more idiomatic.
490536
fn parse_account(mut path: &syn::Path) -> ParseResult<syn::TypePath> {
491537
let path_str = parser::tts_to_string(path).replace(' ', "");

tests/bench/bench.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,9 +1380,9 @@
13801380
"interface4": 1189,
13811381
"interface8": 1748,
13821382
"program1": 779,
1383-
"program2": 920,
1384-
"program4": 1193,
1385-
"program8": 1744,
1383+
"program2": 934,
1384+
"program4": 1221,
1385+
"program8": 1800,
13861386
"signer1": 774,
13871387
"signer2": 1064,
13881388
"signer4": 1637,
@@ -1752,9 +1752,9 @@
17521752
"interface4": 1301,
17531753
"interface8": 1867,
17541754
"program1": 890,
1755-
"program2": 1035,
1756-
"program4": 1313,
1757-
"program8": 1879,
1755+
"program2": 1051,
1756+
"program4": 1345,
1757+
"program8": 1943,
17581758
"signer1": 874,
17591759
"signer2": 1173,
17601760
"signer4": 1759,

tests/custom-program/Anchor.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[provider]
2+
cluster = "localnet"
3+
wallet = "~/.config/solana/id.json"
4+
5+
[programs.localnet]
6+
custom_program = "FdQ5d5kJDidxLP8qBm2d4G47QbDMWk6iWJ3QkYY2UAP7"
7+
8+
[scripts]
9+
test = "yarn run ts-mocha -t 1000000 tests/*.ts"
10+
11+
[test.validator]
12+
url = "https://api.mainnet-beta.solana.com"
13+
14+
[[test.validator.clone]]
15+
address = "9cxLzxjrTeodcbaEU3KCNGE1a4yFZEcdJ7uEXN378S4U"
16+
17+
[[test.validator.clone]]
18+
address = "PhoeNiXZ8ByJGLkxNfZRnkUfjvmuYqLR89jjFHGqdXY"
19+
20+
[[test.validator.clone]]
21+
address = "dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH"

tests/custom-program/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[workspace]
2+
members = [
3+
"programs/*"
4+
]
5+
resolver = "2"
6+
7+
[profile.release]
8+
overflow-checks = true

0 commit comments

Comments
 (0)