Skip to main content

hopper_core/collections/
journal.rs

1//! Append-only journal for on-chain audit trails.
2//!
3//! A `Journal` is a bounded, append-only log of fixed-size entries.
4//! Once full, it either rejects new entries (strict mode) or wraps
5//! around like a ring buffer (circular mode).
6//!
7//! ## Wire Format
8//!
9//! ```text
10//! [write_head: u32 LE]     -- index of next write position
11//! [total_written: u32 LE]  -- total entries ever written (for wrap detection)
12//! [flags: u32 LE]          -- bit 0: circular mode
13//! [_reserved: u32 LE]
14//! [entry 0: T bytes]
15//! [entry 1: T bytes]
16//! ...
17//! [entry capacity-1: T bytes]
18//! ```
19//!
20//! ## Usage
21//!
22//! ```ignore
23//! #[repr(C)]
24//! #[derive(Clone, Copy)]
25//! struct AuditEntry {
26//!     actor: [u8; 32],
27//!     action: u8,
28//!     timestamp: WireU64,
29//! }
30//!
31//! let mut journal = Journal::<AuditEntry>::from_bytes_mut(data)?;
32//! journal.append(AuditEntry { ... })?;
33//!
34//! // Read latest entries
35//! let latest = journal.latest()?;
36//! ```
37
38use crate::account::{FixedLayout, Pod};
39use hopper_runtime::error::ProgramError;
40
41/// Journal header size in bytes.
42pub const JOURNAL_HEADER_SIZE: usize = 16;
43
44/// Flag: circular mode (wrap around when full).
45pub const JOURNAL_FLAG_CIRCULAR: u32 = 1 << 0;
46
47/// An append-only journal of fixed-size entries.
48pub struct Journal<'a, T: Pod + FixedLayout> {
49    data: &'a mut [u8],
50    capacity: usize,
51    _phantom: core::marker::PhantomData<T>,
52}
53
54impl<'a, T: Pod + FixedLayout> Journal<'a, T> {
55    /// Parse a journal from a mutable byte slice.
56    #[inline]
57    pub fn from_bytes_mut(data: &'a mut [u8]) -> Result<Self, ProgramError> {
58        if data.len() < JOURNAL_HEADER_SIZE {
59            return Err(ProgramError::AccountDataTooSmall);
60        }
61        let usable = data.len() - JOURNAL_HEADER_SIZE;
62        if T::SIZE == 0 {
63            return Err(ProgramError::InvalidArgument);
64        }
65        let capacity = usable / T::SIZE;
66        Ok(Self {
67            data,
68            capacity,
69            _phantom: core::marker::PhantomData,
70        })
71    }
72
73    /// Create a read-only journal view.
74    #[inline]
75    pub fn from_bytes(data: &[u8]) -> Result<JournalReader<'_, T>, ProgramError> {
76        if data.len() < JOURNAL_HEADER_SIZE {
77            return Err(ProgramError::AccountDataTooSmall);
78        }
79        let usable = data.len() - JOURNAL_HEADER_SIZE;
80        if T::SIZE == 0 {
81            return Err(ProgramError::InvalidArgument);
82        }
83        let capacity = usable / T::SIZE;
84        Ok(JournalReader {
85            data,
86            capacity,
87            _phantom: core::marker::PhantomData,
88        })
89    }
90
91    /// Maximum number of entries.
92    #[inline(always)]
93    pub fn capacity(&self) -> usize {
94        self.capacity
95    }
96
97    /// Current write head position.
98    #[inline(always)]
99    pub fn write_head(&self) -> u32 {
100        u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]])
101    }
102
103    /// Total entries ever written.
104    #[inline(always)]
105    pub fn total_written(&self) -> u32 {
106        u32::from_le_bytes([self.data[4], self.data[5], self.data[6], self.data[7]])
107    }
108
109    /// Journal flags.
110    #[inline(always)]
111    pub fn flags(&self) -> u32 {
112        u32::from_le_bytes([self.data[8], self.data[9], self.data[10], self.data[11]])
113    }
114
115    /// Whether circular mode is enabled.
116    #[inline(always)]
117    pub fn is_circular(&self) -> bool {
118        self.flags() & JOURNAL_FLAG_CIRCULAR != 0
119    }
120
121    /// Whether the journal has wrapped at least once (total_written > capacity).
122    #[inline(always)]
123    pub fn has_wrapped(&self) -> bool {
124        (self.total_written() as usize) > self.capacity
125    }
126
127    /// Number of valid entries (min of total_written and capacity).
128    #[inline(always)]
129    pub fn entry_count(&self) -> usize {
130        let total = self.total_written() as usize;
131        if total < self.capacity {
132            total
133        } else {
134            self.capacity
135        }
136    }
137
138    /// Append an entry to the journal.
139    #[inline]
140    pub fn append(&mut self, entry: T) -> Result<(), ProgramError> {
141        let mut head = self.write_head() as usize;
142
143        if head >= self.capacity {
144            if !self.is_circular() {
145                return Err(ProgramError::AccountDataTooSmall);
146            }
147            // Normalize: wrap head back into range
148            head %= self.capacity;
149        }
150
151        if !self.is_circular() && head >= self.capacity {
152            return Err(ProgramError::AccountDataTooSmall);
153        }
154
155        // Write entry at head
156        let offset = JOURNAL_HEADER_SIZE + head * T::SIZE;
157        let end = offset + T::SIZE;
158        if end > self.data.len() {
159            return Err(ProgramError::AccountDataTooSmall);
160        }
161
162        // SAFETY: T: Pod, bounds checked, alignment-1.
163        unsafe {
164            core::ptr::copy_nonoverlapping(
165                &entry as *const T as *const u8,
166                self.data.as_mut_ptr().add(offset),
167                T::SIZE,
168            );
169        }
170
171        // Advance head
172        let new_head = if self.is_circular() {
173            ((head + 1) % self.capacity) as u32
174        } else {
175            (head + 1) as u32
176        };
177        self.set_write_head(new_head);
178
179        // Increment total written
180        let total = self.total_written().wrapping_add(1);
181        self.set_total_written(total);
182
183        Ok(())
184    }
185
186    /// Read entry at logical index (0 = oldest visible entry).
187    #[inline]
188    pub fn read(&self, index: usize) -> Result<T, ProgramError> {
189        let count = self.entry_count();
190        if index >= count {
191            return Err(ProgramError::InvalidArgument);
192        }
193
194        let physical = if self.total_written() as usize > self.capacity {
195            // Wrapped: oldest is at write_head
196            (self.write_head() as usize + index) % self.capacity
197        } else {
198            index
199        };
200
201        let offset = JOURNAL_HEADER_SIZE + physical * T::SIZE;
202        let end = offset + T::SIZE;
203        if end > self.data.len() {
204            return Err(ProgramError::AccountDataTooSmall);
205        }
206
207        // SAFETY: Bounds checked. T: Pod, alignment-1.
208        Ok(unsafe { core::ptr::read_unaligned(self.data.as_ptr().add(offset) as *const T) })
209    }
210
211    /// Read the most recent entry.
212    #[inline]
213    pub fn latest(&self) -> Result<T, ProgramError> {
214        let count = self.entry_count();
215        if count == 0 {
216            return Err(ProgramError::InvalidArgument);
217        }
218        self.read(count - 1)
219    }
220
221    /// Bytes required for a journal with the given capacity.
222    #[inline(always)]
223    pub const fn required_bytes(capacity: usize) -> usize {
224        JOURNAL_HEADER_SIZE + capacity * T::SIZE
225    }
226
227    /// Initialize the journal header (circular or strict).
228    #[inline]
229    pub fn init(&mut self, circular: bool) {
230        self.set_write_head(0);
231        self.set_total_written(0);
232        let flags: u32 = if circular { JOURNAL_FLAG_CIRCULAR } else { 0 };
233        self.data[8..12].copy_from_slice(&flags.to_le_bytes());
234        self.data[12..16].copy_from_slice(&0u32.to_le_bytes());
235    }
236
237    #[inline(always)]
238    fn set_write_head(&mut self, head: u32) {
239        self.data[0..4].copy_from_slice(&head.to_le_bytes());
240    }
241
242    #[inline(always)]
243    fn set_total_written(&mut self, total: u32) {
244        self.data[4..8].copy_from_slice(&total.to_le_bytes());
245    }
246}
247
248/// Read-only journal view.
249pub struct JournalReader<'a, T: Pod + FixedLayout> {
250    data: &'a [u8],
251    capacity: usize,
252    _phantom: core::marker::PhantomData<T>,
253}
254
255impl<'a, T: Pod + FixedLayout> JournalReader<'a, T> {
256    /// Capacity.
257    #[inline(always)]
258    pub fn capacity(&self) -> usize {
259        self.capacity
260    }
261
262    /// Write head.
263    #[inline(always)]
264    pub fn write_head(&self) -> u32 {
265        u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]])
266    }
267
268    /// Total written.
269    #[inline(always)]
270    pub fn total_written(&self) -> u32 {
271        u32::from_le_bytes([self.data[4], self.data[5], self.data[6], self.data[7]])
272    }
273
274    /// Number of valid entries.
275    #[inline(always)]
276    pub fn entry_count(&self) -> usize {
277        let total = self.total_written() as usize;
278        if total < self.capacity {
279            total
280        } else {
281            self.capacity
282        }
283    }
284
285    /// Is circular.
286    #[inline(always)]
287    pub fn is_circular(&self) -> bool {
288        let flags = u32::from_le_bytes([self.data[8], self.data[9], self.data[10], self.data[11]]);
289        flags & JOURNAL_FLAG_CIRCULAR != 0
290    }
291
292    /// Read entry at logical index.
293    #[inline]
294    pub fn read(&self, index: usize) -> Result<T, ProgramError> {
295        let count = self.entry_count();
296        if index >= count {
297            return Err(ProgramError::InvalidArgument);
298        }
299
300        let physical = if self.total_written() as usize > self.capacity {
301            (self.write_head() as usize + index) % self.capacity
302        } else {
303            index
304        };
305
306        let offset = JOURNAL_HEADER_SIZE + physical * T::SIZE;
307        let end = offset + T::SIZE;
308        if end > self.data.len() {
309            return Err(ProgramError::AccountDataTooSmall);
310        }
311
312        // 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.
313        Ok(unsafe { core::ptr::read_unaligned(self.data.as_ptr().add(offset) as *const T) })
314    }
315
316    /// Read the most recent entry.
317    #[inline]
318    pub fn latest(&self) -> Result<T, ProgramError> {
319        let count = self.entry_count();
320        if count == 0 {
321            return Err(ProgramError::InvalidArgument);
322        }
323        self.read(count - 1)
324    }
325}