Skip to main content

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}