diff --git a/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs b/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs
index 57902b35e5c9..54ed3c12bb62 100644
--- a/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs
+++ b/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs
@@ -992,6 +992,88 @@ async fn test_list_transactions_unfiltered_and_sender_filter() {
);
}
+/// (after = item n, before = item n + 1) serves nothing and ends with a CursorBound frame from the
+/// ascending stop side (before cursor.)
+#[sim_test]
+async fn test_list_transactions_after_before_adjacent_empty_results() {
+ let cluster = new_cluster().await;
+ let sender = cluster.get_address_0();
+ let tx = transfer_self(&cluster, sender).await;
+ let (start, end) = checkpoint_range(&[&tx]);
+
+ let mut client = new_ledger_client(&cluster).await;
+
+ let mut req = ListTransactionsRequest::default();
+ req.read_mask = Some(FieldMask::from_paths(["digest"]));
+ req.start_checkpoint = Some(start);
+ req.end_checkpoint = Some(end);
+ req.options = Some(query_options(100));
+ let baseline = list_transactions_result(&mut client, req).await;
+ assert!(baseline.end);
+ assert!(
+ baseline.transactions.len() >= 2,
+ "need at least two transactions for adjacency windows"
+ );
+
+ let after = first_transaction_cursor(&baseline, "first item cursor");
+ let before = baseline.transactions[1]
+ .watermark
+ .as_ref()
+ .and_then(|w| w.cursor.clone())
+ .expect("second item cursor");
+ let mut req = ListTransactionsRequest::default();
+ req.read_mask = Some(FieldMask::from_paths(["digest"]));
+ req.start_checkpoint = Some(start);
+ req.end_checkpoint = Some(end);
+ req.options = Some(query_options_between(3, after.clone(), before));
+ let resp = list_transactions_result(&mut client, req).await;
+ assert!(resp.transactions.is_empty());
+ assert!(resp.end);
+ assert_eq!(resp.end_reason, Some(QueryEndReason::CursorBound));
+ // The frame is the before side's stamp (a Boundary token at its coordinate,
+ // pinned exactly at unit level); opaquely: present, and not the after side.
+ assert!(resp.end_cursor.is_some());
+ assert_ne!(resp.end_cursor, Some(after));
+}
+
+/// When the `after` cursor falls on or after the last item of the window, nothing is served and
+/// ends in CheckpointBound frame.
+#[sim_test]
+async fn test_list_transactions_after_last_item_ends_at_window_bound() {
+ let cluster = new_cluster().await;
+ let sender = cluster.get_address_0();
+ let tx = transfer_self(&cluster, sender).await;
+ let (start, end) = checkpoint_range(&[&tx]);
+
+ let mut client = new_ledger_client(&cluster).await;
+
+ let mut req = ListTransactionsRequest::default();
+ req.read_mask = Some(FieldMask::from_paths(["digest"]));
+ req.start_checkpoint = Some(start);
+ req.end_checkpoint = Some(end);
+ req.options = Some(query_options(100));
+ let baseline = list_transactions_result(&mut client, req).await;
+ assert!(baseline.end);
+
+ let last_cursor = last_transaction_cursor(&baseline, "last item cursor");
+
+ let mut req = ListTransactionsRequest::default();
+ req.read_mask = Some(FieldMask::from_paths(["digest"]));
+ req.start_checkpoint = Some(start);
+ req.end_checkpoint = Some(end);
+ req.options = Some(query_options_after(3, last_cursor.clone()));
+ let resp = list_transactions_result(&mut client, req).await;
+
+ assert!(resp.transactions.is_empty());
+ assert!(resp.end);
+ assert_eq!(resp.end_reason, Some(QueryEndReason::CheckpointBound));
+ assert_ne!(
+ resp.end_cursor,
+ Some(last_cursor),
+ "the drain frame carries the window's cursor, not the request echo"
+ );
+}
+
#[sim_test]
async fn test_list_transactions_rich_mask_matches_get_transaction() {
let cluster = new_cluster().await;
diff --git a/crates/sui-rpc-api/src/ledger_history/query_options.rs b/crates/sui-rpc-api/src/ledger_history/query_options.rs
index 6e7db5d17849..1537dc70a023 100644
--- a/crates/sui-rpc-api/src/ledger_history/query_options.rs
+++ b/crates/sui-rpc-api/src/ledger_history/query_options.rs
@@ -100,12 +100,9 @@ pub struct ResolvedScan
{
}
/// How a lane reads wire cursors: the token's coordinate in the lane's
-/// position space, and the lo-bound its resume admits.
+/// position space.
pub trait ScanCursor
{
fn coordinate(&self) -> P;
- /// Dense lanes canonicalize an Item cursor to its inclusive successor;
- /// lanes without a successor keep the exclusive raw bound.
- fn resume_lo(&self) -> Bound
;
}
impl IntraTxCoordinate {
@@ -422,7 +419,13 @@ where
self.entry_checkpoint = self.entry_checkpoint.max(checkpoint);
}
- let candidate = cursor.resume_lo();
+ // Symbolic resume in every lane: an Item admits strictly-after, a
+ // Boundary admits from itself; successor arithmetic exists only at
+ // the store edge.
+ let candidate = match cursor.kind {
+ sui_rpc_cursor::CursorKind::Item => Bound::Excluded(position),
+ sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position),
+ };
if !lower_bound_gte(candidate, self.bounds.lo) {
return None;
@@ -632,18 +635,6 @@ impl ScanCursor for CursorToken {
Position::Events { .. } => unreachable!("validated at decode"),
}
}
-
- /// Item cursors resume at their inclusive successor. Saturating at
- /// `u64::MAX` is exact, not lossy: no half-open scan range can contain an
- /// item at MAX, so `Included(MAX)` admits nothing and the interval
- /// resolves empty through the ordinary bounds check.
- fn resume_lo(&self) -> Bound {
- let position: u64 = self.coordinate();
- match self.kind {
- sui_rpc_cursor::CursorKind::Item => Bound::Included(position.saturating_add(1)),
- sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position),
- }
- }
}
impl ScanCursor for CursorToken {
@@ -660,14 +651,6 @@ impl ScanCursor for CursorToken {
_ => unreachable!("validated at decode"),
}
}
-
- fn resume_lo(&self) -> Bound {
- let position: IntraTxCoordinate = self.coordinate();
- match self.kind {
- sui_rpc_cursor::CursorKind::Item => Bound::Excluded(position),
- sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position),
- }
- }
}
impl From for (u64, u32) {
@@ -1034,6 +1017,25 @@ mod tests {
assert_eq!(bounds.tx_range(), None);
}
+ /// Every after-Item cursor now resumes as an Excluded lo; the successor
+ /// arithmetic and its `u64::MAX` saturation live here at the store edge.
+ #[test]
+ fn to_range_collapses_excluded_lo_at_successor() {
+ let bounds = ScanBounds {
+ lo: Bound::Excluded(14u64),
+ hi: Bound::Excluded(20u64),
+ };
+ assert_eq!(bounds.to_range(), 15..20);
+
+ // The saturated successor at the unoccupiable sentinel admits
+ // nothing, through the ordinary emptiness of MAX..MAX.
+ let bounds = ScanBounds {
+ lo: Bound::Excluded(u64::MAX),
+ hi: Bound::Unbounded,
+ };
+ assert!(bounds.to_range().is_empty());
+ }
+
#[test]
fn parses_cursors_and_ordering() {
let after = tx_item(2, 20).encode();
@@ -1181,24 +1183,36 @@ mod tests {
}
);
assert_eq!(bounded.end_position, 11);
+ }
+ // (descending, after = Item 11, before = Item 12): the cursors leave the
+ // empty gap (11, 12), so the fetch serves nothing and the CursorBound
+ // frame comes from the descending stop side (the after cursor, at 11).
+ #[test]
+ fn descending_before_adjacent_to_after_empties_at_after_cursor() {
let options = QueryOptions {
+ limit_items: 2,
+ ordering: Ordering::Descending,
+ after: Some(tx_item(1, 11)),
before: Some(tx_item(1, 12)),
- ..options
};
+ let crossed = resolved_range(10..20).apply_cursor_bounds(&options);
assert_eq!(
- resolved_range(10..20).apply_cursor_bounds(&options),
+ crossed,
ResolvedScan {
+ bounds: ScanBounds {
+ lo: Bound::Excluded(11),
+ hi: Bound::Excluded(12),
+ },
entry_checkpoint: 0,
- ..empty_resolved_range(
- 1,
- 12,
- RangeExhaustion::CursorBound {
- kind: sui_rpc_cursor::CursorKind::Boundary,
- },
- )
+ end_checkpoint: 1,
+ end_position: 11,
+ exhaustion: RangeExhaustion::CursorBound {
+ kind: sui_rpc_cursor::CursorKind::Boundary,
+ },
}
);
+ assert!(crossed.range().is_empty());
}
#[test]
@@ -1909,26 +1923,37 @@ mod tests {
);
}
- /// (ascending, after inside the window): Item admits strictly after its
- /// coordinate, Boundary from it; entry checkpoint rises; terminal untouched.
+ // An after-Item cursor makes the exclusive lower bound one past the cursor, while an
+ // after-Boundary cursor makes the lower bound equal to the cursor.
#[test]
fn tx_after_cursor_tightens_ascending_lower_bound() {
- // Item at n resumes at n + 1; Boundary at n resumes at n.
+ // Item at n admits strictly-after symbolically; the successor lives
+ // at the store edge, so both forms fetch from n + 1.
let options = after_options(Ordering::Ascending, tx_item(3, 12));
+ // Resolves to `(12, 20)`
+ let tightened = resolved_range(10..20).apply_cursor_bounds(&options);
assert_eq!(
- resolved_range(10..20).apply_cursor_bounds(&options),
+ tightened,
ResolvedScan {
- bounds: ScanBounds::from_range(13..20),
+ bounds: ScanBounds {
+ lo: Bound::Excluded(12),
+ hi: Bound::Excluded(20),
+ },
entry_checkpoint: 3,
..resolved_range(10..20)
}
);
+ // Projection into Range sets the lower bound as n + 1
+ assert_eq!(tightened.range(), 13..20);
let options = after_options(Ordering::Ascending, tx_boundary(3, 12));
assert_eq!(
resolved_range(10..20).apply_cursor_bounds(&options),
ResolvedScan {
- bounds: ScanBounds::from_range(12..20),
+ bounds: ScanBounds {
+ lo: Bound::Included(12),
+ hi: Bound::Excluded(20),
+ },
entry_checkpoint: 3,
..resolved_range(10..20)
}
@@ -1945,7 +1970,10 @@ mod tests {
assert_eq!(
resolved_range(10..20).apply_cursor_bounds(&options),
ResolvedScan {
- bounds: ScanBounds::from_range(13..20),
+ bounds: ScanBounds {
+ lo: Bound::Excluded(12),
+ hi: Bound::Excluded(20),
+ },
entry_checkpoint: 0,
end_checkpoint: 3,
end_position: 12,
@@ -2107,31 +2135,6 @@ mod tests {
);
}
- #[test]
- fn item_cursor_can_be_used_as_after_or_before() {
- let token = CursorToken::item(Position::Transactions {
- checkpoint: 1,
- tx_seq: 11,
- })
- .encode();
-
- let mut request = ProtoQueryOptions::default();
- request.after = Some(token.clone());
- let options = query_options_from_proto(Some(&request)).unwrap();
- assert_eq!(
- resolved_range(10..20).apply_cursor_bounds(&options).range(),
- 12..20
- );
-
- request.after = None;
- request.before = Some(token);
- let options = query_options_from_proto(Some(&request)).unwrap();
- assert_eq!(
- resolved_range(10..20).apply_cursor_bounds(&options).range(),
- 10..11
- );
- }
-
/// Resume-equivalence witness for the raw cursor echo: an after-Item at n
/// admits exactly what the successor-form echo it replaced (Boundary at
/// n + 1) admits, so feeding either token back scans the same interval.
@@ -2143,12 +2146,12 @@ mod tests {
let item = after_options(Ordering::Ascending, tx_item(3, 24));
let successor = after_options(Ordering::Ascending, tx_boundary(3, 25));
- // Non-empty remainder: identical scan bounds either way.
+ // Item(3, 24) results in the same range as Boundary(3, 25)
assert_eq!(
- resolved_range(10..30).apply_cursor_bounds(&item).bounds,
+ resolved_range(10..30).apply_cursor_bounds(&item).range(),
resolved_range(10..30)
.apply_cursor_bounds(&successor)
- .bounds,
+ .range(),
);
// Drained window: both resumptions admit nothing.
@@ -2159,4 +2162,90 @@ mod tests {
.is_empty()
);
}
+
+ /// For some ResolvedScan, if its bounds are Excluded(A) and Excluded(A + 1), its bounds
+ /// report non-empty, but its range is empty.
+ /// `test_list_transactions_after_last_item_ends_at_window_bound` checks that the result set is
+ /// empty.
+ #[test]
+ fn tx_after_item_at_window_edge_defers_to_drain() {
+ let options = after_options(Ordering::Ascending, tx_item(5, 19));
+ let initial_range = resolved_range(10..20);
+ let resolved = initial_range.clone().apply_cursor_bounds(&options);
+ assert_eq!(
+ resolved,
+ ResolvedScan {
+ bounds: ScanBounds {
+ lo: Bound::Excluded(19),
+ hi: Bound::Excluded(20),
+ },
+ entry_checkpoint: 5,
+ ..initial_range
+ }
+ );
+ assert!(!resolved.is_empty());
+ assert!(resolved.range().is_empty());
+ }
+
+ /// Exclusive after on descending bumps the bounds, and sets the end_checkpoint, end_position,
+ /// and exhaustion to the after cursor.
+ #[test]
+ fn tx_after_item_at_window_edge_descending_keeps_stamped_terminal() {
+ let options = after_options(Ordering::Descending, tx_item(5, 19));
+ let initial_range = resolved_range(10..20);
+ let resolved = initial_range.clone().apply_cursor_bounds(&options);
+ assert_eq!(
+ resolved,
+ ResolvedScan {
+ bounds: ScanBounds {
+ lo: Bound::Excluded(19),
+ hi: Bound::Excluded(20),
+ },
+ end_checkpoint: 5,
+ end_position: 19,
+ exhaustion: RangeExhaustion::CursorBound {
+ kind: sui_rpc_cursor::CursorKind::Boundary,
+ },
+ ..initial_range
+ }
+ );
+ assert!(resolved.range().is_empty());
+ }
+
+ /// On a descending scan, when the after-Item cursor is exactly on the terminating window, the
+ /// terminal values remain unchanged; the cursor does not overwrite the original bounds as it is
+ /// not additionally restrictive.
+ #[test]
+ fn tx_after_item_below_window_start_descending_is_noop() {
+ let options = after_options(Ordering::Descending, tx_item(5, 9));
+ assert_eq!(
+ resolved_range(10..20).apply_cursor_bounds(&options),
+ resolved_range(10..20)
+ );
+ }
+
+ #[test]
+ fn item_cursor_can_be_used_as_after_or_before() {
+ let token = CursorToken::item(Position::Transactions {
+ checkpoint: 1,
+ tx_seq: 11,
+ })
+ .encode();
+
+ let mut request = ProtoQueryOptions::default();
+ request.after = Some(token.clone());
+ let options = query_options_from_proto(Some(&request)).unwrap();
+ assert_eq!(
+ resolved_range(10..20).apply_cursor_bounds(&options).range(),
+ 12..20
+ );
+
+ request.after = None;
+ request.before = Some(token);
+ let options = query_options_from_proto(Some(&request)).unwrap();
+ assert_eq!(
+ resolved_range(10..20).apply_cursor_bounds(&options).range(),
+ 10..11
+ );
+ }
}