Skip to main content

hopper_runtime/
remaining.rs

1//! Remaining-accounts accessor with strict and passthrough modes.
2//!
3//! The declared context validates exactly `ACCOUNT_COUNT` accounts.
4//! Any accounts beyond that index are "remaining": pool participants,
5//! keeper bot recipients, arbitrary fanout destinations, remainder
6//! destinations for sweeps, and so on. Hopper exposes two ways to
7//! consume them.
8//!
9//! ## Strict mode
10//!
11//! Default. The accessor rejects any remaining account whose address
12//! matches a previously seen account (either declared or already
13//! yielded). Protects against accidental double-spending when a
14//! caller tries to alias one slot into two different roles.
15//!
16//! ```ignore
17//! let rem = ctx.remaining_accounts();
18//! for maybe_acc in rem.iter() {
19//!     let acc = maybe_acc?; // errors on duplicate
20//!     // ...
21//! }
22//! ```
23//!
24//! ## Passthrough mode
25//!
26//! Opt-in. Preserves duplicates verbatim. Use when the caller is
27//! expected to pass the same account in multiple roles (batched CPI
28//! fan-in, for example).
29//!
30//! ```ignore
31//! let rem = ctx.remaining_accounts_passthrough();
32//! ```
33//!
34//! Both modes are O(n) with no heap and no syscalls. Strict mode
35//! keeps a small const-sized seen-address cache sized at 64; past
36//! that, it falls back to a linear scan of the declared slice plus
37//! the yielded-view cursor.
38
39use crate::{account::AccountView, account_wrappers::Signer, error::ProgramError};
40
41/// Upper bound on remaining-account iterator length. Matches Quasar's
42/// `MAX_REMAINING_ACCOUNTS` so programs porting from one framework to
43/// the other see the same ceiling. Exceeding this returns an error
44/// rather than risking unbounded stack usage in the seen-address cache.
45pub const MAX_REMAINING_ACCOUNTS: usize = 64;
46
47/// Error surface for the remaining-accounts accessor.
48#[derive(Copy, Clone, Debug, PartialEq, Eq)]
49pub enum RemainingError {
50    /// Two remaining-account slots resolved to the same address, or a
51    /// remaining-account address matched an already-declared account.
52    /// Only strict mode emits this.
53    DuplicateAccount,
54    /// More than [`MAX_REMAINING_ACCOUNTS`] were accessed via the
55    /// iterator.
56    Overflow,
57}
58
59impl From<RemainingError> for ProgramError {
60    fn from(e: RemainingError) -> Self {
61        match e {
62            RemainingError::DuplicateAccount => ProgramError::InvalidAccountData,
63            RemainingError::Overflow => ProgramError::InvalidArgument,
64        }
65    }
66}
67
68/// Duplicate-handling policy for a [`RemainingAccounts`] view.
69#[derive(Copy, Clone, Eq, PartialEq, Debug)]
70pub enum RemainingMode {
71    /// Reject any yielded account whose address matches a declared or
72    /// previously-yielded account. Safe default for pool programs
73    /// and anything that intends every slot to be distinct.
74    Strict,
75    /// Yield every slot as is. Use when the caller is expected to
76    /// pass aliases (batched fan-in, self-transfers, etc.).
77    Passthrough,
78}
79
80/// Zero-allocation remaining-accounts view.
81///
82/// Construct via [`RemainingAccounts::strict`] or
83/// [`RemainingAccounts::passthrough`] from the declared slice and the
84/// full accounts slice. `#[hopper::context]` emits
85/// `ctx.remaining_accounts()` and `ctx.remaining_accounts_passthrough()`
86/// accessors that wire these up for you.
87pub struct RemainingAccounts<'a> {
88    /// Already-validated context accounts, used for dedup in strict mode.
89    declared: &'a [AccountView],
90    /// Accounts beyond the declared count.
91    remaining: &'a [AccountView],
92    /// Duplicate-handling policy.
93    mode: RemainingMode,
94}
95
96impl<'a> RemainingAccounts<'a> {
97    /// Build a strict accessor. Iteration rejects duplicates.
98    #[inline(always)]
99    pub fn strict(declared: &'a [AccountView], remaining: &'a [AccountView]) -> Self {
100        Self {
101            declared,
102            remaining,
103            mode: RemainingMode::Strict,
104        }
105    }
106
107    /// Build a passthrough accessor. Iteration preserves duplicates.
108    #[inline(always)]
109    pub fn passthrough(declared: &'a [AccountView], remaining: &'a [AccountView]) -> Self {
110        Self {
111            declared,
112            remaining,
113            mode: RemainingMode::Passthrough,
114        }
115    }
116
117    /// Length of the remaining slice, irrespective of mode.
118    #[inline(always)]
119    pub fn len(&self) -> usize {
120        self.remaining.len()
121    }
122
123    /// True when there are no remaining accounts.
124    #[inline(always)]
125    pub fn is_empty(&self) -> bool {
126        self.remaining.is_empty()
127    }
128
129    /// The active duplicate-handling policy for this view.
130    #[inline(always)]
131    pub fn mode(&self) -> RemainingMode {
132        self.mode
133    }
134
135    /// The raw remaining-account slice backing this view.
136    #[inline(always)]
137    pub fn as_slice(&self) -> &'a [AccountView] {
138        self.remaining
139    }
140
141    /// Random access by index. Passthrough returns the slot as is;
142    /// strict returns an error when the resolved slot aliases a
143    /// previously-seen account (declared or yielded before `index`).
144    pub fn get(&self, index: usize) -> Result<Option<&'a AccountView>, ProgramError> {
145        if index >= self.remaining.len() {
146            return Ok(None);
147        }
148        let candidate = &self.remaining[index];
149        match self.mode {
150            RemainingMode::Passthrough => Ok(Some(candidate)),
151            RemainingMode::Strict => {
152                if index >= MAX_REMAINING_ACCOUNTS {
153                    return Err(RemainingError::Overflow.into());
154                }
155                // Scan declared.
156                for d in self.declared {
157                    if d.address() == candidate.address() {
158                        return Err(RemainingError::DuplicateAccount.into());
159                    }
160                }
161                // Scan remaining[0..index].
162                for r in &self.remaining[..index] {
163                    if r.address() == candidate.address() {
164                        return Err(RemainingError::DuplicateAccount.into());
165                    }
166                }
167                Ok(Some(candidate))
168            }
169        }
170    }
171
172    /// Validate the remaining tail as at most `N` account views.
173    ///
174    /// In strict mode this also rejects aliases to declared accounts
175    /// and duplicate remaining slots before returning the typed set.
176    pub fn account_views<const N: usize>(
177        &self,
178    ) -> Result<RemainingAccountViews<'a, N>, ProgramError> {
179        if self.remaining.len() > N {
180            return Err(RemainingError::Overflow.into());
181        }
182        let mut items: [Option<&'a AccountView>; N] = [None; N];
183        let mut index = 0;
184        while index < self.remaining.len() {
185            let account = self.get(index)?.ok_or(ProgramError::NotEnoughAccountKeys)?;
186            items[index] = Some(account);
187            index += 1;
188        }
189        Ok(RemainingAccountViews { items, len: index })
190    }
191
192    /// Validate the remaining tail as at most `N` signer accounts.
193    ///
194    /// This is the common multisig case: the handler gets a bounded,
195    /// duplicate-safe signer set instead of raw account iteration.
196    pub fn signers<const N: usize>(&self) -> Result<RemainingSigners<'a, N>, ProgramError> {
197        if self.remaining.len() > N {
198            return Err(RemainingError::Overflow.into());
199        }
200        let mut items: [Option<Signer<'a>>; N] = [None; N];
201        let mut index = 0;
202        while index < self.remaining.len() {
203            let account = self.get(index)?.ok_or(ProgramError::NotEnoughAccountKeys)?;
204            items[index] = Some(Signer::try_new(account)?);
205            index += 1;
206        }
207        Ok(RemainingSigners { items, len: index })
208    }
209
210    /// Sequential iterator. Yields each account in declaration order,
211    /// errors on duplicates in strict mode, preserves them in
212    /// passthrough mode.
213    #[inline(always)]
214    pub fn iter(&self) -> RemainingIter<'a> {
215        RemainingIter {
216            declared: self.declared,
217            remaining: self.remaining,
218            mode: self.mode,
219            index: 0,
220        }
221    }
222}
223
224/// Iterator yielded by [`RemainingAccounts::iter`].
225pub struct RemainingIter<'a> {
226    declared: &'a [AccountView],
227    remaining: &'a [AccountView],
228    mode: RemainingMode,
229    index: usize,
230}
231
232impl<'a> Iterator for RemainingIter<'a> {
233    type Item = Result<&'a AccountView, ProgramError>;
234
235    fn next(&mut self) -> Option<Self::Item> {
236        if self.index >= self.remaining.len() {
237            return None;
238        }
239        if self.index >= MAX_REMAINING_ACCOUNTS {
240            // Pin the cursor so repeated calls after overflow stay
241            // cheap and deterministic.
242            self.index = self.remaining.len();
243            return Some(Err(RemainingError::Overflow.into()));
244        }
245        let candidate = &self.remaining[self.index];
246        let i = self.index;
247        self.index = self.index.wrapping_add(1);
248
249        if matches!(self.mode, RemainingMode::Strict) {
250            for d in self.declared {
251                if d.address() == candidate.address() {
252                    return Some(Err(RemainingError::DuplicateAccount.into()));
253                }
254            }
255            for r in &self.remaining[..i] {
256                if r.address() == candidate.address() {
257                    return Some(Err(RemainingError::DuplicateAccount.into()));
258                }
259            }
260        }
261        Some(Ok(candidate))
262    }
263}
264
265/// Bounded, validated remaining account-view set.
266pub struct RemainingAccountViews<'a, const N: usize> {
267    items: [Option<&'a AccountView>; N],
268    len: usize,
269}
270
271impl<'a, const N: usize> RemainingAccountViews<'a, N> {
272    /// Number of parsed account views.
273    #[inline(always)]
274    pub const fn len(&self) -> usize {
275        self.len
276    }
277
278    /// True when the parsed set is empty.
279    #[inline(always)]
280    pub const fn is_empty(&self) -> bool {
281        self.len == 0
282    }
283
284    /// Return account `index` if it exists.
285    #[inline(always)]
286    pub fn get(&self, index: usize) -> Option<&'a AccountView> {
287        if index >= self.len {
288            None
289        } else {
290            self.items[index]
291        }
292    }
293
294    /// Iterate over the parsed account views.
295    #[inline(always)]
296    pub fn iter(&self) -> RemainingAccountViewIter<'_, 'a, N> {
297        RemainingAccountViewIter {
298            set: self,
299            index: 0,
300        }
301    }
302}
303
304/// Iterator over a bounded account-view set.
305pub struct RemainingAccountViewIter<'set, 'a, const N: usize> {
306    set: &'set RemainingAccountViews<'a, N>,
307    index: usize,
308}
309
310impl<'a, const N: usize> Iterator for RemainingAccountViewIter<'_, 'a, N> {
311    type Item = &'a AccountView;
312
313    fn next(&mut self) -> Option<Self::Item> {
314        if self.index >= self.set.len {
315            return None;
316        }
317        let item = self.set.items[self.index];
318        self.index += 1;
319        item
320    }
321}
322
323/// Bounded, validated remaining signer set.
324pub struct RemainingSigners<'a, const N: usize> {
325    items: [Option<Signer<'a>>; N],
326    len: usize,
327}
328
329impl<'a, const N: usize> RemainingSigners<'a, N> {
330    /// Number of parsed signers.
331    #[inline(always)]
332    pub const fn len(&self) -> usize {
333        self.len
334    }
335
336    /// True when the parsed set is empty.
337    #[inline(always)]
338    pub const fn is_empty(&self) -> bool {
339        self.len == 0
340    }
341
342    /// Return signer `index` if it exists.
343    #[inline(always)]
344    pub fn get(&self, index: usize) -> Option<Signer<'a>> {
345        if index >= self.len {
346            None
347        } else {
348            self.items[index]
349        }
350    }
351
352    /// Iterate over the parsed signers.
353    #[inline(always)]
354    pub fn iter(&self) -> RemainingSignerIter<'_, 'a, N> {
355        RemainingSignerIter {
356            set: self,
357            index: 0,
358        }
359    }
360}
361
362/// Iterator over a bounded signer set.
363pub struct RemainingSignerIter<'set, 'a, const N: usize> {
364    set: &'set RemainingSigners<'a, N>,
365    index: usize,
366}
367
368impl<'a, const N: usize> Iterator for RemainingSignerIter<'_, 'a, N> {
369    type Item = Signer<'a>;
370
371    fn next(&mut self) -> Option<Self::Item> {
372        if self.index >= self.set.len {
373            return None;
374        }
375        let item = self.set.items[self.index];
376        self.index += 1;
377        item
378    }
379}
380
381/// Ergonomic fall-through used by the proc-macro codegen when the user
382/// wants to just burn through remaining accounts without a mode.
383#[inline(always)]
384pub fn strict<'a>(
385    declared: &'a [AccountView],
386    remaining: &'a [AccountView],
387) -> RemainingAccounts<'a> {
388    RemainingAccounts::strict(declared, remaining)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    // `AccountView` is backend-specific; we cannot construct one under
396    // a non-Solana `cfg`. These tests exist to keep the module
397    // exercised at compile time even when the construction helpers
398    // live behind `target_os = "solana"`.
399
400    #[test]
401    fn error_variants_surface_as_program_error() {
402        let dup: ProgramError = RemainingError::DuplicateAccount.into();
403        assert_eq!(dup, ProgramError::InvalidAccountData);
404        let ovf: ProgramError = RemainingError::Overflow.into();
405        assert_eq!(ovf, ProgramError::InvalidArgument);
406    }
407
408    #[test]
409    fn max_remaining_matches_quasar() {
410        // If we ever change this, also update the Quasar parity doc.
411        assert_eq!(MAX_REMAINING_ACCOUNTS, 64);
412    }
413}