Skip to content

Commit 7c40b9d

Browse files
rustopianilya-bobyrfebo
authored
zero-copy slot hashes sysvar (with checked alternatives) (#152)
Adds SlotHashes sysvar support. Checked and unchecked zero-copy paths are both included. Binary search is used, but zerocopy Iterator and copy into buffer are also implemented for those with different SlotHashes access needs. --------- Co-authored-by: Illia Bobyr <[email protected]> Co-authored-by: Fernando Otero <[email protected]>
1 parent 9be19b9 commit 7c40b9d

File tree

10 files changed

+1451
-2
lines changed

10 files changed

+1451
-2
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/setup/solana.dic

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,8 @@ RPC
6464
ed25519
6565
performant
6666
syscall/S
67-
bitmask
67+
bitmask
68+
pinocchio
69+
mainnet
70+
getters
71+
PRNG

sdk/pinocchio/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ unexpected_cfgs = { level = "warn", check-cfg = [
1919

2020
[features]
2121
std = []
22+
23+
[dev-dependencies]
24+
five8_const = { workspace = true }

sdk/pinocchio/src/sysvars/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod clock;
1010
pub mod fees;
1111
pub mod instructions;
1212
pub mod rent;
13+
pub mod slot_hashes;
1314

1415
/// Return value indicating that the `offset + length` is greater than the length of
1516
/// the sysvar data.
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
//! Efficient, zero-copy access to `SlotHashes` sysvar data.
2+
3+
pub mod raw;
4+
#[doc(inline)]
5+
pub use raw::{fetch_into, fetch_into_unchecked, validate_fetch_offset};
6+
7+
#[cfg(test)]
8+
mod test;
9+
#[cfg(test)]
10+
mod test_edge;
11+
#[cfg(test)]
12+
mod test_raw;
13+
#[cfg(test)]
14+
mod test_utils;
15+
16+
use crate::{
17+
account_info::{AccountInfo, Ref},
18+
program_error::ProgramError,
19+
pubkey::Pubkey,
20+
sysvars::clock::Slot,
21+
};
22+
use core::{mem, ops::Deref, slice::from_raw_parts};
23+
#[cfg(feature = "std")]
24+
use std::boxed::Box;
25+
26+
/// `SysvarS1otHashes111111111111111111111111111`
27+
pub const SLOTHASHES_ID: Pubkey = [
28+
6, 167, 213, 23, 25, 47, 10, 175, 198, 242, 101, 227, 251, 119, 204, 122, 218, 130, 197, 41,
29+
208, 190, 59, 19, 110, 45, 0, 85, 32, 0, 0, 0,
30+
];
31+
/// Number of bytes in a hash.
32+
pub const HASH_BYTES: usize = 32;
33+
/// Sysvar data is:
34+
/// `len` (8 bytes): little-endian entry count (`≤ 512`)
35+
/// `entries`(`len × 40 bytes`): consecutive `(u64 slot, [u8;32] hash)` pairs
36+
/// Size of the entry count field at the beginning of sysvar data.
37+
pub const NUM_ENTRIES_SIZE: usize = mem::size_of::<u64>();
38+
/// Size of a slot number in bytes.
39+
pub const SLOT_SIZE: usize = mem::size_of::<Slot>();
40+
/// Size of a single slot hash entry.
41+
pub const ENTRY_SIZE: usize = SLOT_SIZE + HASH_BYTES;
42+
/// Maximum number of slot hash entries that can be stored in the sysvar.
43+
pub const MAX_ENTRIES: usize = 512;
44+
/// Max size of the sysvar data in bytes. 20488. Golden on mainnet (never smaller)
45+
pub const MAX_SIZE: usize = NUM_ENTRIES_SIZE + MAX_ENTRIES * ENTRY_SIZE;
46+
/// A single hash.
47+
pub type Hash = [u8; HASH_BYTES];
48+
49+
/// A single entry in the `SlotHashes` sysvar.
50+
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
51+
#[repr(C)]
52+
pub struct SlotHashEntry {
53+
/// The slot number stored as little-endian bytes.
54+
slot_le: [u8; 8],
55+
/// The hash corresponding to the slot.
56+
pub hash: Hash,
57+
}
58+
59+
// Fail compilation if `SlotHashEntry` is not byte-aligned.
60+
const _: [(); 1] = [(); mem::align_of::<SlotHashEntry>()];
61+
62+
/// `SlotHashes` provides read-only, zero-copy access to `SlotHashes` sysvar bytes.
63+
pub struct SlotHashes<T: Deref<Target = [u8]>> {
64+
data: T,
65+
}
66+
67+
/// Log a `Hash` from a program.
68+
pub fn log(hash: &Hash) {
69+
crate::pubkey::log(hash);
70+
}
71+
72+
/// Reads the entry count from the first 8 bytes of data.
73+
/// Returns None if the data is too short.
74+
#[inline(always)]
75+
pub(crate) fn read_entry_count_from_bytes(data: &[u8]) -> Option<usize> {
76+
if data.len() < NUM_ENTRIES_SIZE {
77+
return None;
78+
}
79+
Some(unsafe {
80+
// SAFETY: `data` is guaranteed to be at least `NUM_ENTRIES_SIZE` bytes long by the
81+
// preceding length check, so it is sound to read the first 8 bytes and interpret
82+
// them as a little-endian `u64`.
83+
u64::from_le_bytes(*(data.as_ptr() as *const [u8; NUM_ENTRIES_SIZE]))
84+
} as usize)
85+
}
86+
87+
/// Reads the entry count from the first 8 bytes of data.
88+
///
89+
/// # Safety
90+
/// Caller must ensure data has at least `NUM_ENTRIES_SIZE` bytes.
91+
#[inline(always)]
92+
pub(crate) unsafe fn read_entry_count_from_bytes_unchecked(data: &[u8]) -> usize {
93+
u64::from_le_bytes(*(data.as_ptr() as *const [u8; NUM_ENTRIES_SIZE])) as usize
94+
}
95+
96+
/// Validates `SlotHashes` data format.
97+
///
98+
/// The function checks:
99+
/// 1. The buffer is large enough to contain the entry count.
100+
/// 2. The buffer length is sufficient to hold the declared number of entries.
101+
///
102+
/// It returns `Ok(())` if the data is well-formed, otherwise an appropriate
103+
/// `ProgramError` describing the issue.
104+
#[inline]
105+
fn parse_and_validate_data(data: &[u8]) -> Result<(), ProgramError> {
106+
if data.len() < NUM_ENTRIES_SIZE {
107+
return Err(ProgramError::AccountDataTooSmall);
108+
}
109+
110+
// SAFETY: We've confirmed that data has enough bytes to read the entry count.
111+
let num_entries = unsafe { read_entry_count_from_bytes_unchecked(data) };
112+
113+
let min_size = NUM_ENTRIES_SIZE + num_entries * ENTRY_SIZE;
114+
if data.len() < min_size {
115+
return Err(ProgramError::AccountDataTooSmall);
116+
}
117+
118+
Ok(())
119+
}
120+
121+
impl SlotHashEntry {
122+
/// Returns the slot number as a `u64`.
123+
#[inline(always)]
124+
pub fn slot(&self) -> Slot {
125+
u64::from_le_bytes(self.slot_le)
126+
}
127+
}
128+
129+
impl<T: Deref<Target = [u8]>> SlotHashes<T> {
130+
/// Creates a `SlotHashes` instance with validation of the entry count and buffer size.
131+
///
132+
/// This constructor validates that the buffer has at least enough bytes to contain
133+
/// the declared number of entries. The buffer can be any size above the minimum required,
134+
/// making it suitable for both full `MAX_SIZE` buffers and smaller test data.
135+
/// Does not validate that entries are sorted in descending order.
136+
#[inline(always)]
137+
pub fn new(data: T) -> Result<Self, ProgramError> {
138+
parse_and_validate_data(&data)?;
139+
// SAFETY: `parse_and_validate_data` verifies that the data slice has at least
140+
// `NUM_ENTRIES_SIZE` bytes for the entry count and enough additional bytes to
141+
// contain the declared number of entries, thus upholding all invariants required
142+
// by `SlotHashes::new_unchecked`.
143+
Ok(unsafe { Self::new_unchecked(data) })
144+
}
145+
146+
/// Creates a `SlotHashes` instance without validation.
147+
///
148+
/// This is an unsafe constructor that bypasses all validation checks for performance.
149+
/// In debug builds, it still runs `parse_and_validate_data` as a sanity check.
150+
///
151+
/// # Safety
152+
///
153+
/// This function is unsafe because it does not validate the data size or format.
154+
/// The caller must ensure:
155+
/// 1. The underlying byte slice in `data` represents valid `SlotHashes` data
156+
/// (length prefix plus entries, where entries are sorted in descending order by slot).
157+
/// 2. The data slice has at least `NUM_ENTRIES_SIZE + (declared_entries * ENTRY_SIZE)` bytes.
158+
/// 3. The first 8 bytes contain a valid entry count in little-endian format.
159+
///
160+
#[inline(always)]
161+
pub unsafe fn new_unchecked(data: T) -> Self {
162+
if cfg!(debug_assertions) {
163+
parse_and_validate_data(&data)
164+
.expect("`data` matches all the same requirements as for `new()`");
165+
}
166+
167+
SlotHashes { data }
168+
}
169+
170+
/// Returns the number of `SlotHashEntry` items accessible.
171+
#[inline(always)]
172+
pub fn len(&self) -> usize {
173+
// SAFETY: `SlotHashes::new` and `new_unchecked` guarantee that `self.data` has at
174+
// least `NUM_ENTRIES_SIZE` bytes, so reading the entry count without additional
175+
// checks is safe.
176+
unsafe { read_entry_count_from_bytes_unchecked(&self.data) }
177+
}
178+
179+
/// Returns if the sysvar is empty.
180+
#[inline(always)]
181+
pub fn is_empty(&self) -> bool {
182+
self.len() == 0
183+
}
184+
185+
/// Returns a `&[SlotHashEntry]` view into the underlying data.
186+
///
187+
/// Call once and reuse the slice if you need many look-ups.
188+
///
189+
/// The constructor (in the safe path that called `parse_and_validate_data`)
190+
/// or caller (if unsafe `new_unchecked` path) is responsible for ensuring
191+
/// the slice is big enough and properly aligned.
192+
#[inline(always)]
193+
pub fn entries(&self) -> &[SlotHashEntry] {
194+
unsafe {
195+
// SAFETY: The slice begins `NUM_ENTRIES_SIZE` bytes into `self.data`, which
196+
// is guaranteed by parse_and_validate_data() to have at least `len * ENTRY_SIZE`
197+
// additional bytes. The pointer is properly aligned for `SlotHashEntry` (which
198+
// a compile-time assertion ensures is alignment 1).
199+
from_raw_parts(
200+
self.data.as_ptr().add(NUM_ENTRIES_SIZE) as *const SlotHashEntry,
201+
self.len(),
202+
)
203+
}
204+
}
205+
206+
/// Gets a reference to the entry at `index` or `None` if out of bounds.
207+
#[inline(always)]
208+
pub fn get_entry(&self, index: usize) -> Option<&SlotHashEntry> {
209+
if index >= self.len() {
210+
return None;
211+
}
212+
Some(unsafe { self.get_entry_unchecked(index) })
213+
}
214+
215+
/// Finds the hash for a specific slot using binary search.
216+
///
217+
/// Returns the hash if the slot is found, or `None` if not found.
218+
/// Assumes entries are sorted by slot in descending order.
219+
/// If calling repeatedly, prefer getting `entries()` in caller
220+
/// to avoid repeated slice construction.
221+
#[inline(always)]
222+
pub fn get_hash(&self, target_slot: Slot) -> Option<&Hash> {
223+
self.position(target_slot)
224+
.map(|index| unsafe { &self.get_entry_unchecked(index).hash })
225+
}
226+
227+
/// Finds the position (index) of a specific slot using binary search.
228+
///
229+
/// Returns the index if the slot is found, or `None` if not found.
230+
/// Assumes entries are sorted by slot in descending order.
231+
/// If calling repeatedly, prefer getting `entries()` in caller
232+
/// to avoid repeated slice construction.
233+
#[inline(always)]
234+
pub fn position(&self, target_slot: Slot) -> Option<usize> {
235+
self.entries()
236+
.binary_search_by(|probe_entry| probe_entry.slot().cmp(&target_slot).reverse())
237+
.ok()
238+
}
239+
240+
/// Returns a reference to the entry at `index` **without** bounds checking.
241+
///
242+
/// # Safety
243+
/// Caller must guarantee that `index < self.len()`.
244+
#[inline(always)]
245+
pub unsafe fn get_entry_unchecked(&self, index: usize) -> &SlotHashEntry {
246+
debug_assert!(index < self.len());
247+
// SAFETY: Caller guarantees `index < self.len()`. The data pointer is valid
248+
// and aligned for `SlotHashEntry`. The offset calculation points to a
249+
// valid entry within the allocated data.
250+
let entries_ptr = self.data.as_ptr().add(NUM_ENTRIES_SIZE) as *const SlotHashEntry;
251+
&*entries_ptr.add(index)
252+
}
253+
}
254+
255+
impl<'a, T: Deref<Target = [u8]>> IntoIterator for &'a SlotHashes<T> {
256+
type Item = &'a SlotHashEntry;
257+
type IntoIter = core::slice::Iter<'a, SlotHashEntry>;
258+
259+
fn into_iter(self) -> Self::IntoIter {
260+
self.entries().iter()
261+
}
262+
}
263+
264+
impl<'a> SlotHashes<Ref<'a, [u8]>> {
265+
/// Creates a `SlotHashes` instance by safely borrowing data from an `AccountInfo`.
266+
///
267+
/// This function verifies that:
268+
/// - The account key matches the `SLOTHASHES_ID`
269+
/// - The account data can be successfully borrowed
270+
///
271+
/// Returns a `SlotHashes` instance that borrows the account's data for zero-copy access.
272+
/// The returned instance is valid for the lifetime of the borrow.
273+
///
274+
/// # Errors
275+
/// - `ProgramError::InvalidArgument` if the account key doesn't match the `SlotHashes` sysvar ID
276+
/// - `ProgramError::AccountBorrowFailed` if the account data is already mutably borrowed
277+
#[inline(always)]
278+
pub fn from_account_info(account_info: &'a AccountInfo) -> Result<Self, ProgramError> {
279+
if account_info.key() != &SLOTHASHES_ID {
280+
return Err(ProgramError::InvalidArgument);
281+
}
282+
283+
let data_ref = account_info.try_borrow_data()?;
284+
285+
// SAFETY: The account was validated to be the `SlotHashes` sysvar.
286+
Ok(unsafe { SlotHashes::new_unchecked(data_ref) })
287+
}
288+
}
289+
290+
#[cfg(feature = "std")]
291+
impl SlotHashes<Box<[u8]>> {
292+
/// Fills the provided buffer with the full `SlotHashes` sysvar data.
293+
///
294+
/// # Safety
295+
/// The caller must ensure the buffer pointer is valid for `MAX_SIZE` bytes.
296+
/// The syscall will write exactly `MAX_SIZE` bytes to the buffer.
297+
#[inline(always)]
298+
unsafe fn fill_from_sysvar(buffer_ptr: *mut u8) -> Result<(), ProgramError> {
299+
crate::sysvars::get_sysvar_unchecked(buffer_ptr, &SLOTHASHES_ID, 0, MAX_SIZE)?;
300+
301+
// For tests on builds that don't actually fill the buffer.
302+
#[cfg(not(target_os = "solana"))]
303+
core::ptr::write_bytes(buffer_ptr, 0, NUM_ENTRIES_SIZE);
304+
305+
Ok(())
306+
}
307+
308+
/// Allocates an optimal buffer for the sysvar data based on available features.
309+
#[inline(always)]
310+
fn allocate_and_fetch() -> Result<Box<[u8]>, ProgramError> {
311+
let mut buf = std::vec::Vec::with_capacity(MAX_SIZE);
312+
unsafe {
313+
// SAFETY: `buf` was allocated with capacity `MAX_SIZE` so its
314+
// pointer is valid for exactly that many bytes. `fill_from_sysvar`
315+
// writes `MAX_SIZE` bytes, and we immediately set the length to
316+
// `MAX_SIZE`, marking the entire buffer as initialized before it is
317+
// turned into a boxed slice.
318+
Self::fill_from_sysvar(buf.as_mut_ptr())?;
319+
buf.set_len(MAX_SIZE);
320+
}
321+
Ok(buf.into_boxed_slice())
322+
}
323+
324+
/// Fetches the `SlotHashes` sysvar data directly via syscall. This copies
325+
/// the full sysvar data (`MAX_SIZE` bytes).
326+
#[inline(always)]
327+
pub fn fetch() -> Result<Self, ProgramError> {
328+
let data_init = Self::allocate_and_fetch()?;
329+
330+
// SAFETY: The data was initialized by the syscall.
331+
Ok(unsafe { SlotHashes::new_unchecked(data_init) })
332+
}
333+
}

0 commit comments

Comments
 (0)