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};
105 let account_meta = InstructionAccount {
106 pubkey: event_authority.address(),
107 is_signer: true,
108 is_writable: false,
109 };
110 let ix = InstructionView {
111 program_id,
112 accounts: ::core::slice::from_ref(&account_meta),
113 data,
114 };
115 // Array-of-slices form the native CPI surface expects for
116 // signer seeds: one signer, one seed list.
117 let signer_list = [authority_seeds];
118 let account_views = [event_authority];
119 crate::cpi::invoke_signed::<1>(&ix, &account_views, &signer_list[..])
120 }
121
122 #[cfg(any(
123 not(target_os = "solana"),
124 feature = "legacy-pinocchio-compat",
125 feature = "solana-program-backend",
126 ))]
127 {
128 let _ = (program_id, event_authority, data, authority_seeds);
129 // Off-chain or under a non-native backend: the self-CPI path
130 // is a no-op so host-side tests do not need a CPI runtime.
131 // Returning Ok keeps the handler happy; tests should assert
132 // on the encoded bytes via encode_event_cpi instead.
133 Ok(())
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn encodes_marker_tag_and_payload_in_order() {
143 let mut buf = [0u8; 16];
144 let len = encode_event_cpi(0x42, &[1, 2, 3, 4], &mut buf).unwrap();
145 assert_eq!(len, 7);
146 assert_eq!(&buf[..len], &[0xE0, 0x1E, 0x42, 1, 2, 3, 4]);
147 }
148
149 #[test]
150 fn rejects_short_buffer() {
151 let mut buf = [0u8; 3];
152 let len = encode_event_cpi(0, &[1, 2, 3, 4], &mut buf);
153 assert!(len.is_none());
154 }
155
156 #[test]
157 fn zero_payload_is_valid() {
158 let mut buf = [0u8; 3];
159 let len = encode_event_cpi(0x7F, &[], &mut buf).unwrap();
160 assert_eq!(len, 3);
161 assert_eq!(&buf[..len], &[0xE0, 0x1E, 0x7F]);
162 }
163
164 #[test]
165 fn reserved_marker_is_stable() {
166 assert_eq!(CPI_EVENT_MARKER, [0xE0, 0x1E]);
167 }
168}