diff --git a/.github/workflows/reusable-tests.yaml b/.github/workflows/reusable-tests.yaml index 7b8af78491..f6ac214058 100644 --- a/.github/workflows/reusable-tests.yaml +++ b/.github/workflows/reusable-tests.yaml @@ -414,6 +414,8 @@ jobs: path: tests/declare-id - cmd: cd tests/declare-program && anchor test --skip-lint path: tests/declare-program + - cmd: cd tests/custom-program && anchor test --skip-lint + path: tests/custom-program - cmd: cd tests/typescript && anchor test --skip-lint && npx tsc --noEmit path: tests/typescript # zero-copy tests cause `/usr/bin/ld: final link failed: No space left on device` diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b357ce94..7ea8718773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The minor version will be incremented upon a breaking change and the patch versi - 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)). - lang: Replace `solana-program` crate with smaller crates ([#3819](https://github.com/solana-foundation/anchor/pull/3819)). - 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)). +- 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)). - lang: Use `solana-invoke` instead of `solana_cpi::invoke` ([#3900](https://github.com/solana-foundation/anchor/pull/3900)). - client: remove `solana-client` from `anchor-client` and `cli` ([#3877](https://github.com/solana-foundation/anchor/pull/3877)). - idl: Build IDL on stable Rustc ([#3842](https://github.com/solana-foundation/anchor/pull/3842)). diff --git a/bench/COMPUTE_UNITS.md b/bench/COMPUTE_UNITS.md index 3aa5615ff1..e796228b98 100644 --- a/bench/COMPUTE_UNITS.md +++ b/bench/COMPUTE_UNITS.md @@ -286,9 +286,9 @@ Solana version: 2.1.0 | interface4 | 1,189 | - | | interface8 | 1,748 | - | | program1 | 779 | - | -| program2 | 920 | - | -| program4 | 1,193 | - | -| program8 | 1,744 | - | +| program2 | 934 | 🔴 **+14 (1.52%)** | +| program4 | 1,221 | 🔴 **+28 (2.35%)** | +| program8 | 1,800 | 🔴 **+56 (3.21%)** | | signer1 | 774 | - | | signer2 | 1,064 | - | | signer4 | 1,637 | - | diff --git a/lang/src/accounts/program.rs b/lang/src/accounts/program.rs index 122b57a924..80b7c265a9 100644 --- a/lang/src/accounts/program.rs +++ b/lang/src/accounts/program.rs @@ -21,15 +21,24 @@ use std::ops::Deref; /// /// # Table of Contents /// - [Basic Functionality](#basic-functionality) +/// - [Generic Program Validation](#generic-program-validation) /// - [Out of the Box Types](#out-of-the-box-types) /// /// # Basic Functionality /// +/// For `Program<'info, T>` where T implements Id: /// Checks: /// /// - `account_info.key == expected_program` /// - `account_info.executable == true` /// +/// # Generic Program Validation +/// +/// For `Program<'info>` (without type parameter): +/// - Only checks: `account_info.executable == true` +/// - Use this when you only need to verify that an address is executable, +/// without validating against a specific program ID. +/// /// # Example /// ```ignore /// #[program] @@ -65,6 +74,16 @@ use std::ops::Deref; /// - `program_data`'s constraint checks that its upgrade authority is the `authority` account. /// - Finally, `authority` needs to sign the transaction. /// +/// ## Generic Program Example +/// ```ignore +/// #[derive(Accounts)] +/// pub struct ValidateExecutableProgram<'info> { +/// // Only validates that the provided account is executable +/// pub any_program: Program<'info>, +/// pub authority: Signer<'info>, +/// } +/// ``` +/// /// # Out of the Box Types /// /// 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; /// - [`Token`](https://docs.rs/anchor-spl/latest/anchor_spl/token/struct.Token.html) /// #[derive(Clone)] -pub struct Program<'info, T> { +pub struct Program<'info, T = ()> { info: &'info AccountInfo<'info>, _phantom: PhantomData, } @@ -128,13 +147,15 @@ impl<'a, T: Id> TryFrom<&'a AccountInfo<'a>> for Program<'a, T> { type Error = Error; /// Deserializes the given `info` into a `Program`. fn try_from(info: &'a AccountInfo<'a>) -> Result { - if info.key != &T::id() { + // Special handling for unit type () - only check executable, not program ID + let is_unit_type = T::id() == Pubkey::default(); + + if !is_unit_type && info.key != &T::id() { return Err(Error::from(ErrorCode::InvalidProgramId).with_pubkeys((*info.key, T::id()))); } if !info.executable { return Err(ErrorCode::InvalidProgramExecutable.into()); } - Ok(Program::new(info)) } } @@ -195,3 +216,13 @@ impl Key for Program<'_, T> { *self.info.key } } + +// Implement Id trait for unit type to support Program<'info> without type parameter +impl crate::Id for () { + fn id() -> Pubkey { + // For generic programs, this should never be called since they don't validate specific program IDs. + // However, we need to implement it to satisfy the trait bounds. + // Using a special marker value that indicates "any program" + Pubkey::default() + } +} diff --git a/lang/syn/src/idl/accounts.rs b/lang/syn/src/idl/accounts.rs index a66957bc71..b481281f46 100644 --- a/lang/syn/src/idl/accounts.rs +++ b/lang/syn/src/idl/accounts.rs @@ -146,10 +146,17 @@ fn get_address(acc: &Field) -> TokenStream { match &acc.ty { Ty::Program(_) | Ty::Sysvar(_) => { let ty = acc.account_ty(); - let id_trait = matches!(acc.ty, Ty::Program(_)) - .then(|| quote!(anchor_lang::Id)) - .unwrap_or_else(|| quote!(anchor_lang::solana_program::sysvar::SysvarId)); - quote! { Some(<#ty as #id_trait>::id().to_string()) } + // Check if this is the unit type marker (for generic Program<'info>) + let ty_str = quote!(#ty).to_string(); + if ty_str == "" || ty_str == "__SolanaProgramUnitType" { + // For generic programs, we don't have a specific address + quote! { None } + } else { + let id_trait = matches!(acc.ty, Ty::Program(_)) + .then(|| quote!(anchor_lang::Id)) + .unwrap_or_else(|| quote!(anchor_lang::solana_program::sysvar::SysvarId)); + quote! { Some(<#ty as #id_trait>::id().to_string()) } + } } _ => acc .constraints diff --git a/lang/syn/src/lib.rs b/lang/syn/src/lib.rs index 579380aa46..e561350990 100644 --- a/lang/syn/src/lib.rs +++ b/lang/syn/src/lib.rs @@ -344,6 +344,20 @@ impl Field { Sysvar<#account> } } + Ty::Program(ty) => { + let program = &ty.account_type_path; + // Check if this is the generic Program<'info> (unit type) + let program_str = quote!(#program).to_string(); + if program_str == "__SolanaProgramUnitType" { + quote! { + #container_ty<'info> + } + } else { + quote! { + #container_ty<'info, #program> + } + } + } _ => quote! { #container_ty<#account_ty> }, @@ -543,8 +557,14 @@ impl Field { }, Ty::Program(ty) => { let program = &ty.account_type_path; - quote! { - #program + // Check if this is the special marker for generic Program<'info> (unit type) + let program_str = quote!(#program).to_string(); + if program_str == "__SolanaProgramUnitType" { + quote! {} + } else { + quote! { + #program + } } } Ty::Interface(ty) => { diff --git a/lang/syn/src/parser/accounts/mod.rs b/lang/syn/src/parser/accounts/mod.rs index b823f939a4..83f82d60d8 100644 --- a/lang/syn/src/parser/accounts/mod.rs +++ b/lang/syn/src/parser/accounts/mod.rs @@ -477,7 +477,7 @@ fn parse_interface_account_ty(path: &syn::Path) -> ParseResult ParseResult { - let account_type_path = parse_account(path)?; + let account_type_path = parse_program_account(path)?; Ok(ProgramTy { account_type_path }) } @@ -486,6 +486,52 @@ fn parse_interface_ty(path: &syn::Path) -> ParseResult { Ok(InterfaceTy { account_type_path }) } +// Special parsing function for Program that handles both Program<'info> and Program<'info, T> +fn parse_program_account(path: &syn::Path) -> ParseResult { + let segments = &path.segments[0]; + match &segments.arguments { + syn::PathArguments::AngleBracketed(args) => { + match args.args.len() { + // Program<'info> - only lifetime, no type parameter + 1 => { + // Create a special marker for unit type that gets handled later + use syn::{Path, PathSegment, PathArguments}; + let path_segment = PathSegment { + ident: syn::Ident::new("__SolanaProgramUnitType", proc_macro2::Span::call_site()), + arguments: PathArguments::None, + }; + + Ok(syn::TypePath { + qself: None, + path: Path { + leading_colon: None, + segments: std::iter::once(path_segment).collect(), + }, + }) + } + // Program<'info, T> - lifetime and type + 2 => { + match &args.args[1] { + syn::GenericArgument::Type(syn::Type::Path(ty_path)) => Ok(ty_path.clone()), + _ => Err(ParseError::new( + args.args[1].span(), + "second bracket argument must be a type", + )), + } + } + _ => Err(ParseError::new( + args.args.span(), + "Program must have either just a lifetime (Program<'info>) or a lifetime and type (Program<'info, T>)", + )), + } + } + _ => Err(ParseError::new( + segments.arguments.span(), + "expected angle brackets with lifetime or lifetime and type", + )), + } +} + // TODO: this whole method is a hack. Do something more idiomatic. fn parse_account(mut path: &syn::Path) -> ParseResult { let path_str = parser::tts_to_string(path).replace(' ', ""); diff --git a/tests/bench/bench.json b/tests/bench/bench.json index cbebb7849f..2301c2ebdc 100644 --- a/tests/bench/bench.json +++ b/tests/bench/bench.json @@ -1380,9 +1380,9 @@ "interface4": 1189, "interface8": 1748, "program1": 779, - "program2": 920, - "program4": 1193, - "program8": 1744, + "program2": 934, + "program4": 1221, + "program8": 1800, "signer1": 774, "signer2": 1064, "signer4": 1637, @@ -1752,9 +1752,9 @@ "interface4": 1301, "interface8": 1867, "program1": 890, - "program2": 1035, - "program4": 1313, - "program8": 1879, + "program2": 1051, + "program4": 1345, + "program8": 1943, "signer1": 874, "signer2": 1173, "signer4": 1759, diff --git a/tests/custom-program/Anchor.toml b/tests/custom-program/Anchor.toml new file mode 100644 index 0000000000..653b37e73b --- /dev/null +++ b/tests/custom-program/Anchor.toml @@ -0,0 +1,21 @@ +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[programs.localnet] +custom_program = "FdQ5d5kJDidxLP8qBm2d4G47QbDMWk6iWJ3QkYY2UAP7" + +[scripts] +test = "yarn run ts-mocha -t 1000000 tests/*.ts" + +[test.validator] +url = "https://api.mainnet-beta.solana.com" + +[[test.validator.clone]] +address = "9cxLzxjrTeodcbaEU3KCNGE1a4yFZEcdJ7uEXN378S4U" + +[[test.validator.clone]] +address = "PhoeNiXZ8ByJGLkxNfZRnkUfjvmuYqLR89jjFHGqdXY" + +[[test.validator.clone]] +address = "dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH" \ No newline at end of file diff --git a/tests/custom-program/Cargo.toml b/tests/custom-program/Cargo.toml new file mode 100644 index 0000000000..97d6280542 --- /dev/null +++ b/tests/custom-program/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true diff --git a/tests/custom-program/package.json b/tests/custom-program/package.json new file mode 100644 index 0000000000..ee17d56bd0 --- /dev/null +++ b/tests/custom-program/package.json @@ -0,0 +1,22 @@ +{ + "name": "custom-program", + "version": "0.31.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" + }, + "scripts": { + "test": "anchor test" + }, + "dependencies": { + "ts-mocha": "^11.1.0" + } +} diff --git a/tests/custom-program/programs/custom-program/Cargo.toml b/tests/custom-program/programs/custom-program/Cargo.toml new file mode 100644 index 0000000000..90212a17c4 --- /dev/null +++ b/tests/custom-program/programs/custom-program/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "custom-program" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "custom_program" + +[features] +no-entrypoint = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = { path = "../../../../lang" } diff --git a/tests/custom-program/programs/custom-program/Xargo.toml b/tests/custom-program/programs/custom-program/Xargo.toml new file mode 100644 index 0000000000..475fb71ed1 --- /dev/null +++ b/tests/custom-program/programs/custom-program/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/custom-program/programs/custom-program/src/lib.rs b/tests/custom-program/programs/custom-program/src/lib.rs new file mode 100644 index 0000000000..56761f8a4e --- /dev/null +++ b/tests/custom-program/programs/custom-program/src/lib.rs @@ -0,0 +1,54 @@ +use anchor_lang::prelude::*; + +declare_id!("FdQ5d5kJDidxLP8qBm2d4G47QbDMWk6iWJ3QkYY2UAP7"); + +pub const CUSTOM_PROGRAM_ID: Pubkey = pubkey!("PhoeNiXZ8ByJGLkxNfZRnkUfjvmuYqLR89jjFHGqdXY"); +pub const NON_EXECUTABLE_ACCOUNT_ID: Pubkey = + pubkey!("2myyNegEA6pjAHmmEsJC6JdYhW51gwxQW7ZCTWvwaKTk"); +pub const CUSTOM_PROGRAM_ADDRESS: Pubkey = pubkey!("dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH"); + +// Define a marker struct for our custom program ID +pub struct CustomProgramMarker; + +impl Id for CustomProgramMarker { + fn id() -> Pubkey { + id() + } +} + +#[program] +mod custom_program { + use super::*; + + pub fn test_program_validation(ctx: Context) -> Result<()> { + // This demonstrates both types of program validation: + // - generic_program: only validates executable (any program) + // - system_program: validates both program ID and executable + msg!( + "Generic program key: {}", + ctx.accounts.generic_program.key() + ); + msg!("System program key: {}", ctx.accounts.system_program.key()); + msg!( + "Custom program key: {}", + ctx.accounts.custom_program_input.key() + ); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct TestProgramValidation<'info> { + /// Generic program - only validates executable (any program ID) + pub generic_program: Program<'info>, + + /// Specific system program - validates both program ID and executable + pub system_program: Program<'info, System>, + + /// Custom program with specific type - validates both program ID and executable + pub custom_program_input: Program<'info, CustomProgramMarker>, + + /// Program with an address constraint - validates both program ID and executable + #[account(address = CUSTOM_PROGRAM_ADDRESS)] + pub custom_program_address: Program<'info>, +} diff --git a/tests/custom-program/tests/custom-program.ts b/tests/custom-program/tests/custom-program.ts new file mode 100644 index 0000000000..8ac4fd2a13 --- /dev/null +++ b/tests/custom-program/tests/custom-program.ts @@ -0,0 +1,86 @@ +import * as anchor from "@coral-xyz/anchor"; +import { AnchorError, Program } from "@coral-xyz/anchor"; +import { CustomProgram } from "../target/types/custom_program"; +import { assert } from "chai"; + +const CUSTOM_PROGRAM_ID = "PhoeNiXZ8ByJGLkxNfZRnkUfjvmuYqLR89jjFHGqdXY"; + +// This was an Executable Data account for our custom program which is not executable +const NON_EXECUTABLE_ACCOUNT_ID = + "9cxLzxjrTeodcbaEU3KCNGE1a4yFZEcdJ7uEXN378S4U"; + +const CUSTOM_PROGRAM_ADDRESS = "dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH"; + +describe("custom_program", () => { + anchor.setProvider(anchor.AnchorProvider.local()); + const program = anchor.workspace.CustomProgram as Program; + + it("Should pass test program validation", async () => { + try { + await program.methods + .testProgramValidation() + .accounts({ + genericProgram: new anchor.web3.PublicKey(CUSTOM_PROGRAM_ID), + systemProgram: anchor.web3.SystemProgram.programId, + customProgramInput: program.programId, + customProgramAddress: new anchor.web3.PublicKey( + CUSTOM_PROGRAM_ADDRESS + ), + }) + .rpc(); + assert.ok(true); + } catch (_err) { + assert(false); + } + }); + + it("Should fail test program validation", async () => { + try { + await program.methods + .testProgramValidation() + .accounts({ + genericProgram: new anchor.web3.PublicKey(CUSTOM_PROGRAM_ID), + systemProgram: anchor.web3.SystemProgram.programId, + customProgramInput: program.programId, + customProgramAddress: new anchor.web3.PublicKey( + NON_EXECUTABLE_ACCOUNT_ID + ), + }) + .rpc(); + assert.ok(false); + } catch (_err) { + assert.ok(true); + assert.isTrue(_err instanceof AnchorError); + const err: AnchorError = _err; + assert.strictEqual(err.error.errorCode.number, 3009); + assert.strictEqual( + err.error.errorMessage, + "Program account is not executable" + ); + } + }); + + it("Should fail test program address mismatch", async () => { + try { + await program.methods + .testProgramValidation() + .accounts({ + genericProgram: new anchor.web3.PublicKey(CUSTOM_PROGRAM_ID), + systemProgram: anchor.web3.SystemProgram.programId, + customProgramInput: program.programId, + customProgramAddress: new anchor.web3.PublicKey(CUSTOM_PROGRAM_ID), + }) + .rpc(); + assert.ok(false); + } catch (_err) { + assert.ok(true); + assert.isTrue(_err instanceof AnchorError); + const err: AnchorError = _err; + assert.strictEqual(err.error.errorCode.number, 2012); + assert.strictEqual( + err.error.errorMessage, + "An address constraint was violated" + ); + } + }); +}); diff --git a/tests/custom-program/tsconfig.json b/tests/custom-program/tsconfig.json new file mode 100644 index 0000000000..dc2b28af30 --- /dev/null +++ b/tests/custom-program/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai", "node"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/tests/package.json b/tests/package.json index 41c258ceb9..afb9d34a5b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -16,6 +16,7 @@ "composite", "custom-coder", "custom-discriminator", + "custom-program", "declare-id", "declare-program", "errors",