Skip to main content

hopper_native/
slot_hashes.rs

1//! The hash of a given slot, read from the SlotHashes sysvar without
2//! copying it.
3//!
4//! SlotHashes holds the last 512 `(slot, hash)` pairs, newest first:
5//! 20,488 bytes. Copying it into a program costs a heap buffer larger than
6//! most programs' whole heap, and passing it as an account costs a slot in
7//! the transaction. Neither is needed: `sol_get_sysvar` copies any range of
8//! a sysvar, and it charges the same for 40 bytes as for 2,400 (the syscall
9//! base plus one memory-operation floor, 110 compute units).
10//!
11//! So the lookup reads windows of [`WINDOW`] entries.
12//!
13//! 1. One read takes the entry count and the newest window. A commit-reveal
14//!    or replay guard asks about a slot a few slots old, and that read
15//!    answers it: 110 compute units.
16//! 2. For an older slot, the entry for slot `s` sits at index
17//!    `newest - s` when no slot in between was skipped, and at a lower
18//!    index when some were. The second read is the window that ends at
19//!    that index, which holds the entry unless more than a window of slots
20//!    were skipped in between: 220 compute units.
21//! 3. Otherwise the search interpolates between the slots it has read on
22//!    each side, and halves the range on every other probe so that slots
23//!    spread unevenly cannot slow it down. A few more reads finish the job.
24//!
25//! The lookup says why a slot has no hash: it was skipped (no block was
26//! produced), it is older than the sysvar reaches, or it is ahead of the
27//! newest entry. A program that must tell "skipped" from "too old" does
28//! not have to guess.
29//!
30//! The search is written over a reader function, so it runs in unit tests
31//! against a synthetic sysvar image; [`slot_hash`] plugs in the syscall.
32
33use crate::error::ProgramError;
34use crate::sysvar::{get_sysvar_prefix_at, SLOT_HASHES_ID};
35
36/// Entries read per syscall. Sixteen entries are 640 bytes on the stack
37/// and cost what one entry costs.
38pub const WINDOW: usize = 16;
39
40/// Bytes of one `(slot, hash)` entry.
41pub const ENTRY_LEN: usize = 40;
42
43/// The most entries SlotHashes holds.
44pub const MAX_ENTRIES: usize = 512;
45
46const HEADER_LEN: usize = 8;
47
48/// What the sysvar says about a slot.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum SlotHashStatus {
51    /// The slot produced a block with this hash.
52    Found([u8; 32]),
53    /// The slot is inside the range the sysvar covers and has no entry: no
54    /// block was produced in it.
55    Skipped,
56    /// The slot is older than the oldest entry.
57    TooOld,
58    /// The slot is newer than the newest entry (the current slot's hash is
59    /// not known until the slot ends), or the sysvar is empty.
60    Ahead,
61}
62
63/// The answer and what it cost.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub struct SlotHashLookup {
66    pub status: SlotHashStatus,
67    /// Sysvar reads made, each 110 compute units on chain.
68    pub reads: u8,
69}
70
71impl SlotHashLookup {
72    /// The hash, when the slot has one.
73    #[inline(always)]
74    pub const fn hash(&self) -> Option<[u8; 32]> {
75        match self.status {
76            SlotHashStatus::Found(hash) => Some(hash),
77            _ => None,
78        }
79    }
80}
81
82/// The hash of `slot`, or `None` when the sysvar has none for it.
83/// [`slot_hash_lookup`] says which of the three reasons applies.
84#[inline]
85pub fn slot_hash(slot: u64) -> Result<Option<[u8; 32]>, ProgramError> {
86    Ok(slot_hash_lookup(slot)?.hash())
87}
88
89/// Look `slot` up in the SlotHashes sysvar.
90#[inline]
91pub fn slot_hash_lookup(slot: u64) -> Result<SlotHashLookup, ProgramError> {
92    slot_hash_lookup_with(slot, |offset, dst| {
93        get_sysvar_prefix_at(&SLOT_HASHES_ID, offset, dst)
94    })
95}
96
97#[inline(always)]
98fn entry_slot(window: &[u8], index: usize) -> u64 {
99    let at = index * ENTRY_LEN;
100    u64::from_le_bytes([
101        window[at],
102        window[at + 1],
103        window[at + 2],
104        window[at + 3],
105        window[at + 4],
106        window[at + 5],
107        window[at + 6],
108        window[at + 7],
109    ])
110}
111
112#[inline(always)]
113fn entry_hash(window: &[u8], index: usize) -> [u8; 32] {
114    let at = index * ENTRY_LEN + 8;
115    let mut hash = [0u8; 32];
116    hash.copy_from_slice(&window[at..at + 32]);
117    hash
118}
119
120/// The search over any reader of the sysvar's bytes.
121///
122/// `read(offset, dst)` fills `dst` from the sysvar starting at `offset`
123/// and returns `Ok(true)`, or returns `Ok(false)` when the range runs past
124/// the sysvar's end, the contract of
125/// [`get_sysvar_prefix_at`](crate::sysvar::get_sysvar_prefix_at).
126pub fn slot_hash_lookup_with<R>(target: u64, mut read: R) -> Result<SlotHashLookup, ProgramError>
127where
128    R: FnMut(u64, &mut [u8]) -> Result<bool, ProgramError>,
129{
130    let mut reads = 0u8;
131    let done = |status, reads| Ok(SlotHashLookup { status, reads });
132
133    // The count and the newest window in one read. A sysvar shorter than
134    // that (a fresh test validator) is read again at its real length.
135    let mut head = [0u8; HEADER_LEN + WINDOW * ENTRY_LEN];
136    reads += 1;
137    let count = if read(0, &mut head)? {
138        read_count(&head)?
139    } else {
140        reads += 1;
141        if !read(0, &mut head[..HEADER_LEN])? {
142            return Err(ProgramError::UnsupportedSysvar);
143        }
144        let count = read_count(&head)?;
145        if count >= WINDOW {
146            // The long read failed although the sysvar holds a full
147            // window: the header does not describe the sysvar.
148            return Err(ProgramError::UnsupportedSysvar);
149        }
150        if count > 0 {
151            reads += 1;
152            if !read(0, &mut head[..HEADER_LEN + count * ENTRY_LEN])? {
153                return Err(ProgramError::UnsupportedSysvar);
154            }
155        }
156        count
157    };
158    if count == 0 {
159        return done(SlotHashStatus::Ahead, reads);
160    }
161
162    let have = if count < WINDOW { count } else { WINDOW };
163    let window = &head[HEADER_LEN..HEADER_LEN + have * ENTRY_LEN];
164    let newest = entry_slot(window, 0);
165    if target > newest {
166        return done(SlotHashStatus::Ahead, reads);
167    }
168    if let Some(status) = scan(window, have, target, true) {
169        return done(status, reads);
170    }
171    // Every entry of the newest window is newer than the target.
172    if have == count {
173        return done(SlotHashStatus::TooOld, reads);
174    }
175
176    // Entries are strictly descending, so entry `i` has a slot of at most
177    // `newest - i`: the target's entry, if any, is at index `newest -
178    // target` or below it, and exactly there when no slot in between was
179    // skipped.
180    let last = count - 1;
181    let bound = newest - target;
182    // Entries above `hi` cannot be the target.
183    let mut hi = if bound > last as u64 {
184        last
185    } else {
186        bound as usize
187    };
188    // Invariant: every entry below `lo` is newer than the target.
189    let mut lo = have;
190    // The nearest entry on each side of the range that was actually read:
191    // the search interpolates between their slots.
192    let mut newer = (have - 1, entry_slot(window, have - 1));
193    let mut older: Option<(usize, u64)> = None;
194    let mut buffer = [0u8; WINDOW * ENTRY_LEN];
195    let mut bisect = false;
196    while lo <= hi {
197        let span = hi - lo + 1;
198        let width = if span < WINDOW { span } else { WINDOW };
199        let highest_start = hi + 1 - width;
200        let start = match older {
201            // Nothing older has been read: the window that ends at the
202            // bound, where the entry is when few slots were skipped.
203            None => highest_start,
204            // Every other probe halves the range, so a run of slots
205            // spread unevenly cannot make the search walk.
206            Some(_) if bisect => lo + (span - width) / 2,
207            // Where the target falls between the two entries if the slots
208            // between them are spread evenly.
209            Some((older_index, older_slot)) => {
210                let gap = (older_index - newer.0) as u128;
211                let run = (newer.1 - older_slot) as u128;
212                let guess = newer.0 + ((newer.1 - target) as u128 * gap / run) as usize;
213                let centred = guess.saturating_sub(width / 2);
214                if centred < lo {
215                    lo
216                } else if centred > highest_start {
217                    highest_start
218                } else {
219                    centred
220                }
221            }
222        };
223        bisect = older.is_some() && !bisect;
224        let window = &mut buffer[..width * ENTRY_LEN];
225        reads = reads.saturating_add(1);
226        let offset = (HEADER_LEN + start * ENTRY_LEN) as u64;
227        if !read(offset, window)? {
228            return Err(ProgramError::UnsupportedSysvar);
229        }
230        // The entry before this window is known to be newer than the
231        // target only when the window starts at `lo`.
232        if let Some(status) = scan(window, width, target, start == lo) {
233            return done(status, reads);
234        }
235        let first_slot = entry_slot(window, 0);
236        if first_slot < target {
237            // The whole window is older than the target.
238            older = Some((start, first_slot));
239            hi = start - 1;
240        } else {
241            // The whole window is newer than the target.
242            let end = start + width - 1;
243            newer = (end, entry_slot(window, width - 1));
244            lo = end + 1;
245        }
246    }
247    // `lo` passed `hi`, so every entry that could be the target is newer
248    // than it. If an older entry was read, the target's slot lies between
249    // two entries and has none of its own. If none was, the range ran to
250    // the last entry, and the target is older than the sysvar reaches.
251    if older.is_some() {
252        done(SlotHashStatus::Skipped, reads)
253    } else {
254        done(SlotHashStatus::TooOld, reads)
255    }
256}
257
258#[inline(always)]
259fn read_count(head: &[u8]) -> Result<usize, ProgramError> {
260    let count = u64::from_le_bytes([
261        head[0], head[1], head[2], head[3], head[4], head[5], head[6], head[7],
262    ]);
263    if count > MAX_ENTRIES as u64 {
264        return Err(ProgramError::UnsupportedSysvar);
265    }
266    Ok(count as usize)
267}
268
269/// Look for `target` among the `len` entries of `window`. `Found` when it
270/// is there; `Skipped` when the window shows an entry newer than the
271/// target directly followed by an older one (the entry before the window
272/// counts as newer when `newer_before` says so); `None` when the window
273/// does not decide.
274#[inline(always)]
275fn scan(window: &[u8], len: usize, target: u64, newer_before: bool) -> Option<SlotHashStatus> {
276    let mut i = 0;
277    while i < len {
278        let slot = entry_slot(window, i);
279        if slot == target {
280            return Some(SlotHashStatus::Found(entry_hash(window, i)));
281        }
282        if slot < target {
283            return if i > 0 || newer_before {
284                Some(SlotHashStatus::Skipped)
285            } else {
286                None
287            };
288        }
289        i += 1;
290    }
291    None
292}
293
294#[cfg(test)]
295mod tests {
296    extern crate std;
297
298    use super::*;
299    use std::vec::Vec;
300
301    fn hash_of(slot: u64) -> [u8; 32] {
302        let mut hash = [0u8; 32];
303        hash[..8].copy_from_slice(&slot.to_le_bytes());
304        hash[8..16].copy_from_slice(&(!slot).to_le_bytes());
305        hash[31] = 0x5a;
306        hash
307    }
308
309    /// A sysvar image holding `slots`, which must be strictly descending.
310    fn image(slots: &[u64]) -> Vec<u8> {
311        let mut out = (slots.len() as u64).to_le_bytes().to_vec();
312        for slot in slots {
313            out.extend_from_slice(&slot.to_le_bytes());
314            out.extend_from_slice(&hash_of(*slot));
315        }
316        out
317    }
318
319    fn lookup(image: &[u8], target: u64) -> SlotHashLookup {
320        let mut reads = 0u8;
321        let result = slot_hash_lookup_with(target, |offset, dst| {
322            reads += 1;
323            let start = offset as usize;
324            match start.checked_add(dst.len()) {
325                Some(end) if end <= image.len() => {
326                    dst.copy_from_slice(&image[start..end]);
327                    Ok(true)
328                }
329                _ => Ok(false),
330            }
331        })
332        .unwrap();
333        assert_eq!(result.reads, reads, "the lookup counts its own reads");
334        result
335    }
336
337    /// The answer by definition, from the list itself.
338    fn expected(slots: &[u64], target: u64) -> SlotHashStatus {
339        match slots.first() {
340            None => SlotHashStatus::Ahead,
341            Some(newest) if target > *newest => SlotHashStatus::Ahead,
342            _ if slots.contains(&target) => SlotHashStatus::Found(hash_of(target)),
343            _ if target < *slots.last().unwrap() => SlotHashStatus::TooOld,
344            _ => SlotHashStatus::Skipped,
345        }
346    }
347
348    /// Descending slots from `newest`, `count` of them, skipping a slot
349    /// wherever `skip` says so.
350    fn chain(newest: u64, count: usize, mut skip: impl FnMut(u64) -> bool) -> Vec<u64> {
351        let mut slots = Vec::new();
352        let mut slot = newest;
353        while slots.len() < count {
354            if slots.is_empty() || !skip(slot) {
355                slots.push(slot);
356            }
357            if slot == 0 {
358                break;
359            }
360            slot -= 1;
361        }
362        slots
363    }
364
365    fn check_every_slot(slots: &[u64]) -> u8 {
366        let sysvar = image(slots);
367        let newest = slots.first().copied().unwrap_or(0);
368        let oldest = slots.last().copied().unwrap_or(0);
369        let mut worst = 0;
370        let low = oldest.saturating_sub(3);
371        for target in low..=newest + 3 {
372            let got = lookup(&sysvar, target);
373            assert_eq!(
374                got.status,
375                expected(slots, target),
376                "target {target} in a list of {} from {newest} to {oldest}",
377                slots.len()
378            );
379            worst = worst.max(got.reads);
380        }
381        worst
382    }
383
384    #[test]
385    fn a_full_sysvar_with_no_skips_answers_in_two_reads() {
386        let slots = chain(1_000_000, MAX_ENTRIES, |_| false);
387        let sysvar = image(&slots);
388        assert_eq!(sysvar.len(), 20_488);
389        // The newest window: one read.
390        for back in 0..WINDOW as u64 {
391            let got = lookup(&sysvar, 1_000_000 - back);
392            assert_eq!(got.hash(), Some(hash_of(1_000_000 - back)));
393            assert_eq!(got.reads, 1);
394        }
395        // Anything older: the window that ends at the expected index.
396        for back in WINDOW as u64..MAX_ENTRIES as u64 {
397            let got = lookup(&sysvar, 1_000_000 - back);
398            assert_eq!(got.hash(), Some(hash_of(1_000_000 - back)));
399            assert_eq!(got.reads, 2, "{back} slots back");
400        }
401        assert_eq!(check_every_slot(&slots), 2);
402    }
403
404    #[test]
405    fn skipped_slots_are_reported_and_cost_little() {
406        // About one slot in twenty skipped, the rate of a busy cluster.
407        let slots = chain(5_000_000, MAX_ENTRIES, |slot| slot % 19 == 7);
408        let worst = check_every_slot(&slots);
409        assert!(worst <= 4, "{worst} reads in the worst case");
410        let sysvar = image(&slots);
411        let skipped = (4_999_900..5_000_000u64).find(|s| s % 19 == 7).unwrap();
412        assert_eq!(lookup(&sysvar, skipped).status, SlotHashStatus::Skipped);
413        // Few skips in between: still two reads for a slot 100 back.
414        assert_eq!(lookup(&sysvar, 5_000_000 - 100).reads, 2);
415    }
416
417    #[test]
418    fn long_gaps_fall_back_to_the_search() {
419        // A cluster that lost most of its slots: every third slot lands.
420        let slots = chain(9_000, MAX_ENTRIES, |slot| slot % 3 != 0);
421        let worst = check_every_slot(&slots);
422        assert!(worst <= 8, "{worst} reads in the worst case");
423        // One gap of 300 slots in the middle of the list.
424        let slots = chain(80_000, MAX_ENTRIES, |slot| (79_500..79_800).contains(&slot));
425        let worst = check_every_slot(&slots);
426        assert!(worst <= 8, "{worst} reads in the worst case");
427    }
428
429    #[test]
430    fn every_length_from_empty_to_full() {
431        for count in 0..=40usize {
432            let slots = chain(700, count, |slot| slot % 5 == 1);
433            check_every_slot(&slots);
434        }
435        for count in [63, 64, 65, 255, 256, 257, 511, 512] {
436            let slots = chain(90_000, count, |slot| slot % 11 == 3);
437            check_every_slot(&slots);
438        }
439        // A list that reaches slot zero.
440        let slots = chain(30, 31, |_| false);
441        assert_eq!(*slots.last().unwrap(), 0);
442        check_every_slot(&slots);
443    }
444
445    #[test]
446    fn the_reasons_are_told_apart() {
447        let slots = chain(1_000, 100, |slot| slot == 950);
448        let sysvar = image(&slots);
449        let oldest = *slots.last().unwrap();
450        assert_eq!(lookup(&sysvar, 1_001).status, SlotHashStatus::Ahead);
451        assert_eq!(lookup(&sysvar, u64::MAX).status, SlotHashStatus::Ahead);
452        assert_eq!(lookup(&sysvar, 950).status, SlotHashStatus::Skipped);
453        assert_eq!(lookup(&sysvar, oldest - 1).status, SlotHashStatus::TooOld);
454        assert_eq!(lookup(&sysvar, 0).status, SlotHashStatus::TooOld);
455        assert_eq!(lookup(&sysvar, oldest).hash(), Some(hash_of(oldest)));
456        assert_eq!(lookup(&image(&[]), 5).status, SlotHashStatus::Ahead);
457    }
458
459    #[test]
460    fn a_header_that_does_not_describe_the_sysvar_is_refused() {
461        // A count above the sysvar's maximum.
462        let mut sysvar = image(&chain(100, 20, |_| false));
463        sysvar[..8].copy_from_slice(&513u64.to_le_bytes());
464        let result = slot_hash_lookup_with(90, |offset, dst| {
465            let start = offset as usize;
466            if start + dst.len() > sysvar.len() {
467                return Ok(false);
468            }
469            dst.copy_from_slice(&sysvar[start..start + dst.len()]);
470            Ok(true)
471        });
472        assert_eq!(result.err(), Some(ProgramError::UnsupportedSysvar));
473
474        // A count that promises more entries than the sysvar holds.
475        let mut sysvar = image(&chain(100, 20, |_| false));
476        sysvar[..8].copy_from_slice(&400u64.to_le_bytes());
477        let result = slot_hash_lookup_with(60, |offset, dst| {
478            let start = offset as usize;
479            if start + dst.len() > sysvar.len() {
480                return Ok(false);
481            }
482            dst.copy_from_slice(&sysvar[start..start + dst.len()]);
483            Ok(true)
484        });
485        assert_eq!(result.err(), Some(ProgramError::UnsupportedSysvar));
486
487        // A reader that fails is reported, not swallowed.
488        let result = slot_hash_lookup_with(60, |_, _| Err(ProgramError::InvalidArgument));
489        assert_eq!(result.err(), Some(ProgramError::InvalidArgument));
490    }
491}