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}