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