hopper_runtime/cpi_event.rs
1//! Self-CPI event emission primitives.
2//!
3//! Log output is lossy. Transaction metadata is not. A program that
4//! needs events to arrive at indexers regardless of log truncation
5//! invokes itself with a distinctive CPI whose bytes carry the event
6//! payload. This module provides the building blocks: a reserved
7//! discriminator so the dispatcher can route the CPI to a no-op
8//! sentinel, a wire-format helper for the instruction data, and a
9//! one-line pattern programs can copy.
10//!
11//! ## Wire format
12//!
13//! ```text
14//! [0..2] CPI_EVENT_MARKER (0xE0, 0x1E)
15//! [2] event tag (the byte from `#[hopper::event(tag = N)]`)
16//! [3..] event payload (from `HopperEvent::as_bytes()`)
17//! ```
18//!
19//! The two-byte marker is the reserved Hopper discriminator for
20//! self-CPI events and is unlikely to collide with any sensibly
21//! chosen user discriminator. The user-facing instruction
22//! declaration for the sentinel is:
23//!
24//! ```ignore
25//! #[instruction(discriminator = [0xE0, 0x1E])]
26//! fn __hopper_event_sink(_ctx: &mut Context<'_>) -> ProgramResult {
27//! Ok(())
28//! }
29//! ```
30//!
31//! ## Why this pattern works
32//!
33//! Anchor's `emit_cpi!` uses the same trick: a self-CPI carrying
34//! payload bytes guarantees the event appears in the transaction's
35//! inner-instruction list, which RPC nodes do not truncate. Indexers
36//! scan for the reserved marker and decode the tail as the event.
37//!
38//! Hopper's version is leaner: a two-byte marker plus a one-byte
39//! event tag gives the indexer everything it needs to route without
40//! the Anchor eight-byte discriminator overhead.
41
42/// The reserved self-CPI event discriminator.
43///
44/// Placed at the start of every `emit_event_cpi` instruction and
45/// must be matched by a sentinel `#[instruction(discriminator = [0xE0, 0x1E])]`
46/// no-op handler in the calling program.
47pub const CPI_EVENT_MARKER: [u8; 2] = [0xE0, 0x1E];
48
49/// Canonical PDA seed for the Hopper event-authority. Match this in
50/// the program's sentinel handler setup so the CPI signer resolves.
51pub const EVENT_AUTHORITY_SEED: &[u8] = b"__hopper_event_authority";
52
53/// Fill an out buffer with the CPI wire format for an event.
54///
55/// Returns the number of bytes written. Caller picks the buffer size;
56/// `2 + 1 + E::PACKED_SIZE` is always sufficient. Returns `None` if
57/// the out buffer is too small.
58///
59/// Zero-alloc. Compiles to a pair of `copy_from_slice` calls.
60///
61/// ```ignore
62/// let mut buf = [0u8; 2 + 1 + Deposited::PACKED_SIZE];
63/// let len = hopper_runtime::cpi_event::encode_event_cpi(
64/// Deposited::TAG,
65/// event.as_bytes(),
66/// &mut buf,
67/// ).unwrap();
68///
69/// // Build an InstructionView and invoke_signed from here. The
70/// // sentinel handler accepts the CPI and returns Ok(()).
71/// ```
72#[inline]
73pub fn encode_event_cpi(event_tag: u8, event_payload: &[u8], out: &mut [u8]) -> Option<usize> {
74 let total = 2 + 1 + event_payload.len();
75 if out.len() < total {
76 return None;
77 }
78 out[0..2].copy_from_slice(&CPI_EVENT_MARKER);
79 out[2] = event_tag;
80 out[3..total].copy_from_slice(event_payload);
81 Some(total)
82}
83
84/// Invoke a self-CPI carrying the encoded event payload.
85///
86/// Builds the one-account instruction (event-authority as signer) and
87/// hands it to the active backend's `invoke_signed`. The native
88/// backend path is the load-bearing one; a legacy-pinocchio-compat or
89/// solana-program-backend build routes through their respective
90/// compat shims.
91///
92/// This is the function [`crate::hopper_emit_cpi!`] calls. Users who
93/// want finer-grained control over the CPI (extra accounts, custom
94/// signer) can call this directly with their own encoded data.
95#[inline]
96pub fn invoke_event_cpi(
97 program_id: &crate::address::Address,
98 event_authority: &crate::account::AccountView,
99 data: &[u8],
100 authority_seeds: &[&[u8]],
101) -> crate::result::ProgramResult {
102 #[cfg(all(target_os = "solana", feature = "hopper-native-backend"))]
103 {
104 use crate::instruction::{InstructionAccount, InstructionView, Seed, Signer};
105 if authority_seeds.len() > crate::address::MAX_SEEDS {
106 return Err(crate::error::ProgramError::MaxSeedLengthExceeded);
107 }
108
109 let account_meta = InstructionAccount {
110 address: event_authority.address(),
111 is_signer: true,
112 is_writable: false,
113 };
114 let ix = InstructionView {
115 program_id,
116 accounts: ::core::slice::from_ref(&account_meta),
117 data,
118 };
119 let mut seed_storage: [::core::mem::MaybeUninit<Seed<'_>>; crate::address::MAX_SEEDS] =
120 // SAFETY: MaybeUninit elements do not require initialization.
121 unsafe { ::core::mem::MaybeUninit::uninit().assume_init() };
122 let mut seed_index = 0;
123 while seed_index < authority_seeds.len() {
124 seed_storage[seed_index].write(Seed::from(authority_seeds[seed_index]));
125 seed_index += 1;
126 }
127 let seed_slice =
128 // SAFETY: The first `authority_seeds.len()` slots were initialized above.
129 unsafe {
130 ::core::slice::from_raw_parts(
131 seed_storage.as_ptr() as *const Seed<'_>,
132 authority_seeds.len(),
133 )
134 };
135 let signer_list = [Signer::from(seed_slice)];
136 let account_views = [event_authority];
137 crate::cpi::invoke_signed::<1>(&ix, &account_views, &signer_list)
138 }
139
140 #[cfg(any(
141 not(target_os = "solana"),
142 feature = "legacy-pinocchio-compat",
143 feature = "solana-program-backend",
144 ))]
145 {
146 let _ = (program_id, event_authority, data, authority_seeds);
147 // Off-chain or under a non-native backend: the self-CPI path
148 // is a no-op so host-side tests do not need a CPI runtime.
149 // Returning Ok keeps the handler happy; tests should assert
150 // on the encoded bytes via encode_event_cpi instead.
151 Ok(())
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn encodes_marker_tag_and_payload_in_order() {
161 let mut buf = [0u8; 16];
162 let len = encode_event_cpi(0x42, &[1, 2, 3, 4], &mut buf).unwrap();
163 assert_eq!(len, 7);
164 assert_eq!(&buf[..len], &[0xE0, 0x1E, 0x42, 1, 2, 3, 4]);
165 }
166
167 #[test]
168 fn rejects_short_buffer() {
169 let mut buf = [0u8; 3];
170 let len = encode_event_cpi(0, &[1, 2, 3, 4], &mut buf);
171 assert!(len.is_none());
172 }
173
174 #[test]
175 fn zero_payload_is_valid() {
176 let mut buf = [0u8; 3];
177 let len = encode_event_cpi(0x7F, &[], &mut buf).unwrap();
178 assert_eq!(len, 3);
179 assert_eq!(&buf[..len], &[0xE0, 0x1E, 0x7F]);
180 }
181
182 #[test]
183 fn reserved_marker_is_stable() {
184 assert_eq!(CPI_EVENT_MARKER, [0xE0, 0x1E]);
185 }
186}