iota_rest_api/
checkpoints.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use axum::extract::{Path, Query, State};
6use iota_sdk2::types::{
7    CheckpointContents, CheckpointDigest, CheckpointSequenceNumber, CheckpointSummary,
8    SignedCheckpointSummary, ValidatorAggregatedSignature,
9};
10use iota_types::storage::ReadStore;
11use tap::Pipe;
12
13use crate::{
14    Direction, Page, RestError, RestService, Result,
15    accept::AcceptFormat,
16    openapi::{ApiEndpoint, OperationBuilder, ResponseBuilder, RouteHandler},
17    reader::StateReader,
18    response::{Bcs, ResponseContent},
19};
20
21#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
22pub struct CheckpointResponse {
23    pub checkpoint: CheckpointSummary,
24    pub signature: ValidatorAggregatedSignature,
25    pub contents: Option<CheckpointContents>,
26}
27
28/// Fetch a Checkpoint
29///
30/// Fetch a checkpoint either by `CheckpointSequenceNumber` (checkpoint height)
31/// or by `CheckpointDigest` and optionally request its contents.
32///
33/// If the checkpoint has been pruned and is not available, a 410 will be
34/// returned.
35pub struct GetCheckpoint;
36
37impl ApiEndpoint<RestService> for GetCheckpoint {
38    fn method(&self) -> axum::http::Method {
39        axum::http::Method::GET
40    }
41
42    fn path(&self) -> &'static str {
43        "/checkpoints/{checkpoint}"
44    }
45
46    fn stable(&self) -> bool {
47        true
48    }
49
50    fn operation(
51        &self,
52        generator: &mut schemars::gen::SchemaGenerator,
53    ) -> openapiv3::v3_1::Operation {
54        OperationBuilder::new()
55            .tag("Checkpoint")
56            .operation_id("Get Checkpoint")
57            .path_parameter::<CheckpointId>("checkpoint", generator)
58            .query_parameters::<GetCheckpointQueryParameters>(generator)
59            .response(
60                200,
61                ResponseBuilder::new()
62                    .json_content::<CheckpointResponse>(generator)
63                    .bcs_content()
64                    .build(),
65            )
66            .response(404, ResponseBuilder::new().build())
67            .response(410, ResponseBuilder::new().build())
68            .response(500, ResponseBuilder::new().build())
69            .build()
70    }
71
72    fn handler(&self) -> RouteHandler<RestService> {
73        RouteHandler::new(self.method(), get_checkpoint)
74    }
75}
76
77async fn get_checkpoint(
78    Path(checkpoint_id): Path<CheckpointId>,
79    Query(parameters): Query<GetCheckpointQueryParameters>,
80    accept: AcceptFormat,
81    State(state): State<StateReader>,
82) -> Result<ResponseContent<CheckpointResponse>> {
83    let SignedCheckpointSummary {
84        checkpoint,
85        signature,
86    } = match checkpoint_id {
87        CheckpointId::SequenceNumber(s) => {
88            let oldest_checkpoint = state.inner().get_lowest_available_checkpoint()?;
89            if s < oldest_checkpoint {
90                return Err(crate::RestError::new(
91                    axum::http::StatusCode::GONE,
92                    "Old checkpoints have been pruned",
93                ));
94            }
95
96            state.inner().get_checkpoint_by_sequence_number(s)
97        }
98        CheckpointId::Digest(d) => state.inner().get_checkpoint_by_digest(&d.into()),
99    }?
100    .ok_or(CheckpointNotFoundError(checkpoint_id))?
101    .into_inner()
102    .try_into()?;
103
104    let contents = if parameters.contents {
105        Some(
106            state
107                .inner()
108                .get_checkpoint_contents_by_sequence_number(checkpoint.sequence_number)?
109                .ok_or(CheckpointNotFoundError(checkpoint_id))?
110                .try_into()?,
111        )
112    } else {
113        None
114    };
115
116    let response = CheckpointResponse {
117        checkpoint,
118        signature,
119        contents,
120    };
121
122    match accept {
123        AcceptFormat::Json => ResponseContent::Json(response),
124        AcceptFormat::Bcs => ResponseContent::Bcs(response),
125    }
126    .pipe(Ok)
127}
128
129#[derive(Debug, Copy, Clone, Eq, PartialEq, schemars::JsonSchema)]
130#[schemars(untagged)]
131pub enum CheckpointId {
132    #[schemars(
133        title = "SequenceNumber",
134        example = "CheckpointSequenceNumber::default"
135    )]
136    /// Sequence number or height of a Checkpoint
137    SequenceNumber(#[schemars(with = "crate::_schemars::U64")] CheckpointSequenceNumber),
138    #[schemars(title = "Digest", example = "example_digest")]
139    /// Base58 encoded 32-byte digest of a Checkpoint
140    Digest(CheckpointDigest),
141}
142
143fn example_digest() -> CheckpointDigest {
144    "4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S"
145        .parse()
146        .unwrap()
147}
148
149impl<'de> serde::Deserialize<'de> for CheckpointId {
150    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
151    where
152        D: serde::Deserializer<'de>,
153    {
154        let raw = String::deserialize(deserializer)?;
155
156        if let Ok(s) = raw.parse::<CheckpointSequenceNumber>() {
157            Ok(Self::SequenceNumber(s))
158        } else if let Ok(d) = raw.parse::<CheckpointDigest>() {
159            Ok(Self::Digest(d))
160        } else {
161            Err(serde::de::Error::custom(format!(
162                "unrecognized checkpoint-id {raw}"
163            )))
164        }
165    }
166}
167
168impl serde::Serialize for CheckpointId {
169    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
170    where
171        S: serde::Serializer,
172    {
173        match self {
174            CheckpointId::SequenceNumber(s) => serializer.serialize_str(&s.to_string()),
175            CheckpointId::Digest(d) => serializer.serialize_str(&d.to_string()),
176        }
177    }
178}
179
180#[derive(Debug)]
181pub struct CheckpointNotFoundError(CheckpointId);
182
183impl std::fmt::Display for CheckpointNotFoundError {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        write!(f, "Checkpoint ")?;
186
187        match self.0 {
188            CheckpointId::SequenceNumber(n) => write!(f, "{n}")?,
189            CheckpointId::Digest(d) => write!(f, "{d}")?,
190        }
191
192        write!(f, " not found")
193    }
194}
195
196impl std::error::Error for CheckpointNotFoundError {}
197
198impl From<CheckpointNotFoundError> for crate::RestError {
199    fn from(value: CheckpointNotFoundError) -> Self {
200        Self::new(axum::http::StatusCode::NOT_FOUND, value.to_string())
201    }
202}
203
204/// Query parameters for the GetCheckpoint endpoint
205#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
206pub struct GetCheckpointQueryParameters {
207    /// Request `CheckpointContents` be included in the response
208    #[serde(default)]
209    pub contents: bool,
210}
211
212/// List Checkpoints
213///
214/// Request a page of checkpoints, and optionally their contents, ordered by
215/// `CheckpointSequenceNumber`.
216///
217/// If the requested page is below the Node's `lowest_available_checkpoint`, a
218/// 410 will be returned.
219pub struct ListCheckpoints;
220
221impl ApiEndpoint<RestService> for ListCheckpoints {
222    fn method(&self) -> axum::http::Method {
223        axum::http::Method::GET
224    }
225
226    fn path(&self) -> &'static str {
227        "/checkpoints"
228    }
229
230    fn stable(&self) -> bool {
231        true
232    }
233
234    fn operation(
235        &self,
236        generator: &mut schemars::gen::SchemaGenerator,
237    ) -> openapiv3::v3_1::Operation {
238        OperationBuilder::new()
239            .tag("Checkpoint")
240            .operation_id("List Checkpoints")
241            .query_parameters::<ListCheckpointsQueryParameters>(generator)
242            .response(
243                200,
244                ResponseBuilder::new()
245                    .json_content::<Vec<CheckpointResponse>>(generator)
246                    .bcs_content()
247                    .header::<String>(crate::types::X_IOTA_CURSOR, generator)
248                    .build(),
249            )
250            .response(410, ResponseBuilder::new().build())
251            .response(500, ResponseBuilder::new().build())
252            .build()
253    }
254
255    fn handler(&self) -> RouteHandler<RestService> {
256        RouteHandler::new(self.method(), list_checkpoints)
257    }
258}
259
260async fn list_checkpoints(
261    Query(parameters): Query<ListCheckpointsQueryParameters>,
262    accept: AcceptFormat,
263    State(state): State<StateReader>,
264) -> Result<Page<CheckpointResponse, CheckpointSequenceNumber>> {
265    let latest_checkpoint = state.inner().get_latest_checkpoint()?.sequence_number;
266    let oldest_checkpoint = state.inner().get_lowest_available_checkpoint()?;
267    let limit = parameters.limit();
268    let start = parameters.start(latest_checkpoint);
269    let direction = parameters.direction();
270
271    if start < oldest_checkpoint {
272        return Err(crate::RestError::new(
273            axum::http::StatusCode::GONE,
274            "Old checkpoints have been pruned",
275        ));
276    }
277
278    let checkpoints = state
279        .checkpoint_iter(direction, start)
280        .take(limit)
281        .map(|result| {
282            result
283                .map_err(Into::into)
284                .and_then(|(checkpoint, contents)| {
285                    let SignedCheckpointSummary {
286                        checkpoint,
287                        signature,
288                    } = checkpoint.try_into()?;
289                    let contents = if parameters.contents {
290                        Some(contents.try_into()?)
291                    } else {
292                        None
293                    };
294                    Ok(CheckpointResponse {
295                        checkpoint,
296                        signature,
297                        contents,
298                    })
299                })
300        })
301        .collect::<Result<Vec<_>>>()?;
302
303    let cursor = checkpoints.last().and_then(|checkpoint| match direction {
304        Direction::Ascending => checkpoint.checkpoint.sequence_number.checked_add(1),
305        Direction::Descending => {
306            let cursor = checkpoint.checkpoint.sequence_number.checked_sub(1);
307            // If we've exhausted our available checkpoint range then there are no more
308            // pages left
309            if cursor < Some(oldest_checkpoint) {
310                None
311            } else {
312                cursor
313            }
314        }
315    });
316
317    match accept {
318        AcceptFormat::Json => ResponseContent::Json(checkpoints),
319        AcceptFormat::Bcs => ResponseContent::Bcs(checkpoints),
320    }
321    .pipe(|entries| Page { entries, cursor })
322    .pipe(Ok)
323}
324
325#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
326pub struct ListCheckpointsQueryParameters {
327    /// Page size limit for the response.
328    ///
329    /// Defaults to `50` if not provided with a maximum page size of `100`.
330    pub limit: Option<u32>,
331    /// The checkpoint to start listing from.
332    ///
333    /// Defaults to the latest checkpoint if not provided.
334    pub start: Option<CheckpointSequenceNumber>,
335    /// The direction to paginate in.
336    ///
337    /// Defaults to `descending` if not provided.
338    pub direction: Option<Direction>,
339    /// Request `CheckpointContents` be included in the response
340    #[serde(default)]
341    pub contents: bool,
342}
343
344impl ListCheckpointsQueryParameters {
345    pub fn limit(&self) -> usize {
346        self.limit
347            .map(|l| (l as usize).clamp(1, crate::MAX_PAGE_SIZE))
348            .unwrap_or(crate::DEFAULT_PAGE_SIZE)
349    }
350
351    pub fn start(&self, default: CheckpointSequenceNumber) -> CheckpointSequenceNumber {
352        self.start.unwrap_or(default)
353    }
354
355    pub fn direction(&self) -> Direction {
356        self.direction.unwrap_or(Direction::Descending)
357    }
358}
359
360/// Fetch a Full Checkpoint
361///
362/// Request a checkpoint and all data associated with it including:
363/// - CheckpointSummary
364/// - Validator Signature
365/// - CheckpointContents
366/// - Transactions, Effects, Events, as well as all input and output objects
367///
368/// If the requested checkpoint is below the Node's
369/// `lowest_available_checkpoint_objects`, a 410 will be returned.
370pub struct GetFullCheckpoint;
371
372impl ApiEndpoint<RestService> for GetFullCheckpoint {
373    fn method(&self) -> axum::http::Method {
374        axum::http::Method::GET
375    }
376
377    fn path(&self) -> &'static str {
378        "/checkpoints/{checkpoint}/full"
379    }
380
381    fn stable(&self) -> bool {
382        // TODO transactions are serialized with an intent message, do we want to change
383        // this format to remove it (and remove user signature duplication)
384        // prior to stabalizing the format?
385        false
386    }
387
388    fn operation(
389        &self,
390        generator: &mut schemars::gen::SchemaGenerator,
391    ) -> openapiv3::v3_1::Operation {
392        OperationBuilder::new()
393            .tag("Checkpoint")
394            .operation_id("Get Full Checkpoint")
395            .path_parameter::<CheckpointId>("checkpoint", generator)
396            .response(200, ResponseBuilder::new().bcs_content().build())
397            .response(404, ResponseBuilder::new().build())
398            .response(410, ResponseBuilder::new().build())
399            .response(500, ResponseBuilder::new().build())
400            .build()
401    }
402
403    fn handler(&self) -> RouteHandler<RestService> {
404        RouteHandler::new(self.method(), get_full_checkpoint)
405    }
406}
407
408async fn get_full_checkpoint(
409    Path(checkpoint_id): Path<CheckpointId>,
410    accept: AcceptFormat,
411    State(state): State<StateReader>,
412) -> Result<Bcs<iota_types::full_checkpoint_content::CheckpointData>> {
413    match accept {
414        AcceptFormat::Bcs => {}
415        _ => {
416            return Err(RestError::new(
417                axum::http::StatusCode::BAD_REQUEST,
418                "invalid accept type; only 'application/bcs' is supported",
419            ));
420        }
421    }
422
423    let verified_summary = match checkpoint_id {
424        CheckpointId::SequenceNumber(s) => {
425            // Since we need object contents we need to check for the lowest available
426            // checkpoint with objects that hasn't been pruned
427            let oldest_checkpoint = state.inner().get_lowest_available_checkpoint_objects()?;
428            if s < oldest_checkpoint {
429                return Err(crate::RestError::new(
430                    axum::http::StatusCode::GONE,
431                    "Old checkpoints have been pruned",
432                ));
433            }
434
435            state.inner().get_checkpoint_by_sequence_number(s)
436        }
437        CheckpointId::Digest(d) => state.inner().get_checkpoint_by_digest(&d.into()),
438    }?
439    .ok_or(CheckpointNotFoundError(checkpoint_id))?;
440
441    let checkpoint_contents = state
442        .inner()
443        .get_checkpoint_contents_by_digest(&verified_summary.content_digest)?
444        .ok_or(CheckpointNotFoundError(checkpoint_id))?;
445
446    let checkpoint_data = state
447        .inner()
448        .get_checkpoint_data(verified_summary, checkpoint_contents)?;
449
450    Ok(Bcs(checkpoint_data))
451}
452
453/// List Full Checkpoints
454///
455/// Request a page of checkpoints and all data associated with them including:
456/// - CheckpointSummary
457/// - Validator Signature
458/// - CheckpointContents
459/// - Transactions, Effects, Events, as well as all input and output objects
460///
461/// If the requested page is below the Node's
462/// `lowest_available_checkpoint_objects`, a 410 will be returned.
463pub struct ListFullCheckpoints;
464
465impl ApiEndpoint<RestService> for ListFullCheckpoints {
466    fn method(&self) -> axum::http::Method {
467        axum::http::Method::GET
468    }
469
470    fn path(&self) -> &'static str {
471        "/checkpoints/full"
472    }
473
474    fn stable(&self) -> bool {
475        // TODO transactions are serialized with an intent message, do we want to change
476        // this format to remove it (and remove user signature duplication)
477        // prior to stabalizing the format?
478        false
479    }
480
481    fn operation(
482        &self,
483        generator: &mut schemars::gen::SchemaGenerator,
484    ) -> openapiv3::v3_1::Operation {
485        OperationBuilder::new()
486            .tag("Checkpoint")
487            .operation_id("List Full Checkpoints")
488            .query_parameters::<ListFullCheckpointsQueryParameters>(generator)
489            .response(200, ResponseBuilder::new().bcs_content().build())
490            .response(410, ResponseBuilder::new().build())
491            .response(500, ResponseBuilder::new().build())
492            .build()
493    }
494
495    fn handler(&self) -> RouteHandler<RestService> {
496        RouteHandler::new(self.method(), list_full_checkpoints)
497    }
498}
499
500async fn list_full_checkpoints(
501    Query(parameters): Query<ListFullCheckpointsQueryParameters>,
502    accept: AcceptFormat,
503    State(state): State<StateReader>,
504) -> Result<Page<iota_types::full_checkpoint_content::CheckpointData, CheckpointSequenceNumber>> {
505    match accept {
506        AcceptFormat::Bcs => {}
507        _ => {
508            return Err(RestError::new(
509                axum::http::StatusCode::BAD_REQUEST,
510                "invalid accept type; only 'application/bcs' is supported",
511            ));
512        }
513    }
514
515    let latest_checkpoint = state.inner().get_latest_checkpoint()?.sequence_number;
516    let oldest_checkpoint = state.inner().get_lowest_available_checkpoint_objects()?;
517    let limit = parameters.limit();
518    let start = parameters.start(latest_checkpoint);
519    let direction = parameters.direction();
520
521    if start < oldest_checkpoint {
522        return Err(crate::RestError::new(
523            axum::http::StatusCode::GONE,
524            "Old checkpoints have been pruned",
525        ));
526    }
527
528    let checkpoints = state
529        .checkpoint_iter(direction, start)
530        .take(
531            // only iterate until we've reached the edge of our objects available window
532            if direction.is_descending() {
533                std::cmp::min(limit, start.saturating_sub(oldest_checkpoint) as usize)
534            } else {
535                limit
536            },
537        )
538        .map(|result| {
539            result
540                .map_err(Into::into)
541                .and_then(|(checkpoint, contents)| {
542                    state
543                        .inner()
544                        .get_checkpoint_data(
545                            iota_types::messages_checkpoint::VerifiedCheckpoint::new_from_verified(
546                                checkpoint,
547                            ),
548                            contents,
549                        )
550                        .map_err(Into::into)
551                })
552        })
553        .collect::<Result<Vec<_>>>()?;
554
555    let cursor = checkpoints.last().and_then(|checkpoint| match direction {
556        Direction::Ascending => checkpoint.checkpoint_summary.sequence_number.checked_add(1),
557        Direction::Descending => {
558            let cursor = checkpoint.checkpoint_summary.sequence_number.checked_sub(1);
559            // If we've exhausted our available object range then there are no more pages
560            // left
561            if cursor < Some(oldest_checkpoint) {
562                None
563            } else {
564                cursor
565            }
566        }
567    });
568
569    ResponseContent::Bcs(checkpoints)
570        .pipe(|entries| Page { entries, cursor })
571        .pipe(Ok)
572}
573
574#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
575pub struct ListFullCheckpointsQueryParameters {
576    /// Page size limit for the response.
577    ///
578    /// Defaults to `5` if not provided with a maximum page size of `10`.
579    pub limit: Option<u32>,
580    /// The checkpoint to start listing from.
581    ///
582    /// Defaults to the latest checkpoint if not provided.
583    pub start: Option<CheckpointSequenceNumber>,
584    /// The direction to paginate in.
585    ///
586    /// Defaults to `descending` if not provided.
587    pub direction: Option<Direction>,
588}
589
590impl ListFullCheckpointsQueryParameters {
591    pub fn limit(&self) -> usize {
592        self.limit.map(|l| (l as usize).clamp(1, 10)).unwrap_or(5)
593    }
594
595    pub fn start(&self, default: CheckpointSequenceNumber) -> CheckpointSequenceNumber {
596        self.start.unwrap_or(default)
597    }
598
599    pub fn direction(&self) -> Direction {
600        self.direction.unwrap_or(Direction::Descending)
601    }
602}