Skip to main content

hopper_core/check/
mod.rs

1//! Multi-tier validation system.
2//!
3//! Hopper supports five validation levels:
4//!
5//! 1. **Account-local**: owner, signer, writable, size, discriminator, layout_id
6//! 2. **Cross-account**: `vault.mint == mint.address()`, authority matches
7//! 3. **State-transition**: status enum transitions, balance bounds
8//! 4. **CPI composition**: post-CPI invariants, no-CPI guards
9//! 5. **Post-mutation**: balance conservation, solvency invariants (via `PostMutationValidator`)
10//!
11//! Validation can be composed with named groups (`ValidationGroup`), instruction-specific
12//! rule packs (`TransitionRulePack`), and multi-group bundles (`ValidationBundle`).
13
14pub mod fast;
15#[cfg(feature = "graph")]
16pub mod graph;
17pub mod guards;
18pub mod modifier;
19pub mod trust;
20
21use hopper_runtime::{
22    address::address_eq, error::ProgramError, AccountView, Address, ProgramResult,
23};
24
25// --- Tier 1: Account-Local -------------------------------------------
26
27/// Check that an account is a signer.
28#[inline(always)]
29pub fn check_signer(account: &AccountView) -> ProgramResult {
30    if !account.is_signer() {
31        return Err(ProgramError::MissingRequiredSignature);
32    }
33    Ok(())
34}
35
36/// Check that an account is writable.
37#[inline(always)]
38pub fn check_writable(account: &AccountView) -> ProgramResult {
39    if !account.is_writable() {
40        return Err(ProgramError::InvalidAccountData);
41    }
42    Ok(())
43}
44
45/// Check that an account is owned by the expected program.
46#[inline(always)]
47pub fn check_owner(account: &AccountView, expected: &Address) -> ProgramResult {
48    if !account.owned_by(expected) {
49        return Err(ProgramError::IncorrectProgramId);
50    }
51    Ok(())
52}
53
54/// Check that an account is executable (a program).
55#[inline(always)]
56pub fn check_executable(account: &AccountView) -> ProgramResult {
57    if !account.executable() {
58        return Err(ProgramError::InvalidAccountData);
59    }
60    Ok(())
61}
62
63/// Check that an account is a specific program: its key matches
64/// `expected_program_id` and it is flagged executable.
65///
66/// Matches the Jiminy-style free-function surface the winning-
67/// architecture design calls for. Equivalent to constructing a
68/// `Program<'info, P>` wrapper without requiring a generic
69/// [`ProgramId`](hopper_runtime::ProgramId) impl - useful for
70/// ad-hoc program pinning where the program ID is only known at
71/// runtime (for instance, a caller-supplied cross-program id).
72#[inline(always)]
73pub fn check_program(account: &AccountView, expected_program_id: &Address) -> ProgramResult {
74    if !address_eq(account.address(), expected_program_id) {
75        return Err(ProgramError::IncorrectProgramId);
76    }
77    if !account.executable() {
78        return Err(ProgramError::InvalidAccountData);
79    }
80    Ok(())
81}
82
83/// Check minimum data size.
84#[inline(always)]
85pub fn check_size(data: &[u8], min_len: usize) -> ProgramResult {
86    if data.len() < min_len {
87        return Err(ProgramError::AccountDataTooSmall);
88    }
89    Ok(())
90}
91
92/// Check that the discriminator byte matches.
93#[inline(always)]
94pub fn check_discriminator(data: &[u8], expected: u8) -> ProgramResult {
95    if data.is_empty() || data[0] != expected {
96        return Err(ProgramError::InvalidAccountData);
97    }
98    Ok(())
99}
100
101/// Check uninitialized: account data is empty.
102#[inline(always)]
103pub fn check_uninitialized(account: &AccountView) -> ProgramResult {
104    if !account.is_data_empty() {
105        return Err(ProgramError::AccountAlreadyInitialized);
106    }
107    Ok(())
108}
109
110/// Check that an account has not been closed (no close sentinel).
111#[inline(always)]
112pub fn check_not_closed(data: &[u8]) -> ProgramResult {
113    if !data.is_empty() && data[0] == crate::account::CLOSE_SENTINEL {
114        return Err(ProgramError::InvalidAccountData);
115    }
116    Ok(())
117}
118
119/// Rent-exempt minimum lamports for a given data size.
120#[inline(always)]
121pub fn rent_exempt_min(data_len: usize) -> u64 {
122    ((128 + data_len) as u64) * 6960
123}
124
125/// Check that an account is rent exempt.
126#[inline(always)]
127pub fn check_rent_exempt(account: &AccountView) -> ProgramResult {
128    let lamports = account.lamports();
129    let data = account.try_borrow()?;
130    let min = rent_exempt_min(data.len());
131    if lamports < min {
132        return Err(ProgramError::InsufficientFunds);
133    }
134    Ok(())
135}
136
137/// Check that the account has at least `min` lamports.
138#[inline(always)]
139pub fn check_lamports_gte(account: &AccountView, min: u64) -> ProgramResult {
140    let lamports = account.lamports();
141    if lamports < min {
142        return Err(ProgramError::InsufficientFunds);
143    }
144    Ok(())
145}
146
147// --- Tier 2: Cross-Account ------------------------------------------
148
149/// Check that two account addresses are equal.
150#[inline(always)]
151pub fn check_keys_eq(a: &AccountView, b: &AccountView) -> ProgramResult {
152    if !address_eq(a.address(), b.address()) {
153        return Err(ProgramError::InvalidAccountData);
154    }
155    Ok(())
156}
157
158/// Fast 32-byte key equality check using 4x u64 comparisons.
159///
160/// Short-circuits on the first differing 8-byte chunk, saving cycles
161/// vs byte-by-byte comparison for addresses that differ early.
162/// hopper-native-inspired optimization.
163#[inline(always)]
164pub fn keys_eq_fast(a: &[u8; 32], b: &[u8; 32]) -> bool {
165    // SAFETY: [u8; 32] is always valid for read_unaligned as u64.
166    // We compare 4 x u64 chunks with short-circuit evaluation.
167    unsafe {
168        let a_ptr = a.as_ptr() as *const u64;
169        let b_ptr = b.as_ptr() as *const u64;
170        core::ptr::read_unaligned(a_ptr) == core::ptr::read_unaligned(b_ptr)
171            && core::ptr::read_unaligned(a_ptr.add(1)) == core::ptr::read_unaligned(b_ptr.add(1))
172            && core::ptr::read_unaligned(a_ptr.add(2)) == core::ptr::read_unaligned(b_ptr.add(2))
173            && core::ptr::read_unaligned(a_ptr.add(3)) == core::ptr::read_unaligned(b_ptr.add(3))
174    }
175}
176
177/// Check if a 32-byte address is all zeros (the default/system address).
178///
179/// Uses an OR-fold: OR all 4 u64 chunks together, then check if the result is zero.
180/// This avoids 32 individual byte comparisons.
181#[inline(always)]
182pub fn is_zero_address(addr: &[u8; 32]) -> bool {
183    // SAFETY: [u8; 32] is always valid for read_unaligned as u64.
184    unsafe {
185        let ptr = addr.as_ptr() as *const u64;
186        let combined = core::ptr::read_unaligned(ptr)
187            | core::ptr::read_unaligned(ptr.add(1))
188            | core::ptr::read_unaligned(ptr.add(2))
189            | core::ptr::read_unaligned(ptr.add(3));
190        combined == 0
191    }
192}
193
194/// Check `has_one`: a stored address in account data matches another account's address.
195///
196/// `stored` is the 32-byte address stored in account data at a given offset.
197/// This is the Anchor-style `has_one` equivalent.
198#[inline(always)]
199pub fn check_has_one(stored: &[u8; 32], account: &AccountView) -> ProgramResult {
200    // SAFETY: Address is [u8; 32]. Reinterpret as reference.
201    let addr: &[u8; 32] = unsafe { &*(account.address() as *const Address as *const [u8; 32]) };
202    if !keys_eq_fast(stored, addr) {
203        return Err(ProgramError::InvalidAccountData);
204    }
205    Ok(())
206}
207
208/// Check that two accounts are unique (different addresses).
209#[inline(always)]
210pub fn check_accounts_unique(a: &AccountView, b: &AccountView) -> ProgramResult {
211    if address_eq(a.address(), b.address()) {
212        return Err(ProgramError::InvalidArgument);
213    }
214    Ok(())
215}
216
217/// Check that three accounts are all unique.
218#[inline(always)]
219pub fn check_accounts_unique_3(a: &AccountView, b: &AccountView, c: &AccountView) -> ProgramResult {
220    if address_eq(a.address(), b.address())
221        || address_eq(a.address(), c.address())
222        || address_eq(b.address(), c.address())
223    {
224        return Err(ProgramError::InvalidArgument);
225    }
226    Ok(())
227}
228
229/// Check an account's address matches an expected value.
230#[inline(always)]
231pub fn check_address(account: &AccountView, expected: &Address) -> ProgramResult {
232    if !address_eq(account.address(), expected) {
233        return Err(ProgramError::InvalidAccountData);
234    }
235    Ok(())
236}
237
238/// Check instruction data meets minimum length.
239#[inline(always)]
240pub fn check_instruction_data_min(data: &[u8], min: usize) -> ProgramResult {
241    if data.len() < min {
242        return Err(ProgramError::InvalidInstructionData);
243    }
244    Ok(())
245}
246
247// --- Tier 3: Combined Checks ----------------------------------------
248
249/// Combined account check: owner + discriminator + minimum size.
250///
251/// This is the most common validation pattern. One function call instead of three.
252#[inline(always)]
253pub fn check_account(
254    account: &AccountView,
255    program_id: &Address,
256    disc: u8,
257    min_size: usize,
258) -> ProgramResult {
259    check_owner(account, program_id)?;
260    let data = account.try_borrow()?;
261    check_size(&data, min_size)?;
262    check_discriminator(&data, disc)?;
263    Ok(())
264}
265
266/// System program check.
267#[inline(always)]
268pub fn check_system_program(account: &AccountView) -> ProgramResult {
269    // System program ID: 11111111111111111111111111111111
270    const SYSTEM_PROGRAM: Address = Address::new_from_array([0; 32]);
271    if *account.address() != SYSTEM_PROGRAM {
272        return Err(ProgramError::IncorrectProgramId);
273    }
274    Ok(())
275}
276
277// --- PDA Helpers ----------------------------------------------------
278
279/// Verify a PDA with bump, using the cheap `create_program_address` path.
280///
281/// This costs ~200 CU vs ~1500 CU for `find_program_address`.
282/// Always use this when you have the bump stored.
283#[inline(always)]
284pub fn verify_pda(
285    account: &AccountView,
286    seeds: &[&[u8]],
287    bump: u8,
288    program_id: &Address,
289) -> ProgramResult {
290    hopper_runtime::pda::verify_pda_with_bump(account, seeds, bump, program_id)
291}
292
293/// Find a PDA and verify it matches the account, returning the bump.
294///
295/// On Hopper Native this uses the fast PDA path (`sol_sha256` +
296/// `sol_curve_validate_point`), which is roughly ~544 CU for a first-try bump.
297/// Prefer `verify_pda` when bump is stored.
298#[inline(always)]
299pub fn find_and_verify_pda(
300    account: &AccountView,
301    seeds: &[&[u8]],
302    program_id: &Address,
303) -> Result<u8, ProgramError> {
304    hopper_runtime::pda::find_and_verify_pda(account, seeds, program_id)
305}
306
307// --- BUMP_OFFSET PDA Optimization ----------------------------------
308//
309// When a layout stores its PDA
310// bump in a known field, we can read it directly from account data and
311// call `create_program_address` (~200 CU) instead of `find_program_address`
312// (~544 CU). Saves ~344 CU per PDA validation.
313
314/// Verify a PDA by reading the bump from account data at a known offset.
315///
316/// This is the **BUMP_OFFSET optimization**: when a layout stores its PDA bump
317/// byte at a compile-time-known offset, we read it directly from account data
318/// and use `create_program_address` (~200 CU) instead of `find_program_address`
319/// (~544 CU). Saves ~344 CU per PDA check.
320///
321/// # Arguments
322/// - `account` -- The account to verify
323/// - `seeds` -- PDA seeds (without bump)
324/// - `bump_offset` -- Byte offset of the bump field in account data
325/// - `program_id` -- The owning program
326#[inline(always)]
327pub fn verify_pda_cached(
328    account: &AccountView,
329    seeds: &[&[u8]],
330    bump_offset: usize,
331    program_id: &Address,
332) -> ProgramResult {
333    #[cfg(target_os = "solana")]
334    {
335        let data = account.try_borrow()?;
336        if bump_offset >= data.len() {
337            return Err(ProgramError::AccountDataTooSmall);
338        }
339        let bump = data[bump_offset];
340        let bump_seed = [bump];
341        let mut all_seeds: [&[u8]; 17] = [&[]; 17];
342        let seed_count = seeds.len();
343        if seed_count > 16 {
344            return Err(ProgramError::InvalidSeeds);
345        }
346        let mut i = 0;
347        while i < seed_count {
348            all_seeds[i] = seeds[i];
349            i += 1;
350        }
351        all_seeds[seed_count] = &bump_seed;
352
353        let derived = Address::create_program_address(&all_seeds[..seed_count + 1], program_id)?;
354
355        if !address_eq(account.address(), &derived) {
356            return Err(ProgramError::InvalidSeeds);
357        }
358        Ok(())
359    }
360    #[cfg(not(target_os = "solana"))]
361    {
362        let _ = (account, seeds, bump_offset, program_id);
363        Err(ProgramError::InvalidSeeds)
364    }
365}
366
367// --- Multi-Owner Foreign Load --------------------------------------
368//
369// Interface<T> + ProgramInterface pattern.
370// Allows loading foreign accounts that could be owned by any of several
371// programs (e.g., Token program OR Token-2022).
372
373/// Check that an account is owned by one of the given program IDs.
374///
375/// Used for multi-program interfaces (e.g., Token vs Token-2022).
376/// Returns the index of the matching owner, or error.
377#[inline]
378pub fn check_owner_multi(
379    account: &AccountView,
380    owners: &[&Address],
381) -> Result<usize, ProgramError> {
382    // SAFETY: Reading the owner field is safe in the context of
383    // account validation; no conflicting borrows exist yet.
384    let acct_owner = unsafe { account.owner() };
385    for (i, expected) in owners.iter().enumerate() {
386        if acct_owner == *expected {
387            return Ok(i);
388        }
389    }
390    Err(ProgramError::IncorrectProgramId)
391}
392
393// --- Instruction Introspection Guards ------------------------------
394//
395// Ported and improved from Jiminy's instruction sysvar analysis.
396// Detects CPI re-entrancy, flash loans, and sandwich attacks.
397
398/// Instructions sysvar address (Sysvar1nstructions1111111111111111111111111).
399#[allow(dead_code)]
400const INSTRUCTIONS_SYSVAR: Address = {
401    // Sysvar1nstructions1111111111111111111111111
402    // This is the base58-decoded address
403    let mut addr = [0u8; 32];
404    addr[0] = 0x06;
405    addr[1] = 0xa7;
406    addr[2] = 0xd5;
407    addr[3] = 0x17;
408    addr[4] = 0x18;
409    addr[5] = 0x7b;
410    addr[6] = 0xd1;
411    addr[7] = 0x66;
412    addr[8] = 0x35;
413    addr[9] = 0xda;
414    addr[10] = 0xd4;
415    addr[11] = 0x04;
416    addr[12] = 0x55;
417    addr[13] = 0xfb;
418    addr[14] = 0x04;
419    addr[15] = 0x6e;
420    addr[16] = 0x12;
421    addr[17] = 0x46;
422    addr[18] = 0x00;
423    addr[19] = 0x00;
424    addr[20] = 0x00;
425    addr[21] = 0x00;
426    addr[22] = 0x00;
427    addr[23] = 0x00;
428    addr[24] = 0x00;
429    addr[25] = 0x00;
430    addr[26] = 0x00;
431    addr[27] = 0x00;
432    addr[28] = 0x00;
433    addr[29] = 0x00;
434    addr[30] = 0x00;
435    addr[31] = 0x00;
436    Address::new_from_array(addr)
437};
438
439/// Read the number of instructions in the current transaction.
440///
441/// The Instructions sysvar stores `num_instructions` as the first u16 LE
442/// at offset 0 in the serialized data.
443#[inline(always)]
444pub fn instruction_count(sysvar_data: &[u8]) -> Result<u16, ProgramError> {
445    if sysvar_data.len() < 2 {
446        return Err(ProgramError::InvalidAccountData);
447    }
448    Ok(u16::from_le_bytes([sysvar_data[0], sysvar_data[1]]))
449}
450
451/// Read the current instruction index from the Instructions sysvar.
452///
453/// The Instructions sysvar stores the current instruction index as the
454/// last u16 LE at offset `data.len() - 2`.
455#[inline(always)]
456pub fn current_instruction_index(sysvar_data: &[u8]) -> Result<u16, ProgramError> {
457    let len = sysvar_data.len();
458    if len < 2 {
459        return Err(ProgramError::InvalidAccountData);
460    }
461    Ok(u16::from_le_bytes([
462        sysvar_data[len - 2],
463        sysvar_data[len - 1],
464    ]))
465}
466
467/// Read the program_id of instruction at the given index.
468///
469/// Instructions sysvar layout:
470/// ```text
471/// [u16 num_instructions]
472/// [u16 offset_0] [u16 offset_1] ... [u16 offset_{n-1}]  <-- offset table
473/// [serialized instruction 0]
474/// [serialized instruction 1]
475/// ...
476/// [u16 current_instruction_index]  <-- last 2 bytes
477/// ```
478///
479/// Per-instruction layout at `sysvar_data[offset]`:
480/// ```text
481/// [u16 num_accounts]
482/// [u8 flags + [u8; 32] pubkey] * num_accounts  (33 bytes each)
483/// [u8; 32] program_id
484/// [u16 data_len]
485/// [u8; data_len] data
486/// ```
487#[inline]
488pub fn read_program_id_at(sysvar_data: &[u8], index: u16) -> Result<[u8; 32], ProgramError> {
489    let num_ix = instruction_count(sysvar_data)?;
490    if index >= num_ix {
491        return Err(ProgramError::InvalidArgument);
492    }
493    // Offset table starts at byte 2, with one u16 per instruction.
494    let offset_entry = 2 + (index as usize) * 2;
495    if offset_entry + 2 > sysvar_data.len() {
496        return Err(ProgramError::InvalidAccountData);
497    }
498    let ix_offset =
499        u16::from_le_bytes([sysvar_data[offset_entry], sysvar_data[offset_entry + 1]]) as usize;
500
501    // At ix_offset: [num_accounts: u16 LE][accounts...]
502    // Each account meta is 33 bytes (1 byte flags + 32 byte pubkey).
503    // After accounts: [program_id: 32 bytes][data_len: u16][data...]
504    if ix_offset + 2 > sysvar_data.len() {
505        return Err(ProgramError::InvalidAccountData);
506    }
507    let num_accounts =
508        u16::from_le_bytes([sysvar_data[ix_offset], sysvar_data[ix_offset + 1]]) as usize;
509    // Skip accounts: each is 1 + 32 = 33 bytes
510    let program_id_offset = ix_offset + 2 + num_accounts * 33;
511    if program_id_offset + 32 > sysvar_data.len() {
512        return Err(ProgramError::InvalidAccountData);
513    }
514    let mut pid = [0u8; 32];
515    pid.copy_from_slice(&sysvar_data[program_id_offset..program_id_offset + 32]);
516    Ok(pid)
517}
518
519/// Require that the current instruction is top-level (not a CPI).
520///
521/// Checks: current instruction's program_id matches `our_program`.
522/// If called via CPI, the current instruction would have a different
523/// program_id, so this fails.
524#[inline]
525pub fn require_top_level(sysvar_data: &[u8], our_program: &Address) -> ProgramResult {
526    let current_idx = current_instruction_index(sysvar_data)?;
527    let pid = read_program_id_at(sysvar_data, current_idx)?;
528    if pid != *our_program.as_array() {
529        return Err(ProgramError::InvalidAccountData);
530    }
531    Ok(())
532}
533
534/// Detect flash-loan bracket: same program called before AND after current.
535///
536/// Returns `Err` if the pattern is detected (the program appears both
537/// before and after the current instruction index).
538#[inline]
539pub fn detect_flash_loan_bracket(sysvar_data: &[u8], our_program: &Address) -> ProgramResult {
540    let current_idx = current_instruction_index(sysvar_data)?;
541    let num_ix = instruction_count(sysvar_data)?;
542
543    let mut before = false;
544    let mut after = false;
545
546    let mut i: u16 = 0;
547    while i < num_ix {
548        if i == current_idx {
549            i += 1;
550            continue;
551        }
552        if let Ok(pid) = read_program_id_at(sysvar_data, i) {
553            if pid == *our_program.as_array() {
554                if i < current_idx {
555                    before = true;
556                } else {
557                    after = true;
558                }
559            }
560        }
561        i += 1;
562    }
563
564    if before && after {
565        return Err(ProgramError::InvalidAccountData);
566    }
567    Ok(())
568}
569
570/// Ensure our program is not invoked after the current instruction.
571///
572/// Prevents post-execution re-entrancy patterns.
573#[inline]
574pub fn check_no_subsequent_invocation(sysvar_data: &[u8], our_program: &Address) -> ProgramResult {
575    let current_idx = current_instruction_index(sysvar_data)?;
576    let num_ix = instruction_count(sysvar_data)?;
577
578    let mut i = current_idx + 1;
579    while i < num_ix {
580        if let Ok(pid) = read_program_id_at(sysvar_data, i) {
581            if pid == *our_program.as_array() {
582                return Err(ProgramError::InvalidAccountData);
583            }
584        }
585        i += 1;
586    }
587    Ok(())
588}