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, 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 [&'a AccountView],
90    /// Accounts beyond the declared count.
91    remaining: &'a [&'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 [&'a AccountView], remaining: &'a [&'a AccountView]) -> Self {
100        Self { declared, remaining, mode: RemainingMode::Strict }
101    }
102
103    /// Build a passthrough accessor. Iteration preserves duplicates.
104    #[inline(always)]
105    pub fn passthrough(
106        declared: &'a [&'a AccountView],
107        remaining: &'a [&'a AccountView],
108    ) -> Self {
109        Self { declared, remaining, mode: RemainingMode::Passthrough }
110    }
111
112    /// Length of the remaining slice, irrespective of mode.
113    #[inline(always)]
114    pub fn len(&self) -> usize {
115        self.remaining.len()
116    }
117
118    /// True when there are no remaining accounts.
119    #[inline(always)]
120    pub fn is_empty(&self) -> bool {
121        self.remaining.is_empty()
122    }
123
124    /// The active duplicate-handling policy for this view.
125    #[inline(always)]
126    pub fn mode(&self) -> RemainingMode {
127        self.mode
128    }
129
130    /// Random access by index. Passthrough returns the slot as is;
131    /// strict returns an error when the resolved slot aliases a
132    /// previously-seen account (declared or yielded before `index`).
133    pub fn get(&self, index: usize) -> Result<Option<&'a AccountView>, ProgramError> {
134        if index >= self.remaining.len() {
135            return Ok(None);
136        }
137        let candidate = self.remaining[index];
138        match self.mode {
139            RemainingMode::Passthrough => Ok(Some(candidate)),
140            RemainingMode::Strict => {
141                if index > MAX_REMAINING_ACCOUNTS {
142                    return Err(RemainingError::Overflow.into());
143                }
144                // Scan declared.
145                for d in self.declared {
146                    if d.address() == candidate.address() {
147                        return Err(RemainingError::DuplicateAccount.into());
148                    }
149                }
150                // Scan remaining[0..index].
151                for r in &self.remaining[..index] {
152                    if r.address() == candidate.address() {
153                        return Err(RemainingError::DuplicateAccount.into());
154                    }
155                }
156                Ok(Some(candidate))
157            }
158        }
159    }
160
161    /// Sequential iterator. Yields each account in declaration order,
162    /// errors on duplicates in strict mode, preserves them in
163    /// passthrough mode.
164    #[inline(always)]
165    pub fn iter(&self) -> RemainingIter<'a> {
166        RemainingIter {
167            declared: self.declared,
168            remaining: self.remaining,
169            mode: self.mode,
170            index: 0,
171        }
172    }
173}
174
175/// Iterator yielded by [`RemainingAccounts::iter`].
176pub struct RemainingIter<'a> {
177    declared: &'a [&'a AccountView],
178    remaining: &'a [&'a AccountView],
179    mode: RemainingMode,
180    index: usize,
181}
182
183impl<'a> Iterator for RemainingIter<'a> {
184    type Item = Result<&'a AccountView, ProgramError>;
185
186    fn next(&mut self) -> Option<Self::Item> {
187        if self.index >= self.remaining.len() {
188            return None;
189        }
190        if self.index >= MAX_REMAINING_ACCOUNTS {
191            // Pin the cursor so repeated calls after overflow stay
192            // cheap and deterministic.
193            self.index = self.remaining.len();
194            return Some(Err(RemainingError::Overflow.into()));
195        }
196        let candidate = self.remaining[self.index];
197        let i = self.index;
198        self.index = self.index.wrapping_add(1);
199
200        if matches!(self.mode, RemainingMode::Strict) {
201            for d in self.declared {
202                if d.address() == candidate.address() {
203                    return Some(Err(RemainingError::DuplicateAccount.into()));
204                }
205            }
206            for r in &self.remaining[..i] {
207                if r.address() == candidate.address() {
208                    return Some(Err(RemainingError::DuplicateAccount.into()));
209                }
210            }
211        }
212        Some(Ok(candidate))
213    }
214}
215
216/// Ergonomic fall-through used by the proc-macro codegen when the user
217/// wants to just burn through remaining accounts without a mode.
218#[inline(always)]
219pub fn strict<'a>(
220    declared: &'a [&'a AccountView],
221    remaining: &'a [&'a AccountView],
222) -> RemainingAccounts<'a> {
223    RemainingAccounts::strict(declared, remaining)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // `AccountView` is backend-specific; we cannot construct one under
231    // a non-Solana `cfg`. These tests exist to keep the module
232    // exercised at compile time even when the construction helpers
233    // live behind `target_os = "solana"`.
234
235    #[test]
236    fn error_variants_surface_as_program_error() {
237        let dup: ProgramError = RemainingError::DuplicateAccount.into();
238        assert_eq!(dup, ProgramError::InvalidAccountData);
239        let ovf: ProgramError = RemainingError::Overflow.into();
240        assert_eq!(ovf, ProgramError::InvalidArgument);
241    }
242
243    #[test]
244    fn max_remaining_matches_quasar() {
245        // If we ever change this, also update the Quasar parity doc.
246        assert_eq!(MAX_REMAINING_ACCOUNTS, 64);
247    }
248}