From d456009fae6513fb2f6164497e5901a5ba33f40a Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 10:28:31 +0530 Subject: [PATCH 01/16] fix(arbitrary_cpi_call): use Instruction program_id local for validation & added new tests --- lints/arbitrary_cpi_call/README.md | 44 ++++++- lints/arbitrary_cpi_call/src/lib.rs | 2 +- lints/arbitrary_cpi_call/src/utils.rs | 18 +-- .../tests/test_program/src/lib.rs | 115 ++++++++++++++++++ 4 files changed, 166 insertions(+), 13 deletions(-) diff --git a/lints/arbitrary_cpi_call/README.md b/lints/arbitrary_cpi_call/README.md index 951de82..f00f19c 100644 --- a/lints/arbitrary_cpi_call/README.md +++ b/lints/arbitrary_cpi_call/README.md @@ -1,7 +1,7 @@ -# `arbitraty_cpi_call` +# `arbitrary_cpi_call` ### What it does -Identifies CPI calls made using user-controlled program IDs without validations. +Identifies CPI calls made using user-controlled program IDs (from accounts or parameters) without validations before the CPI call. ### Why is this bad? Unvalidated program IDs in CPI calls let users to trigger arbitrary programs, leading to potential security breaches or fund loss. @@ -12,4 +12,42 @@ To avoid heavy analysis, we skip nested function analysis when: - **Cmps/switches threshold:** The number of program_id comparisons or if/else switches in the current function exceeds `MAX_CMPS_SWITCHES_RECURSION_THRESHOLD`. - **If/else nesting level:** The current basic block is nested deeper than `MAX_IF_ELSE_NESTING_LEVEL` depth (number of dominating `SwitchInt` blocks). -When any one of the condition triggers, we still run CPI checks for the current function (e.g. we still report arbitrary CPI in that function). We only skip propagating validation from nested functions, so very large or deeply nested code may not get full inter-procedural analysis for now. \ No newline at end of file +When any one of the condition triggers, we still run CPI checks for the current function (e.g. we still report arbitrary CPI in that function). We only skip propagating validation from nested functions, so very large or deeply nested code may not get full inter-procedural analysis for now. + +### Example (worst case) + +```rust +// BAD: program_id is user-controlled and never validated +pub fn invoke_unchecked_program(ctx: Context) -> Result<()> { + use anchor_lang::solana_program::instruction::Instruction; + use anchor_lang::solana_program::program::invoke; + let instruction = Instruction { + program_id: ctx.accounts.unchecked_program.key(), // user can pass any program + accounts: vec![], + data: vec![], + }; + let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; + invoke(&instruction, &account_infos)?; // CPI + Ok(()) +} + +// GOOD: program_id validated against a constant before CPI +pub fn invoke_validated_program(ctx: Context) -> Result<()> { + use anchor_lang::solana_program::instruction::Instruction; + use anchor_lang::solana_program::program::invoke; + const ALLOWED_PROGRAM_ID: Pubkey = Pubkey::new_from_array([42u8; 32]); + require_keys_eq!( + ctx.accounts.unchecked_program.key(), + ALLOWED_PROGRAM_ID, + CustomError::InvalidProgram + ); + let instruction = Instruction { + program_id: ctx.accounts.unchecked_program.key(), + accounts: vec![], + data: vec![], + }; + let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; + invoke(&instruction, &account_infos)?; // CPI + Ok(()) +} +``` diff --git a/lints/arbitrary_cpi_call/src/lib.rs b/lints/arbitrary_cpi_call/src/lib.rs index 0a057f8..af5c90b 100644 --- a/lints/arbitrary_cpi_call/src/lib.rs +++ b/lints/arbitrary_cpi_call/src/lib.rs @@ -182,7 +182,7 @@ fn analyze_arbitrary_cpi_call<'tcx>( let mut switches: Vec = Vec::new(); let mut program_id_cmps: Vec = Vec::new(); - let mut instruction_to_program_id: HashMap = HashMap::new(); + let mut instruction_to_program_id: HashMap = HashMap::new(); for (bb, bbdata) in mir.basic_blocks.iter_enumerated() { for statement in &bbdata.statements { diff --git a/lints/arbitrary_cpi_call/src/utils.rs b/lints/arbitrary_cpi_call/src/utils.rs index 4b0fd61..19d4975 100644 --- a/lints/arbitrary_cpi_call/src/utils.rs +++ b/lints/arbitrary_cpi_call/src/utils.rs @@ -82,7 +82,7 @@ pub fn record_instruction_creation<'tcx>( mir_analyzer: &MirAnalyzer<'_, 'tcx>, bb: BasicBlock, statement: &Statement<'tcx>, - instruction_to_program_id: &mut HashMap, + instruction_to_program_id: &mut HashMap, ) { if let StatementKind::Assign(box (place, rvalue)) = &statement.kind && let Some(dest_local) = place.as_local() @@ -94,7 +94,7 @@ pub fn record_instruction_creation<'tcx>( && let Some(program_id_local) = place.as_local() && mir_analyzer.is_pubkey_type(program_id_local) { - instruction_to_program_id.insert(dest_local, bb); + instruction_to_program_id.insert(dest_local, (bb, program_id_local)); } } @@ -109,7 +109,7 @@ pub fn track_instruction_call<'tcx>( bb: BasicBlock, cpi_calls: &mut HashMap, cpi_contexts: &mut HashMap, - instruction_to_program_id: &HashMap, + instruction_to_program_id: &HashMap, ) { let mir = mir_analyzer.mir; let decl_ty = match mir @@ -128,9 +128,9 @@ pub fn track_instruction_call<'tcx>( let mut program_id_local = None; let mut program_id_bb = None; - if let Some(&pid) = instruction_to_program_id.get(&instruction_local) { - program_id_local = Some(instruction_local); - program_id_bb = Some(pid); + if let Some(&(pid_bb, actual_pid)) = instruction_to_program_id.get(&instruction_local) { + program_id_local = Some(actual_pid); + program_id_bb = Some(pid_bb); } else { let mut to_check = vec![instruction_local]; let mut visited = HashSet::new(); @@ -140,9 +140,9 @@ pub fn track_instruction_call<'tcx>( continue; } - if let Some(&pid) = instruction_to_program_id.get(¤t) { - program_id_local = Some(instruction_local); - program_id_bb = Some(pid); + if let Some(&(pid_bb, actual_pid)) = instruction_to_program_id.get(¤t) { + program_id_local = Some(actual_pid); + program_id_bb = Some(pid_bb); break; } diff --git a/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs b/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs index 8ad819f..b33b06d 100644 --- a/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs +++ b/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs @@ -696,6 +696,97 @@ pub mod arbitrary_cpi_call_tests { ctx.accounts.cpi_call_unsafe(amount)?; Ok(()) } + + // Case 36: Other CPI call(mint_to) with unchecked program ID - unsafe + pub fn cpi_mint_to_unchecked_program( + ctx: Context, + amount: u64, + ) -> Result<()> { + let cpi_accounts = anchor_spl::token::MintTo { + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.to.to_account_info(), + authority: ctx.accounts.authority.to_account_info(), + }; + let cpi_ctx = CpiContext::new(ctx.accounts.unchecked_program.key(), cpi_accounts); + anchor_spl::token::mint_to(cpi_ctx, amount)?; // [arbitrary_cpi_call] + Ok(()) + } + + // Case 37: Associated Token Program create_ata without validation - unsafe + pub fn cpi_create_ata_unchecked_program( + ctx: Context, + ) -> Result<()> { + use anchor_spl::associated_token::{self, Create}; + let cpi_accounts = Create { + payer: ctx.accounts.payer.to_account_info(), + associated_token: ctx.accounts.associated_token.to_account_info(), + authority: ctx.accounts.authority.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + }; + let cpi_ctx = CpiContext::new(ctx.accounts.unchecked_program.key(), cpi_accounts); + associated_token::create(cpi_ctx)?; // [arbitrary_cpi_call] + Ok(()) + } + + // Case 38: Associated Token Program create_ata with validated program ID - safe + pub fn cpi_create_ata_validated_program( + ctx: Context, + ) -> Result<()> { + use anchor_spl::associated_token::{self, Create}; + require_keys_eq!( + ctx.accounts.unchecked_program.key(), + anchor_spl::associated_token::ID, + CustomError::InvalidProgram + ); + let cpi_accounts = Create { + payer: ctx.accounts.payer.to_account_info(), + associated_token: ctx.accounts.associated_token.to_account_info(), + authority: ctx.accounts.authority.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + system_program: ctx.accounts.system_program.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info(), + }; + let cpi_ctx = CpiContext::new(ctx.accounts.unchecked_program.key(), cpi_accounts); + associated_token::create(cpi_ctx)?; // [safe_cpi_call] + Ok(()) + } + + // Case 39: Invoke CPI with unchecked program ID - unsafe + pub fn cpi_custom_program_unchecked(ctx: Context) -> Result<()> { + use anchor_lang::solana_program::instruction::Instruction; + use anchor_lang::solana_program::program::invoke; + + let instruction = Instruction { + program_id: ctx.accounts.unchecked_program.key(), + accounts: vec![], + data: vec![], + }; + let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; + invoke(&instruction, &account_infos)?; // [arbitrary_cpi_call] + Ok(()) + } + + // Case 40: Raw invoke with validated program ID - safe + pub fn cpi_custom_program_checked(ctx: Context) -> Result<()> { + use anchor_lang::solana_program::instruction::Instruction; + use anchor_lang::solana_program::program::invoke; + const VALIDATED_PROGRAM_ID: Pubkey = Pubkey::new_from_array([42u8; 32]); // Consider it as a constant program ID + require_keys_eq!( + ctx.accounts.unchecked_program.key(), + VALIDATED_PROGRAM_ID, + CustomError::InvalidProgram + ); + let instruction = Instruction { + program_id: ctx.accounts.unchecked_program.key(), + accounts: vec![], + data: vec![], + }; + let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; + invoke(&instruction, &account_infos)?; // [safe_cpi_call] + Ok(()) + } } pub fn cpi_call_with_account<'info>( @@ -980,6 +1071,30 @@ pub struct NestedCpiAccounts<'info> { pub system_program: Program<'info, System>, } +#[derive(Accounts)] +pub struct CpiMintToAccounts<'info> { + pub mint: UncheckedAccount<'info>, + #[account(mut)] + pub to: UncheckedAccount<'info>, + pub authority: UncheckedAccount<'info>, + /// CHECK: Target program to validate + pub unchecked_program: UncheckedAccount<'info>, +} + +#[derive(Accounts)] +pub struct CpiCreateAtaAccounts<'info> { + #[account(mut)] + pub payer: UncheckedAccount<'info>, + #[account(mut)] + pub associated_token: UncheckedAccount<'info>, + pub authority: UncheckedAccount<'info>, + pub mint: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, + pub token_program: Program<'info, anchor_spl::token::Token>, + /// CHECK: Target program to validate + pub unchecked_program: UncheckedAccount<'info>, +} + #[derive(Accounts)] pub struct ImplMethodAccounts<'info> { #[account(mut)] From 649e236115d05cdaaa33323a1fbe607aa14905d0 Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 11:07:03 +0530 Subject: [PATCH 02/16] docs(cpi_no_result): update README wording and examples --- lints/cpi_no_result/README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/lints/cpi_no_result/README.md b/lints/cpi_no_result/README.md index c5530a3..de4902b 100644 --- a/lints/cpi_no_result/README.md +++ b/lints/cpi_no_result/README.md @@ -1,7 +1,28 @@ # `cpi_no_result` ### What it does -Detects CPI calls where the result is silently suppressed using methods like `unwrap_or_default()` or `unwrap_or(())`. +Flags CPI calls when **discard the result** with one of these methods: + +- `unwrap_or_default()` +- `unwrap_or(())` or `unwrap_or(some_value)` +- `unwrap_or_else(|_| ...)` + +### Why it matters +Discarding the CPI result makes it unclear how failures are handled. Even though many CPI failures abort the transaction, hiding the result makes the code harder to understand and debug. Using ? or explicit error handling makes it clear when a CPI can fail and ensures failures are handled in a consistent and readable way. + +### Example + +**Flagged:** +```rust +system_program::transfer(cpi_ctx, amount).unwrap_or_default(); // [cpi_no_result] +system_program::transfer(cpi_ctx, amount).unwrap_or(()); // [cpi_no_result] +system_program::transfer(cpi_ctx, amount).unwrap_or_else(|_| ()); // [cpi_no_result] +``` + +**OK():** +```rust +system_program::transfer(cpi_ctx, amount)?; +system_program::transfer(cpi_ctx, amount).unwrap(); +system_program::transfer(cpi_ctx, amount).expect("Transfer failed"); +``` -### Why is this bad? -Silent suppression methods hide CPI failures, allowing the program to continue execution even when critical operations failed, leading to silent failures, security vulnerabilities, potential fund loss, and state corruption. From 9d2aea028d231dd48a3ad4846b98066ada6ff950 Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 11:24:56 +0530 Subject: [PATCH 03/16] docs(direct_lamport_cpi_dos): update README wording and examples docs(direct_lamport_cpi_dos): update README wording and examples --- lints/direct_lamport_cpi_dos/README.md | 30 +++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/lints/direct_lamport_cpi_dos/README.md b/lints/direct_lamport_cpi_dos/README.md index 63bec07..42c813b 100644 --- a/lints/direct_lamport_cpi_dos/README.md +++ b/lints/direct_lamport_cpi_dos/README.md @@ -1,7 +1,31 @@ # `direct_lamport_cpi_dos` ### What it does -Detects when accounts with direct lamport mutations (via `lamports.borrow_mut()`) are not included in subsequent CPI calls. +Flags when an account’s lamports are mutated (e.g. `**ctx.accounts.fee_collector.lamports.borrow_mut() += x`) and that account is not included in a later CPI in the same function. -### Why is this bad? -The Solana runtime performs balance checks on CPI calls using only the accounts involved in the CPI. When an account's lamports are directly mutated but the account is not included in the CPI, the runtime balance check will fail, causing a DoS error. All accounts whose lamports were changed directly must be included in subsequent CPIs as remaining accounts using `with_remaining_accounts`. +### Why it matters +The runtime checks balances for accounts in the CPI. If you changed an account’s lamports but don’t pass it in the CPI, the tx will abort. The runtime catches this too; the lint gives earlier feedback and a clearer message so you can fix it before running the failing path. Include every mutated account in the CPI, e.g. via `with_remaining_accounts`. + +### Example + +**Flagged:** lamport mutation then CPI without that account +```rust +**ctx.accounts.vault.lamports.borrow_mut() -= WITHDRAW_FEE; +**ctx.accounts.fee_collector.lamports.borrow_mut() += WITHDRAW_FEE; + +token::transfer( // [direct_lamport_cpi_dos] — fee_collector not in CPI + CpiContext::new(ctx.accounts.token_program.key(), Transfer { ... }), + amount, +)?; +``` + +**OK():** same mutations, account included in CPI +```rust +**ctx.accounts.vault.lamports.borrow_mut() -= WITHDRAW_FEE; +**ctx.accounts.fee_collector.lamports.borrow_mut() += WITHDRAW_FEE; + +token::transfer( + CpiContext::new(...).with_remaining_accounts(vec![ctx.accounts.fee_collector.to_account_info()]), + amount, +)?; +``` From e5bbc3e375e8437e9f5eb11ca7d86f4b8b080281 Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 11:31:04 +0530 Subject: [PATCH 04/16] docs(duplicate_mutable_accounts): update README examples --- lints/duplicate_mutable_accounts/README.md | 35 +++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lints/duplicate_mutable_accounts/README.md b/lints/duplicate_mutable_accounts/README.md index baa6cfc..9b9f42d 100644 --- a/lints/duplicate_mutable_accounts/README.md +++ b/lints/duplicate_mutable_accounts/README.md @@ -4,4 +4,37 @@ Detects duplicate mutable account usage in Anchor functions, where the same account type is passed into multiple mutable parameters without constraint checks. ### Why is this bad? -Duplicate mutable accounts can lead to unexpected aliasing of mutable data, logical errors, and vulnerabilities like account state corruption in Solana smart contracts. \ No newline at end of file +Duplicate mutable accounts can lead to unexpected aliasing of mutable data, logical errors, and vulnerabilities like account state corruption in Solana smart contracts. + +### Example + +**Flagged:** two mutable accounts of the same type, no constraint +```rust +#[derive(Accounts)] +pub struct UnsafeAccounts<'info> { + pub user_a: Account<'info, User>, // [duplicate_mutable_accounts] + pub user_b: Account<'info, User>, +} + +pub fn update(ctx: Context, a: u64, b: u64) -> Result<()> { + ctx.accounts.user_a.data = a; + ctx.accounts.user_b.data = b; + Ok(()) +} +``` + +**OK:** same-type mutable accounts with a constraint so they must be different +```rust +#[derive(Accounts)] +pub struct SafeAccounts<'info> { + #[account(constraint = user_a.key() != user_b.key())] + pub user_a: Account<'info, User>, + pub user_b: Account<'info, User>, +} + +pub fn update(ctx: Context, a: u64, b: u64) -> Result<()> { + ctx.accounts.user_a.data = a; + ctx.accounts.user_b.data = b; + Ok(()) +} +``` \ No newline at end of file From e89795078f2448b3fedacf289a1f37199c13d07f Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 14:52:32 +0530 Subject: [PATCH 05/16] feat: LazyAccount support for missing_account_reload lint --- anchor-lints-utils/src/diag_items.rs | 19 +++++-- lints/missing_account_reload/src/lib.rs | 41 +++++++++++++- .../missing_account_reload/src/utils/paths.rs | 8 ++- .../tests/test_program/Cargo.toml | 2 +- .../tests/test_program/src/lib.rs | 56 ++++++++++++++++++- 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/anchor-lints-utils/src/diag_items.rs b/anchor-lints-utils/src/diag_items.rs index 6241819..a2f0b68 100644 --- a/anchor-lints-utils/src/diag_items.rs +++ b/anchor-lints-utils/src/diag_items.rs @@ -78,6 +78,8 @@ pub enum DiagnoticItem { SplTokenAccount, /// `spl_token::state::Mint` SplTokenMint, + /// `anchor_lang::prelude::LazyAccount` + AnchorLazyAccount, } impl DiagnoticItem { @@ -149,6 +151,9 @@ impl DiagnoticItem { DiagnoticItem::SplTokenMint => { return None; } + DiagnoticItem::AnchorLazyAccount => { + return None; + } }) } @@ -255,6 +260,7 @@ impl DiagnoticItem { } DiagnoticItem::SplTokenAccount => &["spl_token::state::Account"], DiagnoticItem::SplTokenMint => &["spl_token::state::Mint"], + DiagnoticItem::AnchorLazyAccount => &["anchor_lang::prelude::LazyAccount"], } } @@ -463,10 +469,10 @@ pub fn is_solana_instruction_type(tcx: TyCtxt, ty: Ty) -> bool { pub fn is_box_type(tcx: TyCtxt, ty: Ty) -> bool { let ty = ty.peel_refs(); - if let ty::Adt(adt_def, _) = ty.kind() { - if let Some(box_def_id) = tcx.lang_items().owned_box() { - return adt_def.did() == box_def_id; - } + if let ty::Adt(adt_def, _) = ty.kind() + && let Some(box_def_id) = tcx.lang_items().owned_box() + { + return adt_def.did() == box_def_id; } false } @@ -492,3 +498,8 @@ pub fn is_cpi_builder_constructor_fn(tcx: TyCtxt, def_id: DefId) -> bool { || path.contains("UnlockV1") || path.contains("RevokeStaking") } + +pub fn is_anchor_lazy_account_type(tcx: TyCtxt, ty: Ty) -> bool { + let ty = ty.peel_refs(); + DiagnoticItem::AnchorLazyAccount.defid_is_type(tcx, ty) +} diff --git a/lints/missing_account_reload/src/lib.rs b/lints/missing_account_reload/src/lib.rs index 160a826..98c2e0e 100644 --- a/lints/missing_account_reload/src/lib.rs +++ b/lints/missing_account_reload/src/lib.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use anchor_lints_utils::utils::should_skip_function; use anchor_lints_utils::{ - diag_items::{DiagnoticItem, is_cpi_invoke_fn}, + diag_items::{DiagnoticItem, is_anchor_lazy_account_type, is_cpi_invoke_fn}, mir_analyzer::{AnchorContextInfo, MirAnalyzer}, utils::get_hir_body_from_local_def_id, }; @@ -124,6 +124,25 @@ impl<'tcx> LateLintPass<'tcx> for MissingAccountReload { } } } + // Check for LazyAccount::load/LazyAccount::load_mut (reloads for LazyAccount) + else if let Some(name) = cx.tcx.opt_item_name(*fn_def_id) + && (name.as_str() == "load" || name.as_str() == "load_mut") + && let Some(receiver) = args.first() + && let Operand::Move(place) | Operand::Copy(place) = &receiver.node + && let Some(local) = place.as_local() + && let Some(decl) = mir.local_decls.get(local) + { + let receiver_ty = decl.ty.peel_refs(); + if is_anchor_lazy_account_type(cx.tcx, receiver_ty) + && let Some(account_name_and_local) = + mir_analyzer.extract_account_name_from_local(&local, false) + { + account_reloads + .entry(account_name_and_local.account_name) + .or_default() + .insert(bb); + } + } // Or a CPI invoke function else if is_cpi_invoke_fn(cx.tcx, *fn_def_id) || mir_analyzer.takes_cpi_context(args) @@ -375,6 +394,26 @@ pub fn analyze_nested_function_operations<'tcx>( nested_function_blocks.push(block); } } + // Check for LazyAccount::load/LazyAccount::load_mut (reloads for LazyAccount) + else if let Some(name) = cx.tcx.opt_item_name(*def_id) + && (name.as_str() == "load" || name.as_str() == "load_mut") + && let Some(receiver) = args.first() + && let Operand::Move(place) | Operand::Copy(place) = &receiver.node + && let Some(local) = place.as_local() + && let Some(decl) = mir_body.local_decls.get(local) + { + let receiver_ty = decl.ty.peel_refs(); + if is_anchor_lazy_account_type(cx.tcx, receiver_ty) + && let Some(block) = handle_account_reload_in_nested_function( + &mir_analyzer, + mir_body, + args, + *fn_span, + bb, + ) { + nested_function_blocks.push(block); + } + } // Handle account access (deref) in nested function else if cx .tcx diff --git a/lints/missing_account_reload/src/utils/paths.rs b/lints/missing_account_reload/src/utils/paths.rs index 32c117b..5015b53 100644 --- a/lints/missing_account_reload/src/utils/paths.rs +++ b/lints/missing_account_reload/src/utils/paths.rs @@ -1,8 +1,8 @@ use anchor_lints_utils::{ diag_items::{ is_account_info_type, is_anchor_account_loader_type, is_anchor_account_type, - is_anchor_interface_account_type, is_anchor_signer_type, is_anchor_system_account_type, - is_anchor_unchecked_account_type, is_box_type, + is_anchor_interface_account_type, is_anchor_lazy_account_type, is_anchor_signer_type, + is_anchor_system_account_type, is_anchor_unchecked_account_type, is_box_type, }, mir_analyzer::AnchorContextInfo, }; @@ -58,7 +58,9 @@ pub fn contains_deserialized_data<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> if is_anchor_account_loader_type(cx.tcx, ty) { return false; } - if is_anchor_interface_account_type(cx.tcx, ty) || is_anchor_system_account_type(cx.tcx, ty) + if is_anchor_interface_account_type(cx.tcx, ty) + || is_anchor_system_account_type(cx.tcx, ty) + || is_anchor_lazy_account_type(cx.tcx, ty) { return true; } diff --git a/lints/missing_account_reload/tests/test_program/Cargo.toml b/lints/missing_account_reload/tests/test_program/Cargo.toml index ad0bd3c..cd6dd41 100644 --- a/lints/missing_account_reload/tests/test_program/Cargo.toml +++ b/lints/missing_account_reload/tests/test_program/Cargo.toml @@ -9,4 +9,4 @@ workspace = "../../../../tests" crate-type = ["cdylib"] [dependencies] -anchor-lang = { git = "https://github.com/jamie-osec/anchor", rev = "939b843" } +anchor-lang = { git = "https://github.com/jamie-osec/anchor", rev = "939b843", features = ["lazy-account"] } diff --git a/lints/missing_account_reload/tests/test_program/src/lib.rs b/lints/missing_account_reload/tests/test_program/src/lib.rs index 9fbe52c..77edcca 100644 --- a/lints/missing_account_reload/tests/test_program/src/lib.rs +++ b/lints/missing_account_reload/tests/test_program/src/lib.rs @@ -4,7 +4,7 @@ use anchor_lang::solana_program::{ system_instruction, }; -use anchor_lang::system_program::{transfer, Transfer}; +use anchor_lang::system_program::{Transfer, transfer}; declare_id!("11111111111111111111111111111111"); @@ -319,18 +319,56 @@ pub mod missing_account_reload_tests { } // Pattern 17: CPI call with self implementation - safe - pub fn invoke_with_self_implementation_safe(ctx: Context, amount: u64) -> Result<()> { + pub fn invoke_with_self_implementation_safe( + ctx: Context, + amount: u64, + ) -> Result<()> { ctx.accounts.cpi_call_safe(amount)?; let _final_data = ctx.accounts.pda_account.data; // [safe_account_accessed] Ok(()) } // Pattern 18: CPI call with self implementation - unsafe - pub fn invoke_with_self_implementation_unsafe(ctx: Context, amount: u64) -> Result<()> { + pub fn invoke_with_self_implementation_unsafe( + ctx: Context, + amount: u64, + ) -> Result<()> { ctx.accounts.cpi_call_unsafe(amount)?; let _final_data = ctx.accounts.pda_account.data; // [unsafe_account_accessed] Ok(()) } + + /// Patten 19: load_mut() then CPI — unsafe. + pub fn test_lazy_account_unsafe( + ctx: Context, + amount: u64, + ) -> Result<()> { + let data = ctx.accounts.lazy_acc.load_mut()?; // ref held across CPI + let _ = data.value; + let new_space = ctx.accounts.lazy_acc.to_account_info().data_len() as u64 + amount; + let ix = system_instruction::allocate(&ctx.accounts.lazy_acc.key(), new_space); + let account_infos = vec![ + ctx.accounts.lazy_acc.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ]; + invoke(&ix, &account_infos)?; // [cpi_call] + let _ = data.value; // [unsafe_account_accessed] + Ok(()) + } + + // Pattern 20: load_mut() then CPI with load_mut() — safe + pub fn test_lazy_account_safe(ctx: Context, amount: u64) -> Result<()> { + let new_space = ctx.accounts.lazy_acc.to_account_info().data_len() as u64 + amount; + let ix = system_instruction::allocate(&ctx.accounts.lazy_acc.key(), new_space); + let account_infos = vec![ + ctx.accounts.lazy_acc.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ]; + invoke(&ix, &account_infos)?; + let data = ctx.accounts.lazy_acc.load_mut()?; + let _ = data.value; // [safe_account_accessed] + Ok(()) + } } pub fn cpi_call_safe(ctx_a: &mut Context, amount: u64) -> Result<()> { let from_pubkey = ctx_a.accounts.pda_account.to_account_info(); @@ -609,6 +647,18 @@ impl<'info> SolTransfer3<'info> { } } +#[account] +pub struct LazyLoadable { + pub value: u64, +} + +#[derive(Accounts)] +pub struct LazyAccountCpiAccounts<'info> { + #[account(mut)] + pub lazy_acc: LazyAccount<'info, LazyLoadable>, + pub system_program: Program<'info, System>, +} + #[account] pub struct UserState { pub data: u64, From 5c62dec7c96f4fb9e8c152453e014ae6dfee757c Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 15:00:12 +0530 Subject: [PATCH 06/16] docs: overconstrained_seed_account readme updated with example --- lints/overconstrained_seed_account/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lints/overconstrained_seed_account/README.md b/lints/overconstrained_seed_account/README.md index 425d4ec..59cef21 100644 --- a/lints/overconstrained_seed_account/README.md +++ b/lints/overconstrained_seed_account/README.md @@ -6,3 +6,16 @@ Detects when a seed account used in PDA derivation is overconstrained as `System ### Why is this bad? If a seed account's ownership changes after pool creation (e.g., becomes a token account or mint), future instructions will fail forever because `SystemAccount` enforces `owner == system_program`. This can permanently lock funds in the protocol. +### Example + +```rust +// BAD: Using `SystemAccount` for a seed in a non-init instruction is the bad pattern: +#[derive(Accounts)] +pub struct Withdraw<'info> { + #[account(seeds = [b"pool", creator.key().as_ref()], bump)] + pub pool: Account<'info, Pool>, + pub creator: SystemAccount<'info>, // use UncheckedAccount +} +``` + +Use `UncheckedAccount<'info>` (or another appropriate type) for the seed in non-init instructions so that ownership changes do not break the instruction. From a46dc5d66a159d4955e8f4f2451ab8f050f2d671 Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 15:17:37 +0530 Subject: [PATCH 07/16] docs(pda_signer_account_overlap): readme update --- lints/pda_signer_account_overlap/README.md | 29 +++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lints/pda_signer_account_overlap/README.md b/lints/pda_signer_account_overlap/README.md index 76abc90..14f3076 100644 --- a/lints/pda_signer_account_overlap/README.md +++ b/lints/pda_signer_account_overlap/README.md @@ -1,7 +1,30 @@ # `pda_signer_account_overlap` ### What it does -Detects when user-controlled accounts (`UncheckedAccount` or `Option`) are passed to CPIs that use PDAs as signers. +Warns when a **mutable user-controlled account** (`UncheckedAccount` or `Option`) is passed into a CPI that also uses a **PDA as a signer** (e.g. `CpiContext::new_with_signer`). The lint only fires if both show up in the same CPI and the account is mutable. -### Why is this bad? -This is especially risky when the callee expects the account to be uninitialized. An attacker could pass the PDA signer itself as the account, causing the PDA to be initialized and losing its lamports, leading to security vulnerabilities. +### Why it matters +If the callee program expects an account to be uninitialized and then initializes it inside the CPI (e.g. with `invoke_signed`), an attacker could pass the PDA signer itself—or another account they control—into that slot. The callee might then initialize it with the program’s authority, which can lock lamports or break security assumptions. This pattern has appeared in real bugs (e.g. Meteora-style). The risk is highest when the callee creates or initializes accounts in the CPI. + +### Example + +**Bad:** A mutable `UncheckedAccount` and a PDA are both passed to a CPI that uses a signer. The user can supply any account (including the PDA) for `user_account`. + +```rust +#[derive(Accounts)] +pub struct UnsafeAccountWithPdaSigner<'info> { + #[account(mut)] + pub user_account: UncheckedAccount<'info>, // user-controlled + + #[account(seeds = [b"pool_authority", pool.key().as_ref()], bump)] + pub pool_authority: AccountInfo<'info>, // PDA used as signer + + pub pool: Account<'info, PoolState>, + pub target_program: UncheckedAccount<'info>, +} + +// In the instruction: both user_account and pool_authority are passed to CpiContext::new_with_signer(...) +// So lint warns: user-controlled account passed to CPI with PDA signer +``` + +**Good:** Either avoid passing mutable `UncheckedAccount` in the same CPI as the PDA signer, or add a constraint so the unchecked account cannot be the PDA (e.g. `constraint = user_account.key() != pool_authority.key()`). If the account is not mutable, the lint does not warn. From 74cbd8fcd6b493b1adc96ee1a87aa134f5327dce Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 27 Feb 2026 16:01:09 +0530 Subject: [PATCH 08/16] chore: cargo fmt --- Cargo.toml | 7 ++----- lints/missing_account_reload/src/lib.rs | 7 ++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c074fb0..82dcb5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,7 @@ authors = ["Anchor Contributors"] publish = false [workspace] -members = [ - "anchor-lints-utils", - "lints/*", -] +members = ["anchor-lints-utils", "lints/*"] resolver = "2" [workspace.package] @@ -29,6 +26,6 @@ regex = "1.12.2" disallowed-methods = "deny" [dependencies] +anyhow = "1.0.100" regex = { workspace = true } tokio = { version = "1.48.0", features = ["fs", "rt-multi-thread", "macros"] } -anyhow = "1.0.100" diff --git a/lints/missing_account_reload/src/lib.rs b/lints/missing_account_reload/src/lib.rs index 98c2e0e..7e4cc31 100644 --- a/lints/missing_account_reload/src/lib.rs +++ b/lints/missing_account_reload/src/lib.rs @@ -410,9 +410,10 @@ pub fn analyze_nested_function_operations<'tcx>( args, *fn_span, bb, - ) { - nested_function_blocks.push(block); - } + ) + { + nested_function_blocks.push(block); + } } // Handle account access (deref) in nested function else if cx From 5fefa51868db7d6255413b89db93ea4346b4b4ef Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Thu, 5 Mar 2026 09:42:02 +0530 Subject: [PATCH 09/16] feat: new lint missing_mut_constraint --- Cargo.lock | 10 + README.md | 2 + lints/missing_mut_constraint/Cargo.toml | 24 +++ lints/missing_mut_constraint/README.md | 36 ++++ lints/missing_mut_constraint/src/lib.rs | 191 ++++++++++++++++++ .../tests/test_program/Cargo.toml | 13 ++ .../tests/test_program/src/lib.rs | 76 +++++++ tests/Cargo.lock | 8 + tests/lint_tests.rs | 16 ++ 9 files changed, 376 insertions(+) create mode 100644 lints/missing_mut_constraint/Cargo.toml create mode 100644 lints/missing_mut_constraint/README.md create mode 100644 lints/missing_mut_constraint/src/lib.rs create mode 100644 lints/missing_mut_constraint/tests/test_program/Cargo.toml create mode 100644 lints/missing_mut_constraint/tests/test_program/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index a2f6de0..85b049d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,6 +791,16 @@ dependencies = [ "dylint_testing", ] +[[package]] +name = "missing_mut_constraint" +version = "0.1.0" +dependencies = [ + "anchor-lints-utils", + "clippy_utils", + "dylint_linting", + "dylint_testing", +] + [[package]] name = "missing_owner_check" version = "0.1.0" diff --git a/README.md b/README.md index cf616bc..fcfc7f3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ cargo install cargo-dylint dylint-link | [`direct_lamport_cpi_dos`](lints/direct_lamport_cpi_dos) | | [`overconstrained_seed_account`](lints/overconstrained_seed_account) | | [`unsafe_pyth_price_account`](lints/unsafe_pyth_price_account) | +| [`missing_mut_constraint`](lints/missing_mut_constraint) | ## Usage @@ -68,4 +69,5 @@ cargo test ata_should_use_init_if_needed_tests cargo test direct_lamport_cpi_dos_tests cargo test overconstrained_seed_account_tests cargo test unsafe_pyth_price_account_tests +cargo test missing_mut_constraint_tests ``` diff --git a/lints/missing_mut_constraint/Cargo.toml b/lints/missing_mut_constraint/Cargo.toml new file mode 100644 index 0000000..ba610d9 --- /dev/null +++ b/lints/missing_mut_constraint/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "missing_mut_constraint" +version.workspace = true +edition.workspace = true +publish = false +authors = ["authors go here"] +description = "Detects accounts that are mutated in the instruction but not declared with #[account(mut)]" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anchor-lints-utils.workspace = true +clippy_utils.workspace = true +dylint_linting.workspace = true + +[dev-dependencies] +dylint_testing.workspace = true + +[package.metadata.rust-analyzer] +rustc_private = true + +[lints] +workspace = true diff --git a/lints/missing_mut_constraint/README.md b/lints/missing_mut_constraint/README.md new file mode 100644 index 0000000..2bf26f4 --- /dev/null +++ b/lints/missing_mut_constraint/README.md @@ -0,0 +1,36 @@ +# `missing_mut_constraint` + +### What it does +Detects when an account is mutated in the instruction body but not declared with `#[account(mut)]` in the Anchor accounts struct. + +### Why is this bad? +Mutating an account without the `mut` constraint can cause the runtime to reject the transaction or behave unexpectedly, because the account was not marked as writable. + +### Example + +**Bad:** account mutated without `#[account(mut)]` +```rust +#[derive(Accounts)] +pub struct Update<'info> { + pub vault: Account<'info, Vault>, // missing #[account(mut)] +} + +pub fn update(ctx: Context) -> Result<()> { + ctx.accounts.vault.amount += 1; // mutation + Ok(()) +} +``` + +**Good:** account has `#[account(mut)]` when mutated +```rust +#[derive(Accounts)] +pub struct Update<'info> { + #[account(mut)] + pub vault: Account<'info, Vault>, +} + +pub fn update(ctx: Context) -> Result<()> { + ctx.accounts.vault.amount += 1; + Ok(()) +} +``` \ No newline at end of file diff --git a/lints/missing_mut_constraint/src/lib.rs b/lints/missing_mut_constraint/src/lib.rs new file mode 100644 index 0000000..d07bc82 --- /dev/null +++ b/lints/missing_mut_constraint/src/lib.rs @@ -0,0 +1,191 @@ +#![feature(rustc_private)] +#![warn(unused_extern_crates)] +#![feature(box_patterns)] + +extern crate rustc_hir; +extern crate rustc_middle; +extern crate rustc_span; + +use anchor_lints_utils::{ + mir_analyzer::{AnchorContextInfo, MirAnalyzer}, + utils::{extract_account_constraints, should_skip_function}, +}; +use clippy_utils::diagnostics::span_lint; + +use rustc_hir::{Body as HirBody, FnDecl, def_id::LocalDefId, intravisit::FnKind}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::{ + mir::{Mutability, Place, Rvalue, StatementKind}, + ty::TyKind, +}; +use rustc_span::Span; + +use std::collections::{HashMap, HashSet}; + +dylint_linting::impl_late_lint! { + /// ### What it does + /// Detects when an account is mutated in the instruction body but not declared + /// with `#[account(mut)]` in the Anchor accounts struct. + /// + /// ### Why is this bad? + /// Mutating an account without the `mut` constraint can cause the runtime to + /// reject the transaction or behave unexpectedly, as the account was not + /// marked as writable. + /// + /// ### Example + /// ```rust + /// #[derive(Accounts)] + /// pub struct Update<'info> { + /// pub vault: Account<'info, Vault>, // missing #[account(mut)] + /// } + /// pub fn update(ctx: Context) -> Result<()> { + /// ctx.accounts.vault.amount += 1; // mutation + /// Ok(()) + /// } + /// ``` + /// + /// ### Good + /// ```rust + /// #[derive(Accounts)] + /// pub struct Update<'info> { + /// #[account(mut)] + /// pub vault: Account<'info, Vault>, + /// } + /// ``` + pub MISSING_MUT_CONSTRAINT, + Warn, + "account is mutated but missing #[account(mut)]", + MissingMutConstraint +} + +#[derive(Default)] +pub struct MissingMutConstraint; + + +struct AccountMutability { + span: Span, + mutable: bool, +} + +impl<'tcx> LateLintPass<'tcx> for MissingMutConstraint { + fn check_fn( + &mut self, + cx: &LateContext<'tcx>, + _kind: FnKind<'tcx>, + _: &FnDecl<'tcx>, + body: &HirBody<'tcx>, + main_fn_span: Span, + def_id: LocalDefId, + ) { + if should_skip_function(cx, main_fn_span, def_id) { + return; + } + + let mut mir_analyzer = MirAnalyzer::new(cx, body, def_id); + anchor_lints_utils::utils::ensure_anchor_context_initialized(&mut mir_analyzer, body); + + // Analyze functions that take Anchor context + let Some(anchor_context_info) = mir_analyzer.anchor_context_info.as_ref() else { + return; + }; + + analyze_missing_mut_constraint(cx, &mir_analyzer, anchor_context_info); + } +} + + +fn analyze_missing_mut_constraint<'cx, 'tcx>( + cx: &'cx LateContext<'tcx>, + mir_analyzer: &MirAnalyzer<'cx, 'tcx>, + anchor_context_info: &AnchorContextInfo<'tcx>, +) { + let accounts_struct_ty = &anchor_context_info.anchor_context_account_type; + let TyKind::Adt(adt_def, _generics) = accounts_struct_ty.kind() else { + return; + }; + + if !adt_def.is_struct() && !adt_def.is_union() { + return; + } + + let variant = adt_def.non_enum_variant(); + let mut account_mutability: HashMap = HashMap::new(); + + for field in &variant.fields { + let account_name = field.ident(cx.tcx).to_string(); + let account_span = cx.tcx.def_span(field.did); + let constraints = extract_account_constraints(cx, field); + account_mutability.insert( + account_name, + AccountMutability { + span: account_span, + mutable: constraints.mutable, + }, + ); + } + + let mutated_accounts = collect_mutated_accounts(mir_analyzer); + let mut visited = HashSet::new(); + + for account_name in mutated_accounts { + if visited.contains(&account_name) { + continue; + } + visited.insert(account_name.clone()); + + if let Some(info) = account_mutability.get(&account_name) + && !info.mutable { + span_lint( + cx, + MISSING_MUT_CONSTRAINT, + info.span, + format!( + "account `{}` is mutated in the instruction but is not declared with `#[account(mut)]`", + account_name + ), + ); + } + } +} + +/// Collects the accounts that are mutated in the instruction body +fn collect_mutated_accounts<'cx, 'tcx>(mir_analyzer: &MirAnalyzer<'cx, 'tcx>) -> HashSet { + let mut mutated = HashSet::new(); + + for (_bb, bbdata) in mir_analyzer.mir.basic_blocks.iter_enumerated() { + for stmt in &bbdata.statements { + if let StatementKind::Assign(box (place, rvalue)) = &stmt.kind + && let Some(account_name) = account_name_from_place_or_rvalue(mir_analyzer, place, rvalue) { + mutated.insert(account_name); + } + } + } + + mutated +} + +/// Extracts the account name from a place or rvalue +fn account_name_from_place_or_rvalue<'cx, 'tcx>( + mir_analyzer: &MirAnalyzer<'cx, 'tcx>, + place: &Place<'_>, + rvalue: &Rvalue<'_>, +) -> Option { + let base_local = place.local; + let resolved = mir_analyzer.resolve_to_original_local(base_local, &mut HashSet::new()); + if let Some(acc) = mir_analyzer.extract_account_name_from_local(&resolved, true) { + let name = acc.account_name.split('.').next().unwrap_or(&acc.account_name).to_string(); + return Some(name); + } + + if let Rvalue::Ref(_, borrow_kind, ref_place) = rvalue + && borrow_kind.mutability() == Mutability::Mut { + let base = ref_place.local; + let resolved = mir_analyzer.resolve_to_original_local(base, &mut HashSet::new()); + if let Some(acc) = mir_analyzer.extract_account_name_from_local(&resolved, true) { + let name = acc.account_name.split('.').next().unwrap_or(&acc.account_name).to_string(); + return Some(name); + } + } + + None +} diff --git a/lints/missing_mut_constraint/tests/test_program/Cargo.toml b/lints/missing_mut_constraint/tests/test_program/Cargo.toml new file mode 100644 index 0000000..1809d0b --- /dev/null +++ b/lints/missing_mut_constraint/tests/test_program/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "missing_mut_constraint_test_program" +version = "0.1.0" +edition = "2021" +workspace = "../../../../tests" + + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anchor-lang = { git = "https://github.com/jamie-osec/anchor.git", branch = "anchor-lint" } +anchor-spl = { git = "https://github.com/jamie-osec/anchor.git", branch = "anchor-lint" } diff --git a/lints/missing_mut_constraint/tests/test_program/src/lib.rs b/lints/missing_mut_constraint/tests/test_program/src/lib.rs new file mode 100644 index 0000000..64062c2 --- /dev/null +++ b/lints/missing_mut_constraint/tests/test_program/src/lib.rs @@ -0,0 +1,76 @@ +use anchor_lang::prelude::*; + +declare_id!("11111111111111111111111111111111"); + +#[program] +pub mod missing_mut_constraint { + use super::*; + + // Bad: Account (vault) is mutated but not declared with #[account(mut)] + pub fn update_bad(ctx: Context) -> Result<()> { + ctx.accounts.vault.amount += 1; + Ok(()) + } + + // Good: Account (vault) has #[account(mut)] + pub fn update_good(ctx: Context) -> Result<()> { + ctx.accounts.vault.amount += 1; + Ok(()) + } + + // Good: Account (vault) is only read, no mut needed + pub fn read_only(ctx: Context) -> Result<()> { + let _ = ctx.accounts.vault.amount; + Ok(()) + } + + // Bad: Account (treasury) is mutated but not declared with #[account(mut)] (Account (vault) has mut) + pub fn transfer_bad(ctx: Context) -> Result<()> { + ctx.accounts.vault.amount -= 10; + ctx.accounts.treasury.amount += 10; + Ok(()) + } + + // Good: lamport mutation with #[account(mut)] on payer + pub fn pay_lamports(ctx: Context, amount: u64) -> Result<()> { + **ctx.accounts.payer.lamports.borrow_mut() -= amount; + **ctx.accounts.recipient.lamports.borrow_mut() += amount; + Ok(()) + } +} + +#[account] +pub struct Vault { + pub amount: u64, +} + +#[derive(Accounts)] +pub struct UpdateBad<'info> { + pub vault: Account<'info, Vault>, // [missing_mut_constraint] +} + +#[derive(Accounts)] +pub struct UpdateGood<'info> { + #[account(mut)] + pub vault: Account<'info, Vault>, +} + +#[derive(Accounts)] +pub struct ReadOnly<'info> { + pub vault: Account<'info, Vault>, +} + +#[derive(Accounts)] +pub struct TransferBad<'info> { + #[account(mut)] + pub vault: Account<'info, Vault>, + pub treasury: Account<'info, Vault>, // [missing_mut_constraint] +} + +#[derive(Accounts)] +pub struct PayLamports<'info> { + #[account(mut)] + pub payer: AccountInfo<'info>, + #[account(mut)] + pub recipient: AccountInfo<'info>, +} diff --git a/tests/Cargo.lock b/tests/Cargo.lock index 360dffb..959fafe 100644 --- a/tests/Cargo.lock +++ b/tests/Cargo.lock @@ -1589,6 +1589,14 @@ dependencies = [ "anchor-lang 0.32.1 (git+https://github.com/jamie-osec/anchor?rev=939b843)", ] +[[package]] +name = "missing_mut_constraint_test_program" +version = "0.1.0" +dependencies = [ + "anchor-lang 0.32.1 (git+https://github.com/jamie-osec/anchor.git?branch=anchor-lint)", + "anchor-spl 0.32.1 (git+https://github.com/jamie-osec/anchor.git?branch=anchor-lint)", +] + [[package]] name = "missing_owner_check_test_program" version = "0.1.0" diff --git a/tests/lint_tests.rs b/tests/lint_tests.rs index 78db7a6..2a10d63 100644 --- a/tests/lint_tests.rs +++ b/tests/lint_tests.rs @@ -79,6 +79,11 @@ async fn overconstrained_seed_account_tests() -> Result<()> { run_overconstrained_seed_account_tests().await } +#[tokio::test] +async fn missing_mut_constraint_tests() -> Result<()> { + run_missing_mut_constraint_tests().await +} + #[tokio::test] async fn unsafe_pyth_price_account_tests() -> Result<()> { run_unsafe_pyth_price_account_tests().await @@ -414,6 +419,17 @@ async fn run_overconstrained_seed_account_tests() -> Result<()> { .await } +async fn run_missing_mut_constraint_tests() -> Result<()> { + run_standard_lint_test( + "missing_mut_constraint", + &["missing_mut_constraint"], + "warning: account", + Some("is mutated in the instruction but is not declared with `#[account(mut)]`"), + "missing_mut_constraint", + ) + .await +} + async fn run_unsafe_pyth_price_account_tests() -> Result<()> { run_standard_lint_test( "unsafe_pyth_price_account", From 7169d67b39bbf21e2cf73447b75e09b070f7c917 Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Thu, 5 Mar 2026 09:52:26 +0530 Subject: [PATCH 10/16] Revert "feat: new lint missing_mut_constraint" This reverts commit c5e51577bc3f386396eaffc431451f1102008e31. --- Cargo.lock | 10 - README.md | 2 - lints/missing_mut_constraint/Cargo.toml | 24 --- lints/missing_mut_constraint/README.md | 36 ---- lints/missing_mut_constraint/src/lib.rs | 191 ------------------ .../tests/test_program/Cargo.toml | 13 -- .../tests/test_program/src/lib.rs | 76 ------- tests/Cargo.lock | 8 - tests/lint_tests.rs | 16 -- 9 files changed, 376 deletions(-) delete mode 100644 lints/missing_mut_constraint/Cargo.toml delete mode 100644 lints/missing_mut_constraint/README.md delete mode 100644 lints/missing_mut_constraint/src/lib.rs delete mode 100644 lints/missing_mut_constraint/tests/test_program/Cargo.toml delete mode 100644 lints/missing_mut_constraint/tests/test_program/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 85b049d..a2f6de0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,16 +791,6 @@ dependencies = [ "dylint_testing", ] -[[package]] -name = "missing_mut_constraint" -version = "0.1.0" -dependencies = [ - "anchor-lints-utils", - "clippy_utils", - "dylint_linting", - "dylint_testing", -] - [[package]] name = "missing_owner_check" version = "0.1.0" diff --git a/README.md b/README.md index fcfc7f3..cf616bc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ cargo install cargo-dylint dylint-link | [`direct_lamport_cpi_dos`](lints/direct_lamport_cpi_dos) | | [`overconstrained_seed_account`](lints/overconstrained_seed_account) | | [`unsafe_pyth_price_account`](lints/unsafe_pyth_price_account) | -| [`missing_mut_constraint`](lints/missing_mut_constraint) | ## Usage @@ -69,5 +68,4 @@ cargo test ata_should_use_init_if_needed_tests cargo test direct_lamport_cpi_dos_tests cargo test overconstrained_seed_account_tests cargo test unsafe_pyth_price_account_tests -cargo test missing_mut_constraint_tests ``` diff --git a/lints/missing_mut_constraint/Cargo.toml b/lints/missing_mut_constraint/Cargo.toml deleted file mode 100644 index ba610d9..0000000 --- a/lints/missing_mut_constraint/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "missing_mut_constraint" -version.workspace = true -edition.workspace = true -publish = false -authors = ["authors go here"] -description = "Detects accounts that are mutated in the instruction but not declared with #[account(mut)]" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -anchor-lints-utils.workspace = true -clippy_utils.workspace = true -dylint_linting.workspace = true - -[dev-dependencies] -dylint_testing.workspace = true - -[package.metadata.rust-analyzer] -rustc_private = true - -[lints] -workspace = true diff --git a/lints/missing_mut_constraint/README.md b/lints/missing_mut_constraint/README.md deleted file mode 100644 index 2bf26f4..0000000 --- a/lints/missing_mut_constraint/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# `missing_mut_constraint` - -### What it does -Detects when an account is mutated in the instruction body but not declared with `#[account(mut)]` in the Anchor accounts struct. - -### Why is this bad? -Mutating an account without the `mut` constraint can cause the runtime to reject the transaction or behave unexpectedly, because the account was not marked as writable. - -### Example - -**Bad:** account mutated without `#[account(mut)]` -```rust -#[derive(Accounts)] -pub struct Update<'info> { - pub vault: Account<'info, Vault>, // missing #[account(mut)] -} - -pub fn update(ctx: Context) -> Result<()> { - ctx.accounts.vault.amount += 1; // mutation - Ok(()) -} -``` - -**Good:** account has `#[account(mut)]` when mutated -```rust -#[derive(Accounts)] -pub struct Update<'info> { - #[account(mut)] - pub vault: Account<'info, Vault>, -} - -pub fn update(ctx: Context) -> Result<()> { - ctx.accounts.vault.amount += 1; - Ok(()) -} -``` \ No newline at end of file diff --git a/lints/missing_mut_constraint/src/lib.rs b/lints/missing_mut_constraint/src/lib.rs deleted file mode 100644 index d07bc82..0000000 --- a/lints/missing_mut_constraint/src/lib.rs +++ /dev/null @@ -1,191 +0,0 @@ -#![feature(rustc_private)] -#![warn(unused_extern_crates)] -#![feature(box_patterns)] - -extern crate rustc_hir; -extern crate rustc_middle; -extern crate rustc_span; - -use anchor_lints_utils::{ - mir_analyzer::{AnchorContextInfo, MirAnalyzer}, - utils::{extract_account_constraints, should_skip_function}, -}; -use clippy_utils::diagnostics::span_lint; - -use rustc_hir::{Body as HirBody, FnDecl, def_id::LocalDefId, intravisit::FnKind}; -use rustc_lint::{LateContext, LateLintPass}; -use rustc_middle::{ - mir::{Mutability, Place, Rvalue, StatementKind}, - ty::TyKind, -}; -use rustc_span::Span; - -use std::collections::{HashMap, HashSet}; - -dylint_linting::impl_late_lint! { - /// ### What it does - /// Detects when an account is mutated in the instruction body but not declared - /// with `#[account(mut)]` in the Anchor accounts struct. - /// - /// ### Why is this bad? - /// Mutating an account without the `mut` constraint can cause the runtime to - /// reject the transaction or behave unexpectedly, as the account was not - /// marked as writable. - /// - /// ### Example - /// ```rust - /// #[derive(Accounts)] - /// pub struct Update<'info> { - /// pub vault: Account<'info, Vault>, // missing #[account(mut)] - /// } - /// pub fn update(ctx: Context) -> Result<()> { - /// ctx.accounts.vault.amount += 1; // mutation - /// Ok(()) - /// } - /// ``` - /// - /// ### Good - /// ```rust - /// #[derive(Accounts)] - /// pub struct Update<'info> { - /// #[account(mut)] - /// pub vault: Account<'info, Vault>, - /// } - /// ``` - pub MISSING_MUT_CONSTRAINT, - Warn, - "account is mutated but missing #[account(mut)]", - MissingMutConstraint -} - -#[derive(Default)] -pub struct MissingMutConstraint; - - -struct AccountMutability { - span: Span, - mutable: bool, -} - -impl<'tcx> LateLintPass<'tcx> for MissingMutConstraint { - fn check_fn( - &mut self, - cx: &LateContext<'tcx>, - _kind: FnKind<'tcx>, - _: &FnDecl<'tcx>, - body: &HirBody<'tcx>, - main_fn_span: Span, - def_id: LocalDefId, - ) { - if should_skip_function(cx, main_fn_span, def_id) { - return; - } - - let mut mir_analyzer = MirAnalyzer::new(cx, body, def_id); - anchor_lints_utils::utils::ensure_anchor_context_initialized(&mut mir_analyzer, body); - - // Analyze functions that take Anchor context - let Some(anchor_context_info) = mir_analyzer.anchor_context_info.as_ref() else { - return; - }; - - analyze_missing_mut_constraint(cx, &mir_analyzer, anchor_context_info); - } -} - - -fn analyze_missing_mut_constraint<'cx, 'tcx>( - cx: &'cx LateContext<'tcx>, - mir_analyzer: &MirAnalyzer<'cx, 'tcx>, - anchor_context_info: &AnchorContextInfo<'tcx>, -) { - let accounts_struct_ty = &anchor_context_info.anchor_context_account_type; - let TyKind::Adt(adt_def, _generics) = accounts_struct_ty.kind() else { - return; - }; - - if !adt_def.is_struct() && !adt_def.is_union() { - return; - } - - let variant = adt_def.non_enum_variant(); - let mut account_mutability: HashMap = HashMap::new(); - - for field in &variant.fields { - let account_name = field.ident(cx.tcx).to_string(); - let account_span = cx.tcx.def_span(field.did); - let constraints = extract_account_constraints(cx, field); - account_mutability.insert( - account_name, - AccountMutability { - span: account_span, - mutable: constraints.mutable, - }, - ); - } - - let mutated_accounts = collect_mutated_accounts(mir_analyzer); - let mut visited = HashSet::new(); - - for account_name in mutated_accounts { - if visited.contains(&account_name) { - continue; - } - visited.insert(account_name.clone()); - - if let Some(info) = account_mutability.get(&account_name) - && !info.mutable { - span_lint( - cx, - MISSING_MUT_CONSTRAINT, - info.span, - format!( - "account `{}` is mutated in the instruction but is not declared with `#[account(mut)]`", - account_name - ), - ); - } - } -} - -/// Collects the accounts that are mutated in the instruction body -fn collect_mutated_accounts<'cx, 'tcx>(mir_analyzer: &MirAnalyzer<'cx, 'tcx>) -> HashSet { - let mut mutated = HashSet::new(); - - for (_bb, bbdata) in mir_analyzer.mir.basic_blocks.iter_enumerated() { - for stmt in &bbdata.statements { - if let StatementKind::Assign(box (place, rvalue)) = &stmt.kind - && let Some(account_name) = account_name_from_place_or_rvalue(mir_analyzer, place, rvalue) { - mutated.insert(account_name); - } - } - } - - mutated -} - -/// Extracts the account name from a place or rvalue -fn account_name_from_place_or_rvalue<'cx, 'tcx>( - mir_analyzer: &MirAnalyzer<'cx, 'tcx>, - place: &Place<'_>, - rvalue: &Rvalue<'_>, -) -> Option { - let base_local = place.local; - let resolved = mir_analyzer.resolve_to_original_local(base_local, &mut HashSet::new()); - if let Some(acc) = mir_analyzer.extract_account_name_from_local(&resolved, true) { - let name = acc.account_name.split('.').next().unwrap_or(&acc.account_name).to_string(); - return Some(name); - } - - if let Rvalue::Ref(_, borrow_kind, ref_place) = rvalue - && borrow_kind.mutability() == Mutability::Mut { - let base = ref_place.local; - let resolved = mir_analyzer.resolve_to_original_local(base, &mut HashSet::new()); - if let Some(acc) = mir_analyzer.extract_account_name_from_local(&resolved, true) { - let name = acc.account_name.split('.').next().unwrap_or(&acc.account_name).to_string(); - return Some(name); - } - } - - None -} diff --git a/lints/missing_mut_constraint/tests/test_program/Cargo.toml b/lints/missing_mut_constraint/tests/test_program/Cargo.toml deleted file mode 100644 index 1809d0b..0000000 --- a/lints/missing_mut_constraint/tests/test_program/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "missing_mut_constraint_test_program" -version = "0.1.0" -edition = "2021" -workspace = "../../../../tests" - - -[lib] -crate-type = ["cdylib"] - -[dependencies] -anchor-lang = { git = "https://github.com/jamie-osec/anchor.git", branch = "anchor-lint" } -anchor-spl = { git = "https://github.com/jamie-osec/anchor.git", branch = "anchor-lint" } diff --git a/lints/missing_mut_constraint/tests/test_program/src/lib.rs b/lints/missing_mut_constraint/tests/test_program/src/lib.rs deleted file mode 100644 index 64062c2..0000000 --- a/lints/missing_mut_constraint/tests/test_program/src/lib.rs +++ /dev/null @@ -1,76 +0,0 @@ -use anchor_lang::prelude::*; - -declare_id!("11111111111111111111111111111111"); - -#[program] -pub mod missing_mut_constraint { - use super::*; - - // Bad: Account (vault) is mutated but not declared with #[account(mut)] - pub fn update_bad(ctx: Context) -> Result<()> { - ctx.accounts.vault.amount += 1; - Ok(()) - } - - // Good: Account (vault) has #[account(mut)] - pub fn update_good(ctx: Context) -> Result<()> { - ctx.accounts.vault.amount += 1; - Ok(()) - } - - // Good: Account (vault) is only read, no mut needed - pub fn read_only(ctx: Context) -> Result<()> { - let _ = ctx.accounts.vault.amount; - Ok(()) - } - - // Bad: Account (treasury) is mutated but not declared with #[account(mut)] (Account (vault) has mut) - pub fn transfer_bad(ctx: Context) -> Result<()> { - ctx.accounts.vault.amount -= 10; - ctx.accounts.treasury.amount += 10; - Ok(()) - } - - // Good: lamport mutation with #[account(mut)] on payer - pub fn pay_lamports(ctx: Context, amount: u64) -> Result<()> { - **ctx.accounts.payer.lamports.borrow_mut() -= amount; - **ctx.accounts.recipient.lamports.borrow_mut() += amount; - Ok(()) - } -} - -#[account] -pub struct Vault { - pub amount: u64, -} - -#[derive(Accounts)] -pub struct UpdateBad<'info> { - pub vault: Account<'info, Vault>, // [missing_mut_constraint] -} - -#[derive(Accounts)] -pub struct UpdateGood<'info> { - #[account(mut)] - pub vault: Account<'info, Vault>, -} - -#[derive(Accounts)] -pub struct ReadOnly<'info> { - pub vault: Account<'info, Vault>, -} - -#[derive(Accounts)] -pub struct TransferBad<'info> { - #[account(mut)] - pub vault: Account<'info, Vault>, - pub treasury: Account<'info, Vault>, // [missing_mut_constraint] -} - -#[derive(Accounts)] -pub struct PayLamports<'info> { - #[account(mut)] - pub payer: AccountInfo<'info>, - #[account(mut)] - pub recipient: AccountInfo<'info>, -} diff --git a/tests/Cargo.lock b/tests/Cargo.lock index 959fafe..360dffb 100644 --- a/tests/Cargo.lock +++ b/tests/Cargo.lock @@ -1589,14 +1589,6 @@ dependencies = [ "anchor-lang 0.32.1 (git+https://github.com/jamie-osec/anchor?rev=939b843)", ] -[[package]] -name = "missing_mut_constraint_test_program" -version = "0.1.0" -dependencies = [ - "anchor-lang 0.32.1 (git+https://github.com/jamie-osec/anchor.git?branch=anchor-lint)", - "anchor-spl 0.32.1 (git+https://github.com/jamie-osec/anchor.git?branch=anchor-lint)", -] - [[package]] name = "missing_owner_check_test_program" version = "0.1.0" diff --git a/tests/lint_tests.rs b/tests/lint_tests.rs index 2a10d63..78db7a6 100644 --- a/tests/lint_tests.rs +++ b/tests/lint_tests.rs @@ -79,11 +79,6 @@ async fn overconstrained_seed_account_tests() -> Result<()> { run_overconstrained_seed_account_tests().await } -#[tokio::test] -async fn missing_mut_constraint_tests() -> Result<()> { - run_missing_mut_constraint_tests().await -} - #[tokio::test] async fn unsafe_pyth_price_account_tests() -> Result<()> { run_unsafe_pyth_price_account_tests().await @@ -419,17 +414,6 @@ async fn run_overconstrained_seed_account_tests() -> Result<()> { .await } -async fn run_missing_mut_constraint_tests() -> Result<()> { - run_standard_lint_test( - "missing_mut_constraint", - &["missing_mut_constraint"], - "warning: account", - Some("is mutated in the instruction but is not declared with `#[account(mut)]`"), - "missing_mut_constraint", - ) - .await -} - async fn run_unsafe_pyth_price_account_tests() -> Result<()> { run_standard_lint_test( "unsafe_pyth_price_account", From 4867cbf8e88d2cd1e72280e2cd18c27c18741e93 Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 6 Mar 2026 10:24:22 +0530 Subject: [PATCH 11/16] Revert "feat: LazyAccount support for missing_account_reload lint" This reverts commit e89795078f2448b3fedacf289a1f37199c13d07f. --- anchor-lints-utils/src/diag_items.rs | 19 ++----- lints/missing_account_reload/src/lib.rs | 42 +------------- .../missing_account_reload/src/utils/paths.rs | 8 +-- .../tests/test_program/Cargo.toml | 2 +- .../tests/test_program/src/lib.rs | 56 +------------------ 5 files changed, 12 insertions(+), 115 deletions(-) diff --git a/anchor-lints-utils/src/diag_items.rs b/anchor-lints-utils/src/diag_items.rs index a2f0b68..6241819 100644 --- a/anchor-lints-utils/src/diag_items.rs +++ b/anchor-lints-utils/src/diag_items.rs @@ -78,8 +78,6 @@ pub enum DiagnoticItem { SplTokenAccount, /// `spl_token::state::Mint` SplTokenMint, - /// `anchor_lang::prelude::LazyAccount` - AnchorLazyAccount, } impl DiagnoticItem { @@ -151,9 +149,6 @@ impl DiagnoticItem { DiagnoticItem::SplTokenMint => { return None; } - DiagnoticItem::AnchorLazyAccount => { - return None; - } }) } @@ -260,7 +255,6 @@ impl DiagnoticItem { } DiagnoticItem::SplTokenAccount => &["spl_token::state::Account"], DiagnoticItem::SplTokenMint => &["spl_token::state::Mint"], - DiagnoticItem::AnchorLazyAccount => &["anchor_lang::prelude::LazyAccount"], } } @@ -469,10 +463,10 @@ pub fn is_solana_instruction_type(tcx: TyCtxt, ty: Ty) -> bool { pub fn is_box_type(tcx: TyCtxt, ty: Ty) -> bool { let ty = ty.peel_refs(); - if let ty::Adt(adt_def, _) = ty.kind() - && let Some(box_def_id) = tcx.lang_items().owned_box() - { - return adt_def.did() == box_def_id; + if let ty::Adt(adt_def, _) = ty.kind() { + if let Some(box_def_id) = tcx.lang_items().owned_box() { + return adt_def.did() == box_def_id; + } } false } @@ -498,8 +492,3 @@ pub fn is_cpi_builder_constructor_fn(tcx: TyCtxt, def_id: DefId) -> bool { || path.contains("UnlockV1") || path.contains("RevokeStaking") } - -pub fn is_anchor_lazy_account_type(tcx: TyCtxt, ty: Ty) -> bool { - let ty = ty.peel_refs(); - DiagnoticItem::AnchorLazyAccount.defid_is_type(tcx, ty) -} diff --git a/lints/missing_account_reload/src/lib.rs b/lints/missing_account_reload/src/lib.rs index 7e4cc31..160a826 100644 --- a/lints/missing_account_reload/src/lib.rs +++ b/lints/missing_account_reload/src/lib.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use anchor_lints_utils::utils::should_skip_function; use anchor_lints_utils::{ - diag_items::{DiagnoticItem, is_anchor_lazy_account_type, is_cpi_invoke_fn}, + diag_items::{DiagnoticItem, is_cpi_invoke_fn}, mir_analyzer::{AnchorContextInfo, MirAnalyzer}, utils::get_hir_body_from_local_def_id, }; @@ -124,25 +124,6 @@ impl<'tcx> LateLintPass<'tcx> for MissingAccountReload { } } } - // Check for LazyAccount::load/LazyAccount::load_mut (reloads for LazyAccount) - else if let Some(name) = cx.tcx.opt_item_name(*fn_def_id) - && (name.as_str() == "load" || name.as_str() == "load_mut") - && let Some(receiver) = args.first() - && let Operand::Move(place) | Operand::Copy(place) = &receiver.node - && let Some(local) = place.as_local() - && let Some(decl) = mir.local_decls.get(local) - { - let receiver_ty = decl.ty.peel_refs(); - if is_anchor_lazy_account_type(cx.tcx, receiver_ty) - && let Some(account_name_and_local) = - mir_analyzer.extract_account_name_from_local(&local, false) - { - account_reloads - .entry(account_name_and_local.account_name) - .or_default() - .insert(bb); - } - } // Or a CPI invoke function else if is_cpi_invoke_fn(cx.tcx, *fn_def_id) || mir_analyzer.takes_cpi_context(args) @@ -394,27 +375,6 @@ pub fn analyze_nested_function_operations<'tcx>( nested_function_blocks.push(block); } } - // Check for LazyAccount::load/LazyAccount::load_mut (reloads for LazyAccount) - else if let Some(name) = cx.tcx.opt_item_name(*def_id) - && (name.as_str() == "load" || name.as_str() == "load_mut") - && let Some(receiver) = args.first() - && let Operand::Move(place) | Operand::Copy(place) = &receiver.node - && let Some(local) = place.as_local() - && let Some(decl) = mir_body.local_decls.get(local) - { - let receiver_ty = decl.ty.peel_refs(); - if is_anchor_lazy_account_type(cx.tcx, receiver_ty) - && let Some(block) = handle_account_reload_in_nested_function( - &mir_analyzer, - mir_body, - args, - *fn_span, - bb, - ) - { - nested_function_blocks.push(block); - } - } // Handle account access (deref) in nested function else if cx .tcx diff --git a/lints/missing_account_reload/src/utils/paths.rs b/lints/missing_account_reload/src/utils/paths.rs index 5015b53..32c117b 100644 --- a/lints/missing_account_reload/src/utils/paths.rs +++ b/lints/missing_account_reload/src/utils/paths.rs @@ -1,8 +1,8 @@ use anchor_lints_utils::{ diag_items::{ is_account_info_type, is_anchor_account_loader_type, is_anchor_account_type, - is_anchor_interface_account_type, is_anchor_lazy_account_type, is_anchor_signer_type, - is_anchor_system_account_type, is_anchor_unchecked_account_type, is_box_type, + is_anchor_interface_account_type, is_anchor_signer_type, is_anchor_system_account_type, + is_anchor_unchecked_account_type, is_box_type, }, mir_analyzer::AnchorContextInfo, }; @@ -58,9 +58,7 @@ pub fn contains_deserialized_data<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> if is_anchor_account_loader_type(cx.tcx, ty) { return false; } - if is_anchor_interface_account_type(cx.tcx, ty) - || is_anchor_system_account_type(cx.tcx, ty) - || is_anchor_lazy_account_type(cx.tcx, ty) + if is_anchor_interface_account_type(cx.tcx, ty) || is_anchor_system_account_type(cx.tcx, ty) { return true; } diff --git a/lints/missing_account_reload/tests/test_program/Cargo.toml b/lints/missing_account_reload/tests/test_program/Cargo.toml index cd6dd41..ad0bd3c 100644 --- a/lints/missing_account_reload/tests/test_program/Cargo.toml +++ b/lints/missing_account_reload/tests/test_program/Cargo.toml @@ -9,4 +9,4 @@ workspace = "../../../../tests" crate-type = ["cdylib"] [dependencies] -anchor-lang = { git = "https://github.com/jamie-osec/anchor", rev = "939b843", features = ["lazy-account"] } +anchor-lang = { git = "https://github.com/jamie-osec/anchor", rev = "939b843" } diff --git a/lints/missing_account_reload/tests/test_program/src/lib.rs b/lints/missing_account_reload/tests/test_program/src/lib.rs index 77edcca..9fbe52c 100644 --- a/lints/missing_account_reload/tests/test_program/src/lib.rs +++ b/lints/missing_account_reload/tests/test_program/src/lib.rs @@ -4,7 +4,7 @@ use anchor_lang::solana_program::{ system_instruction, }; -use anchor_lang::system_program::{Transfer, transfer}; +use anchor_lang::system_program::{transfer, Transfer}; declare_id!("11111111111111111111111111111111"); @@ -319,56 +319,18 @@ pub mod missing_account_reload_tests { } // Pattern 17: CPI call with self implementation - safe - pub fn invoke_with_self_implementation_safe( - ctx: Context, - amount: u64, - ) -> Result<()> { + pub fn invoke_with_self_implementation_safe(ctx: Context, amount: u64) -> Result<()> { ctx.accounts.cpi_call_safe(amount)?; let _final_data = ctx.accounts.pda_account.data; // [safe_account_accessed] Ok(()) } // Pattern 18: CPI call with self implementation - unsafe - pub fn invoke_with_self_implementation_unsafe( - ctx: Context, - amount: u64, - ) -> Result<()> { + pub fn invoke_with_self_implementation_unsafe(ctx: Context, amount: u64) -> Result<()> { ctx.accounts.cpi_call_unsafe(amount)?; let _final_data = ctx.accounts.pda_account.data; // [unsafe_account_accessed] Ok(()) } - - /// Patten 19: load_mut() then CPI — unsafe. - pub fn test_lazy_account_unsafe( - ctx: Context, - amount: u64, - ) -> Result<()> { - let data = ctx.accounts.lazy_acc.load_mut()?; // ref held across CPI - let _ = data.value; - let new_space = ctx.accounts.lazy_acc.to_account_info().data_len() as u64 + amount; - let ix = system_instruction::allocate(&ctx.accounts.lazy_acc.key(), new_space); - let account_infos = vec![ - ctx.accounts.lazy_acc.to_account_info(), - ctx.accounts.system_program.to_account_info(), - ]; - invoke(&ix, &account_infos)?; // [cpi_call] - let _ = data.value; // [unsafe_account_accessed] - Ok(()) - } - - // Pattern 20: load_mut() then CPI with load_mut() — safe - pub fn test_lazy_account_safe(ctx: Context, amount: u64) -> Result<()> { - let new_space = ctx.accounts.lazy_acc.to_account_info().data_len() as u64 + amount; - let ix = system_instruction::allocate(&ctx.accounts.lazy_acc.key(), new_space); - let account_infos = vec![ - ctx.accounts.lazy_acc.to_account_info(), - ctx.accounts.system_program.to_account_info(), - ]; - invoke(&ix, &account_infos)?; - let data = ctx.accounts.lazy_acc.load_mut()?; - let _ = data.value; // [safe_account_accessed] - Ok(()) - } } pub fn cpi_call_safe(ctx_a: &mut Context, amount: u64) -> Result<()> { let from_pubkey = ctx_a.accounts.pda_account.to_account_info(); @@ -647,18 +609,6 @@ impl<'info> SolTransfer3<'info> { } } -#[account] -pub struct LazyLoadable { - pub value: u64, -} - -#[derive(Accounts)] -pub struct LazyAccountCpiAccounts<'info> { - #[account(mut)] - pub lazy_acc: LazyAccount<'info, LazyLoadable>, - pub system_program: Program<'info, System>, -} - #[account] pub struct UserState { pub data: u64, From 767878d93d72dbd628b83eb49eb745f216cae13e Mon Sep 17 00:00:00 2001 From: Umar Sheik Date: Fri, 6 Mar 2026 10:25:20 +0530 Subject: [PATCH 12/16] Revert "fix(arbitrary_cpi_call): use Instruction program_id local for validation & added new tests" This reverts commit d456009fae6513fb2f6164497e5901a5ba33f40a. --- lints/arbitrary_cpi_call/README.md | 44 +------ lints/arbitrary_cpi_call/src/lib.rs | 2 +- lints/arbitrary_cpi_call/src/utils.rs | 18 +-- .../tests/test_program/src/lib.rs | 115 ------------------ 4 files changed, 13 insertions(+), 166 deletions(-) diff --git a/lints/arbitrary_cpi_call/README.md b/lints/arbitrary_cpi_call/README.md index f00f19c..951de82 100644 --- a/lints/arbitrary_cpi_call/README.md +++ b/lints/arbitrary_cpi_call/README.md @@ -1,7 +1,7 @@ -# `arbitrary_cpi_call` +# `arbitraty_cpi_call` ### What it does -Identifies CPI calls made using user-controlled program IDs (from accounts or parameters) without validations before the CPI call. +Identifies CPI calls made using user-controlled program IDs without validations. ### Why is this bad? Unvalidated program IDs in CPI calls let users to trigger arbitrary programs, leading to potential security breaches or fund loss. @@ -12,42 +12,4 @@ To avoid heavy analysis, we skip nested function analysis when: - **Cmps/switches threshold:** The number of program_id comparisons or if/else switches in the current function exceeds `MAX_CMPS_SWITCHES_RECURSION_THRESHOLD`. - **If/else nesting level:** The current basic block is nested deeper than `MAX_IF_ELSE_NESTING_LEVEL` depth (number of dominating `SwitchInt` blocks). -When any one of the condition triggers, we still run CPI checks for the current function (e.g. we still report arbitrary CPI in that function). We only skip propagating validation from nested functions, so very large or deeply nested code may not get full inter-procedural analysis for now. - -### Example (worst case) - -```rust -// BAD: program_id is user-controlled and never validated -pub fn invoke_unchecked_program(ctx: Context) -> Result<()> { - use anchor_lang::solana_program::instruction::Instruction; - use anchor_lang::solana_program::program::invoke; - let instruction = Instruction { - program_id: ctx.accounts.unchecked_program.key(), // user can pass any program - accounts: vec![], - data: vec![], - }; - let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; - invoke(&instruction, &account_infos)?; // CPI - Ok(()) -} - -// GOOD: program_id validated against a constant before CPI -pub fn invoke_validated_program(ctx: Context) -> Result<()> { - use anchor_lang::solana_program::instruction::Instruction; - use anchor_lang::solana_program::program::invoke; - const ALLOWED_PROGRAM_ID: Pubkey = Pubkey::new_from_array([42u8; 32]); - require_keys_eq!( - ctx.accounts.unchecked_program.key(), - ALLOWED_PROGRAM_ID, - CustomError::InvalidProgram - ); - let instruction = Instruction { - program_id: ctx.accounts.unchecked_program.key(), - accounts: vec![], - data: vec![], - }; - let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; - invoke(&instruction, &account_infos)?; // CPI - Ok(()) -} -``` +When any one of the condition triggers, we still run CPI checks for the current function (e.g. we still report arbitrary CPI in that function). We only skip propagating validation from nested functions, so very large or deeply nested code may not get full inter-procedural analysis for now. \ No newline at end of file diff --git a/lints/arbitrary_cpi_call/src/lib.rs b/lints/arbitrary_cpi_call/src/lib.rs index af5c90b..0a057f8 100644 --- a/lints/arbitrary_cpi_call/src/lib.rs +++ b/lints/arbitrary_cpi_call/src/lib.rs @@ -182,7 +182,7 @@ fn analyze_arbitrary_cpi_call<'tcx>( let mut switches: Vec = Vec::new(); let mut program_id_cmps: Vec = Vec::new(); - let mut instruction_to_program_id: HashMap = HashMap::new(); + let mut instruction_to_program_id: HashMap = HashMap::new(); for (bb, bbdata) in mir.basic_blocks.iter_enumerated() { for statement in &bbdata.statements { diff --git a/lints/arbitrary_cpi_call/src/utils.rs b/lints/arbitrary_cpi_call/src/utils.rs index 19d4975..4b0fd61 100644 --- a/lints/arbitrary_cpi_call/src/utils.rs +++ b/lints/arbitrary_cpi_call/src/utils.rs @@ -82,7 +82,7 @@ pub fn record_instruction_creation<'tcx>( mir_analyzer: &MirAnalyzer<'_, 'tcx>, bb: BasicBlock, statement: &Statement<'tcx>, - instruction_to_program_id: &mut HashMap, + instruction_to_program_id: &mut HashMap, ) { if let StatementKind::Assign(box (place, rvalue)) = &statement.kind && let Some(dest_local) = place.as_local() @@ -94,7 +94,7 @@ pub fn record_instruction_creation<'tcx>( && let Some(program_id_local) = place.as_local() && mir_analyzer.is_pubkey_type(program_id_local) { - instruction_to_program_id.insert(dest_local, (bb, program_id_local)); + instruction_to_program_id.insert(dest_local, bb); } } @@ -109,7 +109,7 @@ pub fn track_instruction_call<'tcx>( bb: BasicBlock, cpi_calls: &mut HashMap, cpi_contexts: &mut HashMap, - instruction_to_program_id: &HashMap, + instruction_to_program_id: &HashMap, ) { let mir = mir_analyzer.mir; let decl_ty = match mir @@ -128,9 +128,9 @@ pub fn track_instruction_call<'tcx>( let mut program_id_local = None; let mut program_id_bb = None; - if let Some(&(pid_bb, actual_pid)) = instruction_to_program_id.get(&instruction_local) { - program_id_local = Some(actual_pid); - program_id_bb = Some(pid_bb); + if let Some(&pid) = instruction_to_program_id.get(&instruction_local) { + program_id_local = Some(instruction_local); + program_id_bb = Some(pid); } else { let mut to_check = vec![instruction_local]; let mut visited = HashSet::new(); @@ -140,9 +140,9 @@ pub fn track_instruction_call<'tcx>( continue; } - if let Some(&(pid_bb, actual_pid)) = instruction_to_program_id.get(¤t) { - program_id_local = Some(actual_pid); - program_id_bb = Some(pid_bb); + if let Some(&pid) = instruction_to_program_id.get(¤t) { + program_id_local = Some(instruction_local); + program_id_bb = Some(pid); break; } diff --git a/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs b/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs index b33b06d..8ad819f 100644 --- a/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs +++ b/lints/arbitrary_cpi_call/tests/test_program/src/lib.rs @@ -696,97 +696,6 @@ pub mod arbitrary_cpi_call_tests { ctx.accounts.cpi_call_unsafe(amount)?; Ok(()) } - - // Case 36: Other CPI call(mint_to) with unchecked program ID - unsafe - pub fn cpi_mint_to_unchecked_program( - ctx: Context, - amount: u64, - ) -> Result<()> { - let cpi_accounts = anchor_spl::token::MintTo { - mint: ctx.accounts.mint.to_account_info(), - to: ctx.accounts.to.to_account_info(), - authority: ctx.accounts.authority.to_account_info(), - }; - let cpi_ctx = CpiContext::new(ctx.accounts.unchecked_program.key(), cpi_accounts); - anchor_spl::token::mint_to(cpi_ctx, amount)?; // [arbitrary_cpi_call] - Ok(()) - } - - // Case 37: Associated Token Program create_ata without validation - unsafe - pub fn cpi_create_ata_unchecked_program( - ctx: Context, - ) -> Result<()> { - use anchor_spl::associated_token::{self, Create}; - let cpi_accounts = Create { - payer: ctx.accounts.payer.to_account_info(), - associated_token: ctx.accounts.associated_token.to_account_info(), - authority: ctx.accounts.authority.to_account_info(), - mint: ctx.accounts.mint.to_account_info(), - system_program: ctx.accounts.system_program.to_account_info(), - token_program: ctx.accounts.token_program.to_account_info(), - }; - let cpi_ctx = CpiContext::new(ctx.accounts.unchecked_program.key(), cpi_accounts); - associated_token::create(cpi_ctx)?; // [arbitrary_cpi_call] - Ok(()) - } - - // Case 38: Associated Token Program create_ata with validated program ID - safe - pub fn cpi_create_ata_validated_program( - ctx: Context, - ) -> Result<()> { - use anchor_spl::associated_token::{self, Create}; - require_keys_eq!( - ctx.accounts.unchecked_program.key(), - anchor_spl::associated_token::ID, - CustomError::InvalidProgram - ); - let cpi_accounts = Create { - payer: ctx.accounts.payer.to_account_info(), - associated_token: ctx.accounts.associated_token.to_account_info(), - authority: ctx.accounts.authority.to_account_info(), - mint: ctx.accounts.mint.to_account_info(), - system_program: ctx.accounts.system_program.to_account_info(), - token_program: ctx.accounts.token_program.to_account_info(), - }; - let cpi_ctx = CpiContext::new(ctx.accounts.unchecked_program.key(), cpi_accounts); - associated_token::create(cpi_ctx)?; // [safe_cpi_call] - Ok(()) - } - - // Case 39: Invoke CPI with unchecked program ID - unsafe - pub fn cpi_custom_program_unchecked(ctx: Context) -> Result<()> { - use anchor_lang::solana_program::instruction::Instruction; - use anchor_lang::solana_program::program::invoke; - - let instruction = Instruction { - program_id: ctx.accounts.unchecked_program.key(), - accounts: vec![], - data: vec![], - }; - let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; - invoke(&instruction, &account_infos)?; // [arbitrary_cpi_call] - Ok(()) - } - - // Case 40: Raw invoke with validated program ID - safe - pub fn cpi_custom_program_checked(ctx: Context) -> Result<()> { - use anchor_lang::solana_program::instruction::Instruction; - use anchor_lang::solana_program::program::invoke; - const VALIDATED_PROGRAM_ID: Pubkey = Pubkey::new_from_array([42u8; 32]); // Consider it as a constant program ID - require_keys_eq!( - ctx.accounts.unchecked_program.key(), - VALIDATED_PROGRAM_ID, - CustomError::InvalidProgram - ); - let instruction = Instruction { - program_id: ctx.accounts.unchecked_program.key(), - accounts: vec![], - data: vec![], - }; - let account_infos = vec![ctx.accounts.unchecked_program.to_account_info()]; - invoke(&instruction, &account_infos)?; // [safe_cpi_call] - Ok(()) - } } pub fn cpi_call_with_account<'info>( @@ -1071,30 +980,6 @@ pub struct NestedCpiAccounts<'info> { pub system_program: Program<'info, System>, } -#[derive(Accounts)] -pub struct CpiMintToAccounts<'info> { - pub mint: UncheckedAccount<'info>, - #[account(mut)] - pub to: UncheckedAccount<'info>, - pub authority: UncheckedAccount<'info>, - /// CHECK: Target program to validate - pub unchecked_program: UncheckedAccount<'info>, -} - -#[derive(Accounts)] -pub struct CpiCreateAtaAccounts<'info> { - #[account(mut)] - pub payer: UncheckedAccount<'info>, - #[account(mut)] - pub associated_token: UncheckedAccount<'info>, - pub authority: UncheckedAccount<'info>, - pub mint: UncheckedAccount<'info>, - pub system_program: Program<'info, System>, - pub token_program: Program<'info, anchor_spl::token::Token>, - /// CHECK: Target program to validate - pub unchecked_program: UncheckedAccount<'info>, -} - #[derive(Accounts)] pub struct ImplMethodAccounts<'info> { #[account(mut)] From 8d00c20fb3696764e9e5c09088d8f8fdd3df40a8 Mon Sep 17 00:00:00 2001 From: Jamie Hill-Daniel <134328753+jamie-osec@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:58:39 +0000 Subject: [PATCH 13/16] chore: Use `cargo-install` action to install package (#14) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 955a31b..0153faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,20 +15,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Cache cargo registry, git, bin, and target + - name: Cache cargo registry, git, bin, and target dirs uses: actions/cache@v4 with: path: | - ~/.cargo/bin ~/.cargo/registry ~/.cargo/git + ~/.cargo/bin target tests/target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Install nightly + rustfmt run: rustup update nightly && rustup default nightly && rustup component add rustfmt clippy - name: Install dylint - run: cargo install --locked cargo-dylint@5.0.0 dylint-link@5.0.0 + run: cargo install --locked --force cargo-dylint@5.0.0 dylint-link@5.0.0 - name: Format run: cargo fmt --check - name: Build From d4c56c84481a2f2ed63f197745d77bc716277520 Mon Sep 17 00:00:00 2001 From: Umar Date: Sun, 8 Mar 2026 08:40:23 +0530 Subject: [PATCH 14/16] Update lints/cpi_no_result/README.md Co-authored-by: Jamie Hill-Daniel <134328753+jamie-osec@users.noreply.github.com> --- lints/cpi_no_result/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lints/cpi_no_result/README.md b/lints/cpi_no_result/README.md index de4902b..fdc870f 100644 --- a/lints/cpi_no_result/README.md +++ b/lints/cpi_no_result/README.md @@ -14,9 +14,9 @@ Discarding the CPI result makes it unclear how failures are handled. Even though **Flagged:** ```rust -system_program::transfer(cpi_ctx, amount).unwrap_or_default(); // [cpi_no_result] -system_program::transfer(cpi_ctx, amount).unwrap_or(()); // [cpi_no_result] -system_program::transfer(cpi_ctx, amount).unwrap_or_else(|_| ()); // [cpi_no_result] +system_program::transfer(cpi_ctx, amount).unwrap_or_default(); +system_program::transfer(cpi_ctx, amount).unwrap_or(()); +system_program::transfer(cpi_ctx, amount).unwrap_or_else(|_| ()); ``` **OK():** From 3298995a9379d26d954181961e0d73c061ca89ef Mon Sep 17 00:00:00 2001 From: Umar Date: Sun, 8 Mar 2026 08:40:41 +0530 Subject: [PATCH 15/16] Update lints/direct_lamport_cpi_dos/README.md Co-authored-by: Jamie Hill-Daniel <134328753+jamie-osec@users.noreply.github.com> --- lints/direct_lamport_cpi_dos/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lints/direct_lamport_cpi_dos/README.md b/lints/direct_lamport_cpi_dos/README.md index 42c813b..59ef12a 100644 --- a/lints/direct_lamport_cpi_dos/README.md +++ b/lints/direct_lamport_cpi_dos/README.md @@ -12,12 +12,11 @@ The runtime checks balances for accounts in the CPI. If you changed an account ```rust **ctx.accounts.vault.lamports.borrow_mut() -= WITHDRAW_FEE; **ctx.accounts.fee_collector.lamports.borrow_mut() += WITHDRAW_FEE; - -token::transfer( // [direct_lamport_cpi_dos] — fee_collector not in CPI +// fee_collector not in CPI +token::transfer( CpiContext::new(ctx.accounts.token_program.key(), Transfer { ... }), amount, )?; -``` **OK():** same mutations, account included in CPI ```rust From f547cf8e440bcdb72b6f505b2ac46fb14695ae7d Mon Sep 17 00:00:00 2001 From: Umar Date: Sun, 8 Mar 2026 08:40:49 +0530 Subject: [PATCH 16/16] Update lints/duplicate_mutable_accounts/README.md Co-authored-by: Jamie Hill-Daniel <134328753+jamie-osec@users.noreply.github.com> --- lints/duplicate_mutable_accounts/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lints/duplicate_mutable_accounts/README.md b/lints/duplicate_mutable_accounts/README.md index 9b9f42d..2b931b0 100644 --- a/lints/duplicate_mutable_accounts/README.md +++ b/lints/duplicate_mutable_accounts/README.md @@ -12,7 +12,7 @@ Duplicate mutable accounts can lead to unexpected aliasing of mutable data, logi ```rust #[derive(Accounts)] pub struct UnsafeAccounts<'info> { - pub user_a: Account<'info, User>, // [duplicate_mutable_accounts] + pub user_a: Account<'info, User>, pub user_b: Account<'info, User>, }