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(
74 event_tag: u8,
75 event_payload: &[u8],
76 out: &mut [u8],
77) -> Option<usize> {
78 let total = 2 + 1 + event_payload.len();
79 if out.len() < total {
80 return None;
81 }
82 out[0..2].copy_from_slice(&CPI_EVENT_MARKER);
83 out[2] = event_tag;
84 out[3..total].copy_from_slice(event_payload);
85 Some(total)
86}
87
88/// Invoke a self-CPI carrying the encoded event payload.
89///
90/// Builds the one-account instruction (event-authority as signer) and
91/// hands it to the active backend's `invoke_signed`. The native
92/// backend path is the load-bearing one; a legacy-pinocchio-compat or
93/// solana-program-backend build routes through their respective
94/// compat shims.
95///
96/// This is the function [`crate::hopper_emit_cpi!`] calls. Users who
97/// want finer-grained control over the CPI (extra accounts, custom
98/// signer) can call this directly with their own encoded data.
99#[inline]
100pub fn invoke_event_cpi(
101 program_id: &crate::address::Address,
102 event_authority: &crate::account::AccountView,
103 data: &[u8],
104 authority_seeds: &[&[u8]],
105) -> crate::result::ProgramResult {
106 #[cfg(all(target_os = "solana", feature = "hopper-native-backend"))]
107 {
108 use crate::instruction::{InstructionAccount, InstructionView};
109 let account_meta = InstructionAccount {
110 pubkey: 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 // Array-of-slices form the native CPI surface expects for
120 // signer seeds: one signer, one seed list.
121 let signer_list = [authority_seeds];
122 let account_views = [event_authority];
123 crate::cpi::invoke_signed::<1>(&ix, &account_views, &signer_list[..])
124 }
125
126 #[cfg(any(
127 not(target_os = "solana"),
128 feature = "legacy-pinocchio-compat",
129 feature = "solana-program-backend",
130 ))]
131 {
132 let _ = (program_id, event_authority, data, authority_seeds);
133 // Off-chain or under a non-native backend: the self-CPI path
134 // is a no-op so host-side tests do not balloon into a CPI
135 // stub. Returning Ok keeps the handler happy; tests should
136 // assert on the encoded bytes via encode_event_cpi instead.
137 Ok(())
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn encodes_marker_tag_and_payload_in_order() {
147 let mut buf = [0u8; 16];
148 let len = encode_event_cpi(0x42, &[1, 2, 3, 4], &mut buf).unwrap();
149 assert_eq!(len, 7);
150 assert_eq!(&buf[..len], &[0xE0, 0x1E, 0x42, 1, 2, 3, 4]);
151 }
152
153 #[test]
154 fn rejects_short_buffer() {
155 let mut buf = [0u8; 3];
156 let len = encode_event_cpi(0, &[1, 2, 3, 4], &mut buf);
157 assert!(len.is_none());
158 }
159
160 #[test]
161 fn zero_payload_is_valid() {
162 let mut buf = [0u8; 3];
163 let len = encode_event_cpi(0x7F, &[], &mut buf).unwrap();
164 assert_eq!(len, 3);
165 assert_eq!(&buf[..len], &[0xE0, 0x1E, 0x7F]);
166 }
167
168 #[test]
169 fn reserved_marker_is_stable() {
170 assert_eq!(CPI_EVENT_MARKER, [0xE0, 0x1E]);
171 }
172}