hopper-runtime 0.2.0

Canonical low-level runtime surface for Hopper. Hopper Native is the primary backend; legacy Pinocchio and solana-program compatibility are explicit opt-ins.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Remaining-accounts accessor with strict and passthrough modes.
//!
//! The declared context validates exactly `ACCOUNT_COUNT` accounts.
//! Any accounts beyond that index are "remaining": pool participants,
//! keeper bot recipients, arbitrary fanout destinations, remainder
//! destinations for sweeps, and so on. Hopper exposes two ways to
//! consume them.
//!
//! ## Strict mode
//!
//! Default. The accessor rejects any remaining account whose address
//! matches a previously seen account (either declared or already
//! yielded). Protects against accidental double-spending when a
//! caller tries to alias one slot into two different roles.
//!
//! ```ignore
//! let rem = ctx.remaining_accounts();
//! for maybe_acc in rem.iter() {
//!     let acc = maybe_acc?; // errors on duplicate
//!     // ...
//! }
//! ```
//!
//! ## Passthrough mode
//!
//! Opt-in. Preserves duplicates verbatim. Use when the caller is
//! expected to pass the same account in multiple roles (batched CPI
//! fan-in, for example).
//!
//! ```ignore
//! let rem = ctx.remaining_accounts_passthrough();
//! ```
//!
//! Both modes are O(n) with no heap and no syscalls. Strict mode
//! keeps a small const-sized seen-address cache sized at 64; past
//! that, it falls back to a linear scan of the declared slice plus
//! the yielded-view cursor.

use crate::{account::AccountView, account_wrappers::Signer, error::ProgramError};

/// Upper bound on remaining-account iterator length. Matches Quasar's
/// `MAX_REMAINING_ACCOUNTS` so programs porting from one framework to
/// the other see the same ceiling. Exceeding this returns an error
/// rather than risking unbounded stack usage in the seen-address cache.
pub const MAX_REMAINING_ACCOUNTS: usize = 64;

/// Error surface for the remaining-accounts accessor.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RemainingError {
    /// Two remaining-account slots resolved to the same address, or a
    /// remaining-account address matched an already-declared account.
    /// Only strict mode emits this.
    DuplicateAccount,
    /// More than [`MAX_REMAINING_ACCOUNTS`] were accessed via the
    /// iterator.
    Overflow,
}

impl From<RemainingError> for ProgramError {
    fn from(e: RemainingError) -> Self {
        match e {
            RemainingError::DuplicateAccount => ProgramError::InvalidAccountData,
            RemainingError::Overflow => ProgramError::InvalidArgument,
        }
    }
}

/// Duplicate-handling policy for a [`RemainingAccounts`] view.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum RemainingMode {
    /// Reject any yielded account whose address matches a declared or
    /// previously-yielded account. Safe default for pool programs
    /// and anything that intends every slot to be distinct.
    Strict,
    /// Yield every slot as is. Use when the caller is expected to
    /// pass aliases (batched fan-in, self-transfers, etc.).
    Passthrough,
}

/// Zero-allocation remaining-accounts view.
///
/// Construct via [`RemainingAccounts::strict`] or
/// [`RemainingAccounts::passthrough`] from the declared slice and the
/// full accounts slice. `#[hopper::context]` emits
/// `ctx.remaining_accounts()` and `ctx.remaining_accounts_passthrough()`
/// accessors that wire these up for you.
pub struct RemainingAccounts<'a> {
    /// Already-validated context accounts, used for dedup in strict mode.
    declared: &'a [AccountView],
    /// Accounts beyond the declared count.
    remaining: &'a [AccountView],
    /// Duplicate-handling policy.
    mode: RemainingMode,
}

impl<'a> RemainingAccounts<'a> {
    /// Build a strict accessor. Iteration rejects duplicates.
    #[inline(always)]
    pub fn strict(declared: &'a [AccountView], remaining: &'a [AccountView]) -> Self {
        Self {
            declared,
            remaining,
            mode: RemainingMode::Strict,
        }
    }

    /// Build a passthrough accessor. Iteration preserves duplicates.
    #[inline(always)]
    pub fn passthrough(declared: &'a [AccountView], remaining: &'a [AccountView]) -> Self {
        Self {
            declared,
            remaining,
            mode: RemainingMode::Passthrough,
        }
    }

