Skip to content

Commit 4b4da7c

Browse files
committed
fix(v2): cache remaining_accounts result on error
1 parent 716a2b8 commit 4b4da7c

2 files changed

Lines changed: 64 additions & 28 deletions

File tree

lang-v2/src/context.rs

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,14 @@ pub struct Context<'a, T: Bumps> {
3939
/// only surface during the trailing walk).
4040
mut_mask: &'static [u64; 4],
4141

42-
/// Cache of the parsed remaining accounts. Populated lazily on the
43-
/// first successful call to [`Self::remaining_accounts`]; subsequent
44-
/// calls return a clone of the cached vec. On failure the cache is
45-
/// left unset so a retry re-executes the walk (a program is unlikely
46-
/// to retry past a `ConstraintDuplicateMutableAccount` error, but we
47-
/// avoid caching stale state regardless).
48-
remaining_cache: Option<alloc::vec::Vec<AccountView>>,
42+
/// Cached outcome of the remaining-accounts walk. `None` until the
43+
/// first call to [`Self::remaining_accounts`]; then stores whichever
44+
/// `Result` that walk produced. Subsequent calls replay the cache
45+
/// (clone of the vec on success, clone of the error on failure) so
46+
/// the cursor is advanced exactly once per instruction — retrying
47+
/// after an error must not call `cursor.next()` again, which would
48+
/// walk past the remaining region.
49+
remaining_cache: Option<Result<alloc::vec::Vec<AccountView>, ProgramError>>,
4950
}
5051

5152
impl<'a, T: Bumps> Context<'a, T> {
@@ -70,9 +71,12 @@ impl<'a, T: Bumps> Context<'a, T> {
7071
}
7172

7273
/// Returns trailing accounts beyond the declared `T` fields as an
73-
/// owned `Vec<AccountView>`. First call walks the cursor and caches;
74-
/// subsequent calls clone the cache. Owned vec avoids borrow conflicts
75-
/// with `self.accounts` / `self.bumps`.
74+
/// owned `Vec<AccountView>`. First call walks the cursor and caches
75+
/// the resulting `Result`; subsequent calls replay the cache (clone
76+
/// of the vec on success, clone of the error on failure). Caching
77+
/// the error is important: the walk advances an unsafe cursor, and
78+
/// a handler that calls this again after an error must not trigger
79+
/// another `cursor.next()` loop.
7680
///
7781
/// After each cursor advance, re-tests the cursor's duplicate bitvec
7882
/// against `T::MUT_MASK`. If a trailing account's dup index resolves
@@ -88,27 +92,29 @@ impl<'a, T: Bumps> Context<'a, T> {
8892
/// the trailing slot as a dup of that declared mut account.
8993
pub fn remaining_accounts(&mut self) -> Result<alloc::vec::Vec<AccountView>, ProgramError> {
9094
if self.remaining_cache.is_none() {
91-
let mut v = alloc::vec::Vec::with_capacity(self.remaining_num as usize);
92-
for _ in 0..self.remaining_num {
93-
// SAFETY: cursor is positioned at the start of the
94-
// remaining region and `remaining_num` is the exact
95-
// number of accounts to walk.
96-
v.push(unsafe { self.cursor.next() });
97-
// If this advance materialized a dup whose earlier
98-
// slot is a declared mut account, reject. `duplicates`
99-
// is `Some` iff at least one dup has ever been seen
100-
// (possibly during the `HEADER_SIZE` walk — but that
101-
// case is already handled in `run_handler`, so any
102-
// overlap here means a trailing account caused it).
103-
if let Some(dups) = self.cursor.duplicates() {
104-
if dups.intersects(self.mut_mask) {
105-
return Err(crate::ErrorCode::ConstraintDuplicateMutableAccount.into());
106-
}
95+
self.remaining_cache = Some(self.walk_remaining());
96+
}
97+
match self.remaining_cache.as_ref().unwrap() {
98+
Ok(v) => Ok(v.clone()),
99+
Err(e) => Err(e.clone()),
100+
}
101+
}
102+
103+
fn walk_remaining(&mut self) -> Result<alloc::vec::Vec<AccountView>, ProgramError> {
104+
let mut v = alloc::vec::Vec::with_capacity(self.remaining_num as usize);
105+
for _ in 0..self.remaining_num {
106+
// SAFETY: cursor is positioned at the start of the remaining
107+
// region and `remaining_num` is the exact number of accounts
108+
// to walk. Walking stops on the first mut-alias error so we
109+
// never advance past the remaining region.
110+
v.push(unsafe { self.cursor.next() });
111+
if let Some(dups) = self.cursor.duplicates() {
112+
if dups.intersects(self.mut_mask) {
113+
return Err(crate::ErrorCode::ConstraintDuplicateMutableAccount.into());
107114
}
108115
}
109-
self.remaining_cache = Some(v);
110116
}
111-
Ok(self.remaining_cache.as_ref().unwrap().clone())
117+
Ok(v)
112118
}
113119
}
114120

lang-v2/tests/remaining_accounts_mut_check.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,36 @@ fn trailing_account_aliasing_nested_mut_is_rejected() {
199199
}
200200
}
201201

202+
#[test]
203+
fn remaining_accounts_error_is_cached_on_failure() {
204+
// Before the fix, Err left `remaining_cache` unset, so a repeat
205+
// call would re-enter the walk loop and call `cursor.next()` again
206+
// — past the remaining region on an unsafe cursor. After the fix,
207+
// the error is cached and replayed; the cursor advances exactly
208+
// once per instruction regardless of how many times the handler
209+
// calls `remaining_accounts()`.
210+
let records = [fresh(1), AccountRecord::Dup { index: 0 }];
211+
let mut buf = SbfInputBuffer::build(&records);
212+
let mut lookup: [MaybeUninit<AccountView>; MAX_ACCOUNTS] =
213+
[const { MaybeUninit::uninit() }; MAX_ACCOUNTS];
214+
let program_id = Address::new_from_array(PROGRAM_ID);
215+
let mut cursor =
216+
unsafe { AccountCursor::new(buf.as_mut_ptr(), lookup.as_mut_ptr() as *mut AccountView) };
217+
let (_views, _dups) = unsafe { cursor.walk_n(1) };
218+
let mut ctx: Context<NoAccounts> =
219+
Context::new(&program_id, NoAccounts, (), &mut cursor, 1, MUT_MASK_SLOT0);
220+
221+
for _ in 0..5 {
222+
match ctx.remaining_accounts() {
223+
Err(ProgramError::Custom(code)) => assert_eq!(code, DUP_MUT_ERROR),
224+
other => panic!(
225+
"expected cached Err(ConstraintDuplicateMutableAccount), got {:?}",
226+
other.map(|v| v.len())
227+
),
228+
}
229+
}
230+
}
231+
202232
#[test]
203233
fn remaining_accounts_result_is_cached_on_success() {
204234
// Regression on the cache path: two successful calls must return

0 commit comments

Comments
 (0)