Skip to main content

hopper_native/
introspect.rs

1//! Instruction introspection -- stack height and sibling instruction access.
2//!
3//! These wrappers support security patterns based on transaction and call-stack
4//! introspection:
5//!
6//! - **CPI guard**: Detect if the current instruction is running inside a CPI
7//!   call (stack height > 1). Prevents unauthorized composition -- e.g., a
8//!   governance instruction that must be top-level only.
9//!
10//! - **Precompile inspection**: Read a previous sibling's program ID and data.
11//!   Program-ID checks alone do not authorize an action. Validate signature
12//!   count, offsets, referenced instruction bytes, and the expected key/message.
13//!
14//! - **Secp256k1 recovery**: Same pattern for Ethereum-compatible signatures.
15//!
16//! Hopper wraps these syscalls behind small typed helpers so programs do not
17//! need to repeat raw unsafe glue at every call site.
18
19use crate::address::Address;
20use crate::error::ProgramError;
21
22/// Get the current instruction stack height.
23///
24/// Returns 1 for top-level instructions invoked by the runtime.
25/// Returns 2+ for instructions running inside a CPI call.
26///
27/// Use this to implement CPI guards that prevent unauthorized composition.
28#[inline(always)]
29pub fn get_stack_height() -> u64 {
30    #[cfg(target_os = "solana")]
31    {
32        // SAFETY: This block is part of Hopper's reviewed zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
33        unsafe { crate::syscalls::sol_get_stack_height() }
34    }
35    #[cfg(not(target_os = "solana"))]
36    {
37        1 // Off-chain: simulate top-level.
38    }
39}
40
41/// Returns true if the current instruction is at the top level
42/// (not running inside a CPI).
43#[inline(always)]
44pub fn is_top_level() -> bool {
45    get_stack_height() <= 1
46}
47
48/// Returns true if the current instruction is running inside a CPI.
49#[inline(always)]
50pub fn is_cpi() -> bool {
51    get_stack_height() > 1
52}
53
54/// Require that the current instruction is NOT a CPI call.
55///
56/// Programs that should never be composed via CPI (governance, admin
57/// instructions, emergency controls) should call this at the top of
58/// their handler. Returns `Err` if the instruction is inside a CPI.
59#[inline(always)]
60pub fn require_top_level() -> Result<(), ProgramError> {
61    if is_top_level() {
62        Ok(())
63    } else {
64        Err(ProgramError::InvalidArgument)
65    }
66}
67
68/// Require that the current instruction IS inside a CPI.
69///
70/// Some instructions are designed to be called only via CPI (callback
71/// patterns, module-internal helpers). This enforces that contract.
72#[inline(always)]
73pub fn require_cpi() -> Result<(), ProgramError> {
74    if is_cpi() {
75        Ok(())
76    } else {
77        Err(ProgramError::InvalidArgument)
78    }
79}
80
81// ---- Processed sibling instructions ----------------------------------
82
83/// Metadata about a previously processed sibling instruction.
84#[derive(Clone, Debug)]
85pub struct ProcessedInstruction {
86    /// Program ID that executed the instruction.
87    pub program_id: Address,
88    /// Instruction data.
89    pub data: [u8; 1232],
90    /// Actual length of instruction data.
91    pub data_len: usize,
92    /// Number of accounts involved.
93    pub accounts_len: usize,
94}
95
96/// Account metadata returned by the sibling-instruction syscall.
97///
98/// This is an owned-address record, unlike CPI's pointer-based metadata.
99#[repr(C)]
100#[derive(Clone, Debug, Default, PartialEq, Eq)]
101pub struct ProcessedInstructionAccount {
102    pub address: Address,
103    pub is_signer: bool,
104    pub is_writable: bool,
105}
106
107const _: () = {
108    assert!(core::mem::size_of::<ProcessedInstructionAccount>() == 34);
109    assert!(core::mem::align_of::<ProcessedInstructionAccount>() == 1);
110    assert!(core::mem::offset_of!(ProcessedInstructionAccount, address) == 0);
111    assert!(core::mem::offset_of!(ProcessedInstructionAccount, is_signer) == 32);
112    assert!(core::mem::offset_of!(ProcessedInstructionAccount, is_writable) == 33);
113};
114
115/// A processed sibling copied into caller-owned scratch buffers.
116///
117/// The slices expose only the initialized instruction prefixes. This describes
118/// an instruction in the runtime trace; it does not prove a token balance change,
119/// validate a signature payload, or replace application authorization.
120#[derive(Debug)]
121pub struct ProcessedInstructionView<'a> {
122    pub program_id: Address,
123    pub data: &'a [u8],
124    pub accounts: &'a [ProcessedInstructionAccount],
125}
126
127/// Read a processed sibling without heap allocation or fixed-size scratch space.
128///
129/// Index zero is the most recent sibling at the current call depth and caller.
130/// Parents and children are not siblings. The first syscall queries exact lengths;
131/// the second copies only when both caller buffers fit. Returns `Ok(None)` for
132/// absence, or `AccountDataTooSmall` for insufficient capacity, never truncation.
133/// No syscall result is treated as an ordinary zero-success program error code.
134///
135/// On host targets there is no instruction trace, so this returns `Ok(None)`.
136/// Test execution history in an SVM or on a cluster. Use the Instructions sysvar
137/// to inspect the transaction-level list by absolute index, especially for
138/// precompile signature payloads and their cross-instruction references.
139#[inline]
140pub fn get_processed_instruction_into<'a>(
141    index: u64,
142    data: &'a mut [u8],
143    accounts: &'a mut [ProcessedInstructionAccount],
144) -> Result<Option<ProcessedInstructionView<'a>>, ProgramError> {
145    read_processed_with(index, data, accounts, sibling_syscall)
146}
147
148fn sibling_syscall(
149    index: u64,
150    meta: &mut ProcessedInstructionMeta,
151    program: &mut Address,
152    data: &mut [u8],
153    accounts: &mut [ProcessedInstructionAccount],
154) -> u64 {
155    #[cfg(target_os = "solana")]
156    {
157        // SAFETY: the private reader advertises only initialized buffer prefixes
158        // that fit these disjoint outputs. Metadata, program and account records
159        // have the runtime's checked C layout; the syscall writes valid bools.
160        unsafe {
161            crate::syscalls::sol_get_processed_sibling_instruction(
162                index,
163                meta as *mut _ as *mut u8,
164                program.0.as_mut_ptr(),
165                data.as_mut_ptr(),
166                accounts.as_mut_ptr().cast(),
167            )
168        }
169    }
170    #[cfg(not(target_os = "solana"))]
171    {
172        let _ = (index, meta, program, data, accounts);
173        0
174    }
175}
176
177fn read_processed_with<'a>(
178    index: u64,
179    data: &'a mut [u8],
180    accounts: &'a mut [ProcessedInstructionAccount],
181    mut syscall: impl FnMut(
182        u64,
183        &mut ProcessedInstructionMeta,
184        &mut Address,
185        &mut [u8],
186        &mut [ProcessedInstructionAccount],
187    ) -> u64,
188) -> Result<Option<ProcessedInstructionView<'a>>, ProgramError> {
189    let mut meta = ProcessedInstructionMeta {
190        data_len: 0,
191        accounts_len: 0,
192    };
193    let mut program_id = Address::default();
194    // Real writable scratch also handles a zero-length sibling during the probe.
195    let mut probe_data = [0];
196    let mut probe_accounts = [ProcessedInstructionAccount::default()];
197    match syscall(
198        index,
199        &mut meta,
200        &mut program_id,
201        &mut probe_data,
202        &mut probe_accounts,
203    ) {
204        0 => return Ok(None),
205        1 => {}
206        _ => return Err(ProgramError::InvalidAccountData),
207    }
208    let data_len = usize::try_from(meta.data_len).map_err(|_| ProgramError::AccountDataTooSmall)?;
209    let accounts_len =
210        usize::try_from(meta.accounts_len).map_err(|_| ProgramError::AccountDataTooSmall)?;
211    if data_len > data.len() || accounts_len > accounts.len() {
212        return Err(ProgramError::AccountDataTooSmall);
213    }
214    let rc = syscall(
215        index,
216        &mut meta,
217        &mut program_id,
218        &mut data[..data_len],
219        &mut accounts[..accounts_len],
220    );
221    if rc != 1 || meta.data_len != data_len as u64 || meta.accounts_len != accounts_len as u64 {
222        return Err(ProgramError::InvalidAccountData);
223    }
224    Ok(Some(ProcessedInstructionView {
225        program_id,
226        data: &data[..data_len],
227        accounts: &accounts[..accounts_len],
228    }))
229}
230
231/// Convenience reader for up to 1,232 data bytes and 64 account metas.
232///
233/// Returns `None` for absence or insufficient capacity. Prefer
234/// [`get_processed_instruction_into`] to select your own scratch budget, inspect
235/// account metadata, and distinguish missing siblings from capacity errors.
236#[inline]
237pub fn get_processed_instruction(index: u64) -> Option<ProcessedInstruction> {
238    let mut data = [0; 1232];
239    let mut accounts = core::array::from_fn::<_, 64, _>(|_| ProcessedInstructionAccount::default());
240    let view = get_processed_instruction_into(index, &mut data, &mut accounts).ok()??;
241    let program_id = view.program_id;
242    let data_len = view.data.len();
243    let accounts_len = view.accounts.len();
244    Some(ProcessedInstruction {
245        program_id,
246        data,
247        data_len,
248        accounts_len,
249    })
250}
251
252/// Well-known precompile address for Ed25519 signature verification.
253pub const ED25519_PROGRAM_ID: Address =
254    crate::address!("Ed25519SigVerify111111111111111111111111111");
255
256/// Well-known precompile address for Secp256k1 signature recovery.
257pub const SECP256K1_PROGRAM_ID: Address =
258    crate::address!("KeccakSecp256k11111111111111111111111111111");
259
260/// Well-known precompile address for Secp256r1 (P-256) signature
261/// verification (SIMD-0075). This is the precompile that backs passkey /
262/// WebAuthn signature checks on Solana.
263pub const SECP256R1_PROGRAM_ID: Address =
264    crate::address!("Secp256r1SigVerify1111111111111111111111111");
265
266/// Check that a previous sibling instruction was to the Ed25519 precompile.
267///
268/// Checks only the program ID. The caller must validate signature count, offsets,
269/// referenced instruction bytes, and the expected public key and message. Use
270/// the Instructions sysvar for transaction-level cross-instruction references.
271///
272/// `sibling_index` is 0 for the most recent sibling, 1 for the one before, etc.
273#[inline]
274pub fn require_ed25519_instruction(
275    sibling_index: u64,
276) -> Result<ProcessedInstruction, ProgramError> {
277    let ix = get_processed_instruction(sibling_index).ok_or(ProgramError::InvalidArgument)?;
278
279    if !crate::address::address_eq(&ix.program_id, &ED25519_PROGRAM_ID) {
280        return Err(ProgramError::IncorrectProgramId);
281    }
282
283    Ok(ix)
284}
285
286/// Check that a previous sibling instruction was to the Secp256k1 precompile.
287/// Checks only the program ID, not payload validity or application authorization.
288#[inline]
289pub fn require_secp256k1_instruction(
290    sibling_index: u64,
291) -> Result<ProcessedInstruction, ProgramError> {
292    let ix = get_processed_instruction(sibling_index).ok_or(ProgramError::InvalidArgument)?;
293
294    if !crate::address::address_eq(&ix.program_id, &SECP256K1_PROGRAM_ID) {
295        return Err(ProgramError::IncorrectProgramId);
296    }
297
298    Ok(ix)
299}
300
301/// Check that a previous sibling instruction was to the Secp256r1
302/// (P-256) precompile, the verification path for passkeys / WebAuthn.
303///
304/// Checks only the program ID. The caller must validate the signature payload,
305/// cross-instruction offsets, expected key/message and application authorization.
306/// This helper does not validate a WebAuthn challenge or relying-party policy.
307///
308/// `sibling_index` is 0 for the most recent sibling, 1 for the one
309/// before, etc.
310#[inline]
311pub fn require_secp256r1_instruction(
312    sibling_index: u64,
313) -> Result<ProcessedInstruction, ProgramError> {
314    let ix = get_processed_instruction(sibling_index).ok_or(ProgramError::InvalidArgument)?;
315
316    if !crate::address::address_eq(&ix.program_id, &SECP256R1_PROGRAM_ID) {
317        return Err(ProgramError::IncorrectProgramId);
318    }
319
320    Ok(ix)
321}
322
323// ---- Internal types for syscall FFI ----------------------------------
324
325#[repr(C)]
326#[allow(dead_code)]
327struct ProcessedInstructionMeta {
328    data_len: u64,
329    accounts_len: u64,
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn absence_does_not_fabricate_an_instruction_or_touch_outputs() {
338        let mut data = [0xa5; 8];
339        let mut accounts = [ProcessedInstructionAccount::default()];
340        let mut calls = 0;
341        let result = read_processed_with(7, &mut data, &mut accounts, |index, _, _, _, _| {
342            assert_eq!(index, 7);
343            calls += 1;
344            0
345        })
346        .unwrap();
347        assert!(result.is_none());
348        assert_eq!(calls, 1);
349        assert_eq!(data, [0xa5; 8]);
350        assert_eq!(accounts, [ProcessedInstructionAccount::default()]);
351        assert!(get_processed_instruction(0).is_none());
352    }
353
354    #[test]
355    fn probes_then_copies_exact_lengths_and_preserves_unused_capacity() {
356        let mut data = [0xa5; 8];
357        let mut accounts =
358            core::array::from_fn::<_, 3, _>(|_| ProcessedInstructionAccount::default());
359        let expected = ProcessedInstructionAccount {
360            address: Address::new_from_array([9; 32]),
361            is_signer: true,
362            is_writable: false,
363        };
364        let mut calls = 0;
365        let view = read_processed_with(
366            2,
367            &mut data,
368            &mut accounts,
369            |index, meta, program, bytes, metas| {
370                assert_eq!(index, 2);
371                calls += 1;
372                if calls == 1 {
373                    assert_eq!((meta.data_len, meta.accounts_len), (0, 0));
374                    meta.data_len = 3;
375                    meta.accounts_len = 1;
376                } else {
377                    assert_eq!((meta.data_len, meta.accounts_len), (3, 1));
378                    assert_eq!((bytes.len(), metas.len()), (3, 1));
379                    *program = Address::new_from_array([7; 32]);
380                    bytes.copy_from_slice(&[4, 5, 6]);
381                    metas[0] = expected.clone();
382                }
383                1
384            },
385        )
386        .unwrap()
387        .unwrap();
388        assert_eq!(calls, 2);
389        assert_eq!(view.program_id, Address::new_from_array([7; 32]));
390        assert_eq!(view.data, &[4, 5, 6]);
391        assert_eq!(view.accounts, &[expected]);
392        assert_eq!(&data[3..], &[0xa5; 5]);
393        assert_eq!(
394            &accounts[1..],
395            &[
396                ProcessedInstructionAccount::default(),
397                ProcessedInstructionAccount::default()
398            ]
399        );
400    }
401
402    #[test]
403    fn insufficient_buffers_are_rejected_before_copy() {
404        for (data_len, accounts_len) in [(9, 1), (3, 2), (u64::MAX, 0), (0, u64::MAX)] {
405            let mut calls = 0;
406            let mut data = [0xa5; 8];
407            let mut accounts = [ProcessedInstructionAccount::default()];
408            let result = read_processed_with(0, &mut data, &mut accounts, |_, meta, _, _, _| {
409                calls += 1;
410                meta.data_len = data_len;
411                meta.accounts_len = accounts_len;
412                1
413            });
414            assert_eq!(result.unwrap_err(), ProgramError::AccountDataTooSmall);
415            assert_eq!(calls, 1);
416            assert_eq!(data, [0xa5; 8]);
417        }
418    }
419
420    #[test]
421    fn zero_length_sibling_is_distinct_from_absence() {
422        let mut calls = 0;
423        let view = read_processed_with(0, &mut [], &mut [], |_, meta, program, _, _| {
424            calls += 1;
425            assert_eq!((meta.data_len, meta.accounts_len), (0, 0));
426            *program = Address::new_from_array([8; 32]);
427            1
428        })
429        .unwrap()
430        .unwrap();
431        assert_eq!(calls, 2);
432        assert_eq!(view.program_id, Address::new_from_array([8; 32]));
433        assert!(view.data.is_empty() && view.accounts.is_empty());
434    }
435
436    #[test]
437    fn unexpected_return_or_changing_lengths_fail_closed() {
438        for (probe_rc, copy_rc, change_lengths) in
439            [(2, 1, false), (1, 0, false), (1, 2, false), (1, 1, true)]
440        {
441            let mut calls = 0;
442            let mut data = [0; 8];
443            let mut accounts = [ProcessedInstructionAccount::default()];
444            let result = read_processed_with(0, &mut data, &mut accounts, |_, meta, _, _, _| {
445                calls += 1;
446                if calls == 1 {
447                    meta.data_len = 3;
448                    probe_rc
449                } else {
450                    if change_lengths {
451                        meta.data_len = 4;
452                    }
453                    copy_rc
454                }
455            });
456            assert_eq!(result.unwrap_err(), ProgramError::InvalidAccountData);
457        }
458    }
459}