    /// Length of the remaining slice, irrespective of mode.
    #[inline(always)]
    pub fn len(&self) -> usize {
        self.remaining.len()
    }

    /// True when there are no remaining accounts.
    #[inline(always)]
    pub fn is_empty(&self) -> bool {
        self.remaining.is_empty()
    }

    /// The active duplicate-handling policy for this view.
    #[inline(always)]
    pub fn mode(&self) -> RemainingMode {
        self.mode
    }

    /// The raw remaining-account slice backing this view.
    #[inline(always)]
    pub fn as_slice(&self) -> &'a [AccountView] {
        self.remaining
    }

    /// Random access by index. Passthrough returns the slot as is;
    /// strict returns an error when the resolved slot aliases a
    /// previously-seen account (declared or yielded before `index`).
    pub fn get(&self, index: usize) -> Result<Option<&'a AccountView>, ProgramError> {
        if index >= self.remaining.len() {
            return Ok(None);
        }
        let candidate = &self.remaining[index];
        match self.mode {
            RemainingMode::Passthrough => Ok(Some(candidate)),
            RemainingMode::Strict => {
                if index >= MAX_REMAINING_ACCOUNTS {
                    return Err(RemainingError::Overflow.into());
                }
                // Scan declared.
                for d in self.declared {
                    if d.address() == candidate.address() {
                        return Err(RemainingError::DuplicateAccount.into());
                    }
                }
                // Scan remaining[0..index].
                for r in &self.remaining[..index] {
                    if r.address() == candidate.address() {
                        return Err(RemainingError::DuplicateAccount.into());
                    }
                }
                Ok(Some(candidate))
            }
        }
    }

    /// Validate the remaining tail as at most `N` account views.
    ///
    /// In strict mode this also rejects aliases to declared accounts
    /// and duplicate remaining slots before returning the typed set.
    pub fn account_views<const N: usize>(
        &self,
    ) -> Result<RemainingAccountViews<'a, N>, ProgramError> {
        if self.remaining.len() > N {
            return Err(RemainingError::Overflow.into());
        }
        let mut items: [Option<&'a AccountView>; N] = [None; N];
        let mut index = 0;
        while index < self.remaining.len() {
            let account = self.get(index)?.ok_or(ProgramError::NotEnoughAccountKeys)?;
            items[index] = Some(account);
            index += 1;
        }
        Ok(RemainingAccountViews { items, len: index })
    }

    /// Validate the remaining tail as at most `N` signer accounts.
    ///
    /// This is the common multisig case: the handler gets a bounded,
    /// duplicate-safe signer set instead of raw account iteration.
    pub fn signers<const N: usize>(&self) -> Result<RemainingSigners<'a, N>, ProgramError> {
        if self.remaining.len() > N {
            return Err(RemainingError::Overflow.into());
        }
        let mut items: [Option<Signer<'a>>; N] = [None; N];
        let mut index = 0;
        while index < self.remaining.len() {
            let account = self.get(index)?.ok_or(ProgramError::NotEnoughAccountKeys)?;
            items[index] = Some(Signer::try_new(account)?);
            index += 1;
        }
        Ok(RemainingSigners { items, len: index })
    }

    /// Sequential iterator. Yields each account in declaration order,
    /// errors on duplicates in strict mode, preserves them in
    /// passthrough mode.
    #[inline(always)]
    pub fn iter(&self) -> RemainingIter<'a> {
        RemainingIter {
            declared: self.declared,
            remaining: self.remaining,
            mode: self.mode,
            index: 0,
        }
    }
}

/// Iterator yielded by [`RemainingAccounts::iter`].
pub struct RemainingIter<'a> {
    declared: &'a [AccountView],
    remaining: &'a [AccountView],
    mode: RemainingMode,
    index: usize,
}

impl<'a> Iterator for RemainingIter<'a> {
    type Item = Result<&'a AccountView, ProgramError>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.index >= self.remaining.len() {
            return None;
        }
        if self.index >= MAX_REMAINING_ACCOUNTS {
            // Pin the cursor so repeated calls after overflow stay
            // cheap and deterministic.
            self.index = self.remaining.len();
            return Some(Err(RemainingError::Overflow.into()));
        }
        let candidate = &self.remaining[self.index];
        let i = self.index;
        self.index = self.index.wrapping_add(1);

        if matches!(self.mode, RemainingMode::Strict) {
            for d in self.declared {
                if d.address() == candidate.address() {
                    return Some(Err(RemainingError::DuplicateAccount.into()));
                }
            }
            for r in &self.remaining[..i] {
                if r.address() == candidate.address() {
                    return Some(Err(RemainingError::DuplicateAccount.into()));
                }
            }
        }
        Some(Ok(candidate))
    }
}

