hopper_core/event/mod.rs
1//! Zero-allocation event emission via `sol_log_data`.
2//!
3//! Events are emitted as raw Pod bytes through the `sol_log_data` syscall.
4//! This is ~100 CU and zero-allocation. For unforgeable events, use
5//! `emit_event_cpi` which self-invokes the current program (~1000 CU).
6//!
7//! ## Dual emission model
8//!
9//! | Function | CU Cost | Spoofable? | Use case |
10//! |---|---|---|---|
11//! | `emit_event` | ~100 | Yes (any program can log) | Fast indexer events |
12//! | `emit_event_cpi` | ~1000 | No (verified via self-CPI) | Trustworthy audit trail |
13
14use crate::account::{FixedLayout, Pod};
15use hopper_runtime::error::ProgramError;
16
17/// Emit a Pod event via `sol_log_data`.
18///
19/// The event is logged as raw bytes. Clients decode using the schema manifest
20/// or known layout. Costs ~100 CU, zero allocation.
21#[inline(always)]
22pub fn emit_event<T: Pod + FixedLayout>(value: &T) -> Result<(), ProgramError> {
23 // SAFETY: T: Pod guarantees all bit patterns valid and no padding invariants.
24 // The resulting slice covers exactly T::SIZE bytes from a valid reference.
25 let bytes = unsafe { core::slice::from_raw_parts(value as *const T as *const u8, T::SIZE) };
26 emit_slices(&[bytes]);
27 Ok(())
28}
29
30/// Emit event with a discriminator prefix for easy client-side filtering.
31///
32/// Layout: `[event_disc: u8][event_data: T::SIZE bytes]`
33#[inline]
34pub fn emit_event_tagged<T: Pod + FixedLayout>(disc: u8, value: &T) -> Result<(), ProgramError> {
35 // SAFETY: T: Pod guarantees all bit patterns valid. Slice covers T::SIZE bytes.
36 let value_bytes =
37 unsafe { core::slice::from_raw_parts(value as *const T as *const u8, T::SIZE) };
38 let disc_bytes = [disc];
39 emit_slices(&[&disc_bytes[..], value_bytes]);
40 Ok(())
41}
42
43/// Emit one or more byte slices as a single `sol_log_data` entry.
44#[inline(always)]
45pub fn emit_slices(segments: &[&[u8]]) {
46 #[cfg(target_os = "solana")]
47 {
48 // SAFETY: segments is a valid slice of (ptr, len) pairs as expected
49 // by the sol_log_data syscall. BPF ABI guarantees layout compatibility.
50 unsafe {
51 hopper_runtime::syscalls::sol_log_data(
52 segments.as_ptr() as *const u8,
53 segments.len() as u64,
54 );
55 }
56 }
57 #[cfg(not(target_os = "solana"))]
58 {
59 let _ = segments;
60 }
61}
62
63/// Emit an unforgeable event via self-CPI (~1000 CU).
64///
65/// This self-invokes the current program with special event data, so
66/// the event appears in the transaction as a CPI from the program itself.
67/// Indexers can verify the event origin matches the program ID, making
68/// spoofing impossible.
69///
70/// Event CPI data layout: `[0xFF, 0xFE][event_disc: u8][event_data: T::SIZE bytes]`
71///
72/// The `0xFF 0xFE` prefix is an invalid instruction discriminator reserved
73/// for event CPI, the program's dispatch will never match it.
74#[cfg(feature = "cpi")]
75#[inline]
76pub fn emit_event_cpi<T: Pod + FixedLayout>(
77 disc: u8,
78 value: &T,
79 program_id: &hopper_runtime::Address,
80 accounts: &[&hopper_runtime::AccountView],
81) -> Result<(), ProgramError> {
82 // Build event data: [0xFF, 0xFE, disc, ...value_bytes]
83 // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
84 let value_bytes =
85 unsafe { core::slice::from_raw_parts(value as *const T as *const u8, T::SIZE) };
86
87 // Emit via sol_log_data first (cheap, for indexers).
88 let disc_byte = [disc];
89 emit_slices(&[&disc_byte[..], value_bytes]);
90
91 // Self-CPI for unforgeable proof.
92 #[cfg(target_os = "solana")]
93 {
94 const EVENT_CPI_PREFIX: [u8; 2] = [0xFF, 0xFE];
95
96 use hopper_runtime::instruction::{InstructionAccount, InstructionView};
97
98 // Build instruction data on stack: prefix + disc + value bytes.
99 // Max event data = 1024 - 3 = 1021 bytes.
100 let data_len = 3 + T::SIZE;
101 if data_len > 1024 {
102 return Err(ProgramError::InvalidArgument);
103 }
104 let mut data_buf = [0u8; 1024];
105 data_buf[0] = EVENT_CPI_PREFIX[0];
106 data_buf[1] = EVENT_CPI_PREFIX[1];
107 data_buf[2] = disc;
108 // SAFETY: value_bytes length = T::SIZE, verified via Pod + FixedLayout.
109 unsafe {
110 core::ptr::copy_nonoverlapping(
111 value_bytes.as_ptr(),
112 data_buf.as_mut_ptr().add(3),
113 T::SIZE,
114 );
115 }
116
117 let empty_accounts: [InstructionAccount; 0] = [];
118 let instruction = InstructionView {
119 program_id,
120 data: &data_buf[..data_len],
121 accounts: &empty_accounts,
122 };
123
124 // Build CPI accounts from the provided account views.
125 let mut cpi_accounts_buf: [core::mem::MaybeUninit<hopper_runtime::instruction::CpiAccount>; 32] =
126 // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
127 unsafe { core::mem::MaybeUninit::uninit().assume_init() };
128 let count = accounts.len().min(32);
129 let mut i = 0;
130 while i < count {
131 cpi_accounts_buf[i] = core::mem::MaybeUninit::new(
132 hopper_runtime::instruction::CpiAccount::from(accounts[i]),
133 );
134 i += 1;
135 }
136 // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
137 let cpi_accounts = unsafe {
138 core::slice::from_raw_parts(
139 cpi_accounts_buf.as_ptr() as *const hopper_runtime::instruction::CpiAccount,
140 count,
141 )
142 };
143
144 // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
145 unsafe {
146 hopper_runtime::cpi::invoke_unchecked(&instruction, cpi_accounts)?;
147 }
148 }
149 #[cfg(not(target_os = "solana"))]
150 {
151 let _ = (program_id, accounts);
152 }
153
154 Ok(())
155}