sui_rpc/light_client/client.rs
1//! The end-to-end `LightClient` facade.
2
3use sui_crypto::bls12381::ValidatorCommitteeSignatureVerifier;
4use sui_sdk_types::Address;
5use sui_sdk_types::Object;
6use sui_sdk_types::ObjectReference;
7use sui_sdk_types::SignedCheckpointSummary;
8use sui_sdk_types::ValidatorCommittee;
9use sui_sdk_types::proof::OcsInclusionProof;
10use sui_sdk_types::proof::OcsNonInclusionProof;
11
12use crate::Client;
13use crate::field::FieldMask;
14use crate::field::FieldMaskUtil;
15use crate::proto::TryFromProtoError;
16use crate::proto::sui::rpc::v2::GetCheckpointRequest;
17use crate::proto::sui::rpc::v2alpha::GetCheckpointObjectProofRequest;
18use crate::proto::sui::rpc::v2alpha::get_checkpoint_object_proof_response;
19
20use super::EpochCache;
21use super::RatchetConfig;
22use super::error::LightClientError;
23use super::error::ObjectDataMismatch;
24use super::ratchet::ratchet_to_checkpoint_with_config;
25
26/// The authenticated outcome of [`LightClient::prove_object_at_checkpoint`].
27///
28/// [`Inclusion`] means the checkpoint modified the object id: the
29/// reference is authenticated by the OCS Merkle proof against the
30/// checkpoint's BLS-verified summary. `object` is `Some` if the
31/// modification left the object live (created, mutated, or unwrapped)
32/// and BCS-decodes to a value whose `(id, version, digest)` matches
33/// `object_ref`. `object` is `None` if the modification was a deletion
34/// or wrap; in that case `object_ref.digest()` carries the framework's
35/// sentinel digest for that operation, and the server omits the
36/// `object_data` bytes (there is no live object state to authenticate).
37///
38/// [`NonInclusion`] means the checkpoint did **not** modify this
39/// object id — cryptographically attested via a sorted-leaf
40/// id-strict-bracketing non-inclusion proof.
41///
42/// **`NonInclusion` does not prove the object doesn't exist on chain.**
43/// An object that was last modified in an earlier checkpoint and was
44/// untouched at the requested checkpoint will produce a
45/// `NonInclusion` result. Callers that need the object's state at a
46/// checkpoint when it wasn't modified there must use a separate query
47/// — for example, ratchet back to the most recent modification, or use
48/// an unauthenticated read via
49/// [`LedgerService.GetObject`](crate::proto::sui::rpc::v2::ledger_service_client::LedgerServiceClient).
50/// An authenticated "current state at this checkpoint when unmodified"
51/// query is future work tied to a different commitment scheme.
52///
53/// [`Inclusion`]: CheckpointObjectProof::Inclusion
54/// [`NonInclusion`]: CheckpointObjectProof::NonInclusion
55#[derive(Debug)]
56#[non_exhaustive]
57pub enum CheckpointObjectProof {
58 /// The checkpoint modified the requested object id.
59 Inclusion {
60 /// The authenticated reference for the modified object.
61 object_ref: ObjectReference,
62 /// The decoded object at the version this checkpoint
63 /// committed to, or `None` if the modification was a deletion
64 /// or wrap.
65 //
66 // Boxed because `Object` is several hundred bytes and the
67 // `NonInclusion` variant carries no data; keeping `Object`
68 // inline would inflate every result by the full
69 // `Inclusion`-payload size for callers that get a
70 // `NonInclusion`.
71 object: Option<Box<Object>>,
72 },
73 /// The checkpoint did not modify the requested object id. See the
74 /// type-level doc for what this does and does not attest.
75 NonInclusion,
76}
77
78/// A light client that authenticates state against a trusted validator
79/// committee.
80///
81/// `LightClient` owns a [`Client`] for talking to a Sui gRPC endpoint and
82/// an [`EpochCache`] seeded with a starting committee. Each verification
83/// call advances the cache forward as far as needed (BLS-verifying each
84/// epoch transition along the way), verifies the response's checkpoint
85/// summary against the now-cached committee, and finally verifies the
86/// returned proof against the trusted summary.
87pub struct LightClient {
88 rpc: Client,
89 archive: Option<Client>,
90 cache: EpochCache,
91 ratchet_config: RatchetConfig,
92}
93
94impl LightClient {
95 /// Build a new `LightClient` seeded with `starting_committee`.
96 ///
97 /// The starting committee must be obtained out of band (e.g. baked
98 /// into the application, or read from a trusted source). It need
99 /// not be the genesis committee — a client that only cares about
100 /// recent state can seed the cache with a known-trusted committee
101 /// at a later epoch (see the `bundled-trust-anchors` feature) and
102 /// skip ratcheting through every prior epoch.
103 pub fn new(rpc: Client, starting_committee: ValidatorCommittee) -> Self {
104 Self {
105 rpc,
106 archive: None,
107 cache: EpochCache::new(starting_committee),
108 ratchet_config: RatchetConfig::default(),
109 }
110 }
111
112 /// Override the [`RatchetConfig`] used when this client advances its
113 /// epoch cache. Defaults to [`RatchetConfig::default`].
114 pub fn with_ratchet_config(mut self, config: RatchetConfig) -> Self {
115 self.ratchet_config = config;
116 self
117 }
118
119 /// Attach an archive endpoint. The ratchet driver will prefer the
120 /// archive for historical reads (`GetEpoch` during discovery and
121 /// `GetCheckpoint` for end-of-epoch summaries) and fall back to
122 /// the fullnode on any archive miss or error.
123 ///
124 /// Archives serve historical data with higher availability and
125 /// tighter latency than typical fullnodes; misses (newer
126 /// checkpoints not yet archived) transparently fall back to the
127 /// fullnode. Both clients are used read-only by the ratchet —
128 /// nothing in the cache becomes trustworthy solely because the
129 /// archive served it; every end-of-epoch summary is still
130 /// BLS-verified against the cache's current committee.
131 pub fn with_archive(mut self, archive: Client) -> Self {
132 self.archive = Some(archive);
133 self
134 }
135
136 /// Read-only access to the client's epoch cache, for inspection.
137 pub fn epoch_cache(&self) -> &EpochCache {
138 &self.cache
139 }
140
141 /// Mutable access to the underlying RPC client, for callers that
142 /// want to issue additional gRPC requests through the same channel.
143 pub fn rpc(&mut self) -> &mut Client {
144 &mut self.rpc
145 }
146
147 /// Return the sequence number of the network's most recent
148 /// checkpoint, as reported by `LedgerService.GetCheckpoint(latest)`.
149 ///
150 /// The result is **not** trust-anchored — it's a single read from
151 /// the server with no signature verification. Streaming clients use
152 /// it as a starting cursor (e.g., "begin reading events from the
153 /// checkpoint after this one"); any subsequent claim about an
154 /// object's state at this checkpoint must still flow through
155 /// [`Self::prove_object_at_checkpoint`] for cryptographic
156 /// authentication.
157 pub async fn latest_checkpoint_seq(&mut self) -> Result<u64, LightClientError> {
158 let request = GetCheckpointRequest::latest()
159 .with_read_mask(FieldMask::from_paths(["sequence_number"]));
160 let response = self
161 .rpc
162 .ledger_client()
163 .get_checkpoint(request)
164 .await?
165 .into_inner();
166 response
167 .checkpoint
168 .and_then(|c| c.sequence_number)
169 .ok_or_else(|| TryFromProtoError::missing("checkpoint.sequence_number").into())
170 }
171
172 /// Prove what (if anything) the checkpoint at `checkpoint_seq` did
173 /// to `object_id`, returning a [`CheckpointObjectProof`] that
174 /// distinguishes the four cases (created/mutated/unwrapped,
175 /// deleted/wrapped, or not modified at all).
176 ///
177 /// The full chain of trust:
178 ///
179 /// 1. Call the alpha
180 /// [`ProofService.GetCheckpointObjectProof`](crate::proto::sui::rpc::v2alpha::proof_service_client)
181 /// RPC with `(object_id, checkpoint_seq)`.
182 /// 2. Ratchet the epoch cache forward to cover the checkpoint
183 /// whose summary the server returned, BLS-verifying each
184 /// intervening end-of-epoch summary along the way.
185 /// 3. BCS-decode the returned `SignedCheckpointSummary`, sanity-check
186 /// that its `sequence_number` matches `checkpoint_seq`, and
187 /// BLS-verify its aggregate signature against the now-trusted
188 /// committee for that epoch.
189 /// 4. Dispatch on the response's `proof` oneof:
190 /// - **Inclusion**: sanity-check the returned `ObjectReference`'s
191 /// object id matches the request, verify the OCS inclusion
192 /// proof against the trusted summary, and (when `object_data`
193 /// is present) BCS-decode it and confirm its
194 /// `(id, version, digest)` match the authenticated leaf.
195 /// Absent `object_data` signals a deletion or wrap, in which
196 /// case `object` is returned as `None`.
197 /// - **NonInclusion**: verify the OCS non-inclusion proof
198 /// against the trusted summary for `object_id`. The proof's
199 /// id-strict-bracketing rules out any leaf with `object_id`,
200 /// proving the checkpoint did not modify it.
201 ///
202 /// **This method does not answer "what is the state of `object_id`
203 /// at checkpoint `checkpoint_seq`?"** — that question requires
204 /// walking back to the most recent modification or an entirely
205 /// different commitment scheme. See [`CheckpointObjectProof`]'s
206 /// type-level documentation for details.
207 pub async fn prove_object_at_checkpoint(
208 &mut self,
209 object_id: &Address,
210 checkpoint_seq: u64,
211 ) -> Result<CheckpointObjectProof, LightClientError> {
212 // 1. Fetch the proof from the alpha ProofService.
213 let request = GetCheckpointObjectProofRequest::default()
214 .with_object_id(object_id.to_string())
215 .with_checkpoint(checkpoint_seq);
216 let response = self
217 .rpc
218 .proof_client()
219 .get_checkpoint_object_proof(request)
220 .await?
221 .into_inner();
222
223 let summary_bytes = response
224 .checkpoint_summary
225 .ok_or_else(|| TryFromProtoError::missing("checkpoint_summary"))?;
226 let proof = response
227 .proof
228 .ok_or_else(|| TryFromProtoError::missing("proof"))?;
229
230 // 2. BCS-decode the signed checkpoint summary, sanity-check
231 // sequence number, ratchet, then BLS-verify.
232 let signed_summary: SignedCheckpointSummary = bcs::from_bytes(&summary_bytes)?;
233 let summary_seq = signed_summary.checkpoint.sequence_number;
234 if summary_seq != checkpoint_seq {
235 return Err(LightClientError::CheckpointMismatch {
236 requested: checkpoint_seq,
237 returned: summary_seq,
238 });
239 }
240
241 ratchet_to_checkpoint_with_config(
242 &mut self.rpc,
243 self.archive.as_mut(),
244 &mut self.cache,
245 summary_seq,
246 &self.ratchet_config,
247 )
248 .await?;
249
250 let summary_epoch = signed_summary.checkpoint.epoch;
251 let committee = self.cache.committee_for_epoch(summary_epoch).ok_or(
252 LightClientError::NoCommitteeForEpoch {
253 epoch: summary_epoch,
254 },
255 )?;
256 let verifier = ValidatorCommitteeSignatureVerifier::new((*committee).clone())?;
257 verifier
258 .verify_checkpoint_summary(&signed_summary.checkpoint, &signed_summary.signature)?;
259
260 // 3. Dispatch on the proof variant.
261 match proof {
262 get_checkpoint_object_proof_response::Proof::Inclusion(inclusion_proto) => {
263 verify_inclusion(&signed_summary, object_id, inclusion_proto)
264 }
265 get_checkpoint_object_proof_response::Proof::NonInclusion(non_inclusion_proto) => {
266 verify_non_inclusion(&signed_summary, object_id, non_inclusion_proto)
267 }
268 }
269 }
270}
271
272/// Authenticate an `Inclusion` response: check the object id matches,
273/// verify the OCS inclusion proof against the trusted summary, and
274/// (when present) BCS-decode and cross-check `object_data` against the
275/// authenticated leaf.
276fn verify_inclusion(
277 signed_summary: &SignedCheckpointSummary,
278 object_id: &Address,
279 inclusion_proto: crate::proto::sui::rpc::v2alpha::OcsInclusionProof,
280) -> Result<CheckpointObjectProof, LightClientError> {
281 let object_ref_proto = inclusion_proto
282 .object_ref
283 .as_ref()
284 .ok_or_else(|| TryFromProtoError::missing("proof.inclusion.object_ref"))?;
285 let object_ref: ObjectReference = object_ref_proto.try_into()?;
286
287 // The server could otherwise authenticate a different object id —
288 // the inclusion proof would still verify cryptographically, but
289 // wouldn't answer the caller's question.
290 if object_ref.object_id() != object_id {
291 return Err(LightClientError::ObjectIdMismatch {
292 requested: *object_id,
293 returned: *object_ref.object_id(),
294 });
295 }
296
297 let inclusion_proof: OcsInclusionProof = (&inclusion_proto).try_into()?;
298 inclusion_proof.verify(&signed_summary.checkpoint, &object_ref)?;
299
300 // BCS-decode the object data when present and cross-check.
301 // Absence is the in-band signal for a deletion or wrap, in which
302 // case the leaf's digest is a framework sentinel and there is no
303 // live object state to authenticate.
304 let object = inclusion_proto
305 .object_data
306 .as_ref()
307 .map(|bytes| -> Result<Box<Object>, LightClientError> {
308 let object: Object = bcs::from_bytes(bytes)?;
309 let returned_ref =
310 ObjectReference::new(object.object_id(), object.version(), object.digest());
311 if returned_ref != object_ref {
312 return Err(LightClientError::ObjectDataMismatch(Box::new(
313 ObjectDataMismatch {
314 expected: object_ref.clone(),
315 returned: returned_ref,
316 },
317 )));
318 }
319 Ok(Box::new(object))
320 })
321 .transpose()?;
322
323 Ok(CheckpointObjectProof::Inclusion { object_ref, object })
324}
325
326/// Authenticate a `NonInclusion` response: verify the OCS non-inclusion
327/// proof against the trusted summary for `object_id`.
328fn verify_non_inclusion(
329 signed_summary: &SignedCheckpointSummary,
330 object_id: &Address,
331 non_inclusion_proto: crate::proto::sui::rpc::v2alpha::OcsNonInclusionProof,
332) -> Result<CheckpointObjectProof, LightClientError> {
333 let non_inclusion_proof: OcsNonInclusionProof = (&non_inclusion_proto).try_into()?;
334 non_inclusion_proof.verify(&signed_summary.checkpoint, object_id)?;
335 Ok(CheckpointObjectProof::NonInclusion)
336}