Skip to content

Commit 8d58e9a

Browse files
author
Akash Thota
committed
feat: solana-foundation#3921 linting for missign account reload
1 parent 56b21ed commit 8d58e9a

23 files changed

Lines changed: 885 additions & 0 deletions

File tree

.github/workflows/reusable-tests.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,8 @@ jobs:
462462
path: tests/idl
463463
- cmd: cd tests/lazy-account && anchor test
464464
path: tests/lazy-account
465+
- cmd: cd tests/missing-account-reload && ./build-all.sh
466+
path: tests/missing-account-reload
465467
steps:
466468
- uses: actions/checkout@v3
467469
- uses: ./.github/actions/setup/

lang/syn/src/parser/context.rs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use anyhow::{anyhow, Result};
22
use std::collections::BTreeMap;
33
use std::path::{Path, PathBuf};
44
use syn::parse::{Error as ParseError, Result as ParseResult};
5+
use syn::spanned::Spanned;
56
use syn::{Ident, ImplItem, ImplItemConst, Type, TypePath};
67

78
/// Crate parse context
@@ -82,8 +83,216 @@ impl CrateContext {
8283
};
8384
}
8485
}
86+
87+
// Check for missing account reloads after CPI calls
88+
for ctx in self.modules.values() {
89+
self.check_missing_account_reloads_simple(ctx)?;
90+
}
91+
8592
Ok(())
8693
}
94+
95+
/// looking for common patterns
96+
fn check_missing_account_reloads_simple(&self, module: &ParsedModule) -> Result<()> {
97+
for item in &module.items {
98+
if let syn::Item::Mod(mod_item) = item {
99+
if mod_item
100+
.attrs
101+
.iter()
102+
.any(|attr| attr.path.is_ident("program"))
103+
{
104+
if let Some((_, items)) = &mod_item.content {
105+
for item in items {
106+
match item {
107+
syn::Item::Fn(fn_item) => {
108+
// Check functions for missing account reloads
109+
self.check_function_for_missing_reloads_simple(
110+
fn_item,
111+
&module.file,
112+
)?;
113+
}
114+
syn::Item::Impl(impl_item) => {
115+
for impl_item in &impl_item.items {
116+
if let syn::ImplItem::Method(method) = impl_item {
117+
self.check_method_for_missing_reloads_simple(
118+
method,
119+
&module.file,
120+
)?;
121+
}
122+
}
123+
}
124+
_ => {} // Ignore other items
125+
}
126+
}
127+
}
128+
}
129+
}
130+
}
131+
Ok(())
132+
}
133+
134+
/// reload after CPI calls means invoke/invoke_signed
135+
fn check_function_for_missing_reloads_simple(
136+
&self,
137+
function: &syn::ItemFn,
138+
file: &Path,
139+
) -> Result<()> {
140+
let function_str = quote::quote! { #function }.to_string();
141+
142+
// invoke(...) patterns
143+
if function_str.contains("invoke") {
144+
let lines: Vec<&str> = function_str.lines().collect();
145+
let mut found_invoke = false;
146+
let mut found_account_access_after_invoke = false;
147+
let mut found_reload = false;
148+
let mut account_variables = Vec::new();
149+
150+
for line in lines.iter() {
151+
let line = line.trim();
152+
153+
if line.contains("invoke") {
154+
found_invoke = true;
155+
found_reload = line.contains(".reload()");
156+
continue;
157+
}
158+
159+
if line.contains("ctx.accounts") && line.contains("let") && line.contains("=") {
160+
if let Some(var_name) = self.extract_account_variable_name(line) {
161+
account_variables.push(var_name);
162+
}
163+
}
164+
165+
if found_invoke {
166+
if line.contains(".reload()") {
167+
found_reload = true;
168+
continue;
169+
}
170+
171+
for var_name in &account_variables {
172+
if line.contains(&format!("{}.", var_name)) && !found_reload {
173+
// Found account field access without reload after invoke
174+
found_account_access_after_invoke = true;
175+
break;
176+
}
177+
}
178+
179+
// Also check for direct ctx.accounts access
180+
if line.contains("ctx.accounts") && !line.contains(".reload()") && !found_reload
181+
{
182+
found_account_access_after_invoke = true;
183+
break;
184+
}
185+
}
186+
}
187+
188+
// If we found account access after invoke without reload, report error
189+
if found_account_access_after_invoke {
190+
return Err(anyhow!(
191+
r#"
192+
{}:{}:{}
193+
Missing account reload!
194+
195+
Account data is accessed after a CPI call (invoke/invoke_signed) without being reloaded.
196+
CPI calls can modify account data, so you must call `ctx.accounts.<account_name>.reload()?`
197+
after the CPI call and before accessing the account data.
198+
See https://www.anchor-lang.com/docs/account-types#reloading-accounts for more information.
199+
"#,
200+
file.canonicalize().unwrap().display(),
201+
function.sig.span().start().line,
202+
function.sig.span().start().column
203+
));
204+
}
205+
}
206+
207+
Ok(())
208+
}
209+
210+
fn check_method_for_missing_reloads_simple(
211+
&self,
212+
method: &syn::ImplItemMethod,
213+
file: &Path,
214+
) -> Result<()> {
215+
let method_str = quote::quote! { #method }.to_string();
216+
217+
if method_str.contains("invoke") {
218+
let lines: Vec<&str> = method_str.lines().collect();
219+
let mut found_invoke = false;
220+
let mut found_account_access_after_invoke = false;
221+
let mut found_reload = false;
222+
let mut account_variables = Vec::new();
223+
224+
for line in &lines {
225+
let line = line.trim();
226+
227+
if line.contains("invoke") {
228+
found_invoke = true;
229+
found_reload = false;
230+
continue;
231+
}
232+
233+
if line.contains("ctx.accounts") && line.contains("let") && line.contains("=") {
234+
if let Some(var_name) = self.extract_account_variable_name(line) {
235+
account_variables.push(var_name);
236+
}
237+
}
238+
239+
if found_invoke {
240+
if line.contains(".reload()") {
241+
found_reload = true;
242+
continue;
243+
}
244+
245+
for var_name in &account_variables {
246+
if line.contains(&format!("{}.", var_name)) && !found_reload {
247+
found_account_access_after_invoke = true;
248+
break;
249+
}
250+
}
251+
252+
// Also check for direct ctx.accounts access
253+
if line.contains("ctx.accounts") && !line.contains(".reload()") && !found_reload
254+
{
255+
found_account_access_after_invoke = true;
256+
break;
257+
}
258+
}
259+
}
260+
261+
// If we found account access after invoke without reload, report error
262+
if found_account_access_after_invoke {
263+
return Err(anyhow!(
264+
r#"
265+
{}:{}:{}
266+
Missing account reload!
267+
268+
Account data is accessed after a CPI call (invoke/invoke_signed) without being reloaded.
269+
CPI calls can modify account data, so you must call `ctx.accounts.<account_name>.reload()?`
270+
after the CPI call and before accessing the account data.
271+
See https://www.anchor-lang.com/docs/account-types#reloading-accounts for more information.
272+
"#,
273+
file.canonicalize().unwrap().display(),
274+
method.sig.span().start().line,
275+
method.sig.span().start().column
276+
));
277+
}
278+
}
279+
280+
Ok(())
281+
}
282+
283+
/// Extract account variable name from assignment line
284+
/// e.g., "let user_account = &mut ctx.accounts.user_account;" -> "user_account"
285+
fn extract_account_variable_name(&self, line: &str) -> Option<String> {
286+
if let Some(start) = line.find("let ") {
287+
if let Some(end) = line[start + 4..].find(" =") {
288+
let var_name = line[start + 4..start + 4 + end].trim();
289+
if !var_name.is_empty() {
290+
return Some(var_name.to_string());
291+
}
292+
}
293+
}
294+
None
295+
}
87296
}
88297

89298
/// Module parse context
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[programs.localnet]
2+
missing_account_reload = "145kzfkNvXKJx97e3beWGaB4azDNqv6KcjGrvZym6fQb"
3+
4+
[provider]
5+
cluster = "localnet"
6+
wallet = "~/.config/solana/id.json"
7+
8+
[scripts]
9+
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[workspace]
2+
members = [
3+
"programs/bad-one"
4+
]
5+
resolver = "2"
6+
7+
[profile.release]
8+
overflow-checks = true
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "missing-account-reload",
3+
"version": "0.31.1",
4+
"license": "(MIT OR Apache-2.0)",
5+
"homepage": "https://github.com/coral-xyz/anchor#readme",
6+
"bugs": {
7+
"url": "https://github.com/coral-xyz/anchor/issues"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/coral-xyz/anchor.git"
12+
},
13+
"engines": {
14+
"node": ">=17"
15+
},
16+
"scripts": {
17+
"test": "anchor test"
18+
}
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "bad-one"
3+
version = "0.1.0"
4+
description = "Created with Anchor"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "bad_one"
10+
11+
[features]
12+
no-entrypoint = []
13+
no-idl = []
14+
no-log-ix-name = []
15+
cpi = ["no-entrypoint"]
16+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
17+
default = []
18+
19+
[dependencies]
20+
anchor-lang = { path = "../../../../../lang", features = ["init-if-needed"] }
21+
anchor-spl = { path = "../../../../../spl" }

0 commit comments

Comments
 (0)