From 19ef8ffa0fee01cdc8427af4ea2e6d93effae3f1 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Jul 2026 10:43:48 +0300 Subject: [PATCH 01/13] feat: Implement Proof of Inclusion support in poi-rs module --- Cargo.toml | 3 +- poi-rs/Cargo.toml | 18 +++++ poi-rs/src/error.rs | 20 ++++++ poi-rs/src/lib.rs | 12 ++++ poi-rs/src/proof.rs | 127 +++++++++++++++++++++++++++++++++ poi-rs/src/target.rs | 51 +++++++++++++ poi-rs/tests/proof_contract.rs | 13 ++++ 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 poi-rs/Cargo.toml create mode 100644 poi-rs/src/error.rs create mode 100644 poi-rs/src/lib.rs create mode 100644 poi-rs/src/proof.rs create mode 100644 poi-rs/src/target.rs create mode 100644 poi-rs/tests/proof_contract.rs diff --git a/Cargo.toml b/Cargo.toml index 75d4fb2..ab4c2c5 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] @@ -19,6 +19,7 @@ chrono = { version = "0.4", default-features = false } hyper = "1" iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.25.0" } iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "35a27488b887e28e844a1e46d7edb78605871155", default-features = false } +iota-types = { git = "https://github.com/iotaledger/iota.git", package = "iota-types", tag = "v1.25.0" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.21", default-features = false, package = "iota_interaction" } iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.21", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.21", 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..645aa86 --- /dev/null +++ b/poi-rs/Cargo.toml @@ -0,0 +1,18 @@ +[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] +iota-sdk-types.workspace = true +iota-types.workspace = true +serde.workspace = true +serde_json = { workspace = true, features = ["alloc"] } +thiserror.workspace = true diff --git a/poi-rs/src/error.rs b/poi-rs/src/error.rs new file mode 100644 index 0000000..4801231 --- /dev/null +++ b/poi-rs/src/error.rs @@ -0,0 +1,20 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Errors returned by Proof of Inclusion proof-contract operations. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The proof uses a format version this crate cannot verify. + #[error("unsupported Proof of Inclusion proof format version: {version}")] + UnsupportedProofFormatVersion { + /// Unsupported proof-format version. + version: u16, + }, + /// The proof could not be serialized or deserialized. + #[error("proof serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} + +/// Result alias for Proof of Inclusion operations. +pub type Result = core::result::Result; diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs new file mode 100644 index 0000000..e54cb01 --- /dev/null +++ b/poi-rs/src/lib.rs @@ -0,0 +1,12 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Proof of Inclusion support for the IOTA Notarization Toolkit. + +pub mod error; +pub mod proof; +pub mod target; + +pub use error::{Error, Result}; +pub use proof::{Proof, ProofVersion, TransactionProof}; +pub use target::ProofTargets; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs new file mode 100644 index 0000000..f375d2a --- /dev/null +++ b/poi-rs/src/proof.rs @@ -0,0 +1,127 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::{ + digests::ChainIdentifier, + effects::{TransactionEffects, TransactionEvents}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, + transaction::Transaction, +}; +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::target::ProofTargets; + +/// 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<()> { + if self == Self::CURRENT { + Ok(()) + } else { + Err(Error::UnsupportedProofFormatVersion { version: self.value() }) + } + } +} + +/// Transaction evidence packaged in a Proof of Inclusion envelope. +#[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, + } + } +} + +/// Versioned Proof of Inclusion envelope. +#[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 target. + pub contents_proof: TransactionProof, +} + +impl Proof { + /// Creates a proof envelope from an explicit target and transaction proof. + pub fn new( + chain: ChainIdentifier, + target: ProofTargets, + checkpoint_summary: CertifiedCheckpointSummary, + contents_proof: TransactionProof, + ) -> Self { + Self { + version: ProofVersion::CURRENT, + chain, + target, + checkpoint_summary, + contents_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> { + Ok(serde_json::to_vec(self)?) + } + + /// Validates proof-format version. + pub fn validate(&self) -> Result<()> { + self.version.validate() + } +} diff --git a/poi-rs/src/target.rs b/poi-rs/src/target.rs new file mode 100644 index 0000000..9900bc4 --- /dev/null +++ b/poi-rs/src/target.rs @@ -0,0 +1,51 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::{CheckpointContents, Event, Transaction, TransactionEffects, TransactionEvents}; +use iota_types::committee::Committee; +use iota_types::{base_types::ObjectRef, digests::TransactionDigest, event::EventID, object::Object}; +use serde::{Deserialize, Serialize}; + +/// Define aspects of IOTA state that need to be certified in a proof +#[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 { + /// Create a new empty proof target. An empty proof target still ensures + /// that the checkpoint summary is correct. + pub fn new() -> Self { + Self::default() + } + + /// Add an object to be certified by object reference and content. A + /// verified proof will ensure that both the reference and content are + /// correct. Note that some content is metadata such as the transaction + /// that created this object. + pub fn add_object(mut self, object_ref: ObjectRef, object: Object) -> Self { + self.objects.push((object_ref, object)); + self + } + + /// Add an event to be certified by event ID and content. A verified proof + /// will ensure that both the ID and content are correct. + pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { + self.events.push((event_id, event)); + self + } + + /// Add the next committee to be certified. A verified proof will ensure + /// that the next committee is correct. + pub fn set_committee(mut self, committee: Committee) -> Self { + self.committee = Some(committee); + self + } +} diff --git a/poi-rs/tests/proof_contract.rs b/poi-rs/tests/proof_contract.rs new file mode 100644 index 0000000..3e937fd --- /dev/null +++ b/poi-rs/tests/proof_contract.rs @@ -0,0 +1,13 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use poi_rs::{Error, Proof, ProofVersion}; + +#[test] +fn current_proof_format_version_is_one() { + assert_eq!(ProofVersion::CURRENT.value(), 1); + assert_eq!( + ProofVersion::new(ProofVersion::CURRENT.value()).unwrap(), + ProofVersion::CURRENT + ); +} From 8f3722eb1b4bfc7101120505378103fda1b602fd Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 3 Jul 2026 11:32:19 +0300 Subject: [PATCH 02/13] feat: Add Proof of Inclusion support with verification and error handling --- poi-rs/README.md | 54 +++++++++++ poi-rs/src/error.rs | 45 +++++++++ poi-rs/src/lib.rs | 8 +- poi-rs/src/proof.rs | 172 +++++++++++++++++++++++++++++++-- poi-rs/src/target.rs | 38 +++++--- poi-rs/tests/proof_contract.rs | 23 ++++- 6 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 poi-rs/README.md diff --git a/poi-rs/README.md b/poi-rs/README.md new file mode 100644 index 0000000..42c4e07 --- /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. +- `Error`: Typed verification and serialization errors. diff --git a/poi-rs/src/error.rs b/poi-rs/src/error.rs index 4801231..ea51b1d 100644 --- a/poi-rs/src/error.rs +++ b/poi-rs/src/error.rs @@ -11,6 +11,51 @@ pub enum Error { /// Unsupported proof-format version. version: u16, }, + /// The checkpoint summary or its contents failed verification. + #[error("checkpoint summary verification failed: {reason}")] + CheckpointSummaryVerification { + /// Verification failure details from the underlying IOTA type. + reason: String, + }, + /// 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, /// The proof could not be serialized or deserialized. #[error("proof serialization error: {0}")] Serialization(#[from] serde_json::Error), diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index e54cb01..eaea4de 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -1,12 +1,16 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Proof of Inclusion support for the IOTA Notarization Toolkit. +#![doc = include_str!("../README.md")] +#![warn(missing_docs, rustdoc::all)] +/// Error types returned by proof operations. pub mod error; +/// Proof data types and offline verification. pub mod proof; +/// Target claims authenticated by a proof. pub mod target; pub use error::{Error, Result}; -pub use proof::{Proof, ProofVersion, TransactionProof}; +pub use proof::{Proof, ProofVerifier, ProofVersion, TransactionProof}; pub use target::ProofTargets; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index f375d2a..8900ce7 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use iota_types::{ + committee::Committee, digests::ChainIdentifier, - effects::{TransactionEffects, TransactionEvents}, - messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, + effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, EndOfEpochData}, transaction::Transaction, }; use serde::{Deserialize, Serialize}; @@ -44,6 +45,10 @@ impl ProofVersion { } /// 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. @@ -73,7 +78,11 @@ impl TransactionProof { } } -/// Versioned Proof of Inclusion envelope. +/// 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. @@ -84,24 +93,26 @@ pub struct Proof { pub target: ProofTargets, /// Certified checkpoint summary. pub checkpoint_summary: CertifiedCheckpointSummary, - /// Transaction evidence for the target. - pub contents_proof: TransactionProof, + /// 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, - contents_proof: TransactionProof, + transaction_proof: TransactionProof, ) -> Self { Self { version: ProofVersion::CURRENT, chain, target, checkpoint_summary, - contents_proof, + transaction_proof, } } @@ -125,3 +136,150 @@ impl Proof { 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<()> { + proof.validate()?; + + let summary = &proof.checkpoint_summary; + let contents = Some(&proof.transaction_proof.checkpoint_contents); + + summary + .verify_with_contents(self.committee, contents) + .map_err(|err| Error::CheckpointSummaryVerification { + reason: err.to_string(), + })?; + + 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<()> { + let Some(expected_committee) = &targets.committee else { + return Ok(()); + }; + + let Some(EndOfEpochData { + next_epoch_committee, .. + }) = &summary.end_of_epoch_data + else { + return Err(Error::MissingEndOfEpochCommittee); + }; + + let actual_committee = Committee::new( + summary.epoch().checked_add(1).ok_or(Error::NextEpochOverflow)?, + next_epoch_committee.iter().cloned().collect(), + ); + + if actual_committee != *expected_committee { + return Err(Error::CommitteeMismatch); + } + + Ok(()) + } + + fn verify_transaction_proof( + &self, + summary: &CertifiedCheckpointSummary, + transaction_proof: &TransactionProof, + ) -> Result<()> { + let execution_digests = transaction_proof.effects.execution_digests(); + if transaction_proof.transaction.digest() != &execution_digests.transaction { + return Err(Error::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(Error::TransactionNotInCheckpoint); + } + + if transaction_proof.effects.events_digest() + != transaction_proof.events.as_ref().map(|events| events.digest()).as_ref() + { + return Err(Error::EventsDigestMismatch); + } + + Ok(()) + } + + fn verify_event_targets(&self, targets: &ProofTargets, transaction_proof: &TransactionProof) -> Result<()> { + if targets.events.is_empty() { + return Ok(()); + } + + let Some(events) = &transaction_proof.events else { + return Err(Error::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(Error::EventTransactionMismatch); + } + + let event_index = event_id.event_seq as usize; + let Some(actual_event) = events.get(event_index) else { + return Err(Error::EventSequenceOutOfBounds { + sequence: event_id.event_seq, + }); + }; + + if actual_event != event { + return Err(Error::EventContentsMismatch); + } + } + + Ok(()) + } + + fn verify_object_targets(&self, targets: &ProofTargets, transaction_proof: &TransactionProof) -> Result<()> { + 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.compute_object_reference() { + return Err(Error::ObjectReferenceMismatch); + } + + changed_objects + .iter() + .find(|changed_object_ref| &changed_object_ref.0 == object_ref) + .ok_or(Error::ObjectNotFound)?; + } + + Ok(()) + } +} diff --git a/poi-rs/src/target.rs b/poi-rs/src/target.rs index 9900bc4..57b2d2d 100644 --- a/poi-rs/src/target.rs +++ b/poi-rs/src/target.rs @@ -1,12 +1,19 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_sdk_types::{CheckpointContents, Event, Transaction, TransactionEffects, TransactionEvents}; use iota_types::committee::Committee; -use iota_types::{base_types::ObjectRef, digests::TransactionDigest, event::EventID, object::Object}; +use iota_types::{ + base_types::ObjectRef, + event::{Event, EventID}, + object::Object, +}; use serde::{Deserialize, Serialize}; -/// Define aspects of IOTA state that need to be certified in a proof +/// 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. @@ -20,30 +27,35 @@ pub struct ProofTargets { } impl ProofTargets { - /// Create a new empty proof target. An empty proof target still ensures - /// that the checkpoint summary is correct. + /// Creates an empty target set. + /// + /// Empty targets are mainly useful while constructing proofs incrementally. pub fn new() -> Self { Self::default() } - /// Add an object to be certified by object reference and content. A - /// verified proof will ensure that both the reference and content are - /// correct. Note that some content is metadata such as the transaction - /// that created this object. + /// 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 } - /// Add an event to be certified by event ID and content. A verified proof - /// will ensure that both the ID and content are correct. + /// 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 } - /// Add the next committee to be certified. A verified proof will ensure - /// that the next committee is correct. + /// 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/proof_contract.rs b/poi-rs/tests/proof_contract.rs index 3e937fd..3c6ddfd 100644 --- a/poi-rs/tests/proof_contract.rs +++ b/poi-rs/tests/proof_contract.rs @@ -1,7 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use poi_rs::{Error, Proof, ProofVersion}; +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() { @@ -11,3 +16,19 @@ fn current_proof_format_version_is_one() { ProofVersion::CURRENT ); } + +#[test] +fn proof_requires_transaction_witness() { + 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; +} From 8e1d9c971c6affde0253779b5165120c99acb57e Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 7 Jul 2026 10:27:32 +0300 Subject: [PATCH 03/13] feat: Implement ProofVersion conversion and add verifier tests for transaction proofs --- poi-rs/src/proof.rs | 8 +++ poi-rs/tests/verifier_contract.rs | 83 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 poi-rs/tests/verifier_contract.rs diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 8900ce7..9a1f403 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -44,6 +44,14 @@ impl ProofVersion { } } +impl TryFrom for ProofVersion { + type Error = Error; + + 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 diff --git a/poi-rs/tests/verifier_contract.rs b/poi-rs/tests/verifier_contract.rs new file mode 100644 index 0000000..8ff5c31 --- /dev/null +++ b/poi-rs/tests/verifier_contract.rs @@ -0,0 +1,83 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::gas::GasCostSummary; +use iota_types::{ + base_types::ExecutionData, + committee::Committee, + digests::ChainIdentifier, + effects::TransactionEvents, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, FullCheckpointContents}, +}; +use poi_rs::{Error, Proof, ProofTargets, ProofVerifier, TransactionProof}; + +fn test_execution_data() -> ExecutionData { + FullCheckpointContents::random_for_testing() + .into_iter() + .next() + .expect("test checkpoint contents includes one transaction") +} + +fn test_proof() -> (Committee, Proof) { + let execution_data = test_execution_data(); + 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, proof) +} + +#[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::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::EventsDigestMismatch))); +} From 6b7b0b79a15f3c2b5fdeb39cf1e816d781f72c11 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 7 Jul 2026 10:34:03 +0300 Subject: [PATCH 04/13] test: Cover PoI verifier failure cases --- poi-rs/tests/verifier_contract.rs | 95 +++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/poi-rs/tests/verifier_contract.rs b/poi-rs/tests/verifier_contract.rs index 8ff5c31..b27c77a 100644 --- a/poi-rs/tests/verifier_contract.rs +++ b/poi-rs/tests/verifier_contract.rs @@ -7,7 +7,9 @@ use iota_types::{ committee::Committee, digests::ChainIdentifier, effects::TransactionEvents, - messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, FullCheckpointContents}, + messages_checkpoint::{ + CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, + }, }; use poi_rs::{Error, Proof, ProofTargets, ProofVerifier, TransactionProof}; @@ -18,9 +20,10 @@ fn test_execution_data() -> ExecutionData { .expect("test checkpoint contents includes one transaction") } -fn test_proof() -> (Committee, Proof) { - let execution_data = test_execution_data(); - let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); +fn sign_checkpoint_summary( + checkpoint_contents: &CheckpointContents, + end_of_epoch_data: Option, +) -> (Committee, CertifiedCheckpointSummary) { let checkpoint_summary = CheckpointSummary { epoch: 0, sequence_number: 0, @@ -30,17 +33,32 @@ fn test_proof() -> (Committee, Proof) { epoch_rolling_gas_cost_summary: GasCostSummary::default(), timestamp_ms: 0, checkpoint_commitments: Vec::new(), - end_of_epoch_data: None, + 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, - ProofTargets::new(), + targets, checkpoint_summary, TransactionProof::new( checkpoint_contents, @@ -53,6 +71,19 @@ fn test_proof() -> (Committee, Proof) { (committee, proof) } +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(); @@ -81,3 +112,55 @@ fn verifier_rejects_events_digest_mismatch() { assert!(matches!(result, Err(Error::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::CheckpointSummaryVerification { .. }))); +} + +#[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::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::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::CommitteeMismatch))); +} From f410224fc58e713122765e66a177da1e62d50160 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 7 Jul 2026 18:11:05 +0300 Subject: [PATCH 05/13] feat: Add gRPC client support for Proof of Inclusion and enhance error handling --- Cargo.toml | 2 + poi-rs/Cargo.toml | 6 + poi-rs/README.md | 2 +- poi-rs/src/error.rs | 65 ----- poi-rs/src/lib.rs | 11 +- poi-rs/src/proof.rs | 206 +++++++++++--- poi-rs/src/source.rs | 372 ++++++++++++++++++++++++++ poi-rs/tests/construction_contract.rs | 91 +++++++ poi-rs/tests/proof_contract.rs | 2 +- poi-rs/tests/verifier_contract.rs | 14 +- 10 files changed, 663 insertions(+), 108 deletions(-) delete mode 100644 poi-rs/src/error.rs create mode 100644 poi-rs/src/source.rs create mode 100644 poi-rs/tests/construction_contract.rs diff --git a/Cargo.toml b/Cargo.toml index ab4c2c5..832f4d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ bcs = "0.1" chrono = { version = "0.4", default-features = false } hyper = "1" iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.25.0" } +iota-grpc-client = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-client", rev = "35a27488b887e28e844a1e46d7edb78605871155" } +iota-grpc-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-types", rev = "35a27488b887e28e844a1e46d7edb78605871155" } iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "35a27488b887e28e844a1e46d7edb78605871155", default-features = false } iota-types = { git = "https://github.com/iotaledger/iota.git", package = "iota-types", tag = "v1.25.0" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.21", default-features = false, package = "iota_interaction" } diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index 645aa86..81c581d 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -11,8 +11,14 @@ 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 + +[dev-dependencies] +tokio.workspace = true diff --git a/poi-rs/README.md b/poi-rs/README.md index 42c4e07..b740858 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -51,4 +51,4 @@ trust the authenticated target claims relative to the supplied committee. - `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. -- `Error`: Typed verification and serialization errors. +- `VerifyError`, `SourceError`, `SerializationError`, and `VersionError`: Operation-specific errors. diff --git a/poi-rs/src/error.rs b/poi-rs/src/error.rs deleted file mode 100644 index ea51b1d..0000000 --- a/poi-rs/src/error.rs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// Errors returned by Proof of Inclusion proof-contract operations. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - /// The proof uses a format version this crate cannot verify. - #[error("unsupported Proof of Inclusion proof format version: {version}")] - UnsupportedProofFormatVersion { - /// Unsupported proof-format version. - version: u16, - }, - /// The checkpoint summary or its contents failed verification. - #[error("checkpoint summary verification failed: {reason}")] - CheckpointSummaryVerification { - /// Verification failure details from the underlying IOTA type. - reason: String, - }, - /// 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, - /// The proof could not be serialized or deserialized. - #[error("proof serialization error: {0}")] - Serialization(#[from] serde_json::Error), -} - -/// Result alias for Proof of Inclusion operations. -pub type Result = core::result::Result; diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index eaea4de..970137d 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -4,13 +4,16 @@ #![doc = include_str!("../README.md")] #![warn(missing_docs, rustdoc::all)] -/// Error types returned by proof operations. -pub mod error; /// 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 error::{Error, Result}; -pub use proof::{Proof, ProofVerifier, ProofVersion, TransactionProof}; +pub use proof::{ + Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, + VerifyErrorKind, VersionError, +}; +pub use source::{GrpcSource, Source, SourceError, SourceErrorKind}; pub use target::ProofTargets; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 9a1f403..b9d4693 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::error::Error as StdError; + use iota_types::{ committee::Committee, digests::ChainIdentifier, @@ -10,9 +12,111 @@ use iota_types::{ }; use serde::{Deserialize, Serialize}; -use crate::error::{Error, Result}; use crate::target::ProofTargets; +type BoxError = Box; + +/// 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)] @@ -23,7 +127,7 @@ impl ProofVersion { pub const CURRENT: Self = Self(1); /// Creates a supported proof-format version. - pub fn new(version: u16) -> Result { + pub fn new(version: u16) -> Result { let version = Self(version); version.validate()?; Ok(version) @@ -35,19 +139,19 @@ impl ProofVersion { } /// Returns an error when this version is not supported. - pub fn validate(self) -> Result<()> { + pub fn validate(self) -> Result<(), VersionError> { if self == Self::CURRENT { Ok(()) } else { - Err(Error::UnsupportedProofFormatVersion { version: self.value() }) + Err(VersionError { version: self.value() }) } } } impl TryFrom for ProofVersion { - type Error = Error; + type Error = VersionError; - fn try_from(version: u16) -> Result { + fn try_from(version: u16) -> Result { Self::new(version) } } @@ -135,12 +239,14 @@ impl Proof { } /// Serializes this proof envelope as JSON. - pub fn to_json_vec(&self) -> Result> { - Ok(serde_json::to_vec(self)?) + 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<()> { + pub fn validate(&self) -> Result<(), VersionError> { self.version.validate() } } @@ -169,16 +275,20 @@ impl<'committee> ProofVerifier<'committee> { /// /// The verifier checks the checkpoint summary and all transaction evidence /// before authenticating object, event, or committee targets. - pub fn verify(&self, proof: &Proof) -> Result<()> { - proof.validate()?; + 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(|err| Error::CheckpointSummaryVerification { - reason: err.to_string(), + .map_err(|source| VerifyError { + kind: VerifyErrorKind::CheckpointSummary { + source: Box::new(source), + }, })?; self.verify_committee_target(summary, &proof.target)?; @@ -189,7 +299,11 @@ impl<'committee> ProofVerifier<'committee> { Ok(()) } - fn verify_committee_target(&self, summary: &CertifiedCheckpointSummary, targets: &ProofTargets) -> Result<()> { + fn verify_committee_target( + &self, + summary: &CertifiedCheckpointSummary, + targets: &ProofTargets, + ) -> Result<(), VerifyError> { let Some(expected_committee) = &targets.committee else { return Ok(()); }; @@ -198,16 +312,22 @@ impl<'committee> ProofVerifier<'committee> { next_epoch_committee, .. }) = &summary.end_of_epoch_data else { - return Err(Error::MissingEndOfEpochCommittee); + return Err(VerifyError { + kind: VerifyErrorKind::MissingEndOfEpochCommittee, + }); }; let actual_committee = Committee::new( - summary.epoch().checked_add(1).ok_or(Error::NextEpochOverflow)?, + summary.epoch().checked_add(1).ok_or(VerifyError { + kind: VerifyErrorKind::NextEpochOverflow, + })?, next_epoch_committee.iter().cloned().collect(), ); if actual_committee != *expected_committee { - return Err(Error::CommitteeMismatch); + return Err(VerifyError { + kind: VerifyErrorKind::CommitteeMismatch, + }); } Ok(()) @@ -217,10 +337,12 @@ impl<'committee> ProofVerifier<'committee> { &self, summary: &CertifiedCheckpointSummary, transaction_proof: &TransactionProof, - ) -> Result<()> { + ) -> Result<(), VerifyError> { let execution_digests = transaction_proof.effects.execution_digests(); if transaction_proof.transaction.digest() != &execution_digests.transaction { - return Err(Error::TransactionDigestMismatch); + return Err(VerifyError { + kind: VerifyErrorKind::TransactionDigestMismatch, + }); } let transaction_is_in_checkpoint = transaction_proof @@ -229,49 +351,69 @@ impl<'committee> ProofVerifier<'committee> { .any(|(_, digests)| digests == &execution_digests); if !transaction_is_in_checkpoint { - return Err(Error::TransactionNotInCheckpoint); + 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(Error::EventsDigestMismatch); + return Err(VerifyError { + kind: VerifyErrorKind::EventsDigestMismatch, + }); } Ok(()) } - fn verify_event_targets(&self, targets: &ProofTargets, transaction_proof: &TransactionProof) -> Result<()> { + 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(Error::MissingEvents); + 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(Error::EventTransactionMismatch); + 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(Error::EventSequenceOutOfBounds { - sequence: event_id.event_seq, + return Err(VerifyError { + kind: VerifyErrorKind::EventSequenceOutOfBounds { + sequence: event_id.event_seq, + }, }); }; if actual_event != event { - return Err(Error::EventContentsMismatch); + return Err(VerifyError { + kind: VerifyErrorKind::EventContentsMismatch, + }); } } Ok(()) } - fn verify_object_targets(&self, targets: &ProofTargets, transaction_proof: &TransactionProof) -> Result<()> { + fn verify_object_targets( + &self, + targets: &ProofTargets, + transaction_proof: &TransactionProof, + ) -> Result<(), VerifyError> { if targets.objects.is_empty() { return Ok(()); } @@ -279,13 +421,17 @@ impl<'committee> ProofVerifier<'committee> { let changed_objects = transaction_proof.effects.all_changed_objects(); for (object_ref, object) in &targets.objects { if object_ref != &object.compute_object_reference() { - return Err(Error::ObjectReferenceMismatch); + return Err(VerifyError { + kind: VerifyErrorKind::ObjectReferenceMismatch, + }); } changed_objects .iter() .find(|changed_object_ref| &changed_object_ref.0 == object_ref) - .ok_or(Error::ObjectNotFound)?; + .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..2c5a5c3 --- /dev/null +++ b/poi-rs/src/source.rs @@ -0,0 +1,372 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::error::Error as StdError; + +use async_trait::async_trait; +use iota_grpc_client::{ + CheckpointResponse, Client as GrpcClient, ReadMask, + read_mask_fields::{CheckpointResponseField, TransactionField}, +}; +use iota_grpc_types::v1::transaction::ExecutedTransaction; +use iota_sdk_types::{Digest, SignedTransaction}; +use iota_types::{ + digests::{ChainIdentifier, TransactionDigest}, + effects::{TransactionEffects, TransactionEffectsAPI}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, + transaction::Transaction, +}; + +use crate::{Proof, ProofTargets, TransactionProof}; + +type BoxError = Box; + +/// Error returned when a source cannot build a transaction proof. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to build transaction proof for {transaction_digest}")] +pub struct SourceError { + /// Transaction requested from the source. + pub transaction_digest: TransactionDigest, + /// 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_digest, + kind, + } + } +} + +/// Kind of transaction-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, + /// 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; +} + +/// 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_digest, + kind: SourceErrorKind::FetchTransaction { + source: Box::new(source), + }, + })?; + + transactions.body().first().cloned().ok_or(SourceError { + transaction_digest, + kind: SourceErrorKind::TransactionNotFound, + }) + } + + /// 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_digest, + kind: 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_digest, + kind: 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_digest, + kind: SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + })? + .try_into() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + })?; + let checkpoint_contents: CheckpointContents = checkpoint + .contents() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + })? + .contents() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + }) + .and_then(|contents| { + contents.try_into().map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + }) + })?; + let transaction = executed_transaction + .transaction() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Transaction { + source: Box::new(source), + }, + })? + .transaction() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Transaction { + source: Box::new(source), + }, + })?; + let signatures = executed_transaction + .signatures() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Signatures { + source: Box::new(source), + }, + })? + .signatures + .iter() + .map(|signature| { + signature.signature().map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Signatures { + source: Box::new(source), + }, + }) + }) + .collect::, SourceError>>()?; + let transaction: Transaction = SignedTransaction { + transaction, + signatures, + } + .try_into() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Transaction { + source: Box::new(source), + }, + })?; + let effects: TransactionEffects = executed_transaction + .effects() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Effects { + source: Box::new(source), + }, + })? + .effects() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::Effects { + source: Box::new(source), + }, + })?; + let events = if effects.events_digest().is_some() { + executed_transaction + .events() + .map_err(|source| SourceError { + transaction_digest, + kind: SourceErrorKind::MissingEvents { + source: Box::new(source), + }, + })? + .events() + .map_err(|source| SourceError { + transaction_digest, + kind: 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), + )) + } +} + +// 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 authenticate checkpoint contents. +const CHECKPOINT_PROOF_FIELDS: &[&str] = &[ + CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, + CheckpointResponseField::CHECKPOINT_SIGNATURE, + CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, +]; diff --git a/poi-rs/tests/construction_contract.rs b/poi-rs/tests/construction_contract.rs new file mode 100644 index 0000000..48a2916 --- /dev/null +++ b/poi-rs/tests/construction_contract.rs @@ -0,0 +1,91 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_sdk_types::gas::GasCostSummary; +use iota_types::{ + base_types::ExecutionData, + committee::Committee, + digests::{ChainIdentifier, TransactionDigest}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, FullCheckpointContents}, +}; +use poi_rs::{Proof, ProofTargets, ProofVerifier, Source, SourceError, SourceErrorKind, TransactionProof}; + +#[derive(Default)] +struct MockSource { + proof: 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)) + } +} + +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) +} + +#[tokio::test] +async fn source_builds_transaction_proof() { + let (committee, transaction_digest, proof) = test_proof(); + let source = MockSource { proof: Some(proof) }; + + 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 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.transaction_digest, transaction_digest); + assert!(matches!(error.kind, SourceErrorKind::TransactionNotFound)); +} diff --git a/poi-rs/tests/proof_contract.rs b/poi-rs/tests/proof_contract.rs index 3c6ddfd..3ac5ebc 100644 --- a/poi-rs/tests/proof_contract.rs +++ b/poi-rs/tests/proof_contract.rs @@ -18,7 +18,7 @@ fn current_proof_format_version_is_one() { } #[test] -fn proof_requires_transaction_witness() { +fn proof_requires_transaction_proof() { let transaction_proof_field: fn(Proof) -> TransactionProof = proof_transaction_proof_is_required; let _ = transaction_proof_field; } diff --git a/poi-rs/tests/verifier_contract.rs b/poi-rs/tests/verifier_contract.rs index b27c77a..a15192b 100644 --- a/poi-rs/tests/verifier_contract.rs +++ b/poi-rs/tests/verifier_contract.rs @@ -11,7 +11,7 @@ use iota_types::{ CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, }, }; -use poi_rs::{Error, Proof, ProofTargets, ProofVerifier, TransactionProof}; +use poi_rs::{Proof, ProofTargets, ProofVerifier, TransactionProof, VerifyErrorKind}; fn test_execution_data() -> ExecutionData { FullCheckpointContents::random_for_testing() @@ -100,7 +100,7 @@ fn verifier_rejects_transaction_digest_mismatch() { let result = ProofVerifier::new(&committee).verify(&proof); - assert!(matches!(result, Err(Error::TransactionDigestMismatch))); + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::TransactionDigestMismatch))); } #[test] @@ -110,7 +110,7 @@ fn verifier_rejects_events_digest_mismatch() { let result = ProofVerifier::new(&committee).verify(&proof); - assert!(matches!(result, Err(Error::EventsDigestMismatch))); + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventsDigestMismatch))); } #[test] @@ -122,7 +122,7 @@ fn verifier_rejects_checkpoint_contents_mismatch() { let result = ProofVerifier::new(&committee).verify(&proof); - assert!(matches!(result, Err(Error::CheckpointSummaryVerification { .. }))); + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::CheckpointSummary { .. }))); } #[test] @@ -134,7 +134,7 @@ fn verifier_rejects_transaction_not_in_checkpoint() { let result = ProofVerifier::new(&committee).verify(&proof); - assert!(matches!(result, Err(Error::TransactionNotInCheckpoint))); + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint))); } #[test] @@ -146,7 +146,7 @@ fn verifier_rejects_missing_end_of_epoch_committee() { let result = ProofVerifier::new(&verifying_committee).verify(&proof); - assert!(matches!(result, Err(Error::MissingEndOfEpochCommittee))); + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::MissingEndOfEpochCommittee))); } #[test] @@ -162,5 +162,5 @@ fn verifier_rejects_committee_mismatch() { let result = ProofVerifier::new(&verifying_committee).verify(&proof); - assert!(matches!(result, Err(Error::CommitteeMismatch))); + assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::CommitteeMismatch))); } From a9ba01b40d92fa1b85a5f0d462f46924580e8936 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 9 Jul 2026 09:16:15 +0300 Subject: [PATCH 06/13] feat: Add support for object proofs in source and enhance error handling --- poi-rs/src/lib.rs | 2 +- poi-rs/src/source.rs | 355 ++++++++++++++++++-------- poi-rs/tests/construction_contract.rs | 55 +++- poi-rs/tests/verifier_contract.rs | 28 +- 4 files changed, 333 insertions(+), 107 deletions(-) diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index 970137d..de1953e 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -15,5 +15,5 @@ pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, }; -pub use source::{GrpcSource, Source, SourceError, SourceErrorKind}; +pub use source::{GrpcSource, Source, SourceError, SourceErrorKind, SourceTarget}; pub use target::ProofTargets; diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 2c5a5c3..07bf891 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -1,19 +1,21 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::error::Error as StdError; +use std::{error::Error as StdError, fmt}; use async_trait::async_trait; use iota_grpc_client::{ CheckpointResponse, Client as GrpcClient, ReadMask, - read_mask_fields::{CheckpointResponseField, TransactionField}, + 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}, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, + object::Object, transaction::Transaction, }; @@ -21,13 +23,32 @@ use crate::{Proof, ProofTargets, TransactionProof}; type BoxError = Box; -/// Error returned when a source cannot build a transaction proof. +/// 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), +} + +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:?}"), + } + } +} + +/// Error returned when a source cannot build a proof. #[derive(Debug, thiserror::Error)] #[non_exhaustive] -#[error("failed to build transaction proof for {transaction_digest}")] +#[error("failed to build proof for {target}")] pub struct SourceError { - /// Transaction requested from the source. - pub transaction_digest: TransactionDigest, + /// Target requested from the source. + pub target: SourceTarget, /// Source failure details. #[source] pub kind: SourceErrorKind, @@ -36,14 +57,27 @@ pub struct SourceError { 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 { - transaction_digest, + target: SourceTarget::Object(object_ref), kind, } } } -/// Kind of transaction-proof source failure. +/// Kind of proof source failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SourceErrorKind { @@ -57,6 +91,26 @@ pub enum SourceErrorKind { /// 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 transaction response did not expose a checkpoint sequence number. #[error("transaction response is missing checkpoint sequence")] MissingCheckpointSequence { @@ -138,6 +192,13 @@ pub trait Source { /// 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; } /// gRPC-backed source for transaction proofs. @@ -172,17 +233,70 @@ impl GrpcSource { .client .get_transactions(&[digest], Some(ReadMask::from(TRANSACTION_PROOF_FIELDS))) .await - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::FetchTransaction { - source: Box::new(source), - }, + .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), + }, + ) + })? + .try_into() + .map_err(|source| { + SourceError::object( + object_ref, + SourceErrorKind::Object { + source: Box::new(source), + }, + ) })?; - transactions.body().first().cloned().ok_or(SourceError { - transaction_digest, - kind: SourceErrorKind::TransactionNotFound, - }) + if object.compute_object_reference() != object_ref { + return Err(SourceError::object( + object_ref, + SourceErrorKind::ObjectReferenceMismatch, + )); + } + + Ok(object) } /// Fetches the certified checkpoint summary and contents for an executed transaction. @@ -200,12 +314,14 @@ impl GrpcSource { ) .await .map(|response| response.into_inner()) - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::FetchCheckpoint { - sequence_number, - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) }) } } @@ -214,87 +330,104 @@ impl GrpcSource { 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_digest, - kind: SourceErrorKind::MissingCheckpointSequence { - source: Box::new(source), - }, - })?; + 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_digest, - kind: SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) })? .try_into() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) })?; let checkpoint_contents: CheckpointContents = checkpoint .contents() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) })? .contents() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - }) - .and_then(|contents| { - contents.try_into().map_err(|source| SourceError { + .map_err(|source| { + SourceError::transaction( transaction_digest, - kind: SourceErrorKind::CheckpointContents { + 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_digest, - kind: SourceErrorKind::Transaction { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) })? .transaction() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::Transaction { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) })?; let signatures = executed_transaction .signatures() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::Signatures { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) })? .signatures .iter() .map(|signature| { - signature.signature().map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::Signatures { - source: Box::new(source), - }, + signature.signature().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) }) }) .collect::, SourceError>>()?; @@ -303,42 +436,52 @@ impl Source for GrpcSource { signatures, } .try_into() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::Transaction { - source: Box::new(source), - }, - })?; - let effects: TransactionEffects = executed_transaction - .effects() - .map_err(|source| SourceError { + .map_err(|source| { + SourceError::transaction( transaction_digest, - kind: SourceErrorKind::Effects { + 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_digest, - kind: SourceErrorKind::Effects { - source: Box::new(source), - }, + .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_digest, - kind: SourceErrorKind::MissingEvents { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingEvents { + source: Box::new(source), + }, + ) })? .events() - .map_err(|source| SourceError { - transaction_digest, - kind: SourceErrorKind::Events { - source: Box::new(source), - }, + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Events { + source: Box::new(source), + }, + ) }) .map(Some)? } else { @@ -352,6 +495,13 @@ impl Source for GrpcSource { 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) + } } // Minimum gRPC fields needed to package a transaction proof. @@ -364,6 +514,9 @@ const TRANSACTION_PROOF_FIELDS: &[&str] = &[ 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, diff --git a/poi-rs/tests/construction_contract.rs b/poi-rs/tests/construction_contract.rs index 48a2916..9d2d5e1 100644 --- a/poi-rs/tests/construction_contract.rs +++ b/poi-rs/tests/construction_contract.rs @@ -4,16 +4,20 @@ use async_trait::async_trait; use iota_sdk_types::gas::GasCostSummary; use iota_types::{ - base_types::ExecutionData, + base_types::{ExecutionData, ObjectRef}, committee::Committee, digests::{ChainIdentifier, TransactionDigest}, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, FullCheckpointContents}, + object::Object, +}; +use poi_rs::{ + Proof, ProofTargets, ProofVerifier, Source, SourceError, SourceErrorKind, SourceTarget, TransactionProof, }; -use poi_rs::{Proof, ProofTargets, ProofVerifier, Source, SourceError, SourceErrorKind, TransactionProof}; #[derive(Default)] struct MockSource { proof: Option, + object: Option, } #[async_trait] @@ -23,6 +27,17 @@ impl Source for MockSource { .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.compute_object_reference() == 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) + } } fn test_execution_data() -> ExecutionData { @@ -70,7 +85,10 @@ fn test_proof() -> (Committee, TransactionDigest, Proof) { #[tokio::test] async fn source_builds_transaction_proof() { let (committee, transaction_digest, proof) = test_proof(); - let source = MockSource { proof: Some(proof) }; + let source = MockSource { + proof: Some(proof), + object: None, + }; let proof = source.transaction(transaction_digest).await.unwrap(); @@ -78,6 +96,23 @@ async fn source_builds_transaction_proof() { 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.compute_object_reference(); + 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 transaction_surfaces_source_failures() { let (_, transaction_digest, _) = test_proof(); @@ -86,6 +121,18 @@ async fn transaction_surfaces_source_failures() { let result = source.transaction(transaction_digest).await; let error = result.unwrap_err(); - assert_eq!(error.transaction_digest, transaction_digest); + 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().compute_object_reference(); + 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)); +} diff --git a/poi-rs/tests/verifier_contract.rs b/poi-rs/tests/verifier_contract.rs index a15192b..80d60c8 100644 --- a/poi-rs/tests/verifier_contract.rs +++ b/poi-rs/tests/verifier_contract.rs @@ -3,13 +3,14 @@ use iota_sdk_types::gas::GasCostSummary; use iota_types::{ - base_types::ExecutionData, + base_types::{ExecutionData, dbg_object_id}, committee::Committee, digests::ChainIdentifier, effects::TransactionEvents, messages_checkpoint::{ CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, }, + object::Object, }; use poi_rs::{Proof, ProofTargets, ProofVerifier, TransactionProof, VerifyErrorKind}; @@ -164,3 +165,28 @@ fn verifier_rejects_committee_mismatch() { 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.compute_object_reference(); + 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.compute_object_reference(); + 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))); +} From 477f15bf3f0f91ec9d65fc5aaec9e8ecca399c95 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 9 Jul 2026 09:27:46 +0300 Subject: [PATCH 07/13] feat: Add support for event proofs in source and enhance related error handling --- poi-rs/src/source.rs | 39 +++++++++++++ poi-rs/tests/construction_contract.rs | 70 ++++++++++++++++++++++++ poi-rs/tests/verifier_contract.rs | 79 ++++++++++++++++++++++++++- 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 07bf891..8c35155 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -14,6 +14,7 @@ use iota_types::{ base_types::ObjectRef, digests::{ChainIdentifier, TransactionDigest}, effects::{TransactionEffects, TransactionEffectsAPI}, + event::EventID, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, object::Object, transaction::Transaction, @@ -31,6 +32,8 @@ pub enum SourceTarget { Transaction(TransactionDigest), /// An object proof request. Object(ObjectRef), + /// An event proof request. + Event(EventID), } impl fmt::Display for SourceTarget { @@ -38,6 +41,7 @@ impl fmt::Display for SourceTarget { 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:?}"), } } } @@ -75,6 +79,14 @@ impl SourceError { 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. @@ -111,6 +123,9 @@ pub enum SourceErrorKind { /// 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 { @@ -199,6 +214,13 @@ pub trait Source { /// 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. @@ -502,6 +524,23 @@ impl Source for GrpcSource { 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. diff --git a/poi-rs/tests/construction_contract.rs b/poi-rs/tests/construction_contract.rs index 9d2d5e1..026c9f5 100644 --- a/poi-rs/tests/construction_contract.rs +++ b/poi-rs/tests/construction_contract.rs @@ -7,8 +7,11 @@ use iota_types::{ base_types::{ExecutionData, ObjectRef}, committee::Committee, digests::{ChainIdentifier, TransactionDigest}, + effects::TransactionEvents, + event::{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, @@ -38,6 +41,19 @@ impl Source for MockSource { 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 { @@ -82,6 +98,21 @@ fn test_proof() -> (Committee, TransactionDigest, Proof) { (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(); @@ -113,6 +144,26 @@ async fn source_builds_object_proof() { 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(); @@ -136,3 +187,22 @@ async fn object_surfaces_source_failures() { 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_contract.rs b/poi-rs/tests/verifier_contract.rs index 80d60c8..66a4c28 100644 --- a/poi-rs/tests/verifier_contract.rs +++ b/poi-rs/tests/verifier_contract.rs @@ -5,12 +5,14 @@ use iota_sdk_types::gas::GasCostSummary; use iota_types::{ base_types::{ExecutionData, dbg_object_id}, committee::Committee, - digests::ChainIdentifier, - effects::TransactionEvents, + digests::{ChainIdentifier, TransactionDigest}, + effects::{TestEffectsBuilder, TransactionEvents}, + event::{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}; @@ -72,6 +74,46 @@ fn test_proof_with_targets_and_end_of_epoch_data( (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()) } @@ -190,3 +232,36 @@ fn verifier_rejects_object_not_found_in_transaction_effects() { 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 })) + ); +} From 9827f4316fb932e4e1f0d38002ae43e105a4e441 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Jul 2026 16:09:30 +0300 Subject: [PATCH 08/13] feat: Implement committee resolution for checkpoint verification and add related tests --- poi-rs/src/committee.rs | 490 ++++++++++++++++++ poi-rs/src/lib.rs | 6 + poi-rs/src/proof.rs | 6 +- poi-rs/src/source.rs | 6 +- poi-rs/tests/committee.rs | 43 ++ poi-rs/tests/{proof_contract.rs => proof.rs} | 0 .../{construction_contract.rs => source.rs} | 0 .../{verifier_contract.rs => verifier.rs} | 0 8 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 poi-rs/src/committee.rs create mode 100644 poi-rs/tests/committee.rs rename poi-rs/tests/{proof_contract.rs => proof.rs} (100%) rename poi-rs/tests/{construction_contract.rs => source.rs} (100%) rename poi-rs/tests/{verifier_contract.rs => verifier.rs} (100%) diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs new file mode 100644 index 0000000..d8e5ebe --- /dev/null +++ b/poi-rs/src/committee.rs @@ -0,0 +1,490 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +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; + +/// 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 epoch transition. + #[error("failed to verify epoch {epoch} transition at checkpoint {sequence_number}")] + InvalidTransition { + /// Epoch whose committee was used for verification. + epoch: EpochId, + /// Checkpoint sequence number containing the transition. + 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, + }, +} + +/// Selects how a resolver establishes trust in committee data. +#[derive(Clone)] +enum TrustMode { + /// Accept committee data returned directly by the connected node. + Node, + /// Authenticate committee transitions from an existing trust anchor. + Anchor { committee: Committee }, +} + +/// 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 epoch transition up to the requested epoch. +#[derive(Clone)] +pub struct CommitteeResolver { + client: GrpcClient, + mode: TrustMode, +} + +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: TrustMode::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. + pub fn anchor(client: GrpcClient, committee: Committee) -> Self { + Self { + client, + mode: TrustMode::Anchor { committee }, + } + } + + /// 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 { + TrustMode::Node => self.resolve_from_node(target_epoch).await, + TrustMode::Anchor { committee } => self.resolve_from_anchor(committee, 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()) + } + + /// Walks verified end-of-epoch transitions from the trust anchor to the target epoch. + async fn resolve_from_anchor( + &self, + trusted_committee: &Committee, + 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()); + } + + let current_epoch = self.current_epoch(target_epoch).await?; + if target_epoch > current_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::TargetAheadOfNode { current_epoch }, + )); + } + + let mut committee = trusted_committee.clone(); + while committee.epoch < target_epoch { + committee = self.next_verified_committee(target_epoch, &committee).await?; + } + + 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) + }) + } + + /// Resolves one authenticated committee transition from the current epoch to the next. + async fn next_verified_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?; + + Self::verify_next_committee(current_committee, &summary, sequence_number) + .map_err(|kind| CommitteeResolutionError::new(target_epoch, kind)) + } + + /// 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 a transition. + 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 verify_next_committee( + current_committee: &Committee, + summary: &CertifiedCheckpointSummary, + sequence_number: u64, + ) -> Result { + summary.clone().try_into_verified(current_committee).map_err(|source| { + CommitteeResolutionErrorKind::InvalidTransition { + 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 for an anchored committee transition. +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::*; + + fn signed_transition( + 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_transition_returns_the_next_committee() { + let (current_committee, expected_committee, summary) = signed_transition(3, true); + + let committee = CommitteeResolver::verify_next_committee(¤t_committee, &summary, 42).unwrap(); + + assert_eq!(committee, expected_committee); + } + + #[test] + fn transition_rejects_a_summary_signed_by_another_committee() { + let (_, _, summary) = signed_transition(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::verify_next_committee(&wrong_committee, &summary, 42).unwrap_err(); + + assert!(matches!( + error, + CommitteeResolutionErrorKind::InvalidTransition { + epoch: 3, + sequence_number: 42, + .. + } + )); + } + + #[test] + fn transition_requires_end_of_epoch_data() { + let (current_committee, _, summary) = signed_transition(3, false); + + let error = CommitteeResolver::verify_next_committee(¤t_committee, &summary, 42).unwrap_err(); + + assert!(matches!( + error, + CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number: 42 } + )); + } +} diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index de1953e..e23d196 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -4,6 +4,11 @@ #![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; + +/// Committee resolution for checkpoint verification. +pub mod committee; /// Proof data types and offline verification. pub mod proof; /// Sources for constructing proofs. @@ -11,6 +16,7 @@ pub mod source; /// Target claims authenticated by a proof. pub mod target; +pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index b9d4693..d6eee59 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -1,8 +1,6 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::error::Error as StdError; - use iota_types::{ committee::Committee, digests::ChainIdentifier, @@ -12,9 +10,7 @@ use iota_types::{ }; use serde::{Deserialize, Serialize}; -use crate::target::ProofTargets; - -type BoxError = Box; +use crate::{BoxError, target::ProofTargets}; /// Error returned when a proof-format version is not supported. #[derive(Debug, thiserror::Error)] diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 8c35155..dd71148 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{error::Error as StdError, fmt}; +use std::fmt; use async_trait::async_trait; use iota_grpc_client::{ @@ -20,9 +20,7 @@ use iota_types::{ transaction::Transaction, }; -use crate::{Proof, ProofTargets, TransactionProof}; - -type BoxError = Box; +use crate::{BoxError, Proof, ProofTargets, TransactionProof}; /// Source target requested by the caller. #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee.rs new file mode 100644 index 0000000..40ac114 --- /dev/null +++ b/poi-rs/tests/committee.rs @@ -0,0 +1,43 @@ +// 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}; + +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()); +} diff --git a/poi-rs/tests/proof_contract.rs b/poi-rs/tests/proof.rs similarity index 100% rename from poi-rs/tests/proof_contract.rs rename to poi-rs/tests/proof.rs diff --git a/poi-rs/tests/construction_contract.rs b/poi-rs/tests/source.rs similarity index 100% rename from poi-rs/tests/construction_contract.rs rename to poi-rs/tests/source.rs diff --git a/poi-rs/tests/verifier_contract.rs b/poi-rs/tests/verifier.rs similarity index 100% rename from poi-rs/tests/verifier_contract.rs rename to poi-rs/tests/verifier.rs From c2a4621d825f09602e680e4bcd297a140f3e6c12 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Jul 2026 16:22:06 +0300 Subject: [PATCH 09/13] refactor: Simplify error handling in GrpcSource implementation --- poi-rs/src/source.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 61a8747..f286272 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -299,15 +299,7 @@ impl GrpcSource { }, ) })? - .try_into() - .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( From 3fa6e6e874ca419888fd13e0c86de7b12074f08c Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Jul 2026 13:54:48 +0300 Subject: [PATCH 10/13] feat: Add verified committee cache --- poi-rs/Cargo.toml | 2 - poi-rs/src/cache.rs | 45 +++++++ poi-rs/src/cache/in_memory.rs | 52 ++++++++ poi-rs/src/committee.rs | 238 +++++++++++++++++++++++++++++----- poi-rs/src/lib.rs | 3 + poi-rs/tests/cache.rs | 16 +++ poi-rs/tests/committee.rs | 16 ++- 7 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 poi-rs/src/cache.rs create mode 100644 poi-rs/src/cache/in_memory.rs create mode 100644 poi-rs/tests/cache.rs diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index 81c581d..f702a7b 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -19,6 +19,4 @@ iota-types.workspace = true serde.workspace = true serde_json = { workspace = true, features = ["alloc"] } thiserror.workspace = true - -[dev-dependencies] tokio.workspace = true 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..ae56fea --- /dev/null +++ b/poi-rs/src/cache/in_memory.rs @@ -0,0 +1,52 @@ +// 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(()) + } +} diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index d8e5ebe..adb0ed8 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -1,6 +1,8 @@ // 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}, @@ -10,7 +12,7 @@ use iota_types::{ messages_checkpoint::{CertifiedCheckpointSummary, EndOfEpochData}, }; -use crate::BoxError; +use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; /// Error returned when a committee cannot be resolved for an epoch. #[derive(Debug, thiserror::Error)] @@ -108,12 +110,12 @@ pub enum CommitteeResolutionErrorKind { #[source] source: BoxError, }, - /// The current trusted committee did not authenticate the epoch transition. - #[error("failed to verify epoch {epoch} transition at checkpoint {sequence_number}")] - InvalidTransition { + /// 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 containing the transition. + /// Checkpoint sequence number closing the epoch. sequence_number: u64, /// Underlying checkpoint verification error. #[source] @@ -131,6 +133,15 @@ pub enum CommitteeResolutionErrorKind { /// 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. @@ -138,15 +149,18 @@ pub enum CommitteeResolutionErrorKind { enum TrustMode { /// Accept committee data returned directly by the connected node. Node, - /// Authenticate committee transitions from an existing trust anchor. - Anchor { committee: Committee }, + /// 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 epoch transition up to the requested epoch. +/// blob, and authenticates every end-of-epoch handoff up to the requested epoch. #[derive(Clone)] pub struct CommitteeResolver { client: GrpcClient, @@ -170,11 +184,24 @@ impl CommitteeResolver { /// /// 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. + /// 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: TrustMode::Anchor { committee }, + mode: TrustMode::Anchor { + committee, + cache: Arc::new(cache), + }, } } @@ -191,7 +218,9 @@ impl CommitteeResolver { pub async fn resolve(&self, target_epoch: EpochId) -> Result { match &self.mode { TrustMode::Node => self.resolve_from_node(target_epoch).await, - TrustMode::Anchor { committee } => self.resolve_from_anchor(committee, target_epoch).await, + TrustMode::Anchor { committee, cache } => { + self.resolve_from_anchor(committee, cache.as_ref(), target_epoch).await + } } } @@ -224,10 +253,11 @@ impl CommitteeResolver { Ok(committee.into()) } - /// Walks verified end-of-epoch transitions from the trust anchor to the target epoch. + /// 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 { @@ -243,6 +273,62 @@ impl CommitteeResolver { 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( @@ -251,9 +337,18 @@ impl CommitteeResolver { )); } - let mut committee = trusted_committee.clone(); while committee.epoch < target_epoch { - committee = self.next_verified_committee(target_epoch, &committee).await?; + 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) @@ -279,8 +374,8 @@ impl CommitteeResolver { }) } - /// Resolves one authenticated committee transition from the current epoch to the next. - async fn next_verified_committee( + /// Fetches and authenticates the committee elected for the next epoch. + async fn fetch_next_committee( &self, target_epoch: EpochId, current_committee: &Committee, @@ -289,9 +384,10 @@ impl CommitteeResolver { .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))?; - Self::verify_next_committee(current_committee, &summary, sequence_number) - .map_err(|kind| CommitteeResolutionError::new(target_epoch, kind)) + Ok(next_committee) } /// Fetches the checkpoint sequence number that closes an epoch. @@ -322,7 +418,7 @@ impl CommitteeResolver { }) } - /// Fetches only the signed checkpoint summary required to authenticate a transition. + /// Fetches only the signed checkpoint summary required to authenticate the next committee. async fn certified_checkpoint_summary( &self, target_epoch: EpochId, @@ -370,13 +466,13 @@ impl CommitteeResolver { } /// Verifies an end-of-epoch summary before accepting its next committee. - fn verify_next_committee( + fn authenticate_next_committee( current_committee: &Committee, summary: &CertifiedCheckpointSummary, - sequence_number: u64, ) -> Result { + let sequence_number = summary.sequence_number; summary.clone().try_into_verified(current_committee).map_err(|source| { - CommitteeResolutionErrorKind::InvalidTransition { + CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { epoch: current_committee.epoch, sequence_number, source: Box::new(source), @@ -401,7 +497,7 @@ impl CommitteeResolver { } } -/// Checkpoint fields required for an anchored committee transition. +/// Checkpoint fields required to authenticate the next committee. const CHECKPOINT_SUMMARY_FIELDS: &[&str] = &[ CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, CheckpointResponseField::CHECKPOINT_SIGNATURE, @@ -414,7 +510,22 @@ mod tests { use super::*; - fn signed_transition( + 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) { @@ -450,25 +561,25 @@ mod tests { } #[test] - fn authenticated_transition_returns_the_next_committee() { - let (current_committee, expected_committee, summary) = signed_transition(3, true); + fn authenticated_summary_returns_the_next_committee() { + let (current_committee, expected_committee, summary) = signed_end_of_epoch_summary(3, true); - let committee = CommitteeResolver::verify_next_committee(¤t_committee, &summary, 42).unwrap(); + let committee = CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap(); assert_eq!(committee, expected_committee); } #[test] - fn transition_rejects_a_summary_signed_by_another_committee() { - let (_, _, summary) = signed_transition(3, true); + 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::verify_next_committee(&wrong_committee, &summary, 42).unwrap_err(); + let error = CommitteeResolver::authenticate_next_committee(&wrong_committee, &summary).unwrap_err(); assert!(matches!( error, - CommitteeResolutionErrorKind::InvalidTransition { + CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { epoch: 3, sequence_number: 42, .. @@ -477,14 +588,73 @@ mod tests { } #[test] - fn transition_requires_end_of_epoch_data() { - let (current_committee, _, summary) = signed_transition(3, false); + fn summary_requires_end_of_epoch_data() { + let (current_committee, _, summary) = signed_end_of_epoch_summary(3, false); - let error = CommitteeResolver::verify_next_committee(¤t_committee, &summary, 42).unwrap_err(); + 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 TrustMode::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 index e23d196..33ad1f0 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -7,6 +7,8 @@ /// 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. @@ -16,6 +18,7 @@ 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, 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 index 40ac114..b91ce1f 100644 --- a/poi-rs/tests/committee.rs +++ b/poi-rs/tests/committee.rs @@ -3,7 +3,7 @@ use iota_grpc_client::Client as GrpcClient; use iota_types::committee::Committee; -use poi_rs::{CommitteeResolutionErrorKind, CommitteeResolver}; +use poi_rs::{CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; fn committee_at(epoch: u64) -> Committee { let (committee, _) = Committee::new_simple_test_committee(); @@ -41,3 +41,17 @@ async fn anchor_mode_rejects_an_epoch_before_the_trust_anchor() { 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); +} From 7bcaae4c6bb69b89db6c4f79dd88d4151e99ae21 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Jul 2026 13:58:57 +0300 Subject: [PATCH 11/13] test: Cover committee cache behavior --- poi-rs/src/cache.rs | 33 +++++++++++++++++ poi-rs/src/cache/in_memory.rs | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/poi-rs/src/cache.rs b/poi-rs/src/cache.rs index 61629e2..c604014 100644 --- a/poi-rs/src/cache.rs +++ b/poi-rs/src/cache.rs @@ -43,3 +43,36 @@ pub trait CommitteeCache: Send + Sync { /// Stores a committee after the resolver has authenticated it. async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError>; } + +#[cfg(test)] +mod tests { + use std::{error::Error as _, io}; + + use super::*; + + #[test] + fn cache_is_object_safe() { + let cache = MemoryCommitteeCache::new(); + + let _: &dyn CommitteeCache = &cache; + } + + #[test] + fn conflict_error_identifies_the_epoch() { + let error = CommitteeCacheError::Conflict { epoch: 7 }; + + assert_eq!(error.to_string(), "cached committee conflicts at epoch 7"); + assert!(error.source().is_none()); + } + + #[test] + fn backend_error_preserves_the_epoch_and_source() { + let error = CommitteeCacheError::Backend { + epoch: 11, + source: Box::new(io::Error::other("storage unavailable")), + }; + + assert_eq!(error.to_string(), "committee cache backend failed at epoch 11"); + assert_eq!(error.source().unwrap().to_string(), "storage unavailable"); + } +} diff --git a/poi-rs/src/cache/in_memory.rs b/poi-rs/src/cache/in_memory.rs index ae56fea..b580a06 100644 --- a/poi-rs/src/cache/in_memory.rs +++ b/poi-rs/src/cache/in_memory.rs @@ -50,3 +50,73 @@ impl CommitteeCache for MemoryCommitteeCache { 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)); + } +} From 7f7b5ba4ef5a221c8fb858205c13e39393733a79 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Jul 2026 14:01:35 +0300 Subject: [PATCH 12/13] refactor: Remove unused tests from committee cache module --- poi-rs/src/cache.rs | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/poi-rs/src/cache.rs b/poi-rs/src/cache.rs index c604014..61629e2 100644 --- a/poi-rs/src/cache.rs +++ b/poi-rs/src/cache.rs @@ -43,36 +43,3 @@ pub trait CommitteeCache: Send + Sync { /// Stores a committee after the resolver has authenticated it. async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError>; } - -#[cfg(test)] -mod tests { - use std::{error::Error as _, io}; - - use super::*; - - #[test] - fn cache_is_object_safe() { - let cache = MemoryCommitteeCache::new(); - - let _: &dyn CommitteeCache = &cache; - } - - #[test] - fn conflict_error_identifies_the_epoch() { - let error = CommitteeCacheError::Conflict { epoch: 7 }; - - assert_eq!(error.to_string(), "cached committee conflicts at epoch 7"); - assert!(error.source().is_none()); - } - - #[test] - fn backend_error_preserves_the_epoch_and_source() { - let error = CommitteeCacheError::Backend { - epoch: 11, - source: Box::new(io::Error::other("storage unavailable")), - }; - - assert_eq!(error.to_string(), "committee cache backend failed at epoch 11"); - assert_eq!(error.source().unwrap().to_string(), "storage unavailable"); - } -} From c85eea5d313d94558eca43f73febbcd0518c8730 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Jul 2026 14:04:49 +0300 Subject: [PATCH 13/13] refactor: Rename committee resolution mode --- poi-rs/src/committee.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index adb0ed8..b57a592 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -146,7 +146,7 @@ pub enum CommitteeResolutionErrorKind { /// Selects how a resolver establishes trust in committee data. #[derive(Clone)] -enum TrustMode { +enum CommitteeResolution { /// Accept committee data returned directly by the connected node. Node, /// Authenticate committee lineage from an existing trust anchor. @@ -164,7 +164,7 @@ enum TrustMode { #[derive(Clone)] pub struct CommitteeResolver { client: GrpcClient, - mode: TrustMode, + mode: CommitteeResolution, } impl CommitteeResolver { @@ -176,7 +176,7 @@ impl CommitteeResolver { pub fn node(client: GrpcClient) -> Self { Self { client, - mode: TrustMode::Node, + mode: CommitteeResolution::Node, } } @@ -198,7 +198,7 @@ impl CommitteeResolver { pub fn anchor_with_cache(client: GrpcClient, committee: Committee, cache: impl CommitteeCache + 'static) -> Self { Self { client, - mode: TrustMode::Anchor { + mode: CommitteeResolution::Anchor { committee, cache: Arc::new(cache), }, @@ -217,8 +217,8 @@ impl CommitteeResolver { /// before accepting its successor. pub async fn resolve(&self, target_epoch: EpochId) -> Result { match &self.mode { - TrustMode::Node => self.resolve_from_node(target_epoch).await, - TrustMode::Anchor { committee, cache } => { + CommitteeResolution::Node => self.resolve_from_node(target_epoch).await, + CommitteeResolution::Anchor { committee, cache } => { self.resolve_from_anchor(committee, cache.as_ref(), target_epoch).await } } @@ -621,7 +621,7 @@ mod tests { 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 TrustMode::Anchor { cache, .. } = &resolver.mode else { + let CommitteeResolution::Anchor { cache, .. } = &resolver.mode else { panic!("anchor resolver must have a committee cache"); }; cache.store(&authenticated_committee).await.unwrap();