diff --git a/Cargo.toml b/Cargo.toml index 36638d0..4a331d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ rust-version = "1.85" [workspace] resolver = "2" -members = ["audit-trail-rs", "examples", "notarization-rs"] +members = ["audit-trail-rs", "examples", "notarization-rs", "poi-rs"] exclude = ["bindings/wasm/notarization_wasm", "bindings/wasm/audit_trail_wasm"] [workspace.dependencies] @@ -17,8 +17,11 @@ async-trait = "0.1" bcs = "0.1" chrono = { version = "0.4", default-features = false } hyper = "1" +iota-grpc-client = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-client", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11" } +iota-grpc-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-types", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11" } iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.26.1" } iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11", default-features = false } +iota-types = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction" } iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_ts" } diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml new file mode 100644 index 0000000..f702a7b --- /dev/null +++ b/poi-rs/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "poi-rs" +version = "0.1.0-alpha" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["iota", "proof", "inclusion", "notarization"] +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Proof of Inclusion support for the IOTA Notarization Toolkit." + +[dependencies] +async-trait.workspace = true +iota-grpc-client.workspace = true +iota-grpc-types.workspace = true +iota-sdk-types.workspace = true +iota-types.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["alloc"] } +thiserror.workspace = true +tokio.workspace = true diff --git a/poi-rs/README.md b/poi-rs/README.md new file mode 100644 index 0000000..b740858 --- /dev/null +++ b/poi-rs/README.md @@ -0,0 +1,54 @@ +# IOTA Proof of Inclusion Rust Package + +The Proof of Inclusion Rust package provides proof data types and offline verification for inclusion claims in the IOTA +Notarization Toolkit. + +Use Proof of Inclusion when a verifier needs cryptographic evidence that a transaction, event, or object state is tied to +a certified IOTA checkpoint. The package verifies supplied proof material locally. It does not fetch checkpoints, resolve +committees, or trust the node that supplied the proof. + +## Proof Model + +A `Proof` contains three layers of evidence: + +- A `CertifiedCheckpointSummary` signed by the committee for the checkpoint epoch. +- A `TransactionProof` containing the checkpoint contents, transaction, effects, and optional events. +- `ProofTargets` describing the object, event, or committee claims the caller wants to authenticate. + +The transaction proof is required. A Proof of Inclusion proves inclusion in a certified checkpoint, so the proof envelope +must carry the transaction evidence that links the target claim to the checkpoint contents. + +## Verification + +`ProofVerifier` is the public verification entry point. It receives the authoritative committee for the proof checkpoint +and verifies only the proof material passed by the caller. + +Verification checks: + +- the proof format version is supported +- the checkpoint summary is certified by the supplied committee +- the checkpoint contents match the certified checkpoint summary +- the transaction digest matches the transaction effects +- the transaction effects are included in the checkpoint contents +- packaged events match the event digest recorded in the effects +- requested event targets belong to the transaction and match the packaged event contents +- requested object targets match their object references and appear in the transaction effects +- requested committee targets match the next committee recorded in an end-of-epoch checkpoint + +## Trust Boundaries + +`ProofVerifier` is intentionally offline. It does not make RPC calls and does not decide which committee is authoritative. +Callers must provide the committee that should certify the checkpoint. A higher-level client or cache can resolve committee +history before calling the verifier. + +The verifier treats all proof payloads as untrusted until verification succeeds. After verification succeeds, callers can +trust the authenticated target claims relative to the supplied committee. + +## Main Types + +- `Proof`: Versioned Proof of Inclusion envelope. +- `ProofVersion`: Proof format version used for compatibility checks. +- `TransactionProof`: Transaction, effects, events, and checkpoint contents used to prove inclusion. +- `ProofTargets`: Object, event, and committee claims to authenticate. +- `ProofVerifier`: Offline verifier for `Proof` values. +- `VerifyError`, `SourceError`, `SerializationError`, and `VersionError`: Operation-specific errors. diff --git a/poi-rs/src/cache.rs b/poi-rs/src/cache.rs new file mode 100644 index 0000000..61629e2 --- /dev/null +++ b/poi-rs/src/cache.rs @@ -0,0 +1,45 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::committee::{Committee, EpochId}; + +use crate::BoxError; + +mod in_memory; + +pub use in_memory::MemoryCommitteeCache; + +/// Error returned by a committee cache. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CommitteeCacheError { + /// A cached committee conflicts with authenticated committee data. + #[error("cached committee conflicts at epoch {epoch}")] + Conflict { + /// Epoch whose cached material conflicts. + epoch: EpochId, + }, + /// A cache backend failed to read or write committee material. + #[error("committee cache backend failed at epoch {epoch}")] + Backend { + /// Epoch being accessed when the backend failed. + epoch: EpochId, + /// Underlying backend error. + #[source] + source: BoxError, + }, +} + +/// Stores authenticated committees for anchored resolution. +/// +/// A cache is part of the caller's trust boundary. Implementations must return +/// only committees previously authenticated for the same network and must +/// preserve their integrity after storage. +#[async_trait::async_trait] +pub trait CommitteeCache: Send + Sync { + /// Returns the authenticated committee for `epoch`, when available. + async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError>; + + /// Stores a committee after the resolver has authenticated it. + async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError>; +} diff --git a/poi-rs/src/cache/in_memory.rs b/poi-rs/src/cache/in_memory.rs new file mode 100644 index 0000000..b580a06 --- /dev/null +++ b/poi-rs/src/cache/in_memory.rs @@ -0,0 +1,122 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::BTreeMap, sync::Arc}; + +use iota_types::committee::{Committee, EpochId}; +use tokio::sync::RwLock; + +use super::{CommitteeCache, CommitteeCacheError}; + +/// In-memory committee cache for library usage and tests. +#[derive(Clone, Debug, Default)] +pub struct MemoryCommitteeCache { + committees: Arc>>, +} + +impl MemoryCommitteeCache { + /// Creates an empty in-memory committee cache. + pub fn new() -> Self { + Self::default() + } + + /// Returns the number of cached committees. + pub async fn len(&self) -> usize { + self.committees.read().await.len() + } + + /// Returns whether the cache contains no committees. + pub async fn is_empty(&self) -> bool { + self.committees.read().await.is_empty() + } +} + +#[async_trait::async_trait] +impl CommitteeCache for MemoryCommitteeCache { + async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError> { + Ok(self.committees.read().await.get(&epoch).cloned()) + } + + async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> { + let epoch = committee.epoch; + let mut committees = self.committees.write().await; + + if committees.get(&epoch).is_some_and(|cached| cached != committee) { + return Err(CommitteeCacheError::Conflict { epoch }); + } + + committees.entry(epoch).or_insert_with(|| committee.clone()); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn committee_at(epoch: EpochId) -> Committee { + let (committee, _) = Committee::new_simple_test_committee(); + + Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) + } + + #[tokio::test] + async fn new_cache_is_empty() { + let cache = MemoryCommitteeCache::new(); + + assert!(cache.is_empty().await); + assert_eq!(cache.len().await, 0); + assert!(cache.committee(7).await.unwrap().is_none()); + } + + #[tokio::test] + async fn store_makes_a_committee_available_by_epoch() { + let cache = MemoryCommitteeCache::new(); + let committee = committee_at(7); + + cache.store(&committee).await.unwrap(); + + assert_eq!(cache.committee(7).await.unwrap(), Some(committee)); + assert_eq!(cache.len().await, 1); + assert!(!cache.is_empty().await); + } + + #[tokio::test] + async fn storing_the_same_committee_is_idempotent() { + let cache = MemoryCommitteeCache::new(); + let committee = committee_at(7); + + cache.store(&committee).await.unwrap(); + cache.store(&committee).await.unwrap(); + + assert_eq!(cache.committee(7).await.unwrap(), Some(committee)); + assert_eq!(cache.len().await, 1); + } + + #[tokio::test] + async fn conflicting_committee_is_rejected_without_replacing_the_original() { + let cache = MemoryCommitteeCache::new(); + let original = committee_at(7); + let (conflicting, _) = Committee::new_simple_test_committee_of_size(5); + let conflicting = Committee::new(7, conflicting.voting_rights.iter().cloned().collect()); + cache.store(&original).await.unwrap(); + + let error = cache.store(&conflicting).await.unwrap_err(); + + assert!(matches!(error, CommitteeCacheError::Conflict { epoch: 7 })); + assert_eq!(cache.committee(7).await.unwrap(), Some(original)); + assert_eq!(cache.len().await, 1); + } + + #[tokio::test] + async fn clones_share_cached_committees() { + let cache = MemoryCommitteeCache::new(); + let clone = cache.clone(); + let committee = committee_at(7); + + cache.store(&committee).await.unwrap(); + + assert_eq!(clone.committee(7).await.unwrap(), Some(committee)); + } +} diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs new file mode 100644 index 0000000..b57a592 --- /dev/null +++ b/poi-rs/src/committee.rs @@ -0,0 +1,660 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use iota_grpc_client::{ + Client as GrpcClient, ReadMask, + read_mask_fields::{CheckpointResponseField, EpochField, ServiceInfoField}, +}; +use iota_types::{ + committee::{Committee, EpochId}, + messages_checkpoint::{CertifiedCheckpointSummary, EndOfEpochData}, +}; + +use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; + +/// Error returned when a committee cannot be resolved for an epoch. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to resolve committee for epoch {target_epoch}")] +pub struct CommitteeResolutionError { + /// Epoch whose committee was requested. + pub target_epoch: EpochId, + /// Committee resolution failure details. + #[source] + pub kind: CommitteeResolutionErrorKind, +} + +impl CommitteeResolutionError { + /// Associates a resolution failure with the committee epoch requested by the caller. + fn new(target_epoch: EpochId, kind: CommitteeResolutionErrorKind) -> Self { + Self { target_epoch, kind } + } +} + +/// Kind of committee resolution failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CommitteeResolutionErrorKind { + /// Fetching a committee directly from the trusted node failed. + #[error("failed to fetch committee for epoch {epoch} from the trusted node")] + FetchCommittee { + /// Epoch requested from the node. + epoch: EpochId, + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// Reading a committee returned by the trusted node failed. + #[error("failed to read committee for epoch {epoch} from the trusted node")] + Committee { + /// Epoch requested from the node. + epoch: EpochId, + /// Underlying response error. + #[source] + source: BoxError, + }, + /// The requested epoch predates the trusted committee anchor. + #[error("target epoch is before trusted anchor epoch {anchor_epoch}")] + TargetBeforeAnchor { + /// Earliest epoch authenticated by the resolver. + anchor_epoch: EpochId, + }, + /// Fetching the node's current epoch failed. + #[error("failed to fetch the node's current epoch")] + FetchCurrentEpoch { + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// The service information response omitted the current epoch. + #[error("service information is missing the current epoch")] + MissingCurrentEpoch, + /// The requested epoch is newer than the connected node's current epoch. + #[error("target epoch is ahead of node current epoch {current_epoch}")] + TargetAheadOfNode { + /// Current epoch reported by the connected node. + current_epoch: EpochId, + }, + /// Fetching the last checkpoint of an epoch failed. + #[error("failed to fetch end-of-epoch checkpoint information for epoch {epoch}")] + FetchEpochHistory { + /// Epoch whose last checkpoint was requested. + epoch: EpochId, + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// The epoch response omitted its last checkpoint sequence number. + #[error("epoch {epoch} is missing its last checkpoint")] + MissingLastCheckpoint { + /// Epoch whose last checkpoint was requested. + epoch: EpochId, + }, + /// Fetching a certified end-of-epoch checkpoint summary failed. + #[error("failed to fetch end-of-epoch checkpoint {sequence_number}")] + FetchCheckpoint { + /// Checkpoint sequence number requested from the node. + sequence_number: u64, + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// Reading or converting a checkpoint summary failed. + #[error("failed to read end-of-epoch checkpoint {sequence_number}")] + CheckpointSummary { + /// Checkpoint sequence number returned by the epoch response. + sequence_number: u64, + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// The current trusted committee did not authenticate the end-of-epoch checkpoint. + #[error("failed to verify epoch {epoch} end-of-epoch checkpoint {sequence_number}")] + InvalidEndOfEpochCheckpoint { + /// Epoch whose committee was used for verification. + epoch: EpochId, + /// Checkpoint sequence number closing the epoch. + sequence_number: u64, + /// Underlying checkpoint verification error. + #[source] + source: BoxError, + }, + /// The epoch's last checkpoint did not contain next-epoch data. + #[error("checkpoint {sequence_number} is not an end-of-epoch checkpoint")] + NotEndOfEpoch { + /// Checkpoint sequence number returned by the epoch response. + sequence_number: u64, + }, + /// Incrementing the authenticated epoch would overflow an [`EpochId`]. + #[error("next epoch after {epoch} overflows u64")] + NextEpochOverflow { + /// Authenticated checkpoint epoch. + epoch: EpochId, + }, + /// Reading or writing an authenticated committee in a cache failed. + #[error("committee cache failed at epoch {epoch}")] + Cache { + /// Epoch being resolved through the cache. + epoch: EpochId, + /// Underlying cache error. + #[source] + source: CommitteeCacheError, + }, +} + +/// Selects how a resolver establishes trust in committee data. +#[derive(Clone)] +enum CommitteeResolution { + /// Accept committee data returned directly by the connected node. + Node, + /// Authenticate committee lineage from an existing trust anchor. + Anchor { + committee: Committee, + cache: Arc, + }, +} + +/// Resolves the committee required to verify a checkpoint from a gRPC node. +/// +/// A resolver either accepts committee data directly from a trusted node or +/// starts from a trusted committee, normally obtained from the network genesis +/// blob, and authenticates every end-of-epoch handoff up to the requested epoch. +#[derive(Clone)] +pub struct CommitteeResolver { + client: GrpcClient, + mode: CommitteeResolution, +} + +impl CommitteeResolver { + /// Creates a resolver that trusts the connected node for committee data. + /// + /// This mode does not authenticate committee lineage. Use it only when the + /// node is inside the caller's trust boundary, such as local development or + /// explicitly trusted infrastructure. + pub fn node(client: GrpcClient) -> Self { + Self { + client, + mode: CommitteeResolution::Node, + } + } + + /// Creates a resolver anchored at an already trusted committee. + /// + /// The trusted committee should be obtained from the network genesis blob + /// or from a previously authenticated checkpoint. The connected node is + /// treated only as a source of epoch and checkpoint data. Authenticated + /// committees are retained in memory for subsequent resolutions. + pub fn anchor(client: GrpcClient, committee: Committee) -> Self { + Self::anchor_with_cache(client, committee, MemoryCommitteeCache::new()) + } + + /// Creates an anchored resolver backed by a caller-provided committee cache. + /// + /// The cache is part of the caller's trust boundary and must return only + /// committees authenticated for the same network. Committees fetched by + /// this resolver are cached only after successful authentication. + pub fn anchor_with_cache(client: GrpcClient, committee: Committee, cache: impl CommitteeCache + 'static) -> Self { + Self { + client, + mode: CommitteeResolution::Anchor { + committee, + cache: Arc::new(cache), + }, + } + } + + /// Returns the underlying SDK gRPC client. + pub const fn grpc_client(&self) -> &GrpcClient { + &self.client + } + + /// Resolves the authenticated committee for `target_epoch`. + /// + /// Node mode returns the committee reported by the trusted node. Anchor + /// mode verifies each end-of-epoch checkpoint with the current committee + /// before accepting its successor. + pub async fn resolve(&self, target_epoch: EpochId) -> Result { + match &self.mode { + CommitteeResolution::Node => self.resolve_from_node(target_epoch).await, + CommitteeResolution::Anchor { committee, cache } => { + self.resolve_from_anchor(committee, cache.as_ref(), target_epoch).await + } + } + } + + /// Fetches a committee directly from a node inside the caller's trust boundary. + async fn resolve_from_node(&self, target_epoch: EpochId) -> Result { + let epoch = self + .client + .get_epoch(Some(target_epoch), Some(ReadMask::from(EpochField::COMMITTEE))) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchCommittee { + epoch: target_epoch, + source: Box::new(source), + }, + ) + })? + .into_inner(); + let committee = epoch.committee().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Committee { + epoch: target_epoch, + source: Box::new(source), + }, + ) + })?; + + Ok(committee.into()) + } + + /// Resolves from trusted cached committees before walking authenticated epoch summaries. + async fn resolve_from_anchor( + &self, + trusted_committee: &Committee, + cache: &dyn CommitteeCache, + target_epoch: EpochId, + ) -> Result { + if target_epoch < trusted_committee.epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::TargetBeforeAnchor { + anchor_epoch: trusted_committee.epoch, + }, + )); + } + + if target_epoch == trusted_committee.epoch { + return Ok(trusted_committee.clone()); + } + + if let Some(committee) = cache.committee(target_epoch).await.map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: target_epoch, + source, + }, + ) + })? { + if committee.epoch != target_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: target_epoch, + source: CommitteeCacheError::Conflict { epoch: target_epoch }, + }, + )); + } + + return Ok(committee); + } + + let mut committee = trusted_committee.clone(); + + while committee.epoch < target_epoch { + let next_epoch = committee.epoch + 1; + let Some(cached) = cache.committee(next_epoch).await.map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: next_epoch, + source, + }, + ) + })? + else { + break; + }; + + if cached.epoch != next_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: next_epoch, + source: CommitteeCacheError::Conflict { epoch: next_epoch }, + }, + )); + } + + committee = cached; + } + + if committee.epoch == target_epoch { + return Ok(committee); + } + + let current_epoch = self.current_epoch(target_epoch).await?; + if target_epoch > current_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::TargetAheadOfNode { current_epoch }, + )); + } + + while committee.epoch < target_epoch { + let next_committee = self.fetch_next_committee(target_epoch, &committee).await?; + cache.store(&next_committee).await.map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: next_committee.epoch, + source, + }, + ) + })?; + committee = next_committee; + } + + Ok(committee) + } + + /// Fetches the connected node's current epoch to reject unreachable targets early. + async fn current_epoch(&self, target_epoch: EpochId) -> Result { + self.client + .get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchCurrentEpoch { + source: Box::new(source), + }, + ) + })? + .body() + .epoch + .ok_or_else(|| { + CommitteeResolutionError::new(target_epoch, CommitteeResolutionErrorKind::MissingCurrentEpoch) + }) + } + + /// Fetches and authenticates the committee elected for the next epoch. + async fn fetch_next_committee( + &self, + target_epoch: EpochId, + current_committee: &Committee, + ) -> Result { + let sequence_number = self + .epoch_last_checkpoint(target_epoch, current_committee.epoch) + .await?; + let summary = self.certified_checkpoint_summary(target_epoch, sequence_number).await?; + let next_committee = Self::authenticate_next_committee(current_committee, &summary) + .map_err(|kind| CommitteeResolutionError::new(target_epoch, kind))?; + + Ok(next_committee) + } + + /// Fetches the checkpoint sequence number that closes an epoch. + async fn epoch_last_checkpoint( + &self, + target_epoch: EpochId, + epoch: EpochId, + ) -> Result { + self.client + .get_epoch(Some(epoch), Some(ReadMask::from(EpochField::LAST_CHECKPOINT))) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchEpochHistory { + epoch, + source: Box::new(source), + }, + ) + })? + .into_inner() + .last_checkpoint + .ok_or_else(|| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::MissingLastCheckpoint { epoch }, + ) + }) + } + + /// Fetches only the signed checkpoint summary required to authenticate the next committee. + async fn certified_checkpoint_summary( + &self, + target_epoch: EpochId, + sequence_number: u64, + ) -> Result { + let checkpoint = self + .client + .get_checkpoint_by_sequence_number( + sequence_number, + Some(ReadMask::from(CHECKPOINT_SUMMARY_FIELDS)), + None, + None, + ) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) + })? + .into_inner(); + + let summary = checkpoint.signed_summary().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::CheckpointSummary { + sequence_number, + source: Box::new(source), + }, + ) + })?; + + summary.try_into().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::CheckpointSummary { + sequence_number, + source: Box::new(source), + }, + ) + }) + } + + /// Verifies an end-of-epoch summary before accepting its next committee. + fn authenticate_next_committee( + current_committee: &Committee, + summary: &CertifiedCheckpointSummary, + ) -> Result { + let sequence_number = summary.sequence_number; + summary.clone().try_into_verified(current_committee).map_err(|source| { + CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { + epoch: current_committee.epoch, + sequence_number, + source: Box::new(source), + } + })?; + + let Some(EndOfEpochData { + next_epoch_committee, .. + }) = &summary.end_of_epoch_data + else { + return Err(CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number }); + }; + let next_epoch = summary + .epoch() + .checked_add(1) + .ok_or(CommitteeResolutionErrorKind::NextEpochOverflow { epoch: summary.epoch() })?; + + Ok(Committee::new( + next_epoch, + next_epoch_committee.iter().cloned().collect(), + )) + } +} + +/// Checkpoint fields required to authenticate the next committee. +const CHECKPOINT_SUMMARY_FIELDS: &[&str] = &[ + CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, + CheckpointResponseField::CHECKPOINT_SIGNATURE, +]; + +#[cfg(test)] +mod tests { + use iota_sdk_types::gas::GasCostSummary; + use iota_types::messages_checkpoint::{CheckpointSummary, EndOfEpochData}; + + use super::*; + + struct StaticCache { + committee: Committee, + } + + #[async_trait::async_trait] + impl CommitteeCache for StaticCache { + async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError> { + Ok((self.committee.epoch == epoch).then(|| self.committee.clone())) + } + + async fn store(&self, _committee: &Committee) -> Result<(), CommitteeCacheError> { + Ok(()) + } + } + + fn signed_end_of_epoch_summary( + current_epoch: EpochId, + include_next_committee: bool, + ) -> (Committee, Committee, CertifiedCheckpointSummary) { + let (base_committee, keypairs) = Committee::new_simple_test_committee(); + let current_committee = Committee::new(current_epoch, base_committee.voting_rights.iter().cloned().collect()); + let (next_base_committee, _) = Committee::new_simple_test_committee_of_size(5); + let next_committee = Committee::new( + current_epoch + 1, + next_base_committee.voting_rights.iter().cloned().collect(), + ); + let end_of_epoch_data = include_next_committee.then(|| EndOfEpochData { + next_epoch_committee: next_committee.voting_rights.clone(), + next_epoch_protocol_version: 1.into(), + epoch_commitments: Vec::new(), + epoch_supply_change: 0, + }); + let summary = CheckpointSummary { + epoch: current_epoch, + sequence_number: 42, + network_total_transactions: 0, + content_digest: Default::default(), + previous_digest: None, + epoch_rolling_gas_cost_summary: GasCostSummary::default(), + timestamp_ms: 0, + checkpoint_commitments: Vec::new(), + end_of_epoch_data, + version_specific_data: Vec::new(), + }; + let certified_summary = + CertifiedCheckpointSummary::new_from_keypairs_for_testing(summary, &keypairs, ¤t_committee); + + (current_committee, next_committee, certified_summary) + } + + #[test] + fn authenticated_summary_returns_the_next_committee() { + let (current_committee, expected_committee, summary) = signed_end_of_epoch_summary(3, true); + + let committee = CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap(); + + assert_eq!(committee, expected_committee); + } + + #[test] + fn summary_rejects_a_signature_from_another_committee() { + let (_, _, summary) = signed_end_of_epoch_summary(3, true); + let (wrong_committee, _) = Committee::new_simple_test_committee_of_size(6); + let wrong_committee = Committee::new(3, wrong_committee.voting_rights.iter().cloned().collect()); + + let error = CommitteeResolver::authenticate_next_committee(&wrong_committee, &summary).unwrap_err(); + + assert!(matches!( + error, + CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { + epoch: 3, + sequence_number: 42, + .. + } + )); + } + + #[test] + fn summary_requires_end_of_epoch_data() { + let (current_committee, _, summary) = signed_end_of_epoch_summary(3, false); + + let error = CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap_err(); + + assert!(matches!( + error, + CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number: 42 } + )); + } + + #[tokio::test] + async fn anchored_resolution_resumes_from_an_authenticated_cache() { + let (current_committee, next_committee, summary) = signed_end_of_epoch_summary(3, true); + let authenticated_committee = + CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap(); + let cache = crate::MemoryCommitteeCache::new(); + cache.store(&authenticated_committee).await.unwrap(); + let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); + let resolver = CommitteeResolver::anchor_with_cache(client, current_committee, cache); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, next_committee); + } + + #[tokio::test] + async fn anchor_mode_uses_a_committee_cache_by_default() { + let (current_committee, next_committee, summary) = signed_end_of_epoch_summary(3, true); + let authenticated_committee = + CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap(); + let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); + let resolver = CommitteeResolver::anchor(client, current_committee); + let CommitteeResolution::Anchor { cache, .. } = &resolver.mode else { + panic!("anchor resolver must have a committee cache"); + }; + cache.store(&authenticated_committee).await.unwrap(); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, next_committee); + } + + #[tokio::test] + async fn memory_cache_rejects_a_conflicting_committee() { + let (_, next_committee, _) = signed_end_of_epoch_summary(3, true); + let cache = crate::MemoryCommitteeCache::new(); + cache.store(&next_committee).await.unwrap(); + let (conflicting_committee, _) = Committee::new_simple_test_committee_of_size(6); + let conflicting_committee = Committee::new(4, conflicting_committee.voting_rights.iter().cloned().collect()); + + let error = cache.store(&conflicting_committee).await.unwrap_err(); + + assert!(matches!(error, CommitteeCacheError::Conflict { epoch: 4 })); + } + + #[tokio::test] + async fn anchored_resolution_accepts_a_committee_from_a_trusted_cache() { + let (current_committee, next_committee, _) = signed_end_of_epoch_summary(3, true); + let cache = StaticCache { + committee: next_committee.clone(), + }; + let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); + let resolver = CommitteeResolver::anchor_with_cache(client, current_committee, cache); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, next_committee); + } +} diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs new file mode 100644 index 0000000..33ad1f0 --- /dev/null +++ b/poi-rs/src/lib.rs @@ -0,0 +1,28 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![doc = include_str!("../README.md")] +#![warn(missing_docs, rustdoc::all)] + +/// Shared boxed source error used by the crate's typed errors. +pub(crate) type BoxError = Box; + +/// Verified committee lineage caches for anchored resolution. +pub mod cache; +/// Committee resolution for checkpoint verification. +pub mod committee; +/// Proof data types and offline verification. +pub mod proof; +/// Sources for constructing proofs. +pub mod source; +/// Target claims authenticated by a proof. +pub mod target; + +pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; +pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; +pub use proof::{ + Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, + VerifyErrorKind, VersionError, +}; +pub use source::{GrpcSource, Source, SourceError, SourceErrorKind, SourceTarget}; +pub use target::ProofTargets; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs new file mode 100644 index 0000000..bbeeffe --- /dev/null +++ b/poi-rs/src/proof.rs @@ -0,0 +1,435 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::{ + committee::Committee, + digests::ChainIdentifier, + effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, EndOfEpochData}, + transaction::Transaction, +}; +use serde::{Deserialize, Serialize}; + +use crate::{BoxError, target::ProofTargets}; + +/// Error returned when a proof-format version is not supported. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("unsupported Proof of Inclusion proof format version: {version}")] +pub struct VersionError { + /// Unsupported proof-format version. + pub version: u16, +} + +/// Error returned when a proof cannot be serialized. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to serialize Proof of Inclusion proof")] +pub struct SerializationError { + /// Serialization failure details. + #[source] + pub kind: SerializationErrorKind, +} + +/// Kind of proof-serialization failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SerializationErrorKind { + /// JSON serialization failed. + #[error("json serialization failed")] + Json { + /// Underlying JSON serialization error. + #[source] + source: serde_json::Error, + }, +} + +/// Error returned when offline proof verification fails. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to verify Proof of Inclusion proof")] +pub struct VerifyError { + /// Verification failure details. + #[source] + pub kind: VerifyErrorKind, +} + +/// Kind of offline proof-verification failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum VerifyErrorKind { + /// The proof-format version is not supported. + #[error("proof format version is not supported")] + Version { + /// Unsupported version error. + #[source] + source: VersionError, + }, + /// The checkpoint summary or its contents failed verification. + #[error("checkpoint summary verification failed")] + CheckpointSummary { + /// Underlying checkpoint-verification error. + #[source] + source: BoxError, + }, + /// A committee target was requested but the checkpoint is not an end-of-epoch checkpoint. + #[error("checkpoint summary does not contain an end-of-epoch committee")] + MissingEndOfEpochCommittee, + /// The next epoch value overflowed while checking a committee target. + #[error("next epoch overflows u64")] + NextEpochOverflow, + /// The committee target does not match the checkpoint's next committee. + #[error("committee target does not match the checkpoint summary")] + CommitteeMismatch, + /// Transaction data does not match the transaction digest in the effects. + #[error("transaction digest does not match the execution digest")] + TransactionDigestMismatch, + /// The transaction effects are not included in the checkpoint contents. + #[error("transaction digest not found in the checkpoint contents")] + TransactionNotInCheckpoint, + /// Packaged events do not match the digest recorded in the effects. + #[error("events digest does not match the execution digest")] + EventsDigestMismatch, + /// Event targets require packaged transaction events. + #[error("transaction effects refer to events but event data is missing")] + MissingEvents, + /// The event target belongs to a different transaction. + #[error("event target does not belong to the transaction")] + EventTransactionMismatch, + /// The event target sequence number is outside the packaged event list. + #[error("event sequence number {sequence} is out of bounds")] + EventSequenceOutOfBounds { + /// Requested event sequence. + sequence: u64, + }, + /// The packaged event does not match the event target. + #[error("event target contents do not match")] + EventContentsMismatch, + /// The object content does not compute to the requested object reference. + #[error("object target reference does not match the object")] + ObjectReferenceMismatch, + /// The transaction effects do not include the requested object reference. + #[error("object target was not found in the transaction effects")] + ObjectNotFound, +} + +/// Proof-format version used for compatibility checks and verifier dispatch. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProofVersion(u16); + +impl ProofVersion { + /// Current Proof of Inclusion proof-format version. + pub const CURRENT: Self = Self(1); + + /// Creates a supported proof-format version. + pub fn new(version: u16) -> Result { + let version = Self(version); + version.validate()?; + Ok(version) + } + + /// Returns the numeric proof-format version. + pub const fn value(self) -> u16 { + self.0 + } + + /// Returns an error when this version is not supported. + pub fn validate(self) -> Result<(), VersionError> { + if self == Self::CURRENT { + Ok(()) + } else { + Err(VersionError { version: self.value() }) + } + } +} + +impl TryFrom for ProofVersion { + type Error = VersionError; + + fn try_from(version: u16) -> Result { + Self::new(version) + } +} + +/// Transaction evidence packaged in a Proof of Inclusion envelope. +/// +/// A transaction proof links one transaction to a certified checkpoint. It carries +/// the checkpoint contents, the transaction, its effects, and the transaction +/// events when the transaction emitted events. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransactionProof { + /// Checkpoint contents including the transaction. + pub checkpoint_contents: CheckpointContents, + /// Transaction being authenticated. + pub transaction: Transaction, + /// Effects of the transaction being authenticated. + pub effects: TransactionEffects, + /// Events of the transaction being authenticated, when present. + pub events: Option, +} + +impl TransactionProof { + /// Creates transaction proof evidence. + pub fn new( + checkpoint_contents: CheckpointContents, + transaction: Transaction, + effects: TransactionEffects, + events: Option, + ) -> Self { + Self { + checkpoint_contents, + transaction, + effects, + events, + } + } +} + +/// Proof of Inclusion evidence for targets included in a certified checkpoint. +/// +/// The envelope always carries transaction evidence. This keeps the public Proof +/// of Inclusion contract focused on inclusion claims rather than generic +/// checkpoint-only verification. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Proof { + /// Proof-format version. + pub version: ProofVersion, + /// Chain or network identity. + pub chain: ChainIdentifier, + /// Target claim authenticated by this proof. + pub target: ProofTargets, + /// Certified checkpoint summary. + pub checkpoint_summary: CertifiedCheckpointSummary, + /// Transaction evidence for the inclusion target. + pub transaction_proof: TransactionProof, +} + +impl Proof { + /// Creates a proof envelope from an explicit target and transaction proof. + /// + /// The constructor sets [`ProofVersion::CURRENT`] automatically. + pub fn new( + chain: ChainIdentifier, + target: ProofTargets, + checkpoint_summary: CertifiedCheckpointSummary, + transaction_proof: TransactionProof, + ) -> Self { + Self { + version: ProofVersion::CURRENT, + chain, + target, + checkpoint_summary, + transaction_proof, + } + } + + /// Returns the proof-format version. + pub const fn version(&self) -> ProofVersion { + self.version + } + + /// Returns the proof target. + pub const fn target(&self) -> &ProofTargets { + &self.target + } + + /// Serializes this proof envelope as JSON. + pub fn to_json_vec(&self) -> Result, SerializationError> { + serde_json::to_vec(self).map_err(|source| SerializationError { + kind: SerializationErrorKind::Json { source }, + }) + } + + /// Validates proof-format version. + pub fn validate(&self) -> Result<(), VersionError> { + self.version.validate() + } +} + +/// Offline Proof of Inclusion verifier. +/// +/// `ProofVerifier` verifies only the proof material supplied by the caller. It +/// does not fetch data, resolve committees, or trust a node. +#[derive(Clone, Copy, Debug)] +pub struct ProofVerifier<'committee> { + committee: &'committee Committee, +} + +impl<'committee> ProofVerifier<'committee> { + /// Creates a verifier for proofs certified by `committee`. + pub const fn new(committee: &'committee Committee) -> Self { + Self { committee } + } + + /// Returns the committee used by this verifier. + pub const fn committee(&self) -> &'committee Committee { + self.committee + } + + /// Verifies a Proof of Inclusion. + /// + /// The verifier checks the checkpoint summary and all transaction evidence + /// before authenticating object, event, or committee targets. + pub fn verify(&self, proof: &Proof) -> Result<(), VerifyError> { + proof.validate().map_err(|source| VerifyError { + kind: VerifyErrorKind::Version { source }, + })?; + + let summary = &proof.checkpoint_summary; + let contents = Some(&proof.transaction_proof.checkpoint_contents); + + summary + .verify_with_contents(self.committee, contents) + .map_err(|source| VerifyError { + kind: VerifyErrorKind::CheckpointSummary { + source: Box::new(source), + }, + })?; + + self.verify_committee_target(summary, &proof.target)?; + self.verify_transaction_proof(summary, &proof.transaction_proof)?; + self.verify_event_targets(&proof.target, &proof.transaction_proof)?; + self.verify_object_targets(&proof.target, &proof.transaction_proof)?; + + Ok(()) + } + + fn verify_committee_target( + &self, + summary: &CertifiedCheckpointSummary, + targets: &ProofTargets, + ) -> Result<(), VerifyError> { + let Some(expected_committee) = &targets.committee else { + return Ok(()); + }; + + let Some(EndOfEpochData { + next_epoch_committee, .. + }) = &summary.end_of_epoch_data + else { + return Err(VerifyError { + kind: VerifyErrorKind::MissingEndOfEpochCommittee, + }); + }; + + let actual_committee = Committee::new( + summary.epoch().checked_add(1).ok_or(VerifyError { + kind: VerifyErrorKind::NextEpochOverflow, + })?, + next_epoch_committee.iter().cloned().collect(), + ); + + if actual_committee != *expected_committee { + return Err(VerifyError { + kind: VerifyErrorKind::CommitteeMismatch, + }); + } + + Ok(()) + } + + fn verify_transaction_proof( + &self, + summary: &CertifiedCheckpointSummary, + transaction_proof: &TransactionProof, + ) -> Result<(), VerifyError> { + let execution_digests = transaction_proof.effects.execution_digests(); + if transaction_proof.transaction.digest() != &execution_digests.transaction { + return Err(VerifyError { + kind: VerifyErrorKind::TransactionDigestMismatch, + }); + } + + let transaction_is_in_checkpoint = transaction_proof + .checkpoint_contents + .enumerate_transactions(summary) + .any(|(_, digests)| digests == &execution_digests); + + if !transaction_is_in_checkpoint { + return Err(VerifyError { + kind: VerifyErrorKind::TransactionNotInCheckpoint, + }); + } + + if transaction_proof.effects.events_digest() + != transaction_proof.events.as_ref().map(|events| events.digest()).as_ref() + { + return Err(VerifyError { + kind: VerifyErrorKind::EventsDigestMismatch, + }); + } + + Ok(()) + } + + fn verify_event_targets( + &self, + targets: &ProofTargets, + transaction_proof: &TransactionProof, + ) -> Result<(), VerifyError> { + if targets.events.is_empty() { + return Ok(()); + } + + let Some(events) = &transaction_proof.events else { + return Err(VerifyError { + kind: VerifyErrorKind::MissingEvents, + }); + }; + + let execution_digests = transaction_proof.effects.execution_digests(); + for (event_id, event) in &targets.events { + if event_id.tx_digest != execution_digests.transaction { + return Err(VerifyError { + kind: VerifyErrorKind::EventTransactionMismatch, + }); + } + + let event_index = event_id.event_seq as usize; + let Some(actual_event) = events.get(event_index) else { + return Err(VerifyError { + kind: VerifyErrorKind::EventSequenceOutOfBounds { + sequence: event_id.event_seq, + }, + }); + }; + + if actual_event != event { + return Err(VerifyError { + kind: VerifyErrorKind::EventContentsMismatch, + }); + } + } + + Ok(()) + } + + fn verify_object_targets( + &self, + targets: &ProofTargets, + transaction_proof: &TransactionProof, + ) -> Result<(), VerifyError> { + if targets.objects.is_empty() { + return Ok(()); + } + + let changed_objects = transaction_proof.effects.all_changed_objects(); + for (object_ref, object) in &targets.objects { + if object_ref != &object.as_inner().object_ref() { + return Err(VerifyError { + kind: VerifyErrorKind::ObjectReferenceMismatch, + }); + } + + changed_objects + .iter() + .find(|changed_object_ref| &changed_object_ref.0 == object_ref) + .ok_or(VerifyError { + kind: VerifyErrorKind::ObjectNotFound, + })?; + } + + Ok(()) + } +} diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs new file mode 100644 index 0000000..f286272 --- /dev/null +++ b/poi-rs/src/source.rs @@ -0,0 +1,554 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use async_trait::async_trait; +use iota_grpc_client::{ + CheckpointResponse, Client as GrpcClient, ReadMask, + read_mask_fields::{CheckpointResponseField, ObjectField, TransactionField}, +}; +use iota_grpc_types::v1::transaction::ExecutedTransaction; +use iota_sdk_types::{Digest, SignedTransaction}; +use iota_types::{ + base_types::ObjectRef, + digests::{ChainIdentifier, TransactionDigest}, + effects::{TransactionEffects, TransactionEffectsAPI}, + event::EventID, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, + object::Object, + transaction::Transaction, +}; + +use crate::{BoxError, Proof, ProofTargets, TransactionProof}; + +/// Source target requested by the caller. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SourceTarget { + /// A transaction proof request. + Transaction(TransactionDigest), + /// An object proof request. + Object(ObjectRef), + /// An event proof request. + Event(EventID), +} + +impl fmt::Display for SourceTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transaction(transaction_digest) => write!(f, "transaction {transaction_digest}"), + Self::Object(object_ref) => write!(f, "object {object_ref:?}"), + Self::Event(event_id) => write!(f, "event {event_id:?}"), + } + } +} + +/// Error returned when a source cannot build a proof. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to build proof for {target}")] +pub struct SourceError { + /// Target requested from the source. + pub target: SourceTarget, + /// Source failure details. + #[source] + pub kind: SourceErrorKind, +} + +impl SourceError { + /// Creates a source error for a requested transaction. + pub fn new(transaction_digest: TransactionDigest, kind: SourceErrorKind) -> Self { + Self::transaction(transaction_digest, kind) + } + + /// Creates a source error for a requested transaction. + pub fn transaction(transaction_digest: TransactionDigest, kind: SourceErrorKind) -> Self { + Self { + target: SourceTarget::Transaction(transaction_digest), + kind, + } + } + + /// Creates a source error for a requested object. + pub fn object(object_ref: ObjectRef, kind: SourceErrorKind) -> Self { + Self { + target: SourceTarget::Object(object_ref), + kind, + } + } + + /// Creates a source error for a requested event. + pub fn event(event_id: EventID, kind: SourceErrorKind) -> Self { + Self { + target: SourceTarget::Event(event_id), + kind, + } + } +} + +/// Kind of proof source failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SourceErrorKind { + /// Fetching the transaction from the source failed. + #[error("failed to fetch transaction")] + FetchTransaction { + /// Underlying source error. + #[source] + source: BoxError, + }, + /// The source returned no transaction for the requested digest. + #[error("transaction was not found")] + TransactionNotFound, + /// Fetching the object from the source failed. + #[error("failed to fetch object")] + FetchObject { + /// Underlying source error. + #[source] + source: BoxError, + }, + /// The source returned no object for the requested reference. + #[error("object was not found")] + ObjectNotFound, + /// Reading or converting the object failed. + #[error("failed to read object")] + Object { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// The returned object does not compute to the requested reference. + #[error("object reference does not match the requested reference")] + ObjectReferenceMismatch, + /// The source could not resolve the requested event. + #[error("event was not found")] + EventNotFound, + /// The transaction response did not expose a checkpoint sequence number. + #[error("transaction response is missing checkpoint sequence")] + MissingCheckpointSequence { + /// Underlying response error. + #[source] + source: BoxError, + }, + /// Fetching the checkpoint from the source failed. + #[error("failed to fetch checkpoint {sequence_number}")] + FetchCheckpoint { + /// Checkpoint sequence number requested from the source. + sequence_number: u64, + /// Underlying source error. + #[source] + source: BoxError, + }, + /// Reading or converting the checkpoint summary failed. + #[error("failed to read checkpoint summary")] + CheckpointSummary { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// Reading or converting checkpoint contents failed. + #[error("failed to read checkpoint contents")] + CheckpointContents { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// Reading or converting the signed transaction failed. + #[error("failed to read signed transaction")] + Transaction { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// Reading transaction signatures failed. + #[error("failed to read transaction signatures")] + Signatures { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// Reading transaction effects failed. + #[error("failed to read transaction effects")] + Effects { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// Transaction effects commit to events, but the response did not include events. + #[error("transaction effects refer to events but event data is missing")] + MissingEvents { + /// Underlying response error. + #[source] + source: BoxError, + }, + /// Reading transaction events failed. + #[error("failed to read transaction events")] + Events { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, +} + +/// Source boundary for building Proof of Inclusion envelopes. +/// +/// Implementations may fetch data from gRPC, archive storage, fixtures, or any +/// other source. Returned proofs are still untrusted until verified with +/// [`crate::ProofVerifier`]. +#[async_trait] +pub trait Source { + /// Builds a transaction proof from source data. + /// + /// The returned proof packages the transaction, effects, optional events, + /// certified checkpoint summary, and checkpoint contents. The transaction + /// itself is the authenticated claim, so the proof has no additional object, + /// event, or committee targets. + async fn transaction(&self, transaction_digest: TransactionDigest) -> Result; + + /// Builds an object proof from source data. + /// + /// The source resolves the object reference to the transaction that last + /// created or mutated the object, builds that transaction proof, and attaches + /// the object as a target. Returned proofs remain untrusted until verified. + async fn object(&self, object_ref: ObjectRef) -> Result; + + /// Builds an event proof from source data. + /// + /// The source uses the transaction digest embedded in the event ID, builds + /// that transaction proof, and attaches the event at the requested sequence + /// as a target. Returned proofs remain untrusted until verified. + async fn event(&self, event_id: EventID) -> Result; +} + +/// gRPC-backed source for transaction proofs. +/// +/// `GrpcSource` fetches transaction and checkpoint data from a connected gRPC +/// node and packages it into a [`Proof`]. The node is treated only as a data +/// source: callers still need to verify the returned proof with a trusted +/// committee before trusting any packaged data. +#[derive(Clone)] +pub struct GrpcSource { + client: GrpcClient, +} + +impl GrpcSource { + /// Creates a gRPC-backed source from an SDK gRPC client. + pub fn new(client: GrpcClient) -> Self { + Self { client } + } + + /// Returns the underlying SDK gRPC client. + pub const fn grpc_client(&self) -> &GrpcClient { + &self.client + } + + /// Fetches the executed transaction envelope with the fields needed for inclusion. + async fn fetch_executed_transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result { + let digest = Digest::new(transaction_digest.into_inner()); + let transactions = self + .client + .get_transactions(&[digest], Some(ReadMask::from(TRANSACTION_PROOF_FIELDS))) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchTransaction { + source: Box::new(source), + }, + ) + })?; + + transactions + .body() + .first() + .cloned() + .ok_or_else(|| SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound)) + } + + /// Fetches the object contents for an exact object reference. + async fn fetch_object(&self, object_ref: ObjectRef) -> Result { + let objects = self + .client + .get_objects( + &[(object_ref.object_id, Some(object_ref.version))], + Some(ReadMask::from(OBJECT_PROOF_FIELDS)), + ) + .await + .map_err(|source| { + SourceError::object( + object_ref, + SourceErrorKind::FetchObject { + source: Box::new(source), + }, + ) + })?; + let object: Object = objects + .body() + .first() + .ok_or_else(|| SourceError::object(object_ref, SourceErrorKind::ObjectNotFound))? + .object() + .map_err(|source| { + SourceError::object( + object_ref, + SourceErrorKind::Object { + source: Box::new(source), + }, + ) + })? + .into(); + + if object.as_inner().object_ref() != object_ref { + return Err(SourceError::object( + object_ref, + SourceErrorKind::ObjectReferenceMismatch, + )); + } + + Ok(object) + } + + /// Fetches the certified checkpoint summary and contents for an executed transaction. + async fn fetch_checkpoint_with_contents( + &self, + transaction_digest: TransactionDigest, + sequence_number: u64, + ) -> Result { + self.client + .get_checkpoint_by_sequence_number( + sequence_number, + Some(ReadMask::from(CHECKPOINT_PROOF_FIELDS)), + None, + None, + ) + .await + .map(|response| response.into_inner()) + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) + }) + } +} + +#[async_trait] +impl Source for GrpcSource { + async fn transaction(&self, transaction_digest: TransactionDigest) -> Result { + let executed_transaction = self.fetch_executed_transaction(transaction_digest).await?; + let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingCheckpointSequence { + source: Box::new(source), + }, + ) + })?; + let checkpoint = self + .fetch_checkpoint_with_contents(transaction_digest, checkpoint_sequence_number) + .await?; + let checkpoint_summary: CertifiedCheckpointSummary = checkpoint + .signed_summary() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })? + .try_into() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })?; + let checkpoint_contents: CheckpointContents = checkpoint + .contents() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) + })? + .contents() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) + }) + .and_then(|contents| { + contents.try_into().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) + }) + })?; + let transaction = executed_transaction + .transaction() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })? + .transaction() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let signatures = executed_transaction + .signatures() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + })? + .signatures + .iter() + .map(|signature| { + signature.signature().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + }) + }) + .collect::, SourceError>>()?; + let transaction: Transaction = SignedTransaction { + transaction, + signatures, + } + .try_into() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let effects: TransactionEffects = executed_transaction + .effects() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Effects { + source: Box::new(source), + }, + ) + })? + .effects() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Effects { + source: Box::new(source), + }, + ) + })?; + let events = if effects.events_digest().is_some() { + executed_transaction + .events() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingEvents { + source: Box::new(source), + }, + ) + })? + .events() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Events { + source: Box::new(source), + }, + ) + }) + .map(Some)? + } else { + None + }; + + Ok(Proof::new( + ChainIdentifier::from(*checkpoint_summary.digest()), + ProofTargets::new(), + checkpoint_summary, + TransactionProof::new(checkpoint_contents, transaction, effects, events), + )) + } + + async fn object(&self, object_ref: ObjectRef) -> Result { + let object = self.fetch_object(object_ref).await?; + let mut proof = self.transaction(object.previous_transaction).await?; + proof.target = proof.target.add_object(object_ref, object); + Ok(proof) + } + + async fn event(&self, event_id: EventID) -> Result { + let mut proof = self.transaction(event_id.tx_digest).await?; + let event = proof + .transaction_proof + .events + .as_ref() + .and_then(|events| { + usize::try_from(event_id.event_seq) + .ok() + .and_then(|index| events.get(index)) + }) + .cloned() + .ok_or_else(|| SourceError::event(event_id, SourceErrorKind::EventNotFound))?; + proof.target = proof.target.add_event(event_id, event); + Ok(proof) + } +} + +// Minimum gRPC fields needed to package a transaction proof. +const TRANSACTION_PROOF_FIELDS: &[&str] = &[ + TransactionField::TRANSACTION_BCS, + TransactionField::SIGNATURES, + TransactionField::EFFECTS_BCS, + TransactionField::EVENTS_DIGEST, + TransactionField::EVENTS_EVENTS_BCS, + TransactionField::CHECKPOINT, +]; + +// Minimum gRPC fields needed to package an object target. +const OBJECT_PROOF_FIELDS: &[&str] = &[ObjectField::BCS]; + +// Minimum gRPC fields needed to authenticate checkpoint contents. +const CHECKPOINT_PROOF_FIELDS: &[&str] = &[ + CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, + CheckpointResponseField::CHECKPOINT_SIGNATURE, + CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, +]; diff --git a/poi-rs/src/target.rs b/poi-rs/src/target.rs new file mode 100644 index 0000000..1792a73 --- /dev/null +++ b/poi-rs/src/target.rs @@ -0,0 +1,60 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::Event; +use iota_types::committee::Committee; +use iota_types::{base_types::ObjectRef, event::EventID, object::Object}; +use serde::{Deserialize, Serialize}; + +/// Target claims authenticated by a Proof of Inclusion. +/// +/// Object and event targets are authenticated through the transaction evidence in +/// the proof. Committee targets authenticate the next epoch committee recorded in +/// an end-of-epoch checkpoint summary. +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +pub struct ProofTargets { + /// Objects that need to be certified. + pub objects: Vec<(ObjectRef, Object)>, + + /// Events that need to be certified. + pub events: Vec<(EventID, Event)>, + + /// The next committee being certified. + pub committee: Option, +} + +impl ProofTargets { + /// Creates an empty target set. + /// + /// Empty targets are mainly useful while constructing proofs incrementally. + pub fn new() -> Self { + Self::default() + } + + /// Adds an object target by object reference and object contents. + /// + /// Verification checks that the object computes to the supplied reference and + /// that the transaction effects include the reference. + pub fn add_object(mut self, object_ref: ObjectRef, object: Object) -> Self { + self.objects.push((object_ref, object)); + self + } + + /// Adds an event target by event ID and event contents. + /// + /// Verification checks that the event belongs to the transaction and matches + /// the event stored at the requested event sequence. + pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { + self.events.push((event_id, event)); + self + } + + /// Adds a next-epoch committee target. + /// + /// Verification checks that the checkpoint is an end-of-epoch checkpoint and + /// that its next committee matches the supplied committee. + pub fn set_committee(mut self, committee: Committee) -> Self { + self.committee = Some(committee); + self + } +} diff --git a/poi-rs/tests/cache.rs b/poi-rs/tests/cache.rs new file mode 100644 index 0000000..b5ad162 --- /dev/null +++ b/poi-rs/tests/cache.rs @@ -0,0 +1,16 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use poi_rs::{CommitteeCache, MemoryCommitteeCache}; + +fn accepts_cache_trait_object(_cache: &dyn CommitteeCache) {} + +#[tokio::test] +async fn memory_cache_starts_empty() { + let cache = MemoryCommitteeCache::new(); + + accepts_cache_trait_object(&cache); + assert!(cache.is_empty().await); + assert_eq!(cache.len().await, 0); + assert!(cache.committee(0).await.unwrap().is_none()); +} diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee.rs new file mode 100644 index 0000000..b91ce1f --- /dev/null +++ b/poi-rs/tests/committee.rs @@ -0,0 +1,57 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_grpc_client::Client as GrpcClient; +use iota_types::committee::Committee; +use poi_rs::{CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; + +fn committee_at(epoch: u64) -> Committee { + let (committee, _) = Committee::new_simple_test_committee(); + Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) +} + +fn disconnected_client() -> GrpcClient { + GrpcClient::new("http://127.0.0.1:1").expect("create lazy gRPC client") +} + +#[tokio::test] +async fn anchor_mode_returns_the_trusted_committee_for_its_epoch() { + let trusted_committee = committee_at(7); + let resolver = CommitteeResolver::anchor(disconnected_client(), trusted_committee.clone()); + + let resolved = resolver.resolve(7).await.unwrap(); + + assert_eq!(resolved, trusted_committee); +} + +#[tokio::test] +async fn anchor_mode_rejects_an_epoch_before_the_trust_anchor() { + let resolver = CommitteeResolver::anchor(disconnected_client(), committee_at(7)); + + let error = resolver.resolve(6).await.unwrap_err(); + + assert_eq!(error.target_epoch, 6); + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::TargetBeforeAnchor { anchor_epoch: 7 } + )); +} + +#[tokio::test] +async fn node_mode_has_an_explicit_constructor() { + let _resolver = CommitteeResolver::node(disconnected_client()); +} + +#[tokio::test] +async fn anchor_mode_accepts_a_committee_cache() { + let trusted_committee = committee_at(7); + let resolver = CommitteeResolver::anchor_with_cache( + disconnected_client(), + trusted_committee.clone(), + MemoryCommitteeCache::new(), + ); + + let resolved = resolver.resolve(7).await.unwrap(); + + assert_eq!(resolved, trusted_committee); +} diff --git a/poi-rs/tests/proof.rs b/poi-rs/tests/proof.rs new file mode 100644 index 0000000..3ac5ebc --- /dev/null +++ b/poi-rs/tests/proof.rs @@ -0,0 +1,34 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::committee::Committee; +use poi_rs::{Proof, ProofVerifier, ProofVersion, TransactionProof}; + +fn proof_transaction_proof_is_required(proof: Proof) -> TransactionProof { + proof.transaction_proof +} + +#[test] +fn current_proof_format_version_is_one() { + assert_eq!(ProofVersion::CURRENT.value(), 1); + assert_eq!( + ProofVersion::new(ProofVersion::CURRENT.value()).unwrap(), + ProofVersion::CURRENT + ); +} + +#[test] +fn proof_requires_transaction_proof() { + let transaction_proof_field: fn(Proof) -> TransactionProof = proof_transaction_proof_is_required; + let _ = transaction_proof_field; +} + +#[test] +fn proof_verifier_is_the_public_verification_entrypoint() { + let (committee, _) = Committee::new_simple_test_committee(); + let verifier = ProofVerifier::new(&committee); + let verify_method = ProofVerifier::verify; + + assert_eq!(verifier.committee().epoch, committee.epoch); + let _ = verify_method; +} diff --git a/poi-rs/tests/source.rs b/poi-rs/tests/source.rs new file mode 100644 index 0000000..157ddf1 --- /dev/null +++ b/poi-rs/tests/source.rs @@ -0,0 +1,208 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_sdk_types::{Event, gas::GasCostSummary}; +use iota_types::{ + base_types::{ExecutionData, ObjectRef}, + committee::Committee, + digests::{ChainIdentifier, TransactionDigest}, + effects::TransactionEvents, + event::EventID, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, FullCheckpointContents}, + object::Object, + sdk_types::{Address, Identifier, ObjectId, StructTag}, +}; +use poi_rs::{ + Proof, ProofTargets, ProofVerifier, Source, SourceError, SourceErrorKind, SourceTarget, TransactionProof, +}; + +#[derive(Default)] +struct MockSource { + proof: Option, + object: Option, +} + +#[async_trait] +impl Source for MockSource { + async fn transaction(&self, transaction_digest: TransactionDigest) -> Result { + self.proof + .clone() + .ok_or_else(|| SourceError::new(transaction_digest, SourceErrorKind::TransactionNotFound)) + } + + async fn object(&self, object_ref: ObjectRef) -> Result { + let object = self + .object + .clone() + .filter(|object| object.as_inner().object_ref() == object_ref) + .ok_or_else(|| SourceError::object(object_ref, SourceErrorKind::ObjectNotFound))?; + let mut proof = self.transaction(object.previous_transaction).await?; + proof.target = proof.target.add_object(object_ref, object); + Ok(proof) + } + + async fn event(&self, event_id: EventID) -> Result { + let mut proof = self.transaction(event_id.tx_digest).await?; + let event = proof + .transaction_proof + .events + .as_ref() + .and_then(|events| events.get(event_id.event_seq as usize)) + .cloned() + .ok_or_else(|| SourceError::event(event_id, SourceErrorKind::EventNotFound))?; + proof.target = proof.target.add_event(event_id, event); + Ok(proof) + } +} + +fn test_execution_data() -> ExecutionData { + FullCheckpointContents::random_for_testing() + .into_iter() + .next() + .expect("test checkpoint contents includes one transaction") +} + +fn test_proof() -> (Committee, TransactionDigest, Proof) { + let execution_data = test_execution_data(); + let transaction_digest = *execution_data.transaction.digest(); + let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); + let checkpoint_summary = CheckpointSummary { + epoch: 0, + sequence_number: 0, + network_total_transactions: checkpoint_contents.size() as u64, + content_digest: *checkpoint_contents.digest(), + previous_digest: None, + epoch_rolling_gas_cost_summary: GasCostSummary::default(), + timestamp_ms: 0, + checkpoint_commitments: Vec::new(), + end_of_epoch_data: None, + version_specific_data: Vec::new(), + }; + let (committee, keypairs) = Committee::new_simple_test_committee(); + let checkpoint_summary = + CertifiedCheckpointSummary::new_from_keypairs_for_testing(checkpoint_summary, &keypairs, &committee); + let chain = ChainIdentifier::from(*checkpoint_summary.digest()); + let proof = Proof::new( + chain, + ProofTargets::new(), + checkpoint_summary, + TransactionProof::new( + checkpoint_contents, + execution_data.transaction, + execution_data.effects, + None, + ), + ); + + (committee, transaction_digest, proof) +} + +fn test_event(contents: Vec) -> Event { + Event { + package_id: ObjectId::SYSTEM, + module: Identifier::IOTA_SYSTEM_MODULE, + sender: Address::SYSTEM, + type_: StructTag::new( + Address::SYSTEM, + Identifier::IOTA_SYSTEM_MODULE, + Identifier::SYSTEM_EPOCH_INFO_EVENT, + Vec::new(), + ), + contents, + } +} + +#[tokio::test] +async fn source_builds_transaction_proof() { + let (committee, transaction_digest, proof) = test_proof(); + let source = MockSource { + proof: Some(proof), + object: None, + }; + + let proof = source.transaction(transaction_digest).await.unwrap(); + + assert_eq!(proof.transaction_proof.transaction.digest(), &transaction_digest); + ProofVerifier::new(&committee).verify(&proof).unwrap(); +} + +#[tokio::test] +async fn source_builds_object_proof() { + let (_, transaction_digest, proof) = test_proof(); + let mut object = Object::immutable_for_testing(); + object.previous_transaction = transaction_digest; + let object_ref = object.as_inner().object_ref(); + let source = MockSource { + proof: Some(proof), + object: Some(object.clone()), + }; + + let proof = source.object(object_ref).await.unwrap(); + + assert_eq!(proof.transaction_proof.transaction.digest(), &transaction_digest); + assert_eq!(proof.target.objects, vec![(object_ref, object)]); +} + +#[tokio::test] +async fn source_builds_event_proof() { + let (_, transaction_digest, mut proof) = test_proof(); + let event = test_event(vec![1, 2, 3]); + proof.transaction_proof.events = Some(TransactionEvents(vec![event.clone()])); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + let source = MockSource { + proof: Some(proof), + object: None, + }; + + let proof = source.event(event_id).await.unwrap(); + + assert_eq!(proof.transaction_proof.transaction.digest(), &transaction_digest); + assert_eq!(proof.target.events, vec![(event_id, event)]); +} + +#[tokio::test] +async fn transaction_surfaces_source_failures() { + let (_, transaction_digest, _) = test_proof(); + let source = MockSource::default(); + + let result = source.transaction(transaction_digest).await; + + let error = result.unwrap_err(); + assert_eq!(error.target, SourceTarget::Transaction(transaction_digest)); + assert!(matches!(error.kind, SourceErrorKind::TransactionNotFound)); +} + +#[tokio::test] +async fn object_surfaces_source_failures() { + let object_ref = Object::immutable_for_testing().as_inner().object_ref(); + let source = MockSource::default(); + + let result = source.object(object_ref).await; + + let error = result.unwrap_err(); + assert_eq!(error.target, SourceTarget::Object(object_ref)); + assert!(matches!(error.kind, SourceErrorKind::ObjectNotFound)); +} + +#[tokio::test] +async fn event_surfaces_source_failures() { + let (_, transaction_digest, proof) = test_proof(); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + let source = MockSource { + proof: Some(proof), + object: None, + }; + + let result = source.event(event_id).await; + + let error = result.unwrap_err(); + assert_eq!(error.target, SourceTarget::Event(event_id)); + assert!(matches!(error.kind, SourceErrorKind::EventNotFound)); +} diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/verifier.rs new file mode 100644 index 0000000..45d9c1a --- /dev/null +++ b/poi-rs/tests/verifier.rs @@ -0,0 +1,267 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::{Event, gas::GasCostSummary}; +use iota_types::{ + base_types::{ExecutionData, dbg_object_id}, + committee::Committee, + digests::{ChainIdentifier, TransactionDigest}, + effects::{TestEffectsBuilder, TransactionEvents}, + event::EventID, + messages_checkpoint::{ + CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, + }, + object::Object, + sdk_types::{Address, Identifier, ObjectId, StructTag}, +}; +use poi_rs::{Proof, ProofTargets, ProofVerifier, TransactionProof, VerifyErrorKind}; + +fn test_execution_data() -> ExecutionData { + FullCheckpointContents::random_for_testing() + .into_iter() + .next() + .expect("test checkpoint contents includes one transaction") +} + +fn sign_checkpoint_summary( + checkpoint_contents: &CheckpointContents, + end_of_epoch_data: Option, +) -> (Committee, CertifiedCheckpointSummary) { + let checkpoint_summary = CheckpointSummary { + epoch: 0, + sequence_number: 0, + network_total_transactions: checkpoint_contents.size() as u64, + content_digest: *checkpoint_contents.digest(), + previous_digest: None, + epoch_rolling_gas_cost_summary: GasCostSummary::default(), + timestamp_ms: 0, + checkpoint_commitments: Vec::new(), + end_of_epoch_data, + version_specific_data: Vec::new(), + }; + let (committee, keypairs) = Committee::new_simple_test_committee(); + let checkpoint_summary = + CertifiedCheckpointSummary::new_from_keypairs_for_testing(checkpoint_summary, &keypairs, &committee); + + (committee, checkpoint_summary) +} + +fn test_proof() -> (Committee, Proof) { + test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new(), None) +} + +fn test_proof_with_targets_and_end_of_epoch_data( + targets: ProofTargets, + end_of_epoch_data: Option, +) -> (Committee, Proof) { + let execution_data = test_execution_data(); + let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); + let (committee, checkpoint_summary) = sign_checkpoint_summary(&checkpoint_contents, end_of_epoch_data); + let chain = ChainIdentifier::from(*checkpoint_summary.digest()); + + let proof = Proof::new( + chain, + targets, + checkpoint_summary, + TransactionProof::new( + checkpoint_contents, + execution_data.transaction, + execution_data.effects, + None, + ), + ); + + (committee, proof) +} + +fn test_proof_with_events(events: TransactionEvents) -> (Committee, TransactionDigest, Proof) { + let mut execution_data = test_execution_data(); + let transaction_digest = *execution_data.transaction.digest(); + execution_data.effects = TestEffectsBuilder::new(execution_data.transaction.data()) + .with_events_digest(events.digest()) + .build(); + let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); + let (committee, checkpoint_summary) = sign_checkpoint_summary(&checkpoint_contents, None); + let chain = ChainIdentifier::from(*checkpoint_summary.digest()); + + let proof = Proof::new( + chain, + ProofTargets::new(), + checkpoint_summary, + TransactionProof::new( + checkpoint_contents, + execution_data.transaction, + execution_data.effects, + Some(events), + ), + ); + + (committee, transaction_digest, proof) +} + +fn test_event(contents: Vec) -> Event { + Event { + package_id: ObjectId::SYSTEM, + module: Identifier::IOTA_SYSTEM_MODULE, + sender: Address::SYSTEM, + type_: StructTag::new( + Address::SYSTEM, + Identifier::IOTA_SYSTEM_MODULE, + Identifier::SYSTEM_EPOCH_INFO_EVENT, + Vec::new(), + ), + contents, + } +} + +fn epoch_one_committee(committee: &Committee) -> Committee { + Committee::new(1, committee.voting_rights.iter().cloned().collect()) +} + +fn end_of_epoch_data_for(committee: &Committee) -> EndOfEpochData { + EndOfEpochData { + next_epoch_committee: committee.voting_rights.clone(), + next_epoch_protocol_version: 1.into(), + epoch_commitments: Vec::new(), + epoch_supply_change: 0, + } +} + +#[test] +fn verifier_accepts_valid_transaction_proof() { + let (committee, proof) = test_proof(); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(result.is_ok()); +} + +#[test] +fn verifier_rejects_transaction_digest_mismatch() { + let (committee, mut proof) = test_proof(); + proof.transaction_proof.effects = test_execution_data().effects; + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::TransactionDigestMismatch))); +} + +#[test] +fn verifier_rejects_events_digest_mismatch() { + let (committee, mut proof) = test_proof(); + proof.transaction_proof.events = Some(TransactionEvents(Vec::new())); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventsDigestMismatch))); +} + +#[test] +fn verifier_rejects_checkpoint_contents_mismatch() { + let (committee, mut proof) = test_proof(); + let alternate_execution_data = test_execution_data(); + proof.transaction_proof.checkpoint_contents = + CheckpointContents::new_with_digests_only_for_tests([alternate_execution_data.digests()]); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::CheckpointSummary { .. }))); +} + +#[test] +fn verifier_rejects_transaction_not_in_checkpoint() { + let (committee, mut proof) = test_proof(); + let alternate_execution_data = test_execution_data(); + proof.transaction_proof.transaction = alternate_execution_data.transaction; + proof.transaction_proof.effects = alternate_execution_data.effects; + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint))); +} + +#[test] +fn verifier_rejects_missing_end_of_epoch_committee() { + let (committee, _) = Committee::new_simple_test_committee(); + let expected_committee = epoch_one_committee(&committee); + let (verifying_committee, proof) = + test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new().set_committee(expected_committee), None); + + let result = ProofVerifier::new(&verifying_committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::MissingEndOfEpochCommittee))); +} + +#[test] +fn verifier_rejects_committee_mismatch() { + let (actual_next_committee, _) = Committee::new_simple_test_committee(); + let actual_next_committee = epoch_one_committee(&actual_next_committee); + let (wrong_next_committee, _) = Committee::new_simple_test_committee_of_size(5); + let wrong_next_committee = epoch_one_committee(&wrong_next_committee); + let (verifying_committee, proof) = test_proof_with_targets_and_end_of_epoch_data( + ProofTargets::new().set_committee(wrong_next_committee), + Some(end_of_epoch_data_for(&actual_next_committee)), + ); + + let result = ProofVerifier::new(&verifying_committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::CommitteeMismatch))); +} + +#[test] +fn verifier_rejects_object_reference_mismatch() { + let object = Object::immutable_for_testing(); + let mut wrong_object_ref = object.as_inner().object_ref(); + wrong_object_ref.object_id = dbg_object_id(42); + let (committee, proof) = + test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new().add_object(wrong_object_ref, object), None); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::ObjectReferenceMismatch))); +} + +#[test] +fn verifier_rejects_object_not_found_in_transaction_effects() { + let object = Object::immutable_for_testing(); + let object_ref = object.as_inner().object_ref(); + let (committee, proof) = + test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new().add_object(object_ref, object), None); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::ObjectNotFound))); +} + +#[test] +fn verifier_rejects_event_contents_mismatch() { + let event = test_event(vec![1, 2, 3]); + let wrong_event = test_event(vec![9, 9, 9]); + let (committee, transaction_digest, mut proof) = test_proof_with_events(TransactionEvents(vec![event])); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + proof.target = ProofTargets::new().add_event(event_id, wrong_event); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventContentsMismatch))); +} + +#[test] +fn verifier_rejects_event_sequence_out_of_bounds() { + let event = test_event(vec![1, 2, 3]); + let (committee, transaction_digest, mut proof) = test_proof_with_events(TransactionEvents(vec![event.clone()])); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 1, + }; + proof.target = ProofTargets::new().add_event(event_id, event); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!( + matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventSequenceOutOfBounds { sequence: 1 })) + ); +}