iota_light_client/
verifier.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2025 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::sync::Arc;
6
7use anyhow::{Context, Result, anyhow};
8use iota_config::genesis::Genesis;
9use iota_json_rpc_types::{IotaObjectDataOptions, IotaTransactionBlockResponseOptions};
10use iota_sdk::IotaClientBuilder;
11use iota_types::{
12    base_types::{ObjectID, TransactionDigest},
13    committee::Committee,
14    effects::{TransactionEffects, TransactionEffectsAPI, TransactionEvents},
15    full_checkpoint_content::CheckpointData,
16    messages_checkpoint::CheckpointSequenceNumber,
17    object::Object,
18};
19use tracing::info;
20
21use crate::{
22    checkpoint::{CheckpointList, read_checkpoint_list, read_checkpoint_summary},
23    config::Config,
24    object_store::CheckpointStore,
25};
26
27pub fn extract_verified_effects_and_events(
28    checkpoint: &CheckpointData,
29    committee: &Committee,
30    transaction_digest: TransactionDigest,
31) -> Result<(TransactionEffects, Option<TransactionEvents>)> {
32    let summary = &checkpoint.checkpoint_summary;
33
34    // Verify the checkpoint summary using the committee
35    summary.verify_with_contents(committee, Some(&checkpoint.checkpoint_contents))?;
36
37    // Check the validity of the transaction
38    let contents = &checkpoint.checkpoint_contents;
39    let (matching_tx, _) = checkpoint
40        .transactions
41        .iter()
42        .zip(contents.iter())
43        // Note that we get the digest of the effects to ensure this is
44        // indeed the correct effects that are authenticated in the contents.
45        .find(|(tx, digest)| {
46            tx.effects.execution_digests() == **digest && digest.transaction == transaction_digest
47        })
48        .ok_or_else(|| anyhow!("Transaction not found in checkpoint contents"))?;
49
50    // Check the events are all correct.
51    let events_digest = matching_tx.events.as_ref().map(|events| events.digest());
52    anyhow::ensure!(
53        events_digest.as_ref() == matching_tx.effects.events_digest(),
54        "Events digest does not match"
55    );
56
57    // Since we do not check objects we do not return them
58    Ok((matching_tx.effects.clone(), matching_tx.events.clone()))
59}
60
61pub async fn get_verified_object(config: &Config, object_id: ObjectID) -> Result<Object> {
62    let iota_client = Arc::new(
63        IotaClientBuilder::default()
64            .build(config.rpc_url.as_str())
65            .await?,
66    );
67
68    info!("Getting object: {object_id}");
69
70    let read_api = iota_client.read_api();
71    let object_json = read_api
72        .get_object_with_options(object_id, IotaObjectDataOptions::bcs_lossless())
73        .await
74        .expect("Cannot get object");
75    let object = object_json
76        .into_object()
77        .expect("Cannot make into object data");
78    let object: Object = object.try_into().expect("Cannot reconstruct object");
79
80    // Need to authenticate this object
81    let (effects, _) = get_verified_effects_and_events(config, object.previous_transaction)
82        .await
83        .expect("Cannot get effects and events");
84
85    // check that this object ID, version and hash is in the effects
86    let target_object_ref = object.compute_object_reference();
87    effects
88        .all_changed_objects()
89        .iter()
90        .find(|object_ref| object_ref.0 == target_object_ref)
91        .ok_or_else(|| anyhow!("Object not found"))?;
92
93    Ok(object)
94}
95
96pub async fn get_verified_effects_and_events(
97    config: &Config,
98    transaction_digest: TransactionDigest,
99) -> Result<(TransactionEffects, Option<TransactionEvents>)> {
100    let iota_client = IotaClientBuilder::default()
101        .build(config.rpc_url.as_str())
102        .await?;
103    let read_api = iota_client.read_api();
104
105    info!("Getting effects and events for transaction: {transaction_digest}");
106
107    // Lookup the transaction digest and get the checkpoint sequence number
108    let options = IotaTransactionBlockResponseOptions::new();
109    let seq = read_api
110        .get_transaction_with_options(transaction_digest, options)
111        .await
112        .context("Cannot get transaction")?
113        .checkpoint
114        .ok_or_else(|| anyhow!("Transaction not found"))?;
115
116    let checkpoint = if config.checkpoint_store_config.is_some() {
117        let checkpoint_store = CheckpointStore::new(config)?;
118
119        // Download the full checkpoint for this sequence number
120        checkpoint_store
121            .fetch_full_checkpoint(seq)
122            .await
123            .context("Cannot get full checkpoint")?
124    } else {
125        // try REST API (for custom networks)
126        let client = iota_rest_api::Client::new(&config.rpc_url);
127        client.get_full_checkpoint(seq).await?
128    };
129
130    // Load the list of stored checkpoints
131    let checkpoints_list: CheckpointList = read_checkpoint_list(config)?;
132
133    // find the stored checkpoint before the seq checkpoint
134    let prev_ckp_id = checkpoints_list
135        .checkpoints()
136        .iter()
137        .filter(|ckp_id| **ckp_id < seq)
138        .next_back();
139
140    let committee = if let Some(prev_ckp_id) = prev_ckp_id {
141        // Read it from the store
142        let prev_ckp = read_checkpoint_summary(config, *prev_ckp_id)?;
143
144        // Check we have the right checkpoint
145        anyhow::ensure!(
146            prev_ckp.epoch().checked_add(1).unwrap() == checkpoint.checkpoint_summary.epoch(),
147            "Checkpoint sequence number does not match. Need to Sync."
148        );
149
150        // Get the committee from the previous checkpoint
151        let current_committee = prev_ckp
152            .end_of_epoch_data
153            .as_ref()
154            .ok_or_else(|| anyhow!("Expected all checkpoints to be end-of-epoch checkpoints"))?
155            .next_epoch_committee
156            .iter()
157            .cloned()
158            .collect();
159
160        // Make a committee object using this
161        Committee::new(prev_ckp.epoch().checked_add(1).unwrap(), current_committee)
162    } else {
163        // Since we did not find a small committee checkpoint we use the genesis
164        Genesis::load(config.genesis_blob_file_path())?
165            .committee()
166            .context("Cannot load Genesis")?
167    };
168
169    info!("Extracting effects and events for transaction: {transaction_digest}");
170
171    extract_verified_effects_and_events(&checkpoint, &committee, transaction_digest)
172        .context("Cannot extract effects and events")
173}
174
175/// Get the verified checkpoint sequence number for an object.
176/// This function will verify that the object is in the transaction's effects,
177/// and that the transaction is in the checkpoint
178/// and that the checkpoint is signed by the committee
179/// and the committee is read from the verified checkpoint summary
180/// which is signed by the previous committee.
181pub async fn get_verified_checkpoint(
182    config: &Config,
183    object_id: ObjectID,
184) -> Result<CheckpointSequenceNumber> {
185    let iota_client = IotaClientBuilder::default()
186        .build(config.rpc_url.as_str())
187        .await?;
188    let read_api = iota_client.read_api();
189    let object_json = read_api
190        .get_object_with_options(object_id, IotaObjectDataOptions::bcs_lossless())
191        .await
192        .expect("Cannot get object");
193    let object = object_json
194        .into_object()
195        .expect("Cannot make into object data");
196    let object: Object = object.try_into().expect("Cannot reconstruct object");
197
198    // Lookup the transaction id and get the checkpoint sequence number
199    let options = IotaTransactionBlockResponseOptions::new();
200    let seq = read_api
201        .get_transaction_with_options(object.previous_transaction, options)
202        .await
203        .context("Cannot get transaction")?
204        .checkpoint
205        .ok_or_else(|| anyhow!("Transaction not found"))?;
206
207    // Need to authenticate this object
208    let (effects, _) = get_verified_effects_and_events(config, object.previous_transaction)
209        .await
210        .expect("Cannot get effects and events");
211
212    // check that this object ID, version and hash is in the effects
213    let target_object_ref = object.compute_object_reference();
214    effects
215        .all_changed_objects()
216        .iter()
217        .find(|object_ref| object_ref.0 == target_object_ref)
218        .ok_or_else(|| anyhow!("Object not found"))?;
219
220    // Create object store
221    let object_store = CheckpointStore::new(config)?;
222
223    // Download the full checkpoint for this sequence number
224    let full_check_point = object_store
225        .fetch_full_checkpoint(seq)
226        .await
227        .context("Cannot get full checkpoint")?;
228
229    // Load the list of stored checkpoints
230    let checkpoints_list: CheckpointList = read_checkpoint_list(config)?;
231
232    // find the stored checkpoint before the seq checkpoint
233    let prev_ckp_id = checkpoints_list
234        .checkpoints()
235        .iter()
236        .filter(|ckp_id| **ckp_id < seq)
237        .next_back();
238
239    let committee = if let Some(prev_ckp_id) = prev_ckp_id {
240        // Read it from the store
241        let prev_ckp = read_checkpoint_summary(config, *prev_ckp_id)?;
242
243        // Check we have the right checkpoint
244        anyhow::ensure!(
245            prev_ckp.epoch().checked_add(1).unwrap() == full_check_point.checkpoint_summary.epoch(),
246            "Checkpoint sequence number does not match. Need to Sync."
247        );
248
249        // Get the committee from the previous checkpoint
250        let current_committee = prev_ckp
251            .end_of_epoch_data
252            .as_ref()
253            .ok_or_else(|| anyhow!("Expected all checkpoints to be end-of-epoch checkpoints"))?
254            .next_epoch_committee
255            .iter()
256            .cloned()
257            .collect();
258
259        // Make a committee object using this
260        Committee::new(prev_ckp.epoch().checked_add(1).unwrap(), current_committee)
261    } else {
262        // Since we did not find a small committee checkpoint we use the genesis
263        Genesis::load(config.genesis_blob_file_path())?
264            .committee()
265            .context("Cannot load Genesis")?
266    };
267
268    // Verify that committee signed this checkpoint and checkpoint contents with
269    // digest
270    full_check_point
271        .checkpoint_summary
272        .verify_with_contents(&committee, Some(&full_check_point.checkpoint_contents))?;
273
274    anyhow::ensure!(
275        full_check_point
276            .transactions
277            .iter()
278            .any(|t| *t.transaction.digest() == object.previous_transaction),
279        "Transaction not found in checkpoint"
280    );
281    Ok(seq)
282}
283
284#[cfg(test)]
285mod tests {
286    use std::{fs, io::Read, path::PathBuf, str::FromStr};
287
288    use iota_types::{
289        event::Event,
290        messages_checkpoint::{CertifiedCheckpointSummary, FullCheckpointContents},
291    };
292
293    use super::*;
294
295    const FIXTURES_DIR: &str = "tests/fixtures";
296    const FIXTURE_1: &str = "235.sum";
297    const FIXTURE_2: &str = "469.chk";
298
299    async fn read_checkpoint_summary(
300        checkpoint_path: &PathBuf,
301    ) -> anyhow::Result<CertifiedCheckpointSummary> {
302        let mut reader = fs::File::open(checkpoint_path.clone())?;
303        let metadata = fs::metadata(checkpoint_path)?;
304        let mut buffer = vec![0; metadata.len() as usize];
305        reader.read_exact(&mut buffer)?;
306        bcs::from_bytes(&buffer).context("failed to deserialize summary from bcs bytes")
307    }
308
309    async fn read_full_checkpoint(checkpoint_path: &PathBuf) -> anyhow::Result<CheckpointData> {
310        let mut reader = fs::File::open(checkpoint_path.clone())?;
311        let metadata = fs::metadata(checkpoint_path)?;
312        let mut buffer = vec![0; metadata.len() as usize];
313        reader.read_exact(&mut buffer)?;
314        bcs::from_bytes(&buffer).context("failed to deserialize full checkpoint from bcs bytes")
315    }
316
317    async fn read_data() -> (Committee, CheckpointData) {
318        let checkpoint_summary_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
319            .join(FIXTURES_DIR)
320            .join(FIXTURE_1);
321        let summary_checkpoint = read_checkpoint_summary(&checkpoint_summary_path)
322            .await
323            .unwrap();
324        let prev_committee = summary_checkpoint
325            .end_of_epoch_data
326            .as_ref()
327            .expect("Expected all checkpoints to be end-of-epoch checkpoints")
328            .next_epoch_committee
329            .iter()
330            .cloned()
331            .collect();
332        let committee = Committee::new(
333            summary_checkpoint.epoch().checked_add(1).unwrap(),
334            prev_committee,
335        );
336        let full_checkpoint_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
337            .join(FIXTURES_DIR)
338            .join(FIXTURE_2);
339        let full_checkpoint = read_full_checkpoint(&full_checkpoint_path).await.unwrap();
340
341        (committee, full_checkpoint)
342    }
343
344    #[tokio::test]
345    async fn test_checkpoint_all_good() {
346        let (committee, full_checkpoint) = read_data().await;
347        let tx_digest_0 = *full_checkpoint.transactions[0].transaction.digest();
348
349        extract_verified_effects_and_events(&full_checkpoint, &committee, tx_digest_0).unwrap();
350    }
351
352    #[tokio::test]
353    async fn test_checkpoint_bad_committee() {
354        let (mut committee, full_checkpoint) = read_data().await;
355        let tx_digest_0 = *full_checkpoint.transactions[0].transaction.digest();
356
357        // Change committee
358        committee.epoch += 10;
359
360        assert!(
361            extract_verified_effects_and_events(&full_checkpoint, &committee, tx_digest_0,)
362                .is_err()
363        );
364    }
365
366    #[tokio::test]
367    async fn test_checkpoint_no_transaction() {
368        let (committee, full_checkpoint) = read_data().await;
369
370        assert!(
371            extract_verified_effects_and_events(
372                &full_checkpoint,
373                &committee,
374                // tx does not exist
375                TransactionDigest::from_str("11111111111111111111111111111111").unwrap(),
376            )
377            .is_err()
378        );
379    }
380
381    #[tokio::test]
382    async fn test_checkpoint_bad_contents() {
383        let (committee, mut full_checkpoint) = read_data().await;
384        let tx_digest_0 = *full_checkpoint.transactions[0].transaction.digest();
385
386        // Change contents
387        let random_contents = FullCheckpointContents::random_for_testing();
388        full_checkpoint.checkpoint_contents = random_contents.checkpoint_contents();
389
390        assert!(
391            extract_verified_effects_and_events(&full_checkpoint, &committee, tx_digest_0,)
392                .is_err()
393        );
394    }
395
396    #[tokio::test]
397    async fn test_checkpoint_bad_events() {
398        let (committee, mut full_checkpoint) = read_data().await;
399        // Add a random event to the transaction, so the event digest doesn't match
400        let tx0 = &mut full_checkpoint.transactions[0];
401        let tx_digest_0 = *tx0.transaction.digest();
402
403        if tx0.events.is_none() {
404            // if there are no events yet, add them
405            tx0.events = Some(TransactionEvents {
406                data: vec![Event::random_for_testing()],
407            });
408        } else {
409            tx0.events
410                .as_mut()
411                .unwrap()
412                .data
413                .push(Event::random_for_testing());
414        }
415
416        assert!(
417            extract_verified_effects_and_events(&full_checkpoint, &committee, tx_digest_0,)
418                .is_err()
419        );
420    }
421}