/// Bounded, validated remaining account-view set.
pub struct RemainingAccountViews<'a, const N: usize> {
    items: [Option<&'a AccountView>; N],
    len: usize,
}

impl<'a, const N: usize> RemainingAccountViews<'a, N> {
    /// Number of parsed account views.
    #[inline(always)]
    pub const fn len(&self) -> usize {
        self.len
    }

    /// True when the parsed set is empty.
    #[inline(always)]
    pub const fn is_empty(&self) -> bool {
        self.len == 0
    }

    /// Return account `index` if it exists.
    #[inline(always)]
    pub fn get(&self, index: usize) -> Option<&'a AccountView> {
        if index >= self.len {
            None
        } else {
            self.items[index]
        }
    }

    /// Iterate over the parsed account views.
    #[inline(always)]
    pub fn iter(&self) -> RemainingAccountViewIter<'_, 'a, N> {
        RemainingAccountViewIter {
            set: self,
            index: 0,
        }
    }
}

/// Iterator over a bounded account-view set.
pub struct RemainingAccountViewIter<'set, 'a, const N: usize> {
    set: &'set RemainingAccountViews<'a, N>,
    index: usize,
}

impl<'a, const N: usize> Iterator for RemainingAccountViewIter<'_, 'a, N> {
    type Item = &'a AccountView;

    fn next(&mut self) -> Option<Self::Item> {
        if self.index >= self.set.len {
            return None;
        }
        let item = self.set.items[self.index];
        self.index += 1;
        item
    }
}

/// Bounded, validated remaining signer set.
pub struct RemainingSigners<'a, const N: usize> {
    items: [Option<Signer<'a>>; N],
    len: usize,
}

impl<'a, const N: usize> RemainingSigners<'a, N> {
    /// Number of parsed signers.
    #[inline(always)]
    pub const fn len(&self) -> usize {
        self.len
    }

    /// True when the parsed set is empty.
    #[inline(always)]
    pub const fn is_empty(&self) -> bool {
        self.len == 0
    }

    /// Return signer `index` if it exists.
    #[inline(always)]
    pub fn get(&self, index: usize) -> Option<Signer<'a>> {
        if index >= self.len {
            None
        } else {
            self.items[index]
        }
    }

    /// Iterate over the parsed signers.
    #[inline(always)]
    pub fn iter(&self) -> RemainingSignerIter<'_, 'a, N> {
        RemainingSignerIter {
            set: self,
            index: 0,
        }
    }
}

/// Iterator over a bounded signer set.
pub struct RemainingSignerIter<'set, 'a, const N: usize> {
    set: &'set RemainingSigners<'a, N>,
    index: usize,
}

impl<'a, const N: usize> Iterator for RemainingSignerIter<'_, 'a, N> {
    type Item = Signer<'a>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.index >= self.set.len {
            return None;
        }
        let item = self.set.items[self.index];
        self.index += 1;
        item
    }
}

/// Ergonomic fall-through used by the proc-macro codegen when the user
/// wants to just burn through remaining accounts without a mode.
#[inline(always)]
pub fn strict<'a>(
    declared: &'a [AccountView],
    remaining: &'a [AccountView],
) -> RemainingAccounts<'a> {
    RemainingAccounts::strict(declared, remaining)
}

#[cfg(test)]
mod tests {
    use super::*;

    // `AccountView` is backend-specific; we cannot construct one under
    // a non-Solana `cfg`. These tests exist to keep the module
    // exercised at compile time even when the construction helpers
    // live behind `target_os = "solana"`.

    #[test]
    fn error_variants_surface_as_program_error() {
        let dup: ProgramError = RemainingError::DuplicateAccount.into();
        assert_eq!(dup, ProgramError::InvalidAccountData);
        let ovf: ProgramError = RemainingError::Overflow.into();
        assert_eq!(ovf, ProgramError::InvalidArgument);
    }

    #[test]
    fn max_remaining_matches_quasar() {
        // If we ever change this, also update the Quasar parity doc.
        assert_eq!(MAX_REMAINING_ACCOUNTS, 64);
    }
}