Skip to main content

sui_rpc_api/ledger_history/
query_options.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::ops::{Bound, Range};
5
6use bytes::Bytes;
7use sui_inverted_index::ScanDirection;
8use sui_rpc::proto::sui::rpc::v2::Ordering as ProtoOrdering;
9use sui_rpc::proto::sui::rpc::v2::QueryEndReason;
10use sui_rpc::proto::sui::rpc::v2::QueryOptions as ProtoQueryOptions;
11use sui_rpc_cursor::CursorToken;
12use sui_rpc_cursor::Position;
13
14use crate::ErrorReason;
15use crate::RpcError;
16use crate::proto::google::rpc::bad_request::FieldViolation;
17
18const ORDERING_ASCENDING: i32 = ProtoOrdering::Ascending as i32;
19const ORDERING_DESCENDING: i32 = ProtoOrdering::Descending as i32;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
22pub enum Ordering {
23    Ascending,
24    Descending,
25}
26
27/// Event-order coordinate. Boundary cursors may point at slots with no event.
28#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
29pub struct EventPosition {
30    pub tx_seq: u64,
31    pub event_index: u32,
32}
33
34impl EventPosition {
35    /// Fencepost at the first event slot of `tx_seq`; valid as a boundary even
36    /// if the transaction has no events.
37    pub fn start_of_tx(tx_seq: u64) -> Self {
38        Self {
39            tx_seq,
40            event_index: 0,
41        }
42    }
43}
44
45impl From<EventPosition> for (u64, u32) {
46    fn from(position: EventPosition) -> Self {
47        (position.tx_seq, position.event_index)
48    }
49}
50
51impl From<(u64, u32)> for EventPosition {
52    fn from((tx_seq, event_index): (u64, u32)) -> Self {
53        Self {
54            tx_seq,
55            event_index,
56        }
57    }
58}
59
60/// Why a resolved scan interval is exhausted. Fixed at range-resolution time,
61/// carried through scan state, and rendered by [`ScanTerminal`].
62///
63/// [`ScanTerminal`]: crate::ledger_history::watermark::ScanTerminal
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub enum RangeExhaustion {
66    /// Reached the currently indexed ledger tip.
67    LedgerTip,
68    /// Reached the requested (or implicit-genesis) checkpoint range bound.
69    CheckpointBound,
70    /// Truncated by a client `after`/`before` cursor. `kind` is the cursor
71    /// kind the terminal cursor must carry so resume neither repeats nor
72    /// skips an item (Item is preserved only for an ascending event interval
73    /// made empty by an `after` Item cursor; every other site uses Boundary).
74    CursorBound { kind: sui_rpc_cursor::CursorKind },
75}
76
77impl RangeExhaustion {
78    pub fn reason(self) -> QueryEndReason {
79        match self {
80            Self::LedgerTip => QueryEndReason::LedgerTip,
81            Self::CheckpointBound => QueryEndReason::CheckpointBound,
82            Self::CursorBound { .. } => QueryEndReason::CursorBound,
83        }
84    }
85}
86
87/// Validated, normalized form of `QueryOptions` (the proto wire type).
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct QueryOptions {
90    pub limit_items: usize,
91    pub ordering: Ordering,
92    after: Option<CursorToken>,
93    before: Option<CursorToken>,
94}
95
96/// A request's checkpoint bounds resolved into the checkpoint-sequence
97/// interval to scan. Unlike [`ResolvedRange`]/[`ResolvedEventRange`], the
98/// scan domain here *is* checkpoint space, so the entry and terminal
99/// checkpoints derive from `range` directly and need no extra fields.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct ResolvedCheckpointRange {
102    /// Half-open checkpoint-sequence interval to scan (numeric order; a
103    /// descending scan walks it from `end - 1` down to `start`).
104    pub range: Range<u64>,
105    /// Why the interval is exhausted once the scan drains it.
106    pub exhaustion: RangeExhaustion,
107}
108
109/// A request's checkpoint bounds resolved into the transaction-sequence
110/// interval to scan, annotated with the checkpoint-space facts watermark
111/// rendering needs.
112///
113/// Two coordinate spaces meet here: `range` lives in transaction-sequence
114/// space (the scan's key space), while wire watermarks speak checkpoints —
115/// coverage claims are checkpoint numbers and cursors are full
116/// `(checkpoint, tx_seq)` positions. Mapping a tx back to its checkpoint
117/// takes a store lookup, so resolution captures the two endpoint checkpoints
118/// up front.
119///
120/// The endpoint checkpoints are deliberately *not* a `Range`: they are
121/// direction-relative (`entry_checkpoint` is numerically the high checkpoint
122/// of a descending scan), they serve unrelated consumers (coverage clamping
123/// vs. terminal-frame rendering), and only the terminal side carries a
124/// companion position.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct ResolvedRange {
127    /// Half-open tx-sequence interval to scan (numeric order regardless of
128    /// request ordering; a descending scan walks it from `end - 1` down).
129    pub range: Range<u64>,
130    /// Checkpoint containing `end_position`. Together they form the
131    /// terminal-frame cursor position and the natural-completion coverage
132    /// claim when the scan exhausts the interval.
133    pub end_checkpoint: u64,
134    /// Checkpoint containing the interval's first position in scan
135    /// direction. Checkpoint-only because its sole consumer — the
136    /// covered-bound fold — claims coverage at checkpoint granularity: a
137    /// claim before it proves nothing and is suppressed, keeping the wire
138    /// `checkpoint` unset until the scan's first checkpoint is fully
139    /// covered.
140    pub entry_checkpoint: u64,
141    /// The tx-sequence bound the scan *reports* when the interval is
142    /// exhausted (the terminal frame's cursor coordinate, paired with
143    /// `end_checkpoint`). Tracks the scan-direction edge of `range`
144    /// (`range.end` ascending, `range.start` descending) as cursor bounds
145    /// tighten it, but stays pinned to the reported bound if the backend
146    /// further clamps `range` to available history.
147    pub end_position: u64,
148    /// Why the interval is exhausted once the scan drains it.
149    pub exhaustion: RangeExhaustion,
150}
151
152/// Semantic scan bounds over explicit event coordinates.
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub struct EventScanBounds {
155    pub lo: Bound<EventPosition>,
156    pub hi: Bound<EventPosition>,
157}
158
159/// [`ResolvedRange`]'s analogue for event scans: the scan domain is
160/// `(tx_seq, event_index)` coordinates, and the same two endpoint
161/// checkpoints annotate it for watermark rendering (see [`ResolvedRange`]
162/// for why they are not a `Range`).
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct ResolvedEventRange {
165    /// Event-coordinate interval to scan, expressed as explicit lo/hi
166    /// [`Bound`]s (cursor trims need exclusive bounds on either side).
167    pub bounds: EventScanBounds,
168    /// Checkpoint containing the interval's first position in scan
169    /// direction; same coverage-clamp role as
170    /// [`ResolvedRange::entry_checkpoint`].
171    pub entry_checkpoint: u64,
172    /// Checkpoint containing `end_position`; same terminal-frame role as
173    /// [`ResolvedRange::end_checkpoint`].
174    pub end_checkpoint: u64,
175    /// The event coordinate the scan reports when the interval is exhausted
176    /// (terminal-frame cursor position, paired with `end_checkpoint`).
177    pub end_position: EventPosition,
178    /// Why the interval is exhausted once the scan drains it.
179    pub exhaustion: RangeExhaustion,
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183pub struct CheckpointRange {
184    start: u64,
185    end: u64,
186    high_exhaustion: RangeExhaustion,
187    indexed_tip: u64,
188}
189
190impl QueryOptions {
191    pub fn checkpoints_from_proto(
192        request: Option<&ProtoQueryOptions>,
193        default_limit_items: u32,
194        max_limit_items: u32,
195    ) -> Result<Self, RpcError> {
196        Self::from_proto_with_position(request, default_limit_items, max_limit_items, |position| {
197            matches!(position, Position::Checkpoints { .. })
198        })
199    }
200
201    pub fn transactions_from_proto(
202        request: Option<&ProtoQueryOptions>,
203        default_limit_items: u32,
204        max_limit_items: u32,
205    ) -> Result<Self, RpcError> {
206        Self::from_proto_with_position(request, default_limit_items, max_limit_items, |position| {
207            matches!(position, Position::Transactions { .. })
208        })
209    }
210
211    pub fn events_from_proto(
212        request: Option<&ProtoQueryOptions>,
213        default_limit_items: u32,
214        max_limit_items: u32,
215    ) -> Result<Self, RpcError> {
216        Self::from_proto_with_position(request, default_limit_items, max_limit_items, |position| {
217            matches!(position, Position::Events { .. })
218        })
219    }
220
221    fn from_proto_with_position(
222        request: Option<&ProtoQueryOptions>,
223        default_limit_items: u32,
224        max_limit_items: u32,
225        position_matches: fn(&Position) -> bool,
226    ) -> Result<Self, RpcError> {
227        let limit_items = request
228            .and_then(|options| options.limit)
229            .unwrap_or(default_limit_items)
230            .clamp(1, max_limit_items) as usize;
231
232        let ordering = match request.and_then(|options| options.ordering) {
233            None | Some(ORDERING_ASCENDING) => Ordering::Ascending,
234            Some(ORDERING_DESCENDING) => Ordering::Descending,
235            Some(_) => {
236                return Err(FieldViolation::new("options.ordering")
237                    .with_description("invalid ordering")
238                    .with_reason(ErrorReason::FieldInvalid)
239                    .into());
240            }
241        };
242
243        let after = parse_cursor(
244            "options.after",
245            request.and_then(|options| options.after.as_ref()),
246            position_matches,
247        )?;
248        let before = parse_cursor(
249            "options.before",
250            request.and_then(|options| options.before.as_ref()),
251            position_matches,
252        )?;
253
254        Ok(Self {
255            limit_items,
256            ordering,
257            after,
258            before,
259        })
260    }
261
262    /// Options for a subscription stream: an unbounded ascending scan with no
263    /// cursor bounds. The ascending direction is consulted by the watermark
264    /// builders; `limit_items` is irrelevant.
265    pub fn subscription() -> Self {
266        Self {
267            limit_items: usize::MAX,
268            ordering: Ordering::Ascending,
269            after: None,
270            before: None,
271        }
272    }
273
274    pub fn scan_direction(&self) -> ScanDirection {
275        match self.ordering {
276            Ordering::Ascending => ScanDirection::Ascending,
277            Ordering::Descending => ScanDirection::Descending,
278        }
279    }
280
281    pub fn is_ascending(&self) -> bool {
282        matches!(self.ordering, Ordering::Ascending)
283    }
284
285    /// Whether the request explicitly positioned the low end of the scan via an
286    /// `after` cursor. `apply_cursor_bounds` only ever raises `range.start` from
287    /// `after` (in both orderings); `before` bounds the high end. Together with an
288    /// explicit `start_checkpoint`, this lets the pruning-floor check distinguish
289    /// "resume/start from here" (error if below the floor — the data is gone) from
290    /// an open-ended low end (clamp up to the floor).
291    pub fn has_after_cursor(&self) -> bool {
292        self.after.is_some()
293    }
294
295    pub fn apply_cursor_bounds(&self, resolved: ResolvedRange) -> ResolvedRange {
296        if resolved.is_empty() {
297            return resolved;
298        }
299
300        let mut start = resolved.range.start;
301        let mut end = resolved.range.end;
302        let mut end_checkpoint = resolved.end_checkpoint;
303        let mut end_position = resolved.end_position;
304        let mut exhaustion = resolved.exhaustion;
305        let mut entry_checkpoint = resolved.entry_checkpoint;
306        let mut cursor_terminal = None;
307
308        if let Some(cursor) = &self.after {
309            let position = u64_cursor_position(cursor);
310            if matches!(self.ordering, Ordering::Ascending) {
311                entry_checkpoint = entry_checkpoint.max(cursor.position.checkpoint());
312            }
313            let Some(after) = (match cursor.kind {
314                sui_rpc_cursor::CursorKind::Item => position.checked_add(1),
315                sui_rpc_cursor::CursorKind::Boundary => Some(position),
316            }) else {
317                // `u64::MAX` is the unoccupiable exclusive sentinel of these
318                // packed ranges (a real item at MAX could not be represented by
319                // the required exclusive end). A Boundary cursor at MAX is
320                // therefore equivalent to the overflowing Item successor and
321                // cannot re-deliver an item.
322                return ResolvedRange {
323                    entry_checkpoint,
324                    ..ResolvedRange::empty_at(
325                        cursor.position.checkpoint(),
326                        position,
327                        RangeExhaustion::CursorBound {
328                            kind: sui_rpc_cursor::CursorKind::Boundary,
329                        },
330                    )
331                };
332            };
333            if after >= start {
334                start = after;
335                if matches!(self.ordering, Ordering::Descending) || after >= end {
336                    cursor_terminal = Some((cursor.position.checkpoint(), after));
337                }
338                if matches!(self.ordering, Ordering::Descending) {
339                    end_checkpoint = cursor.position.checkpoint();
340                    end_position = after;
341                    exhaustion = RangeExhaustion::CursorBound {
342                        kind: sui_rpc_cursor::CursorKind::Boundary,
343                    };
344                }
345            }
346        }
347
348        if let Some(cursor) = &self.before {
349            let position = u64_cursor_position(cursor);
350            if matches!(self.ordering, Ordering::Descending) {
351                entry_checkpoint = entry_checkpoint.min(cursor.position.checkpoint());
352            }
353            if position <= end {
354                end = position;
355                if matches!(self.ordering, Ordering::Ascending) || position <= start {
356                    cursor_terminal = Some((cursor.position.checkpoint(), position));
357                }
358                if matches!(self.ordering, Ordering::Ascending) {
359                    end_checkpoint = cursor.position.checkpoint();
360                    end_position = position;
361                    exhaustion = RangeExhaustion::CursorBound {
362                        kind: sui_rpc_cursor::CursorKind::Boundary,
363                    };
364                }
365            }
366        }
367
368        if start >= end {
369            if let Some((checkpoint, position)) = cursor_terminal {
370                end_checkpoint = checkpoint;
371                end_position = position;
372            }
373            if self.after.is_some() || self.before.is_some() {
374                exhaustion = RangeExhaustion::CursorBound {
375                    kind: sui_rpc_cursor::CursorKind::Boundary,
376                };
377            }
378            ResolvedRange {
379                range: end_position..end_position,
380                end_checkpoint,
381                end_position,
382                exhaustion,
383                entry_checkpoint,
384            }
385        } else {
386            ResolvedRange {
387                range: start..end,
388                end_checkpoint,
389                end_position,
390                exhaustion,
391                entry_checkpoint,
392            }
393        }
394    }
395
396    pub fn apply_event_cursor_bounds(&self, resolved: ResolvedEventRange) -> ResolvedEventRange {
397        if resolved.is_empty() {
398            return resolved;
399        }
400
401        let mut bounds = resolved.bounds;
402        let mut end_checkpoint = resolved.end_checkpoint;
403        let mut end_position = resolved.end_position;
404        let mut exhaustion = resolved.exhaustion;
405        let mut entry_checkpoint = resolved.entry_checkpoint;
406        let mut cursor_terminal = None;
407
408        if let Some(cursor) = &self.after {
409            let position = event_cursor_position(cursor);
410            if matches!(self.ordering, Ordering::Ascending) {
411                entry_checkpoint = entry_checkpoint.max(cursor.position.checkpoint());
412            }
413            let candidate = match cursor.kind {
414                sui_rpc_cursor::CursorKind::Item => Bound::Excluded(position),
415                sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position),
416            };
417            if lower_bound_gte(candidate, bounds.lo) {
418                let candidate_bounds = EventScanBounds {
419                    lo: candidate,
420                    hi: bounds.hi,
421                };
422                bounds.lo = candidate;
423                if matches!(self.ordering, Ordering::Descending) || candidate_bounds.is_empty() {
424                    let kind = if matches!(self.ordering, Ordering::Ascending) {
425                        cursor.kind
426                    } else {
427                        sui_rpc_cursor::CursorKind::Boundary
428                    };
429                    cursor_terminal = Some((cursor.position.checkpoint(), position, kind));
430                }
431                if matches!(self.ordering, Ordering::Descending) {
432                    end_checkpoint = cursor.position.checkpoint();
433                    end_position = position;
434                    exhaustion = RangeExhaustion::CursorBound {
435                        kind: sui_rpc_cursor::CursorKind::Boundary,
436                    };
437                }
438            }
439        }
440
441        if let Some(cursor) = &self.before {
442            let position = event_cursor_position(cursor);
443            if matches!(self.ordering, Ordering::Descending) {
444                entry_checkpoint = entry_checkpoint.min(cursor.position.checkpoint());
445            }
446            if hi_admits_upper_bound(bounds.hi, position) {
447                let candidate = Bound::Excluded(position);
448                let candidate_bounds = EventScanBounds {
449                    lo: bounds.lo,
450                    hi: candidate,
451                };
452                bounds.hi = candidate;
453                if matches!(self.ordering, Ordering::Ascending) || candidate_bounds.is_empty() {
454                    cursor_terminal = Some((
455                        cursor.position.checkpoint(),
456                        position,
457                        sui_rpc_cursor::CursorKind::Boundary,
458                    ));
459                }
460                if matches!(self.ordering, Ordering::Ascending) {
461                    end_checkpoint = cursor.position.checkpoint();
462                    end_position = position;
463                    exhaustion = RangeExhaustion::CursorBound {
464                        kind: sui_rpc_cursor::CursorKind::Boundary,
465                    };
466                }
467            }
468        }
469
470        // CursorBound bookkeeping records the exact event coordinate at which
471        // the resolved interval terminates. Nonempty intervals terminate at the
472        // ordering-side cursor boundary. An ascending interval made empty by an
473        // `after` Item cursor must retain Item kind: converting that raw
474        // coordinate to Boundary would re-include the item on resume. This also
475        // avoids inventing a lexicographic successor when the event coordinate
476        // is already maximal.
477        if bounds.is_empty() {
478            if let Some((checkpoint, position, kind)) = cursor_terminal {
479                end_checkpoint = checkpoint;
480                end_position = position;
481                exhaustion = RangeExhaustion::CursorBound { kind };
482            } else if self.after.is_some() || self.before.is_some() {
483                exhaustion = RangeExhaustion::CursorBound {
484                    kind: sui_rpc_cursor::CursorKind::Boundary,
485                };
486            }
487            ResolvedEventRange {
488                bounds: EventScanBounds::empty_at(end_position),
489                end_checkpoint,
490                end_position,
491                exhaustion,
492                entry_checkpoint,
493            }
494        } else {
495            ResolvedEventRange {
496                bounds,
497                end_checkpoint,
498                end_position,
499                exhaustion,
500                entry_checkpoint,
501            }
502        }
503    }
504}
505
506fn u64_cursor_position(cursor: &CursorToken) -> u64 {
507    match cursor.position {
508        Position::Checkpoints { checkpoint } => checkpoint,
509        Position::Transactions { tx_seq, .. } => tx_seq,
510        Position::Events { .. } => panic!("event queries must use apply_event_cursor_bounds"),
511    }
512}
513
514fn event_cursor_position(cursor: &CursorToken) -> EventPosition {
515    match cursor.position {
516        Position::Events {
517            tx_seq,
518            event_index,
519            ..
520        } => EventPosition {
521            tx_seq,
522            event_index,
523        },
524        _ => unreachable!("validated at decode"),
525    }
526}
527
528impl ResolvedCheckpointRange {
529    pub fn empty_at(checkpoint: u64, exhaustion: RangeExhaustion) -> Self {
530        Self {
531            range: checkpoint..checkpoint,
532            exhaustion,
533        }
534    }
535
536    pub fn is_empty(&self) -> bool {
537        self.range.is_empty()
538    }
539
540    pub fn terminal_checkpoint(&self, ordering: Ordering) -> u64 {
541        match ordering {
542            Ordering::Ascending => self.range.end,
543            Ordering::Descending => self.range.start,
544        }
545    }
546
547    pub fn with_range(self, range: Range<u64>, ordering: Ordering) -> ResolvedRange {
548        let end_position = match ordering {
549            Ordering::Ascending => range.end,
550            Ordering::Descending => range.start,
551        };
552        let entry_checkpoint = match ordering {
553            Ordering::Ascending => self.range.start,
554            Ordering::Descending => self.range.end.saturating_sub(1),
555        };
556        ResolvedRange {
557            range,
558            end_checkpoint: self.terminal_checkpoint(ordering),
559            end_position,
560            exhaustion: self.exhaustion,
561            entry_checkpoint,
562        }
563    }
564}
565
566impl ResolvedRange {
567    pub fn empty_at(end_checkpoint: u64, end_position: u64, exhaustion: RangeExhaustion) -> Self {
568        Self {
569            range: end_position..end_position,
570            end_checkpoint,
571            end_position,
572            exhaustion,
573            entry_checkpoint: end_checkpoint,
574        }
575    }
576
577    pub fn is_empty(&self) -> bool {
578        self.range.is_empty()
579    }
580
581    /// Reconcile the interval and its watermark metadata after the backend
582    /// clamped the interval's low end to the serving floor (`floor_tx` = the
583    /// effective first scannable transaction, `floor_checkpoint` = its
584    /// containing checkpoint).
585    ///
586    /// A floor inside the interval starts the scan there: an ascending scan
587    /// must not claim coverage below it (entry rises), and a descending scan
588    /// terminates at it (terminal pinned), so no watermark ever claims
589    /// unscanned history. A floor at or past the interval's high end leaves
590    /// an empty intersection with retained history: the interval
591    /// canonicalizes to empty and the terminal metadata stays at the
592    /// requested boundary — the cursor must not move outside the requested
593    /// interval, and the empty interval already claims nothing.
594    pub fn apply_serving_floor(
595        &mut self,
596        floor_tx: u64,
597        floor_checkpoint: u64,
598        options: &QueryOptions,
599    ) {
600        if floor_tx >= self.range.end {
601            // Canonical empty form everywhere in this module: the interval
602            // collapses onto its reported terminal bound.
603            self.range = self.end_position..self.end_position;
604            return;
605        }
606        self.range.start = floor_tx;
607        if options.is_ascending() {
608            self.entry_checkpoint = self.entry_checkpoint.max(floor_checkpoint);
609        } else {
610            self.end_checkpoint = floor_checkpoint;
611            self.end_position = floor_tx;
612        }
613    }
614}
615
616impl EventScanBounds {
617    pub fn tx_span(start_tx: u64, end_tx: u64) -> Self {
618        Self {
619            lo: Bound::Included(EventPosition::start_of_tx(start_tx)),
620            hi: Bound::Excluded(EventPosition::start_of_tx(end_tx)),
621        }
622    }
623
624    pub fn empty_at(position: EventPosition) -> Self {
625        Self {
626            lo: Bound::Included(position),
627            hi: Bound::Excluded(position),
628        }
629    }
630
631    pub fn is_empty(&self) -> bool {
632        match (self.lo, self.hi) {
633            (Bound::Included(a), Bound::Excluded(b))
634            | (Bound::Excluded(a), Bound::Excluded(b))
635            | (Bound::Excluded(a), Bound::Included(b)) => a >= b,
636            (Bound::Included(a), Bound::Included(b)) => a > b,
637            (Bound::Unbounded, _) | (_, Bound::Unbounded) => false,
638        }
639    }
640
641    pub fn contains(&self, position: EventPosition) -> bool {
642        let above_lo = match self.lo {
643            Bound::Included(lo) => position >= lo,
644            Bound::Excluded(lo) => position > lo,
645            Bound::Unbounded => true,
646        };
647        let below_hi = match self.hi {
648            Bound::Included(hi) => position <= hi,
649            Bound::Excluded(hi) => position < hi,
650            Bound::Unbounded => true,
651        };
652        above_lo && below_hi
653    }
654
655    /// Smallest half-open tx range covering every position these bounds could
656    /// admit. An exclusive `hi` at the start of tx N excludes tx N entirely;
657    /// any other bounded endpoint keeps its transaction, since earlier events
658    /// of that tx may still be in bounds. `None` when no tx can qualify.
659    pub fn tx_range(&self) -> Option<Range<u64>> {
660        let start_tx = match self.lo {
661            Bound::Included(position) | Bound::Excluded(position) => position.tx_seq,
662            Bound::Unbounded => 0,
663        };
664        let end_tx = match self.hi {
665            Bound::Excluded(position) if position.event_index == 0 => position.tx_seq,
666            Bound::Included(position) | Bound::Excluded(position) => {
667                position.tx_seq.saturating_add(1)
668            }
669            Bound::Unbounded => u64::MAX,
670        };
671        (start_tx < end_tx).then_some(start_tx..end_tx)
672    }
673}
674
675impl ResolvedEventRange {
676    pub fn empty_at(
677        end_checkpoint: u64,
678        end_position: EventPosition,
679        exhaustion: RangeExhaustion,
680    ) -> Self {
681        Self {
682            bounds: EventScanBounds::empty_at(end_position),
683            end_checkpoint,
684            end_position,
685            exhaustion,
686            entry_checkpoint: end_checkpoint,
687        }
688    }
689
690    pub fn is_empty(&self) -> bool {
691        self.bounds.is_empty()
692    }
693
694    /// [`ResolvedRange::apply_serving_floor`]'s analogue for event scans: a
695    /// floor inside the bounds moves the low bound to the start of the floor
696    /// transaction (ascending entry rises to the floor checkpoint, a
697    /// descending terminal is pinned to it); a floor that empties the bounds
698    /// canonicalizes them to empty at the reported terminal boundary and
699    /// leaves the terminal metadata at the requested boundary.
700    pub fn apply_serving_floor(
701        &mut self,
702        floor_tx: u64,
703        floor_checkpoint: u64,
704        options: &QueryOptions,
705    ) {
706        let floored_lo = Bound::Included(EventPosition::start_of_tx(floor_tx));
707        let floored = EventScanBounds {
708            lo: floored_lo,
709            hi: self.bounds.hi,
710        };
711        if floored.is_empty() {
712            // Canonical empty form everywhere in this module: the interval
713            // collapses onto its reported terminal bound.
714            self.bounds = EventScanBounds::empty_at(self.end_position);
715            return;
716        }
717        self.bounds.lo = floored_lo;
718        if options.is_ascending() {
719            self.entry_checkpoint = self.entry_checkpoint.max(floor_checkpoint);
720        } else {
721            self.end_checkpoint = floor_checkpoint;
722            self.end_position = EventPosition::start_of_tx(floor_tx);
723        }
724    }
725}
726
727fn lower_bound_gte(candidate: Bound<EventPosition>, current: Bound<EventPosition>) -> bool {
728    let Some(candidate) = lower_bound_key(candidate) else {
729        return false;
730    };
731    match lower_bound_key(current) {
732        Some(current) => candidate >= current,
733        None => true,
734    }
735}
736
737fn lower_bound_key(bound: Bound<EventPosition>) -> Option<(EventPosition, u8)> {
738    match bound {
739        Bound::Included(position) => Some((position, 0)),
740        Bound::Excluded(position) => Some((position, 1)),
741        Bound::Unbounded => None,
742    }
743}
744
745fn hi_admits_upper_bound(current: Bound<EventPosition>, candidate: EventPosition) -> bool {
746    match current {
747        Bound::Included(position) | Bound::Excluded(position) => candidate <= position,
748        Bound::Unbounded => true,
749    }
750}
751
752impl CheckpointRange {
753    pub fn from_request(
754        start_checkpoint: Option<u64>,
755        end_checkpoint: Option<u64>,
756        checkpoint_hi_exclusive: u64,
757    ) -> Result<Self, RpcError> {
758        let start = start_checkpoint.unwrap_or(0);
759        if let Some(end) = end_checkpoint
760            && end < start
761        {
762            return Err(FieldViolation::new("end_checkpoint")
763                .with_description(
764                    "end_checkpoint must be greater than or equal to start_checkpoint",
765                )
766                .with_reason(ErrorReason::FieldInvalid)
767                .into());
768        }
769
770        let requested_end = end_checkpoint.unwrap_or(checkpoint_hi_exclusive);
771        let high_exhaustion = if end_checkpoint.is_none() || requested_end > checkpoint_hi_exclusive
772        {
773            RangeExhaustion::LedgerTip
774        } else {
775            RangeExhaustion::CheckpointBound
776        };
777        let end = requested_end.min(checkpoint_hi_exclusive);
778
779        Ok(Self {
780            start,
781            end,
782            high_exhaustion,
783            indexed_tip: checkpoint_hi_exclusive,
784        })
785    }
786
787    pub fn resolve(self, options: &QueryOptions) -> ResolvedCheckpointRange {
788        let mut start = self.start;
789        let mut end = self.end;
790        let mut low_exhaustion = RangeExhaustion::CheckpointBound;
791        let mut high_exhaustion = self.high_exhaustion;
792        let mut cursor_bound = false;
793
794        if let Some(cursor) = &options.after
795            && cursor.position.checkpoint() >= start
796        {
797            start = cursor.position.checkpoint();
798            cursor_bound = true;
799            if matches!(options.ordering, Ordering::Descending) {
800                low_exhaustion = RangeExhaustion::CursorBound {
801                    kind: sui_rpc_cursor::CursorKind::Boundary,
802                };
803            }
804        }
805
806        if let Some(cursor) = &options.before
807            && let Some(upper) = match cursor.kind {
808                sui_rpc_cursor::CursorKind::Item => cursor.position.checkpoint().checked_add(1),
809                sui_rpc_cursor::CursorKind::Boundary => Some(cursor.position.checkpoint()),
810            }
811            && upper <= end
812        {
813            end = upper;
814            cursor_bound = true;
815            if matches!(options.ordering, Ordering::Ascending) {
816                high_exhaustion = RangeExhaustion::CursorBound {
817                    kind: sui_rpc_cursor::CursorKind::Boundary,
818                };
819            }
820        }
821
822        if start >= self.indexed_tip {
823            return ResolvedCheckpointRange::empty_at(self.indexed_tip, RangeExhaustion::LedgerTip);
824        }
825
826        if start >= end {
827            let exhaustion = if cursor_bound {
828                RangeExhaustion::CursorBound {
829                    kind: sui_rpc_cursor::CursorKind::Boundary,
830                }
831            } else {
832                match options.ordering {
833                    Ordering::Ascending => high_exhaustion,
834                    Ordering::Descending => low_exhaustion,
835                }
836            };
837            let checkpoint = match options.ordering {
838                Ordering::Ascending => end,
839                Ordering::Descending => start,
840            };
841            return ResolvedCheckpointRange::empty_at(checkpoint, exhaustion);
842        }
843
844        let exhaustion = match options.ordering {
845            Ordering::Ascending => high_exhaustion,
846            Ordering::Descending => low_exhaustion,
847        };
848        ResolvedCheckpointRange {
849            range: start..end,
850            exhaustion,
851        }
852    }
853}
854
855fn parse_cursor(
856    field: &'static str,
857    cursor: Option<&Bytes>,
858    position_matches: fn(&Position) -> bool,
859) -> Result<Option<CursorToken>, RpcError> {
860    cursor
861        .map(|cursor| {
862            CursorToken::decode(cursor).map_err(|_| invalid_cursor(field, "invalid cursor"))
863        })
864        .transpose()?
865        .map(|token| {
866            if position_matches(&token.position) {
867                Ok(token)
868            } else {
869                Err(invalid_cursor(field, "invalid cursor"))
870            }
871        })
872        .transpose()
873}
874
875fn invalid_cursor(field: &'static str, description: impl Into<String>) -> RpcError {
876    FieldViolation::new(field)
877        .with_description(description)
878        .with_reason(ErrorReason::FieldInvalid)
879        .into()
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    fn query_options_from_proto(
887        request: Option<&ProtoQueryOptions>,
888    ) -> Result<QueryOptions, RpcError> {
889        QueryOptions::transactions_from_proto(request, 100, 1_000)
890    }
891
892    fn resolved_range(range: Range<u64>) -> ResolvedRange {
893        ResolvedRange {
894            range,
895            end_checkpoint: 20,
896            end_position: 20,
897            exhaustion: RangeExhaustion::CheckpointBound,
898            entry_checkpoint: 0,
899        }
900    }
901
902    fn tx_item(checkpoint: u64, tx_seq: u64) -> CursorToken {
903        CursorToken::item(Position::Transactions { checkpoint, tx_seq })
904    }
905
906    fn tx_boundary(checkpoint: u64, tx_seq: u64) -> CursorToken {
907        CursorToken::boundary(Position::Transactions { checkpoint, tx_seq })
908    }
909
910    fn cp_item(checkpoint: u64) -> CursorToken {
911        CursorToken::item(Position::Checkpoints { checkpoint })
912    }
913
914    fn directional_options(ascending: bool) -> QueryOptions {
915        let mut request = ProtoQueryOptions::default();
916        if !ascending {
917            request.ordering = Some(ProtoOrdering::Descending as i32);
918        }
919        query_options_from_proto(Some(&request)).unwrap()
920    }
921
922    /// Serving-floor reconciliation for tx intervals: a floor inside the
923    /// interval moves the scan start and the direction-relevant watermark
924    /// metadata; a floor at/past the high end empties the intersection, and
925    /// the terminal metadata stays at the requested boundary instead of
926    /// moving to a floor outside the requested interval.
927    #[test]
928    fn tx_serving_floor_reconciles_or_canonicalizes_empty() {
929        let asc = directional_options(true);
930        let desc = directional_options(false);
931
932        // Ascending, floor inside 0..100: scan starts at the floor; the
933        // entry claim rises to the floor checkpoint; terminal untouched.
934        let mut resolved = ResolvedRange {
935            range: 0..100,
936            end_checkpoint: 20,
937            end_position: 100,
938            exhaustion: RangeExhaustion::CheckpointBound,
939            entry_checkpoint: 0,
940        };
941        resolved.apply_serving_floor(50, 10, &asc);
942        assert_eq!(resolved.range, 50..100);
943        assert_eq!(resolved.entry_checkpoint, 10);
944        assert_eq!(resolved.end_checkpoint, 20);
945        assert_eq!(resolved.end_position, 100);
946
947        // Descending, floor inside: entry (the high edge) untouched; the
948        // terminal pins to the floor.
949        let mut resolved = ResolvedRange {
950            range: 0..100,
951            end_checkpoint: 0,
952            end_position: 0,
953            exhaustion: RangeExhaustion::CheckpointBound,
954            entry_checkpoint: 20,
955        };
956        resolved.apply_serving_floor(50, 10, &desc);
957        assert_eq!(resolved.range, 50..100);
958        assert_eq!(resolved.entry_checkpoint, 20);
959        assert_eq!(resolved.end_checkpoint, 10);
960        assert_eq!(resolved.end_position, 50);
961
962        // Floor at/past the high end (covers the == boundary), both
963        // directions: empty intersection, canonicalized at the reported
964        // terminal bound; no metadata moves. Descending, the terminal must
965        // NOT move to the floor (checkpoint 10 lies outside the requested
966        // interval).
967        for floor_tx in [40, 50] {
968            let mut resolved = ResolvedRange {
969                range: 0..40,
970                end_checkpoint: 8,
971                end_position: 40,
972                exhaustion: RangeExhaustion::CheckpointBound,
973                entry_checkpoint: 0,
974            };
975            resolved.apply_serving_floor(floor_tx, 10, &asc);
976            assert!(resolved.is_empty());
977            assert_eq!(resolved.range, 40..40);
978            assert_eq!(resolved.entry_checkpoint, 0);
979            assert_eq!(resolved.end_checkpoint, 8);
980            assert_eq!(resolved.end_position, 40);
981
982            let mut resolved = ResolvedRange {
983                range: 0..40,
984                end_checkpoint: 0,
985                end_position: 0,
986                exhaustion: RangeExhaustion::CheckpointBound,
987                entry_checkpoint: 8,
988            };
989            resolved.apply_serving_floor(floor_tx, 10, &desc);
990            assert!(resolved.is_empty());
991            assert_eq!(resolved.range, 0..0);
992            assert_eq!(resolved.entry_checkpoint, 8);
993            assert_eq!(resolved.end_checkpoint, 0);
994            assert_eq!(resolved.end_position, 0);
995        }
996    }
997
998    /// [`ResolvedEventRange::apply_serving_floor`] mirrors the tx behavior in
999    /// event coordinates.
1000    #[test]
1001    fn event_serving_floor_reconciles_or_canonicalizes_empty() {
1002        let asc = directional_options(true);
1003        let desc = directional_options(false);
1004
1005        // Ascending, floor inside: low bound moves, entry rises, terminal
1006        // untouched.
1007        let mut resolved = ResolvedEventRange {
1008            bounds: EventScanBounds::tx_span(0, 100),
1009            end_checkpoint: 20,
1010            end_position: EventPosition::start_of_tx(100),
1011            exhaustion: RangeExhaustion::CheckpointBound,
1012            entry_checkpoint: 0,
1013        };
1014        resolved.apply_serving_floor(50, 10, &asc);
1015        assert_eq!(
1016            resolved.bounds.lo,
1017            Bound::Included(EventPosition::start_of_tx(50))
1018        );
1019        assert_eq!(resolved.entry_checkpoint, 10);
1020        assert_eq!(resolved.end_checkpoint, 20);
1021        assert_eq!(resolved.end_position, EventPosition::start_of_tx(100));
1022
1023        // Descending, floor inside: terminal pins to the floor.
1024        let mut resolved = ResolvedEventRange {
1025            bounds: EventScanBounds::tx_span(0, 100),
1026            end_checkpoint: 0,
1027            end_position: EventPosition::start_of_tx(0),
1028            exhaustion: RangeExhaustion::CheckpointBound,
1029            entry_checkpoint: 20,
1030        };
1031        resolved.apply_serving_floor(50, 10, &desc);
1032        assert_eq!(
1033            resolved.bounds.lo,
1034            Bound::Included(EventPosition::start_of_tx(50))
1035        );
1036        assert_eq!(resolved.entry_checkpoint, 20);
1037        assert_eq!(resolved.end_checkpoint, 10);
1038        assert_eq!(resolved.end_position, EventPosition::start_of_tx(50));
1039
1040        // Floor that empties the bounds (covers the == boundary), both
1041        // directions: canonical empty at the reported terminal bound, no
1042        // metadata moves.
1043        for floor_tx in [40, 50] {
1044            let mut resolved = ResolvedEventRange {
1045                bounds: EventScanBounds::tx_span(0, 40),
1046                end_checkpoint: 8,
1047                end_position: EventPosition::start_of_tx(40),
1048                exhaustion: RangeExhaustion::CheckpointBound,
1049                entry_checkpoint: 0,
1050            };
1051            resolved.apply_serving_floor(floor_tx, 10, &asc);
1052            assert!(resolved.is_empty());
1053            assert_eq!(
1054                resolved.bounds,
1055                EventScanBounds::empty_at(EventPosition::start_of_tx(40))
1056            );
1057            assert_eq!(resolved.entry_checkpoint, 0);
1058            assert_eq!(resolved.end_checkpoint, 8);
1059            assert_eq!(resolved.end_position, EventPosition::start_of_tx(40));
1060
1061            let mut resolved = ResolvedEventRange {
1062                bounds: EventScanBounds::tx_span(0, 40),
1063                end_checkpoint: 0,
1064                end_position: EventPosition::start_of_tx(0),
1065                exhaustion: RangeExhaustion::CheckpointBound,
1066                entry_checkpoint: 8,
1067            };
1068            resolved.apply_serving_floor(floor_tx, 10, &desc);
1069            assert!(resolved.is_empty());
1070            assert_eq!(
1071                resolved.bounds,
1072                EventScanBounds::empty_at(EventPosition::start_of_tx(0))
1073            );
1074            assert_eq!(resolved.entry_checkpoint, 8);
1075            assert_eq!(resolved.end_checkpoint, 0);
1076            assert_eq!(resolved.end_position, EventPosition::start_of_tx(0));
1077        }
1078    }
1079
1080    #[test]
1081    fn tx_range_covers_partial_endpoint_transactions() {
1082        let bounds = EventScanBounds {
1083            lo: Bound::Included(EventPosition {
1084                tx_seq: 10,
1085                event_index: 2,
1086            }),
1087            hi: Bound::Excluded(EventPosition::start_of_tx(13)),
1088        };
1089
1090        assert_eq!(bounds.tx_range(), Some(10..13));
1091    }
1092
1093    #[test]
1094    fn tx_range_keeps_tx_of_nonzero_exclusive_hi() {
1095        let bounds = EventScanBounds {
1096            lo: Bound::Unbounded,
1097            hi: Bound::Excluded(EventPosition {
1098                tx_seq: 13,
1099                event_index: 1,
1100            }),
1101        };
1102
1103        assert_eq!(bounds.tx_range(), Some(0..14));
1104    }
1105
1106    #[test]
1107    fn tx_range_empty_bounds_yield_none() {
1108        let bounds = EventScanBounds::tx_span(10, 10);
1109        assert_eq!(bounds.tx_range(), None);
1110    }
1111
1112    #[test]
1113    fn parses_cursors_and_ordering() {
1114        let after = tx_item(2, 20).encode();
1115        let before = tx_item(3, 30).encode();
1116        let mut request = ProtoQueryOptions::default();
1117        request.limit = Some(500);
1118        request.after = Some(after);
1119        request.before = Some(before);
1120        request.ordering = Some(ProtoOrdering::Descending as i32);
1121
1122        let options = query_options_from_proto(Some(&request)).unwrap();
1123
1124        assert_eq!(options.limit_items, 500);
1125        assert_eq!(options.ordering, Ordering::Descending);
1126        assert_eq!(options.scan_direction(), ScanDirection::Descending);
1127        assert_eq!(
1128            options.apply_cursor_bounds(resolved_range(0..100)).range,
1129            21..30
1130        );
1131    }
1132
1133    #[test]
1134    fn has_after_cursor_reflects_only_the_after_field() {
1135        // No cursors → open-ended low end.
1136        let options = query_options_from_proto(Some(&ProtoQueryOptions::default())).unwrap();
1137        assert!(!options.has_after_cursor());
1138
1139        // `before` bounds the high end, so it must not count as an explicit low end.
1140        let mut request = ProtoQueryOptions::default();
1141        request.before = Some(tx_item(3, 30).encode());
1142        let options = query_options_from_proto(Some(&request)).unwrap();
1143        assert!(!options.has_after_cursor());
1144
1145        // `after` raises the low end → explicit.
1146        let mut request = ProtoQueryOptions::default();
1147        request.after = Some(tx_item(2, 20).encode());
1148        let options = query_options_from_proto(Some(&request)).unwrap();
1149        assert!(options.has_after_cursor());
1150    }
1151
1152    #[test]
1153    fn clamps_limit_items_and_defaults_to_ascending() {
1154        let mut request = ProtoQueryOptions::default();
1155        request.limit = Some(5_000);
1156
1157        let options = query_options_from_proto(Some(&request)).unwrap();
1158
1159        assert_eq!(options.limit_items, 1_000);
1160        assert_eq!(options.ordering, Ordering::Ascending);
1161        assert_eq!(options.scan_direction(), ScanDirection::Ascending);
1162    }
1163
1164    #[test]
1165    fn rejects_malformed_cursors_and_unknown_ordering() {
1166        let mut request = ProtoQueryOptions::default();
1167        request.after = Some(Bytes::from_static(b"short"));
1168        assert!(query_options_from_proto(Some(&request)).is_err());
1169
1170        let mut request = ProtoQueryOptions::default();
1171        request.before = Some(Bytes::from_static(b"short"));
1172        assert!(query_options_from_proto(Some(&request)).is_err());
1173
1174        let mut request = ProtoQueryOptions::default();
1175        request.ordering = Some(99);
1176        assert!(query_options_from_proto(Some(&request)).is_err());
1177    }
1178
1179    #[test]
1180    fn rejects_cursor_for_different_position_variant() {
1181        let token = cp_item(9).encode();
1182        let mut request = ProtoQueryOptions::default();
1183        request.after = Some(token);
1184        assert!(query_options_from_proto(Some(&request)).is_err());
1185    }
1186
1187    #[test]
1188    fn accepts_cursor_regardless_of_filter_scope() {
1189        // Cursors are portable across filters: a Transactions cursor must be
1190        // accepted by a Transactions query even though `query_options_from_proto`
1191        // applies no filter. Position is an absolute, filter-independent
1192        // coordinate, so resuming under a different filter is correct.
1193        let after = tx_item(1, 9).encode();
1194        let before = tx_item(3, 30).encode();
1195        let mut request = ProtoQueryOptions::default();
1196        request.after = Some(after);
1197        request.before = Some(before);
1198        assert!(query_options_from_proto(Some(&request)).is_ok());
1199    }
1200
1201    #[test]
1202    fn accepts_cursors_for_different_checkpoint_range_and_ordering() {
1203        let token = tx_item(9, 9).encode();
1204        let mut request = ProtoQueryOptions::default();
1205        request.after = Some(token);
1206        request.ordering = Some(ProtoOrdering::Descending as i32);
1207
1208        let options = query_options_from_proto(Some(&request)).unwrap();
1209        let range = CheckpointRange::from_request(Some(1_000), Some(1_100), 2_000).unwrap();
1210
1211        assert_eq!(range.resolve(&options).range, 1_000..1_100);
1212    }
1213
1214    #[test]
1215    fn applies_canonical_cursor_bounds() {
1216        let options = QueryOptions {
1217            limit_items: 2,
1218            ordering: Ordering::Ascending,
1219            after: Some(tx_item(1, 11)),
1220            before: None,
1221        };
1222        assert_eq!(
1223            options.apply_cursor_bounds(resolved_range(10..20)).range,
1224            12..20
1225        );
1226
1227        let options = QueryOptions {
1228            after: Some(tx_item(1, u64::MAX)),
1229            ..options
1230        };
1231        assert_eq!(
1232            options.apply_cursor_bounds(resolved_range(10..20)),
1233            ResolvedRange::empty_at(
1234                1,
1235                u64::MAX,
1236                RangeExhaustion::CursorBound {
1237                    kind: sui_rpc_cursor::CursorKind::Boundary,
1238                },
1239            )
1240        );
1241
1242        let options = QueryOptions {
1243            ordering: Ordering::Descending,
1244            after: Some(tx_item(1, 11)),
1245            before: Some(tx_item(1, 19)),
1246            ..options
1247        };
1248        let bounded = options.apply_cursor_bounds(resolved_range(10..20));
1249        assert_eq!(bounded.range, 12..19);
1250        assert_eq!(
1251            bounded.exhaustion,
1252            RangeExhaustion::CursorBound {
1253                kind: sui_rpc_cursor::CursorKind::Boundary,
1254            }
1255        );
1256        assert_eq!(bounded.end_position, 12);
1257
1258        let options = QueryOptions {
1259            before: Some(tx_item(1, 12)),
1260            ..options
1261        };
1262        assert_eq!(
1263            options.apply_cursor_bounds(resolved_range(10..20)),
1264            ResolvedRange {
1265                entry_checkpoint: 0,
1266                ..ResolvedRange::empty_at(
1267                    1,
1268                    12,
1269                    RangeExhaustion::CursorBound {
1270                        kind: sui_rpc_cursor::CursorKind::Boundary,
1271                    },
1272                )
1273            }
1274        );
1275    }
1276
1277    #[test]
1278    fn applies_boundary_cursor_bounds_without_item_offset() {
1279        let options = QueryOptions {
1280            limit_items: 2,
1281            ordering: Ordering::Ascending,
1282            after: Some(tx_boundary(2, 20)),
1283            before: None,
1284        };
1285        assert_eq!(
1286            options.apply_cursor_bounds(resolved_range(10..30)).range,
1287            20..30
1288        );
1289
1290        let options = QueryOptions {
1291            ordering: Ordering::Descending,
1292            after: None,
1293            before: Some(tx_boundary(2, 20)),
1294            ..options
1295        };
1296        assert_eq!(
1297            options.apply_cursor_bounds(resolved_range(10..30)).range,
1298            10..20
1299        );
1300    }
1301
1302    #[test]
1303    fn resolves_checkpoint_range_with_terminal_reason() {
1304        assert_eq!(
1305            CheckpointRange::from_request(None, None, 20)
1306                .unwrap()
1307                .resolve(&query_options_from_proto(None).unwrap())
1308                .exhaustion,
1309            RangeExhaustion::LedgerTip
1310        );
1311        assert!(CheckpointRange::from_request(Some(10), Some(9), 20).is_err());
1312
1313        let range = CheckpointRange::from_request(Some(10), None, 20).unwrap();
1314        let resolved = range.resolve(&query_options_from_proto(None).unwrap());
1315        assert_eq!(resolved.range, 10..20);
1316        assert_eq!(resolved.exhaustion, RangeExhaustion::LedgerTip);
1317
1318        let range = CheckpointRange::from_request(Some(30), None, 20).unwrap();
1319        assert_eq!(
1320            range.resolve(&query_options_from_proto(None).unwrap()),
1321            ResolvedCheckpointRange::empty_at(20, RangeExhaustion::LedgerTip)
1322        );
1323    }
1324
1325    /// The cp-width clamp was removed when scan limiting moved to the runtime
1326    /// bucket-budget path. Whatever range the request asks for is honored at
1327    /// resolve time; the bitmap layer terminates scans on budget exhaustion.
1328    #[test]
1329    fn resolves_checkpoint_range_no_longer_clamped_by_width() {
1330        let options = query_options_from_proto(None).unwrap();
1331        let range = CheckpointRange::from_request(Some(10), Some(10_000_000), 10_000_000).unwrap();
1332        let resolved = range.resolve(&options);
1333        assert_eq!(resolved.range, 10..10_000_000);
1334        assert_eq!(resolved.exhaustion, RangeExhaustion::CheckpointBound);
1335    }
1336
1337    #[test]
1338    fn event_after_item_empty_interval_retains_item_kind() {
1339        let position = Position::Events {
1340            checkpoint: 1,
1341            tx_seq: 3,
1342            event_index: 0,
1343        };
1344        let resolved = ResolvedEventRange {
1345            bounds: EventScanBounds::tx_span(0, 3),
1346            end_checkpoint: 1,
1347            end_position: EventPosition::start_of_tx(3),
1348            exhaustion: RangeExhaustion::CheckpointBound,
1349            entry_checkpoint: 0,
1350        };
1351
1352        let mut request = ProtoQueryOptions::default();
1353        request.after = Some(CursorToken::item(position).encode());
1354        let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1355        let item_bounded = options.apply_event_cursor_bounds(resolved.clone());
1356
1357        assert!(item_bounded.is_empty());
1358        assert_eq!(
1359            item_bounded.end_position,
1360            EventPosition {
1361                tx_seq: 3,
1362                event_index: 0,
1363            }
1364        );
1365        assert_eq!(
1366            item_bounded.exhaustion,
1367            RangeExhaustion::CursorBound {
1368                kind: sui_rpc_cursor::CursorKind::Item,
1369            }
1370        );
1371
1372        request.after = Some(CursorToken::boundary(position).encode());
1373        let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1374        let boundary_bounded = options.apply_event_cursor_bounds(resolved);
1375
1376        assert!(boundary_bounded.is_empty());
1377        assert_eq!(
1378            boundary_bounded.end_position,
1379            EventPosition {
1380                tx_seq: 3,
1381                event_index: 0,
1382            }
1383        );
1384        assert_eq!(
1385            boundary_bounded.exhaustion,
1386            RangeExhaustion::CursorBound {
1387                kind: sui_rpc_cursor::CursorKind::Boundary,
1388            }
1389        );
1390    }
1391
1392    #[test]
1393    fn cursor_fold_advances_entry_checkpoint() {
1394        let ascending = QueryOptions {
1395            limit_items: 100,
1396            ordering: Ordering::Ascending,
1397            after: Some(tx_item(7, 30)),
1398            before: None,
1399        };
1400        let resolved = ResolvedRange {
1401            range: 20..40,
1402            end_checkpoint: 9,
1403            end_position: 40,
1404            exhaustion: RangeExhaustion::CheckpointBound,
1405            entry_checkpoint: 5,
1406        };
1407        assert_eq!(
1408            ascending
1409                .apply_cursor_bounds(resolved.clone())
1410                .entry_checkpoint,
1411            7
1412        );
1413
1414        let descending = QueryOptions {
1415            limit_items: 100,
1416            ordering: Ordering::Descending,
1417            after: None,
1418            before: Some(tx_boundary(7, 30)),
1419        };
1420        let resolved = ResolvedRange {
1421            entry_checkpoint: 9,
1422            ..resolved
1423        };
1424        assert_eq!(descending.apply_cursor_bounds(resolved).entry_checkpoint, 7);
1425    }
1426
1427    #[test]
1428    fn item_cursor_can_be_used_as_after_or_before() {
1429        let token = CursorToken::item(Position::Transactions {
1430            checkpoint: 1,
1431            tx_seq: 11,
1432        })
1433        .encode();
1434
1435        let mut request = ProtoQueryOptions::default();
1436        request.after = Some(token.clone());
1437        let options = query_options_from_proto(Some(&request)).unwrap();
1438        assert_eq!(
1439            options.apply_cursor_bounds(resolved_range(10..20)).range,
1440            12..20
1441        );
1442
1443        request.after = None;
1444        request.before = Some(token);
1445        let options = query_options_from_proto(Some(&request)).unwrap();
1446        assert_eq!(
1447            options.apply_cursor_bounds(resolved_range(10..20)).range,
1448            10..11
1449        );
1450    }
1451}