From 19ef8ffa0fee01cdc8427af4ea2e6d93effae3f1 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Jul 2026 10:43:48 +0300 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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(); From 7cc4d2487c87bced83fe18404a78195d9c0fa62c Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Jul 2026 16:31:43 +0300 Subject: [PATCH 14/21] feat: Add golden tests for proof verification and include fixture data --- poi-rs/src/committee.rs | 141 ++++++++++++--- poi-rs/tests/fixtures/v1/committee.json | 33 ++++ poi-rs/tests/fixtures/v1/event.json | 192 +++++++++++++++++++++ poi-rs/tests/fixtures/v1/object.json | 198 ++++++++++++++++++++++ poi-rs/tests/fixtures/v1/transaction.json | 178 +++++++++++++++++++ poi-rs/tests/golden.rs | 74 ++++++++ poi-rs/tests/verifier.rs | 15 ++ 7 files changed, 806 insertions(+), 25 deletions(-) create mode 100644 poi-rs/tests/fixtures/v1/committee.json create mode 100644 poi-rs/tests/fixtures/v1/event.json create mode 100644 poi-rs/tests/fixtures/v1/object.json create mode 100644 poi-rs/tests/fixtures/v1/transaction.json create mode 100644 poi-rs/tests/golden.rs diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index b57a592..bc569d6 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -338,16 +338,9 @@ impl CommitteeResolver { } while committee.epoch < target_epoch { - let next_committee = self.fetch_next_committee(target_epoch, &committee).await?; - cache.store(&next_committee).await.map_err(|source| { - CommitteeResolutionError::new( - target_epoch, - CommitteeResolutionErrorKind::Cache { - epoch: next_committee.epoch, - source, - }, - ) - })?; + let next_committee = self + .fetch_next_committee(target_epoch, &committee, cache) + .await?; committee = next_committee; } @@ -379,15 +372,14 @@ impl CommitteeResolver { &self, target_epoch: EpochId, current_committee: &Committee, + cache: &dyn CommitteeCache, ) -> Result { let sequence_number = self .epoch_last_checkpoint(target_epoch, current_committee.epoch) .await?; let summary = self.certified_checkpoint_summary(target_epoch, sequence_number).await?; - let next_committee = Self::authenticate_next_committee(current_committee, &summary) - .map_err(|kind| CommitteeResolutionError::new(target_epoch, kind))?; - Ok(next_committee) + Self::authenticate_and_store_next_committee(target_epoch, current_committee, &summary, cache).await } /// Fetches the checkpoint sequence number that closes an epoch. @@ -495,6 +487,29 @@ impl CommitteeResolver { next_epoch_committee.iter().cloned().collect(), )) } + + /// Authenticates a committee handoff before exposing it through the cache. + async fn authenticate_and_store_next_committee( + target_epoch: EpochId, + current_committee: &Committee, + summary: &CertifiedCheckpointSummary, + cache: &dyn CommitteeCache, + ) -> Result { + let next_committee = Self::authenticate_next_committee(current_committee, summary) + .map_err(|kind| CommitteeResolutionError::new(target_epoch, kind))?; + + cache.store(&next_committee).await.map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: next_committee.epoch, + source, + }, + ) + })?; + + Ok(next_committee) + } } /// Checkpoint fields required to authenticate the next committee. @@ -505,6 +520,8 @@ const CHECKPOINT_SUMMARY_FIELDS: &[&str] = &[ #[cfg(test)] mod tests { + use std::sync::Mutex; + use iota_sdk_types::gas::GasCostSummary; use iota_types::messages_checkpoint::{CheckpointSummary, EndOfEpochData}; @@ -514,6 +531,29 @@ mod tests { committee: Committee, } + #[derive(Clone, Default)] + struct RecordingCache { + stored: Arc>>, + } + + impl RecordingCache { + fn stored(&self) -> Vec { + self.stored.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl CommitteeCache for RecordingCache { + async fn committee(&self, _epoch: EpochId) -> Result, CommitteeCacheError> { + Ok(None) + } + + async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> { + self.stored.lock().unwrap().push(committee.clone()); + Ok(()) + } + } + #[async_trait::async_trait] impl CommitteeCache for StaticCache { async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError> { @@ -533,7 +573,7 @@ mod tests { 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, + current_epoch.saturating_add(1), next_base_committee.voting_rights.iter().cloned().collect(), ); let end_of_epoch_data = include_next_committee.then(|| EndOfEpochData { @@ -560,43 +600,94 @@ mod tests { (current_committee, next_committee, certified_summary) } - #[test] - fn authenticated_summary_returns_the_next_committee() { + #[tokio::test] + async fn authenticated_summary_stores_exactly_the_verified_committee() { let (current_committee, expected_committee, summary) = signed_end_of_epoch_summary(3, true); + let cache = RecordingCache::default(); - let committee = CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap(); + let committee = CommitteeResolver::authenticate_and_store_next_committee( + 4, + ¤t_committee, + &summary, + &cache, + ) + .await + .unwrap(); assert_eq!(committee, expected_committee); + assert_eq!(cache.stored(), vec![expected_committee]); } - #[test] - fn summary_rejects_a_signature_from_another_committee() { + #[tokio::test] + async fn invalid_checkpoint_signature_never_reaches_the_cache() { 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 cache = RecordingCache::default(); - let error = CommitteeResolver::authenticate_next_committee(&wrong_committee, &summary).unwrap_err(); + let error = + CommitteeResolver::authenticate_and_store_next_committee(4, &wrong_committee, &summary, &cache) + .await + .unwrap_err(); assert!(matches!( - error, + error.kind, CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { epoch: 3, sequence_number: 42, .. } )); + assert!(cache.stored().is_empty()); } - #[test] - fn summary_requires_end_of_epoch_data() { + #[tokio::test] + async fn checkpoint_without_end_of_epoch_data_never_reaches_the_cache() { let (current_committee, _, summary) = signed_end_of_epoch_summary(3, false); + let cache = RecordingCache::default(); - let error = CommitteeResolver::authenticate_next_committee(¤t_committee, &summary).unwrap_err(); + let error = CommitteeResolver::authenticate_and_store_next_committee( + 4, + ¤t_committee, + &summary, + &cache, + ) + .await + .unwrap_err(); assert!(matches!( - error, + error.kind, CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number: 42 } )); + assert!(cache.stored().is_empty()); + } + + #[tokio::test] + async fn overflowing_next_epoch_never_reaches_the_cache() { + let (current_committee, _, summary) = signed_end_of_epoch_summary(EpochId::MAX, true); + let cache = RecordingCache::default(); + + let error = CommitteeResolver::authenticate_and_store_next_committee( + EpochId::MAX, + ¤t_committee, + &summary, + &cache, + ) + .await + .unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::NextEpochOverflow { epoch: EpochId::MAX } + )); + assert!(cache.stored().is_empty()); + } + + #[tokio::test] + async fn node_resolution_mode_carries_no_anchored_cache() { + let resolver = CommitteeResolver::node(GrpcClient::new("http://127.0.0.1:1").unwrap()); + + assert!(matches!(resolver.mode, CommitteeResolution::Node)); } #[tokio::test] diff --git a/poi-rs/tests/fixtures/v1/committee.json b/poi-rs/tests/fixtures/v1/committee.json new file mode 100644 index 0000000..649c0a9 --- /dev/null +++ b/poi-rs/tests/fixtures/v1/committee.json @@ -0,0 +1,33 @@ +{ + "epoch": 0, + "voting_rights": [ + [ + "jc/20VUECmVvSBmxMRG1LFdGqGunLzlfuv4uw4R9HoFA5iSnUf32tfIFC8cgXPnTAATJCwx0Cv/TJs5nPMKyOi0k1T4q/rKG38Zo/UBgCJ1tKxe3md02+Q0zLlSnozjU", + 2500 + ], + [ + "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ", + 2500 + ], + [ + "rd7vlNiYyI5A297/kcXxBfnPLHR/tvK8N+wD1ske2y4aV4z1RL6LCTHiXyQ9WbDDDZihbOO6HWzx1/UEJpkusK2zE0sFW+gUDS218l+wDYP45CIr8B/WrJOh/0152ljy", + 2500 + ], + [ + "s/1e+1yHJAOkrRPxGZUTYG0jNUqEUkmuoVdWTCP/PBXGyeZSty10DoysuTy8wGhrDsDMDBx2C/tCtDZRn8WoBUt2UzqXqfI5h9CX75ax8lJrsgc/oQp3GZQXcjR+8nT0", + 2500 + ] + ], + "expanded_keys": { + "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ": "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ", + "s/1e+1yHJAOkrRPxGZUTYG0jNUqEUkmuoVdWTCP/PBXGyeZSty10DoysuTy8wGhrDsDMDBx2C/tCtDZRn8WoBUt2UzqXqfI5h9CX75ax8lJrsgc/oQp3GZQXcjR+8nT0": "s/1e+1yHJAOkrRPxGZUTYG0jNUqEUkmuoVdWTCP/PBXGyeZSty10DoysuTy8wGhrDsDMDBx2C/tCtDZRn8WoBUt2UzqXqfI5h9CX75ax8lJrsgc/oQp3GZQXcjR+8nT0", + "jc/20VUECmVvSBmxMRG1LFdGqGunLzlfuv4uw4R9HoFA5iSnUf32tfIFC8cgXPnTAATJCwx0Cv/TJs5nPMKyOi0k1T4q/rKG38Zo/UBgCJ1tKxe3md02+Q0zLlSnozjU": "jc/20VUECmVvSBmxMRG1LFdGqGunLzlfuv4uw4R9HoFA5iSnUf32tfIFC8cgXPnTAATJCwx0Cv/TJs5nPMKyOi0k1T4q/rKG38Zo/UBgCJ1tKxe3md02+Q0zLlSnozjU", + "rd7vlNiYyI5A297/kcXxBfnPLHR/tvK8N+wD1ske2y4aV4z1RL6LCTHiXyQ9WbDDDZihbOO6HWzx1/UEJpkusK2zE0sFW+gUDS218l+wDYP45CIr8B/WrJOh/0152ljy": "rd7vlNiYyI5A297/kcXxBfnPLHR/tvK8N+wD1ske2y4aV4z1RL6LCTHiXyQ9WbDDDZihbOO6HWzx1/UEJpkusK2zE0sFW+gUDS218l+wDYP45CIr8B/WrJOh/0152ljy" + }, + "index_map": { + "rd7vlNiYyI5A297/kcXxBfnPLHR/tvK8N+wD1ske2y4aV4z1RL6LCTHiXyQ9WbDDDZihbOO6HWzx1/UEJpkusK2zE0sFW+gUDS218l+wDYP45CIr8B/WrJOh/0152ljy": 2, + "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ": 1, + "s/1e+1yHJAOkrRPxGZUTYG0jNUqEUkmuoVdWTCP/PBXGyeZSty10DoysuTy8wGhrDsDMDBx2C/tCtDZRn8WoBUt2UzqXqfI5h9CX75ax8lJrsgc/oQp3GZQXcjR+8nT0": 3, + "jc/20VUECmVvSBmxMRG1LFdGqGunLzlfuv4uw4R9HoFA5iSnUf32tfIFC8cgXPnTAATJCwx0Cv/TJs5nPMKyOi0k1T4q/rKG38Zo/UBgCJ1tKxe3md02+Q0zLlSnozjU": 0 + } +} diff --git a/poi-rs/tests/fixtures/v1/event.json b/poi-rs/tests/fixtures/v1/event.json new file mode 100644 index 0000000..9d85d58 --- /dev/null +++ b/poi-rs/tests/fixtures/v1/event.json @@ -0,0 +1,192 @@ +{ + "version": 1, + "chain": "5898bF83xaXFZ6BZaBq7fRCJPmbxzuSUYuv4rbDrMD2W", + "target": { + "objects": [], + "events": [ + [ + { + "txDigest": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "eventSeq": "0" + }, + { + "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "type": "0x3::iota_system::SystemEpochInfoEvent", + "contents": "AQID" + } + ] + ], + "committee": null + }, + "checkpoint_summary": { + "data": { + "epoch": 0, + "sequence_number": 7, + "network_total_transactions": 1, + "content_digest": "CD6NGAT7wv4qp8JwCFxBTeb7hiiD8YdurzJpZDewKgiJ", + "previous_digest": null, + "epoch_rolling_gas_cost_summary": { + "computation_cost": "0", + "computation_cost_burned": "0", + "storage_cost": "0", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "timestamp_ms": 1700000000000, + "checkpoint_commitments": [], + "end_of_epoch_data": null, + "version_specific_data": [] + }, + "auth_signature": { + "epoch": 0, + "signature": "ieRwALkYYOgCi4IVuVAE8DWvZJ+mHgoZmWxQTFFUVR8aRUKyWb5JH5QU1yHHPA2p", + "signers_map": [ + 58, + 48, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 16, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 2, + 0, + 3, + 0 + ] + } + }, + "transaction_proof": { + "checkpoint_contents": { + "V1": { + "transactions": [ + { + "transaction": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "effects": "7VStHbgVuDPaf1tq3h99SGuqwbtQdoPwcoqr3p2FMHjQ" + } + ], + "user_signatures": [ + [] + ] + } + }, + "transaction": { + "data": [ + { + "intent_message": { + "intent": { + "scope": 0, + "version": 0, + "app_id": 0 + }, + "value": { + "V1": { + "kind": { + "Programmable": { + "inputs": [], + "commands": [] + } + }, + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "gas_payment": { + "objects": [ + { + "object_id": "0x0101010101010101010101010101010101010101010101010101010101010101", + "version": "1", + "digest": "11111111111111111111111111111111" + } + ], + "owner": "0x0000000000000000000000000000000000000000000000000000000000000000", + "price": "1", + "budget": "1000000" + }, + "expiration": "None" + } + } + }, + "tx_signatures": [] + } + ], + "auth_signature": {} + }, + "effects": { + "V1": { + "status": { + "success": true + }, + "epoch": "0", + "gas_cost_summary": { + "computation_cost": "0", + "computation_cost_burned": "0", + "storage_cost": "0", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "transaction_digest": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "gas_object_index": 0, + "events_digest": "CGqq9UxqSAkkhpaGsBHB2BE4cuneLn3dGsXmMMqWDoKn", + "dependencies": [], + "lamport_version": "1", + "changed_objects": [ + { + "object_id": "0x0101010101010101010101010101010101010101010101010101010101010101", + "input_state": { + "Data": { + "version": "1", + "digest": "11111111111111111111111111111111", + "owner": { + "Address": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG", + "owner": { + "Address": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "id_operation": "none" + }, + { + "object_id": "0x2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "7vp4HH8URCE27sP3xW1J6PHog4arGxgh6zPGMJFeboK", + "owner": "Immutable" + } + }, + "id_operation": "created" + } + ], + "unchanged_shared_objects": [], + "auxiliary_data_digest": null + } + }, + "events": [ + { + "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "type": "0x3::iota_system::SystemEpochInfoEvent", + "contents": "AQID" + } + ] + } +} diff --git a/poi-rs/tests/fixtures/v1/object.json b/poi-rs/tests/fixtures/v1/object.json new file mode 100644 index 0000000..a5303a3 --- /dev/null +++ b/poi-rs/tests/fixtures/v1/object.json @@ -0,0 +1,198 @@ +{ + "version": 1, + "chain": "5898bF83xaXFZ6BZaBq7fRCJPmbxzuSUYuv4rbDrMD2W", + "target": { + "objects": [ + [ + { + "object_id": "0x2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + "version": "1", + "digest": "7vp4HH8URCE27sP3xW1J6PHog4arGxgh6zPGMJFeboK" + }, + { + "data": { + "Struct": { + "object_type": "0x2::coin::Coin<0x2::iota::IOTA>", + "version": "1", + "contents": "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioAwG4x2RABAA==" + } + }, + "owner": "Immutable", + "previous_transaction": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "storage_rebate": "0" + } + ] + ], + "events": [], + "committee": null + }, + "checkpoint_summary": { + "data": { + "epoch": 0, + "sequence_number": 7, + "network_total_transactions": 1, + "content_digest": "CD6NGAT7wv4qp8JwCFxBTeb7hiiD8YdurzJpZDewKgiJ", + "previous_digest": null, + "epoch_rolling_gas_cost_summary": { + "computation_cost": "0", + "computation_cost_burned": "0", + "storage_cost": "0", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "timestamp_ms": 1700000000000, + "checkpoint_commitments": [], + "end_of_epoch_data": null, + "version_specific_data": [] + }, + "auth_signature": { + "epoch": 0, + "signature": "ieRwALkYYOgCi4IVuVAE8DWvZJ+mHgoZmWxQTFFUVR8aRUKyWb5JH5QU1yHHPA2p", + "signers_map": [ + 58, + 48, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 16, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 2, + 0, + 3, + 0 + ] + } + }, + "transaction_proof": { + "checkpoint_contents": { + "V1": { + "transactions": [ + { + "transaction": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "effects": "7VStHbgVuDPaf1tq3h99SGuqwbtQdoPwcoqr3p2FMHjQ" + } + ], + "user_signatures": [ + [] + ] + } + }, + "transaction": { + "data": [ + { + "intent_message": { + "intent": { + "scope": 0, + "version": 0, + "app_id": 0 + }, + "value": { + "V1": { + "kind": { + "Programmable": { + "inputs": [], + "commands": [] + } + }, + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "gas_payment": { + "objects": [ + { + "object_id": "0x0101010101010101010101010101010101010101010101010101010101010101", + "version": "1", + "digest": "11111111111111111111111111111111" + } + ], + "owner": "0x0000000000000000000000000000000000000000000000000000000000000000", + "price": "1", + "budget": "1000000" + }, + "expiration": "None" + } + } + }, + "tx_signatures": [] + } + ], + "auth_signature": {} + }, + "effects": { + "V1": { + "status": { + "success": true + }, + "epoch": "0", + "gas_cost_summary": { + "computation_cost": "0", + "computation_cost_burned": "0", + "storage_cost": "0", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "transaction_digest": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "gas_object_index": 0, + "events_digest": "CGqq9UxqSAkkhpaGsBHB2BE4cuneLn3dGsXmMMqWDoKn", + "dependencies": [], + "lamport_version": "1", + "changed_objects": [ + { + "object_id": "0x0101010101010101010101010101010101010101010101010101010101010101", + "input_state": { + "Data": { + "version": "1", + "digest": "11111111111111111111111111111111", + "owner": { + "Address": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG", + "owner": { + "Address": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "id_operation": "none" + }, + { + "object_id": "0x2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "7vp4HH8URCE27sP3xW1J6PHog4arGxgh6zPGMJFeboK", + "owner": "Immutable" + } + }, + "id_operation": "created" + } + ], + "unchanged_shared_objects": [], + "auxiliary_data_digest": null + } + }, + "events": [ + { + "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "type": "0x3::iota_system::SystemEpochInfoEvent", + "contents": "AQID" + } + ] + } +} diff --git a/poi-rs/tests/fixtures/v1/transaction.json b/poi-rs/tests/fixtures/v1/transaction.json new file mode 100644 index 0000000..4153165 --- /dev/null +++ b/poi-rs/tests/fixtures/v1/transaction.json @@ -0,0 +1,178 @@ +{ + "version": 1, + "chain": "5898bF83xaXFZ6BZaBq7fRCJPmbxzuSUYuv4rbDrMD2W", + "target": { + "objects": [], + "events": [], + "committee": null + }, + "checkpoint_summary": { + "data": { + "epoch": 0, + "sequence_number": 7, + "network_total_transactions": 1, + "content_digest": "CD6NGAT7wv4qp8JwCFxBTeb7hiiD8YdurzJpZDewKgiJ", + "previous_digest": null, + "epoch_rolling_gas_cost_summary": { + "computation_cost": "0", + "computation_cost_burned": "0", + "storage_cost": "0", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "timestamp_ms": 1700000000000, + "checkpoint_commitments": [], + "end_of_epoch_data": null, + "version_specific_data": [] + }, + "auth_signature": { + "epoch": 0, + "signature": "ieRwALkYYOgCi4IVuVAE8DWvZJ+mHgoZmWxQTFFUVR8aRUKyWb5JH5QU1yHHPA2p", + "signers_map": [ + 58, + 48, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 16, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 2, + 0, + 3, + 0 + ] + } + }, + "transaction_proof": { + "checkpoint_contents": { + "V1": { + "transactions": [ + { + "transaction": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "effects": "7VStHbgVuDPaf1tq3h99SGuqwbtQdoPwcoqr3p2FMHjQ" + } + ], + "user_signatures": [ + [] + ] + } + }, + "transaction": { + "data": [ + { + "intent_message": { + "intent": { + "scope": 0, + "version": 0, + "app_id": 0 + }, + "value": { + "V1": { + "kind": { + "Programmable": { + "inputs": [], + "commands": [] + } + }, + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "gas_payment": { + "objects": [ + { + "object_id": "0x0101010101010101010101010101010101010101010101010101010101010101", + "version": "1", + "digest": "11111111111111111111111111111111" + } + ], + "owner": "0x0000000000000000000000000000000000000000000000000000000000000000", + "price": "1", + "budget": "1000000" + }, + "expiration": "None" + } + } + }, + "tx_signatures": [] + } + ], + "auth_signature": {} + }, + "effects": { + "V1": { + "status": { + "success": true + }, + "epoch": "0", + "gas_cost_summary": { + "computation_cost": "0", + "computation_cost_burned": "0", + "storage_cost": "0", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "transaction_digest": "GYD4BTtwZVGqBJtpjyEevCBU8fde1WwXLWuH92eGjUAT", + "gas_object_index": 0, + "events_digest": "CGqq9UxqSAkkhpaGsBHB2BE4cuneLn3dGsXmMMqWDoKn", + "dependencies": [], + "lamport_version": "1", + "changed_objects": [ + { + "object_id": "0x0101010101010101010101010101010101010101010101010101010101010101", + "input_state": { + "Data": { + "version": "1", + "digest": "11111111111111111111111111111111", + "owner": { + "Address": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "JEKNVnkbo3jma5nREBBJCDoXFVeKkD56V3xKrvRmWxFG", + "owner": { + "Address": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "id_operation": "none" + }, + { + "object_id": "0x2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "7vp4HH8URCE27sP3xW1J6PHog4arGxgh6zPGMJFeboK", + "owner": "Immutable" + } + }, + "id_operation": "created" + } + ], + "unchanged_shared_objects": [], + "auxiliary_data_digest": null + } + }, + "events": [ + { + "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "sender": "0x0000000000000000000000000000000000000000000000000000000000000000", + "type": "0x3::iota_system::SystemEpochInfoEvent", + "contents": "AQID" + } + ] + } +} diff --git a/poi-rs/tests/golden.rs b/poi-rs/tests/golden.rs new file mode 100644 index 0000000..ccc8d15 --- /dev/null +++ b/poi-rs/tests/golden.rs @@ -0,0 +1,74 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::committee::Committee; +use poi_rs::{Proof, ProofVerifier, ProofVersion, VerifyErrorKind}; + +const COMMITTEE: &str = include_str!("fixtures/v1/committee.json"); +const TRANSACTION_PROOF: &str = include_str!("fixtures/v1/transaction.json"); +const OBJECT_PROOF: &str = include_str!("fixtures/v1/object.json"); +const EVENT_PROOF: &str = include_str!("fixtures/v1/event.json"); + +fn verify_fixture(fixture: &str) -> Proof { + let committee: Committee = serde_json::from_str(COMMITTEE).expect("version 1 committee fixture must deserialize"); + let proof: Proof = serde_json::from_str(fixture).expect("version 1 proof fixture must deserialize"); + + ProofVerifier::new(&committee) + .verify(&proof) + .expect("version 1 proof fixture must verify offline"); + + let fixture_value: serde_json::Value = + serde_json::from_str(fixture).expect("version 1 proof fixture must contain valid JSON"); + let serialized_value = serde_json::to_value(&proof).expect("version 1 proof fixture must serialize"); + assert_eq!(serialized_value, fixture_value); + assert_eq!(proof.version(), ProofVersion::CURRENT); + + proof +} + +#[test] +fn transaction_fixture_verifies_offline() { + let proof = verify_fixture(TRANSACTION_PROOF); + + assert!(proof.target().objects.is_empty()); + assert!(proof.target().events.is_empty()); + assert!(proof.target().committee.is_none()); +} + +#[test] +fn object_fixture_verifies_offline() { + let proof = verify_fixture(OBJECT_PROOF); + + assert_eq!(proof.target().objects.len(), 1); + assert!(proof.target().events.is_empty()); + assert!(proof.target().committee.is_none()); +} + +#[test] +fn event_fixture_verifies_offline() { + let proof = verify_fixture(EVENT_PROOF); + + assert!(proof.target().objects.is_empty()); + assert_eq!(proof.target().events.len(), 1); + assert!(proof.target().committee.is_none()); +} + +#[test] +fn unsupported_fixture_version_returns_a_clear_error() { + let committee: Committee = serde_json::from_str(COMMITTEE).expect("version 1 committee fixture must deserialize"); + let mut fixture: serde_json::Value = + serde_json::from_str(TRANSACTION_PROOF).expect("version 1 transaction fixture must contain valid JSON"); + fixture["version"] = serde_json::json!(2); + let proof: Proof = serde_json::from_value(fixture).expect("unsupported proof version must deserialize"); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let VerifyErrorKind::Version { source } = error.kind else { + panic!("unsupported proof version must return a version error"); + }; + + assert_eq!(source.version, 2); + assert_eq!( + source.to_string(), + "unsupported Proof of Inclusion proof format version: 2" + ); +} diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/verifier.rs index 45d9c1a..0a66d55 100644 --- a/poi-rs/tests/verifier.rs +++ b/poi-rs/tests/verifier.rs @@ -249,6 +249,21 @@ fn verifier_rejects_event_contents_mismatch() { assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventContentsMismatch))); } +#[test] +fn verifier_rejects_event_transaction_mismatch() { + let event = test_event(vec![1, 2, 3]); + let (committee, _, mut proof) = test_proof_with_events(TransactionEvents(vec![event.clone()])); + let event_id = EventID { + tx_digest: TransactionDigest::new([0xff; 32]), + event_seq: 0, + }; + 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::EventTransactionMismatch))); +} + #[test] fn verifier_rejects_event_sequence_out_of_bounds() { let event = test_event(vec![1, 2, 3]); From 62b543bbd040c852983d1d7d5b2d6d10fdae5092 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Jul 2026 17:29:24 +0300 Subject: [PATCH 15/21] feat: Add dev-dependencies for iota-config and test-cluster; remove obsolete tests --- poi-rs/Cargo.toml | 4 + poi-rs/src/source.rs | 40 +++--- poi-rs/tests/cache.rs | 16 --- poi-rs/tests/committee.rs | 57 -------- poi-rs/tests/golden.rs | 74 ---------- poi-rs/tests/proof.rs | 34 ----- poi-rs/tests/source.rs | 208 ---------------------------- poi-rs/tests/verifier.rs | 282 -------------------------------------- 8 files changed, 24 insertions(+), 691 deletions(-) delete mode 100644 poi-rs/tests/cache.rs delete mode 100644 poi-rs/tests/committee.rs delete mode 100644 poi-rs/tests/golden.rs delete mode 100644 poi-rs/tests/proof.rs delete mode 100644 poi-rs/tests/source.rs delete mode 100644 poi-rs/tests/verifier.rs diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index f702a7b..5710a77 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -20,3 +20,7 @@ serde.workspace = true serde_json = { workspace = true, features = ["alloc"] } thiserror.workspace = true tokio.workspace = true + +[dev-dependencies] +iota-config = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } +test-cluster = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1", package = "test-cluster" } diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index f286272..9e1a61b 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -22,6 +22,26 @@ use iota_types::{ use crate::{BoxError, Proof, ProofTargets, TransactionProof}; +// Minimum gRPC fields needed to package a transaction proof. +const TRANSACTION_PROOF_FIELDS: &[&str] = &[ + TransactionField::TRANSACTION_BCS, + TransactionField::SIGNATURES, + TransactionField::EFFECTS_BCS, + TransactionField::EVENTS_DIGEST, + TransactionField::EVENTS_EVENTS_BCS, + TransactionField::CHECKPOINT, +]; + +// Minimum gRPC fields needed to package an object target. +const OBJECT_PROOF_FIELDS: &[&str] = &[ObjectField::BCS]; + +// Minimum gRPC fields needed to authenticate checkpoint contents. +const CHECKPOINT_PROOF_FIELDS: &[&str] = &[ + CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, + CheckpointResponseField::CHECKPOINT_SIGNATURE, + CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, +]; + /// Source target requested by the caller. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -532,23 +552,3 @@ impl Source for GrpcSource { Ok(proof) } } - -// Minimum gRPC fields needed to package a transaction proof. -const TRANSACTION_PROOF_FIELDS: &[&str] = &[ - TransactionField::TRANSACTION_BCS, - TransactionField::SIGNATURES, - TransactionField::EFFECTS_BCS, - TransactionField::EVENTS_DIGEST, - TransactionField::EVENTS_EVENTS_BCS, - TransactionField::CHECKPOINT, -]; - -// Minimum gRPC fields needed to package an object target. -const OBJECT_PROOF_FIELDS: &[&str] = &[ObjectField::BCS]; - -// Minimum gRPC fields needed to authenticate checkpoint contents. -const CHECKPOINT_PROOF_FIELDS: &[&str] = &[ - CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, - CheckpointResponseField::CHECKPOINT_SIGNATURE, - CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, -]; diff --git a/poi-rs/tests/cache.rs b/poi-rs/tests/cache.rs deleted file mode 100644 index b5ad162..0000000 --- a/poi-rs/tests/cache.rs +++ /dev/null @@ -1,16 +0,0 @@ -// 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 deleted file mode 100644 index b91ce1f..0000000 --- a/poi-rs/tests/committee.rs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_grpc_client::Client as GrpcClient; -use iota_types::committee::Committee; -use poi_rs::{CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; - -fn committee_at(epoch: u64) -> Committee { - let (committee, _) = Committee::new_simple_test_committee(); - Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) -} - -fn disconnected_client() -> GrpcClient { - GrpcClient::new("http://127.0.0.1:1").expect("create lazy gRPC client") -} - -#[tokio::test] -async fn anchor_mode_returns_the_trusted_committee_for_its_epoch() { - let trusted_committee = committee_at(7); - let resolver = CommitteeResolver::anchor(disconnected_client(), trusted_committee.clone()); - - let resolved = resolver.resolve(7).await.unwrap(); - - assert_eq!(resolved, trusted_committee); -} - -#[tokio::test] -async fn anchor_mode_rejects_an_epoch_before_the_trust_anchor() { - let resolver = CommitteeResolver::anchor(disconnected_client(), committee_at(7)); - - let error = resolver.resolve(6).await.unwrap_err(); - - assert_eq!(error.target_epoch, 6); - assert!(matches!( - error.kind, - CommitteeResolutionErrorKind::TargetBeforeAnchor { anchor_epoch: 7 } - )); -} - -#[tokio::test] -async fn node_mode_has_an_explicit_constructor() { - let _resolver = CommitteeResolver::node(disconnected_client()); -} - -#[tokio::test] -async fn anchor_mode_accepts_a_committee_cache() { - let trusted_committee = committee_at(7); - let resolver = CommitteeResolver::anchor_with_cache( - disconnected_client(), - trusted_committee.clone(), - MemoryCommitteeCache::new(), - ); - - let resolved = resolver.resolve(7).await.unwrap(); - - assert_eq!(resolved, trusted_committee); -} diff --git a/poi-rs/tests/golden.rs b/poi-rs/tests/golden.rs deleted file mode 100644 index ccc8d15..0000000 --- a/poi-rs/tests/golden.rs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_types::committee::Committee; -use poi_rs::{Proof, ProofVerifier, ProofVersion, VerifyErrorKind}; - -const COMMITTEE: &str = include_str!("fixtures/v1/committee.json"); -const TRANSACTION_PROOF: &str = include_str!("fixtures/v1/transaction.json"); -const OBJECT_PROOF: &str = include_str!("fixtures/v1/object.json"); -const EVENT_PROOF: &str = include_str!("fixtures/v1/event.json"); - -fn verify_fixture(fixture: &str) -> Proof { - let committee: Committee = serde_json::from_str(COMMITTEE).expect("version 1 committee fixture must deserialize"); - let proof: Proof = serde_json::from_str(fixture).expect("version 1 proof fixture must deserialize"); - - ProofVerifier::new(&committee) - .verify(&proof) - .expect("version 1 proof fixture must verify offline"); - - let fixture_value: serde_json::Value = - serde_json::from_str(fixture).expect("version 1 proof fixture must contain valid JSON"); - let serialized_value = serde_json::to_value(&proof).expect("version 1 proof fixture must serialize"); - assert_eq!(serialized_value, fixture_value); - assert_eq!(proof.version(), ProofVersion::CURRENT); - - proof -} - -#[test] -fn transaction_fixture_verifies_offline() { - let proof = verify_fixture(TRANSACTION_PROOF); - - assert!(proof.target().objects.is_empty()); - assert!(proof.target().events.is_empty()); - assert!(proof.target().committee.is_none()); -} - -#[test] -fn object_fixture_verifies_offline() { - let proof = verify_fixture(OBJECT_PROOF); - - assert_eq!(proof.target().objects.len(), 1); - assert!(proof.target().events.is_empty()); - assert!(proof.target().committee.is_none()); -} - -#[test] -fn event_fixture_verifies_offline() { - let proof = verify_fixture(EVENT_PROOF); - - assert!(proof.target().objects.is_empty()); - assert_eq!(proof.target().events.len(), 1); - assert!(proof.target().committee.is_none()); -} - -#[test] -fn unsupported_fixture_version_returns_a_clear_error() { - let committee: Committee = serde_json::from_str(COMMITTEE).expect("version 1 committee fixture must deserialize"); - let mut fixture: serde_json::Value = - serde_json::from_str(TRANSACTION_PROOF).expect("version 1 transaction fixture must contain valid JSON"); - fixture["version"] = serde_json::json!(2); - let proof: Proof = serde_json::from_value(fixture).expect("unsupported proof version must deserialize"); - - let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); - let VerifyErrorKind::Version { source } = error.kind else { - panic!("unsupported proof version must return a version error"); - }; - - assert_eq!(source.version, 2); - assert_eq!( - source.to_string(), - "unsupported Proof of Inclusion proof format version: 2" - ); -} diff --git a/poi-rs/tests/proof.rs b/poi-rs/tests/proof.rs deleted file mode 100644 index 3ac5ebc..0000000 --- a/poi-rs/tests/proof.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_types::committee::Committee; -use poi_rs::{Proof, ProofVerifier, ProofVersion, TransactionProof}; - -fn proof_transaction_proof_is_required(proof: Proof) -> TransactionProof { - proof.transaction_proof -} - -#[test] -fn current_proof_format_version_is_one() { - assert_eq!(ProofVersion::CURRENT.value(), 1); - assert_eq!( - ProofVersion::new(ProofVersion::CURRENT.value()).unwrap(), - ProofVersion::CURRENT - ); -} - -#[test] -fn proof_requires_transaction_proof() { - let transaction_proof_field: fn(Proof) -> TransactionProof = proof_transaction_proof_is_required; - let _ = transaction_proof_field; -} - -#[test] -fn proof_verifier_is_the_public_verification_entrypoint() { - let (committee, _) = Committee::new_simple_test_committee(); - let verifier = ProofVerifier::new(&committee); - let verify_method = ProofVerifier::verify; - - assert_eq!(verifier.committee().epoch, committee.epoch); - let _ = verify_method; -} diff --git a/poi-rs/tests/source.rs b/poi-rs/tests/source.rs deleted file mode 100644 index 157ddf1..0000000 --- a/poi-rs/tests/source.rs +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use async_trait::async_trait; -use iota_sdk_types::{Event, gas::GasCostSummary}; -use iota_types::{ - base_types::{ExecutionData, ObjectRef}, - committee::Committee, - digests::{ChainIdentifier, TransactionDigest}, - effects::TransactionEvents, - event::EventID, - messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, FullCheckpointContents}, - object::Object, - sdk_types::{Address, Identifier, ObjectId, StructTag}, -}; -use poi_rs::{ - Proof, ProofTargets, ProofVerifier, Source, SourceError, SourceErrorKind, SourceTarget, TransactionProof, -}; - -#[derive(Default)] -struct MockSource { - proof: Option, - object: Option, -} - -#[async_trait] -impl Source for MockSource { - async fn transaction(&self, transaction_digest: TransactionDigest) -> Result { - self.proof - .clone() - .ok_or_else(|| SourceError::new(transaction_digest, SourceErrorKind::TransactionNotFound)) - } - - async fn object(&self, object_ref: ObjectRef) -> Result { - let object = self - .object - .clone() - .filter(|object| object.as_inner().object_ref() == object_ref) - .ok_or_else(|| SourceError::object(object_ref, SourceErrorKind::ObjectNotFound))?; - let mut proof = self.transaction(object.previous_transaction).await?; - proof.target = proof.target.add_object(object_ref, object); - Ok(proof) - } - - async fn event(&self, event_id: EventID) -> Result { - let mut proof = self.transaction(event_id.tx_digest).await?; - let event = proof - .transaction_proof - .events - .as_ref() - .and_then(|events| events.get(event_id.event_seq as usize)) - .cloned() - .ok_or_else(|| SourceError::event(event_id, SourceErrorKind::EventNotFound))?; - proof.target = proof.target.add_event(event_id, event); - Ok(proof) - } -} - -fn test_execution_data() -> ExecutionData { - FullCheckpointContents::random_for_testing() - .into_iter() - .next() - .expect("test checkpoint contents includes one transaction") -} - -fn test_proof() -> (Committee, TransactionDigest, Proof) { - let execution_data = test_execution_data(); - let transaction_digest = *execution_data.transaction.digest(); - let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); - let checkpoint_summary = CheckpointSummary { - epoch: 0, - sequence_number: 0, - network_total_transactions: checkpoint_contents.size() as u64, - content_digest: *checkpoint_contents.digest(), - previous_digest: None, - epoch_rolling_gas_cost_summary: GasCostSummary::default(), - timestamp_ms: 0, - checkpoint_commitments: Vec::new(), - end_of_epoch_data: None, - version_specific_data: Vec::new(), - }; - let (committee, keypairs) = Committee::new_simple_test_committee(); - let checkpoint_summary = - CertifiedCheckpointSummary::new_from_keypairs_for_testing(checkpoint_summary, &keypairs, &committee); - let chain = ChainIdentifier::from(*checkpoint_summary.digest()); - let proof = Proof::new( - chain, - ProofTargets::new(), - checkpoint_summary, - TransactionProof::new( - checkpoint_contents, - execution_data.transaction, - execution_data.effects, - None, - ), - ); - - (committee, transaction_digest, proof) -} - -fn test_event(contents: Vec) -> Event { - Event { - package_id: ObjectId::SYSTEM, - module: Identifier::IOTA_SYSTEM_MODULE, - sender: Address::SYSTEM, - type_: StructTag::new( - Address::SYSTEM, - Identifier::IOTA_SYSTEM_MODULE, - Identifier::SYSTEM_EPOCH_INFO_EVENT, - Vec::new(), - ), - contents, - } -} - -#[tokio::test] -async fn source_builds_transaction_proof() { - let (committee, transaction_digest, proof) = test_proof(); - let source = MockSource { - proof: Some(proof), - object: None, - }; - - let proof = source.transaction(transaction_digest).await.unwrap(); - - assert_eq!(proof.transaction_proof.transaction.digest(), &transaction_digest); - ProofVerifier::new(&committee).verify(&proof).unwrap(); -} - -#[tokio::test] -async fn source_builds_object_proof() { - let (_, transaction_digest, proof) = test_proof(); - let mut object = Object::immutable_for_testing(); - object.previous_transaction = transaction_digest; - let object_ref = object.as_inner().object_ref(); - let source = MockSource { - proof: Some(proof), - object: Some(object.clone()), - }; - - let proof = source.object(object_ref).await.unwrap(); - - assert_eq!(proof.transaction_proof.transaction.digest(), &transaction_digest); - assert_eq!(proof.target.objects, vec![(object_ref, object)]); -} - -#[tokio::test] -async fn source_builds_event_proof() { - let (_, transaction_digest, mut proof) = test_proof(); - let event = test_event(vec![1, 2, 3]); - proof.transaction_proof.events = Some(TransactionEvents(vec![event.clone()])); - let event_id = EventID { - tx_digest: transaction_digest, - event_seq: 0, - }; - let source = MockSource { - proof: Some(proof), - object: None, - }; - - let proof = source.event(event_id).await.unwrap(); - - assert_eq!(proof.transaction_proof.transaction.digest(), &transaction_digest); - assert_eq!(proof.target.events, vec![(event_id, event)]); -} - -#[tokio::test] -async fn transaction_surfaces_source_failures() { - let (_, transaction_digest, _) = test_proof(); - let source = MockSource::default(); - - let result = source.transaction(transaction_digest).await; - - let error = result.unwrap_err(); - assert_eq!(error.target, SourceTarget::Transaction(transaction_digest)); - assert!(matches!(error.kind, SourceErrorKind::TransactionNotFound)); -} - -#[tokio::test] -async fn object_surfaces_source_failures() { - let object_ref = Object::immutable_for_testing().as_inner().object_ref(); - let source = MockSource::default(); - - let result = source.object(object_ref).await; - - let error = result.unwrap_err(); - assert_eq!(error.target, SourceTarget::Object(object_ref)); - assert!(matches!(error.kind, SourceErrorKind::ObjectNotFound)); -} - -#[tokio::test] -async fn event_surfaces_source_failures() { - let (_, transaction_digest, proof) = test_proof(); - let event_id = EventID { - tx_digest: transaction_digest, - event_seq: 0, - }; - let source = MockSource { - proof: Some(proof), - object: None, - }; - - let result = source.event(event_id).await; - - let error = result.unwrap_err(); - assert_eq!(error.target, SourceTarget::Event(event_id)); - assert!(matches!(error.kind, SourceErrorKind::EventNotFound)); -} diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/verifier.rs deleted file mode 100644 index 0a66d55..0000000 --- a/poi-rs/tests/verifier.rs +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_sdk_types::{Event, gas::GasCostSummary}; -use iota_types::{ - base_types::{ExecutionData, dbg_object_id}, - committee::Committee, - digests::{ChainIdentifier, TransactionDigest}, - effects::{TestEffectsBuilder, TransactionEvents}, - event::EventID, - messages_checkpoint::{ - CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, - }, - object::Object, - sdk_types::{Address, Identifier, ObjectId, StructTag}, -}; -use poi_rs::{Proof, ProofTargets, ProofVerifier, TransactionProof, VerifyErrorKind}; - -fn test_execution_data() -> ExecutionData { - FullCheckpointContents::random_for_testing() - .into_iter() - .next() - .expect("test checkpoint contents includes one transaction") -} - -fn sign_checkpoint_summary( - checkpoint_contents: &CheckpointContents, - end_of_epoch_data: Option, -) -> (Committee, CertifiedCheckpointSummary) { - let checkpoint_summary = CheckpointSummary { - epoch: 0, - sequence_number: 0, - network_total_transactions: checkpoint_contents.size() as u64, - content_digest: *checkpoint_contents.digest(), - previous_digest: None, - epoch_rolling_gas_cost_summary: GasCostSummary::default(), - timestamp_ms: 0, - checkpoint_commitments: Vec::new(), - end_of_epoch_data, - version_specific_data: Vec::new(), - }; - let (committee, keypairs) = Committee::new_simple_test_committee(); - let checkpoint_summary = - CertifiedCheckpointSummary::new_from_keypairs_for_testing(checkpoint_summary, &keypairs, &committee); - - (committee, checkpoint_summary) -} - -fn test_proof() -> (Committee, Proof) { - test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new(), None) -} - -fn test_proof_with_targets_and_end_of_epoch_data( - targets: ProofTargets, - end_of_epoch_data: Option, -) -> (Committee, Proof) { - let execution_data = test_execution_data(); - let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); - let (committee, checkpoint_summary) = sign_checkpoint_summary(&checkpoint_contents, end_of_epoch_data); - let chain = ChainIdentifier::from(*checkpoint_summary.digest()); - - let proof = Proof::new( - chain, - targets, - checkpoint_summary, - TransactionProof::new( - checkpoint_contents, - execution_data.transaction, - execution_data.effects, - None, - ), - ); - - (committee, proof) -} - -fn test_proof_with_events(events: TransactionEvents) -> (Committee, TransactionDigest, Proof) { - let mut execution_data = test_execution_data(); - let transaction_digest = *execution_data.transaction.digest(); - execution_data.effects = TestEffectsBuilder::new(execution_data.transaction.data()) - .with_events_digest(events.digest()) - .build(); - let checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([execution_data.digests()]); - let (committee, checkpoint_summary) = sign_checkpoint_summary(&checkpoint_contents, None); - let chain = ChainIdentifier::from(*checkpoint_summary.digest()); - - let proof = Proof::new( - chain, - ProofTargets::new(), - checkpoint_summary, - TransactionProof::new( - checkpoint_contents, - execution_data.transaction, - execution_data.effects, - Some(events), - ), - ); - - (committee, transaction_digest, proof) -} - -fn test_event(contents: Vec) -> Event { - Event { - package_id: ObjectId::SYSTEM, - module: Identifier::IOTA_SYSTEM_MODULE, - sender: Address::SYSTEM, - type_: StructTag::new( - Address::SYSTEM, - Identifier::IOTA_SYSTEM_MODULE, - Identifier::SYSTEM_EPOCH_INFO_EVENT, - Vec::new(), - ), - contents, - } -} - -fn epoch_one_committee(committee: &Committee) -> Committee { - Committee::new(1, committee.voting_rights.iter().cloned().collect()) -} - -fn end_of_epoch_data_for(committee: &Committee) -> EndOfEpochData { - EndOfEpochData { - next_epoch_committee: committee.voting_rights.clone(), - next_epoch_protocol_version: 1.into(), - epoch_commitments: Vec::new(), - epoch_supply_change: 0, - } -} - -#[test] -fn verifier_accepts_valid_transaction_proof() { - let (committee, proof) = test_proof(); - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(result.is_ok()); -} - -#[test] -fn verifier_rejects_transaction_digest_mismatch() { - let (committee, mut proof) = test_proof(); - proof.transaction_proof.effects = test_execution_data().effects; - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::TransactionDigestMismatch))); -} - -#[test] -fn verifier_rejects_events_digest_mismatch() { - let (committee, mut proof) = test_proof(); - proof.transaction_proof.events = Some(TransactionEvents(Vec::new())); - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventsDigestMismatch))); -} - -#[test] -fn verifier_rejects_checkpoint_contents_mismatch() { - let (committee, mut proof) = test_proof(); - let alternate_execution_data = test_execution_data(); - proof.transaction_proof.checkpoint_contents = - CheckpointContents::new_with_digests_only_for_tests([alternate_execution_data.digests()]); - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::CheckpointSummary { .. }))); -} - -#[test] -fn verifier_rejects_transaction_not_in_checkpoint() { - let (committee, mut proof) = test_proof(); - let alternate_execution_data = test_execution_data(); - proof.transaction_proof.transaction = alternate_execution_data.transaction; - proof.transaction_proof.effects = alternate_execution_data.effects; - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint))); -} - -#[test] -fn verifier_rejects_missing_end_of_epoch_committee() { - let (committee, _) = Committee::new_simple_test_committee(); - let expected_committee = epoch_one_committee(&committee); - let (verifying_committee, proof) = - test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new().set_committee(expected_committee), None); - - let result = ProofVerifier::new(&verifying_committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::MissingEndOfEpochCommittee))); -} - -#[test] -fn verifier_rejects_committee_mismatch() { - let (actual_next_committee, _) = Committee::new_simple_test_committee(); - let actual_next_committee = epoch_one_committee(&actual_next_committee); - let (wrong_next_committee, _) = Committee::new_simple_test_committee_of_size(5); - let wrong_next_committee = epoch_one_committee(&wrong_next_committee); - let (verifying_committee, proof) = test_proof_with_targets_and_end_of_epoch_data( - ProofTargets::new().set_committee(wrong_next_committee), - Some(end_of_epoch_data_for(&actual_next_committee)), - ); - - let result = ProofVerifier::new(&verifying_committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::CommitteeMismatch))); -} - -#[test] -fn verifier_rejects_object_reference_mismatch() { - let object = Object::immutable_for_testing(); - let mut wrong_object_ref = object.as_inner().object_ref(); - wrong_object_ref.object_id = dbg_object_id(42); - let (committee, proof) = - test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new().add_object(wrong_object_ref, object), None); - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::ObjectReferenceMismatch))); -} - -#[test] -fn verifier_rejects_object_not_found_in_transaction_effects() { - let object = Object::immutable_for_testing(); - let object_ref = object.as_inner().object_ref(); - let (committee, proof) = - test_proof_with_targets_and_end_of_epoch_data(ProofTargets::new().add_object(object_ref, object), None); - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::ObjectNotFound))); -} - -#[test] -fn verifier_rejects_event_contents_mismatch() { - let event = test_event(vec![1, 2, 3]); - let wrong_event = test_event(vec![9, 9, 9]); - let (committee, transaction_digest, mut proof) = test_proof_with_events(TransactionEvents(vec![event])); - let event_id = EventID { - tx_digest: transaction_digest, - event_seq: 0, - }; - proof.target = ProofTargets::new().add_event(event_id, wrong_event); - - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(matches!(result, Err(error) if matches!(error.kind, VerifyErrorKind::EventContentsMismatch))); -} - -#[test] -fn verifier_rejects_event_transaction_mismatch() { - let event = test_event(vec![1, 2, 3]); - let (committee, _, mut proof) = test_proof_with_events(TransactionEvents(vec![event.clone()])); - let event_id = EventID { - tx_digest: TransactionDigest::new([0xff; 32]), - event_seq: 0, - }; - 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::EventTransactionMismatch))); -} - -#[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 27fd80b48f106a643172028c556040053a5eda5e Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 14 Jul 2026 14:23:00 +0300 Subject: [PATCH 16/21] feat: Implement ProofBuilder and related components for proof construction and verification --- poi-rs/README.md | 36 ++++++- poi-rs/src/builder.rs | 143 +++++++++++++++++++++++++ poi-rs/src/committee.rs | 34 ++---- poi-rs/src/lib.rs | 5 +- poi-rs/src/source.rs | 16 ++- poi-rs/tests/committee.rs | 85 +++++++++++++++ poi-rs/tests/golden.rs | 65 +++++++++++ poi-rs/tests/proof_builder.rs | 124 +++++++++++++++++++++ poi-rs/tests/proof_of_inclusion.rs | 75 +++++++++++++ poi-rs/tests/utils/mod.rs | 96 +++++++++++++++++ poi-rs/tests/utils/proofs.rs | 110 +++++++++++++++++++ poi-rs/tests/verifier.rs | 166 +++++++++++++++++++++++++++++ 12 files changed, 917 insertions(+), 38 deletions(-) create mode 100644 poi-rs/src/builder.rs create mode 100644 poi-rs/tests/committee.rs create mode 100644 poi-rs/tests/golden.rs create mode 100644 poi-rs/tests/proof_builder.rs create mode 100644 poi-rs/tests/proof_of_inclusion.rs create mode 100644 poi-rs/tests/utils/mod.rs create mode 100644 poi-rs/tests/utils/proofs.rs create mode 100644 poi-rs/tests/verifier.rs diff --git a/poi-rs/README.md b/poi-rs/README.md index b740858..16d37d7 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -4,8 +4,33 @@ The Proof of Inclusion Rust package provides proof data types and offline verifi 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. +a certified IOTA checkpoint. `ProofBuilder` fetches the proof material, while `ProofVerifier` verifies that material +locally without trusting the source that supplied it. + +## Proof Construction + +`ProofBuilder` provides explicit constructors for the public IOTA networks. The builder does not select a default network, +so the calling application always chooses where it fetches proof material. + +```rust,no_run +use iota_types::digests::TransactionDigest; +use poi_rs::ProofBuilder; + +# async fn example() -> Result<(), Box> { +let transaction_digest: TransactionDigest = todo!(); +let proof = ProofBuilder::mainnet()? + .transaction(transaction_digest) + .build() + .await?; +# Ok(()) +# } +``` + +Use `ProofBuilder::testnet()` or `ProofBuilder::devnet()` for the other public networks. Applications can pass a custom +`Source` to `ProofBuilder::new(source)` when they use a private node, archive, fixture, or local test cluster. + +Network selection configures only the proof source. It does not make the returned proof trusted or select an authoritative +committee for verification. ## Proof Model @@ -38,8 +63,8 @@ Verification checks: ## 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. +Callers must provide the committee that should certify the checkpoint. `CommitteeResolver` can resolve committee history +before the caller invokes 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. @@ -50,5 +75,8 @@ trust the authenticated target claims relative to the supplied committee. - `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. +- `ProofBuilder`: Network-aware or custom-source proof construction. +- `Source`: Extensible boundary for gRPC nodes, archives, fixtures, and other proof sources. +- `CommitteeResolver`: Trusted-node or anchored committee resolution. - `ProofVerifier`: Offline verifier for `Proof` values. - `VerifyError`, `SourceError`, `SerializationError`, and `VersionError`: Operation-specific errors. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs new file mode 100644 index 0000000..c595eb8 --- /dev/null +++ b/poi-rs/src/builder.rs @@ -0,0 +1,143 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_grpc_client::Client as GrpcClient; +use iota_types::{base_types::ObjectRef, digests::TransactionDigest, event::EventID}; + +use crate::{Proof, Source, SourceError, SourceTarget, source::GrpcSource}; + +/// Error returned when a proof cannot be constructed by [`ProofBuilder`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ProofBuilderError { + /// No proof target was selected before building. + #[error("proof builder requires a target")] + MissingTarget, + /// More than one proof target was selected. + #[error("stacked proof targets are not supported yet")] + MultipleTargets, + /// The configured source failed to construct the requested proof. + #[error("proof source failed")] + Source { + /// Underlying source failure. + #[source] + source: SourceError, + }, +} + +/// Constructs Proof of Inclusion evidence from a caller-provided [`Source`]. +/// +/// The builder keeps proof construction independent of a specific transport. +/// SDK gRPC clients can be adapted through [`ProofBuilder::from_grpc_client`]. +pub struct ProofBuilder { + source: S, + targets: Vec, +} + +impl ProofBuilder { + /// Creates a proof builder connected to the public IOTA mainnet gRPC endpoint. + /// + /// Selecting an endpoint does not establish verification trust. Verify the + /// constructed proof with a committee trusted for mainnet. + pub fn mainnet() -> iota_grpc_client::Result { + GrpcClient::new_mainnet().map(Self::from_grpc_client) + } + + /// Creates a proof builder connected to the public IOTA testnet gRPC endpoint. + /// + /// Selecting an endpoint does not establish verification trust. Verify the + /// constructed proof with a committee trusted for testnet. + pub fn testnet() -> iota_grpc_client::Result { + GrpcClient::new_testnet().map(Self::from_grpc_client) + } + + /// Creates a proof builder connected to the public IOTA devnet gRPC endpoint. + /// + /// Selecting an endpoint does not establish verification trust. Verify the + /// constructed proof with a committee trusted for devnet. + pub fn devnet() -> iota_grpc_client::Result { + GrpcClient::new_devnet().map(Self::from_grpc_client) + } + + /// Creates a proof builder backed by an existing SDK gRPC client. + pub fn from_grpc_client(client: GrpcClient) -> Self { + Self::new(GrpcSource::new(client)) + } +} + +impl ProofBuilder { + /// Creates a proof builder backed by `source`. + pub fn new(source: S) -> Self { + Self { + source, + targets: Vec::new(), + } + } + + /// Adds a transaction proof target. + pub fn transaction(mut self, transaction_digest: TransactionDigest) -> Self { + self.targets.push(SourceTarget::Transaction(transaction_digest)); + self + } + + /// Adds an object proof target. + pub fn object(mut self, object_ref: ObjectRef) -> Self { + self.targets.push(SourceTarget::Object(object_ref)); + self + } + + /// Adds an event proof target. + pub fn event(mut self, event_id: EventID) -> Self { + self.targets.push(SourceTarget::Event(event_id)); + self + } + + /// Builds the requested proof from the configured source. + pub async fn build(self) -> Result { + let [target] = self.targets.as_slice() else { + return Err(if self.targets.is_empty() { + ProofBuilderError::MissingTarget + } else { + ProofBuilderError::MultipleTargets + }); + }; + + let proof = match *target { + SourceTarget::Transaction(transaction_digest) => self.source.transaction(transaction_digest).await, + SourceTarget::Object(object_ref) => self.source.object(object_ref).await, + SourceTarget::Event(event_id) => self.source.event(event_id).await, + } + .map_err(|source| ProofBuilderError::Source { source })?; + + Ok(proof) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mainnet_uses_the_sdk_mainnet_endpoint() { + let builder = ProofBuilder::mainnet().expect("mainnet builder must be configured"); + let expected = GrpcClient::new_mainnet().expect("SDK mainnet client must be configured"); + + assert_eq!(builder.source.grpc_client().uri(), expected.uri()); + } + + #[tokio::test] + async fn testnet_uses_the_sdk_testnet_endpoint() { + let builder = ProofBuilder::testnet().expect("testnet builder must be configured"); + let expected = GrpcClient::new_testnet().expect("SDK testnet client must be configured"); + + assert_eq!(builder.source.grpc_client().uri(), expected.uri()); + } + + #[tokio::test] + async fn devnet_uses_the_sdk_devnet_endpoint() { + let builder = ProofBuilder::devnet().expect("devnet builder must be configured"); + let expected = GrpcClient::new_devnet().expect("SDK devnet client must be configured"); + + assert_eq!(builder.source.grpc_client().uri(), expected.uri()); + } +} diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index bc569d6..88f5ac0 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -338,9 +338,7 @@ impl CommitteeResolver { } while committee.epoch < target_epoch { - let next_committee = self - .fetch_next_committee(target_epoch, &committee, cache) - .await?; + let next_committee = self.fetch_next_committee(target_epoch, &committee, cache).await?; committee = next_committee; } @@ -605,14 +603,10 @@ mod tests { let (current_committee, expected_committee, summary) = signed_end_of_epoch_summary(3, true); let cache = RecordingCache::default(); - let committee = CommitteeResolver::authenticate_and_store_next_committee( - 4, - ¤t_committee, - &summary, - &cache, - ) - .await - .unwrap(); + let committee = + CommitteeResolver::authenticate_and_store_next_committee(4, ¤t_committee, &summary, &cache) + .await + .unwrap(); assert_eq!(committee, expected_committee); assert_eq!(cache.stored(), vec![expected_committee]); @@ -625,10 +619,9 @@ mod tests { let wrong_committee = Committee::new(3, wrong_committee.voting_rights.iter().cloned().collect()); let cache = RecordingCache::default(); - let error = - CommitteeResolver::authenticate_and_store_next_committee(4, &wrong_committee, &summary, &cache) - .await - .unwrap_err(); + let error = CommitteeResolver::authenticate_and_store_next_committee(4, &wrong_committee, &summary, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -646,14 +639,9 @@ mod tests { let (current_committee, _, summary) = signed_end_of_epoch_summary(3, false); let cache = RecordingCache::default(); - let error = CommitteeResolver::authenticate_and_store_next_committee( - 4, - ¤t_committee, - &summary, - &cache, - ) - .await - .unwrap_err(); + let error = CommitteeResolver::authenticate_and_store_next_committee(4, ¤t_committee, &summary, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index 33ad1f0..b931fba 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; +/// Proof construction builders. +pub mod builder; /// Verified committee lineage caches for anchored resolution. pub mod cache; /// Committee resolution for checkpoint verification. @@ -18,11 +20,12 @@ pub mod source; /// Target claims authenticated by a proof. pub mod target; +pub use builder::{ProofBuilder, ProofBuilderError}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, }; -pub use source::{GrpcSource, Source, SourceError, SourceErrorKind, SourceTarget}; +pub use source::{Source, SourceError, SourceErrorKind, SourceTarget}; pub use target::ProofTargets; diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 9e1a61b..03abe12 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -241,25 +241,21 @@ pub trait Source { async fn event(&self, event_id: EventID) -> Result; } -/// gRPC-backed source for transaction proofs. +/// Proof source backed by an SDK gRPC client. /// -/// `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)] +/// Applications normally construct this source through the network and client +/// convenience constructors on [`crate::ProofBuilder`]. pub struct GrpcSource { client: GrpcClient, } impl GrpcSource { - /// Creates a gRPC-backed source from an SDK gRPC client. - pub fn new(client: GrpcClient) -> Self { + pub(crate) fn new(client: GrpcClient) -> Self { Self { client } } - /// Returns the underlying SDK gRPC client. - pub const fn grpc_client(&self) -> &GrpcClient { + #[cfg(test)] + pub(crate) const fn grpc_client(&self) -> &GrpcClient { &self.client } diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee.rs new file mode 100644 index 0000000..72208e0 --- /dev/null +++ b/poi-rs/tests/committee.rs @@ -0,0 +1,85 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod utils; + +use iota_grpc_client::Client as GrpcClient; +use iota_types::committee::Committee; +use poi_rs::{CommitteeCache, CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; +use utils::{advance_to_epoch, genesis_committee, grpc_client, start_test_cluster}; + +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("disconnected gRPC client must be constructed") +} + +#[tokio::test] +async fn genesis_anchor_authenticates_committees_through_epoch_ten() { + let cluster = start_test_cluster().await; + let genesis = genesis_committee(&cluster); + let expected = advance_to_epoch(&cluster, 10).await; + let cache = MemoryCommitteeCache::new(); + let resolver = CommitteeResolver::anchor_with_cache(grpc_client(&cluster), genesis, cache.clone()); + + let resolved = resolver + .resolve(10) + .await + .expect("epoch 10 committee must resolve from genesis"); + + assert_eq!(resolved, expected[10]); + assert_eq!(cache.len().await, 10); + for epoch in 1..=10 { + assert_eq!( + cache.committee(epoch).await.unwrap(), + Some(expected[epoch as usize].clone()) + ); + } +} + +#[tokio::test] +async fn epoch_before_the_trust_anchor_is_rejected() { + 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 epoch_ahead_of_the_node_is_rejected_without_caching() { + let cluster = start_test_cluster().await; + let cache = MemoryCommitteeCache::new(); + let resolver = + CommitteeResolver::anchor_with_cache(grpc_client(&cluster), genesis_committee(&cluster), cache.clone()); + + let error = resolver.resolve(1).await.unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::TargetAheadOfNode { current_epoch: 0 } + )); + assert!(cache.is_empty().await); +} + +#[tokio::test] +async fn trusted_node_resolution_does_not_write_to_an_anchor_cache() { + let cluster = start_test_cluster().await; + let cache = MemoryCommitteeCache::new(); + let resolver = CommitteeResolver::node(grpc_client(&cluster)); + + let resolved = resolver + .resolve(0) + .await + .expect("trusted node must return its genesis committee"); + + assert_eq!(resolved, *cluster.committee()); + assert!(cache.is_empty().await); +} diff --git a/poi-rs/tests/golden.rs b/poi-rs/tests/golden.rs new file mode 100644 index 0000000..4cf07f3 --- /dev/null +++ b/poi-rs/tests/golden.rs @@ -0,0 +1,65 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::committee::Committee; +use poi_rs::{Proof, ProofVerifier, ProofVersion, VerifyErrorKind}; + +const COMMITTEE: &str = include_str!("fixtures/v1/committee.json"); +const TRANSACTION: &str = include_str!("fixtures/v1/transaction.json"); +const OBJECT: &str = include_str!("fixtures/v1/object.json"); +const EVENT: &str = include_str!("fixtures/v1/event.json"); + +fn assert_version_one_compatibility(fixture: &str) -> Proof { + let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); + let proof: Proof = serde_json::from_str(fixture).expect("proof fixture must deserialize"); + + ProofVerifier::new(&committee) + .verify(&proof) + .expect("proof fixture must verify offline"); + assert_eq!( + serde_json::to_value(&proof).expect("proof fixture must serialize"), + serde_json::from_str::(fixture).expect("proof fixture must be valid JSON") + ); + assert_eq!(proof.version(), ProofVersion::CURRENT); + + proof +} + +#[test] +fn version_one_transaction_fixture_remains_compatible() { + let proof = assert_version_one_compatibility(TRANSACTION); + + assert!(proof.target().objects.is_empty()); + assert!(proof.target().events.is_empty()); +} + +#[test] +fn version_one_object_fixture_remains_compatible() { + let proof = assert_version_one_compatibility(OBJECT); + + assert_eq!(proof.target().objects.len(), 1); + assert!(proof.target().events.is_empty()); +} + +#[test] +fn version_one_event_fixture_remains_compatible() { + let proof = assert_version_one_compatibility(EVENT); + + assert!(proof.target().objects.is_empty()); + assert_eq!(proof.target().events.len(), 1); +} + +#[test] +fn unsupported_fixture_version_returns_the_version_number() { + let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); + let mut fixture: serde_json::Value = serde_json::from_str(TRANSACTION).expect("proof fixture must be valid JSON"); + fixture["version"] = serde_json::json!(2); + let proof: Proof = serde_json::from_value(fixture).expect("unsupported proof version must deserialize"); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let VerifyErrorKind::Version { source } = error.kind else { + panic!("unsupported proof version must return a version error"); + }; + + assert_eq!(source.version, 2); +} diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs new file mode 100644 index 0000000..0c536f6 --- /dev/null +++ b/poi-rs/tests/proof_builder.rs @@ -0,0 +1,124 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod utils; + +use async_trait::async_trait; +use iota_types::base_types::ObjectRef; +use iota_types::{digests::TransactionDigest, event::EventID, object::Object}; +use poi_rs::{Proof, ProofBuilder, ProofBuilderError, Source, SourceError, SourceErrorKind, SourceTarget}; +use utils::{grpc_client, staking_tx, start_test_cluster}; + +struct RejectingSource; + +#[async_trait] +impl Source for RejectingSource { + async fn transaction(&self, transaction_digest: TransactionDigest) -> Result { + Err(SourceError::transaction( + transaction_digest, + SourceErrorKind::TransactionNotFound, + )) + } + + async fn object(&self, object_ref: ObjectRef) -> Result { + Err(SourceError::object(object_ref, SourceErrorKind::ObjectNotFound)) + } + + async fn event(&self, event_id: EventID) -> Result { + Err(SourceError::event(event_id, SourceErrorKind::EventNotFound)) + } +} + +#[tokio::test] +async fn builder_accepts_a_custom_source() { + let transaction_digest = TransactionDigest::random(); + + let error = ProofBuilder::new(RejectingSource) + .transaction(transaction_digest) + .build() + .await + .unwrap_err(); + + let ProofBuilderError::Source { source } = error else { + panic!("custom source error must be preserved"); + }; + assert_eq!(source.target, SourceTarget::Transaction(transaction_digest)); + assert!(matches!(source.kind, SourceErrorKind::TransactionNotFound)); +} + +#[tokio::test] +async fn builder_without_a_target_is_rejected() { + let error = ProofBuilder::new(RejectingSource).build().await.unwrap_err(); + + assert!(matches!(error, ProofBuilderError::MissingTarget)); +} + +#[tokio::test] +async fn multiple_targets_are_rejected_until_stacking_is_supported() { + let error = ProofBuilder::new(RejectingSource) + .transaction(TransactionDigest::random()) + .transaction(TransactionDigest::random()) + .build() + .await + .unwrap_err(); + + assert!(matches!(error, ProofBuilderError::MultipleTargets)); +} + +#[tokio::test] +async fn unknown_transaction_returns_a_fetch_error() { + let cluster = start_test_cluster().await; + let transaction_digest = TransactionDigest::random(); + + let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .transaction(transaction_digest) + .build() + .await + .unwrap_err(); + + let ProofBuilderError::Source { source } = error else { + panic!("missing transaction must return a source error"); + }; + assert_eq!(source.target, SourceTarget::Transaction(transaction_digest)); + assert!(matches!(source.kind, SourceErrorKind::FetchTransaction { .. })); +} + +#[tokio::test] +async fn unknown_object_returns_a_fetch_error() { + let cluster = start_test_cluster().await; + let object_ref = Object::immutable_for_testing().as_inner().object_ref(); + + let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .object(object_ref) + .build() + .await + .unwrap_err(); + + let ProofBuilderError::Source { source } = error else { + panic!("missing object must return a source error"); + }; + assert_eq!(source.target, SourceTarget::Object(object_ref)); + assert!(matches!(source.kind, SourceErrorKind::FetchObject { .. })); +} + +#[tokio::test] +async fn event_sequence_outside_the_transaction_is_rejected() { + let cluster = start_test_cluster().await; + let transaction_digest = staking_tx(&cluster).await; + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: u64::MAX, + }; + + let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .event(event_id) + .build() + .await + .unwrap_err(); + + let ProofBuilderError::Source { source } = error else { + panic!("missing event must return a source error"); + }; + assert_eq!(source.target, SourceTarget::Event(event_id)); + assert!(matches!(source.kind, SourceErrorKind::EventNotFound)); +} diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_of_inclusion.rs new file mode 100644 index 0000000..8a12eb3 --- /dev/null +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -0,0 +1,75 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod utils; + +use iota_types::event::EventID; +use poi_rs::{CommitteeResolver, ProofBuilder, ProofVerifier}; +use utils::{grpc_client, staking_tx, start_test_cluster, transfer_tx}; + +#[tokio::test] +async fn transaction_proof_verifies_with_the_resolved_committee() { + let cluster = start_test_cluster().await; + let transfer = transfer_tx(&cluster).await; + let client = grpc_client(&cluster); + + let proof = ProofBuilder::from_grpc_client(client.clone()) + .transaction(transfer.digest) + .build() + .await + .expect("transaction proof must be constructed"); + let committee = CommitteeResolver::node(client) + .resolve(proof.checkpoint_summary.epoch()) + .await + .expect("checkpoint committee must resolve"); + + ProofVerifier::new(&committee) + .verify(&proof) + .expect("transaction proof must verify"); +} + +#[tokio::test] +async fn object_proof_verifies_with_the_resolved_committee() { + let cluster = start_test_cluster().await; + let transfer = transfer_tx(&cluster).await; + let client = grpc_client(&cluster); + + let proof = ProofBuilder::from_grpc_client(client.clone()) + .object(transfer.gas_object) + .build() + .await + .expect("object proof must be constructed"); + let committee = CommitteeResolver::node(client) + .resolve(proof.checkpoint_summary.epoch()) + .await + .expect("checkpoint committee must resolve"); + + ProofVerifier::new(&committee) + .verify(&proof) + .expect("object proof must verify"); +} + +#[tokio::test] +async fn event_proof_verifies_with_the_resolved_committee() { + let cluster = start_test_cluster().await; + let transaction_digest = staking_tx(&cluster).await; + let client = grpc_client(&cluster); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + + let proof = ProofBuilder::from_grpc_client(client.clone()) + .event(event_id) + .build() + .await + .expect("event proof must be constructed"); + let committee = CommitteeResolver::node(client) + .resolve(proof.checkpoint_summary.epoch()) + .await + .expect("checkpoint committee must resolve"); + + ProofVerifier::new(&committee) + .verify(&proof) + .expect("event proof must verify"); +} diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs new file mode 100644 index 0000000..5fcb2b7 --- /dev/null +++ b/poi-rs/tests/utils/mod.rs @@ -0,0 +1,96 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// Each integration-test file is compiled as a separate crate, so helpers used +// by sibling test crates otherwise appear unused. +#![allow(dead_code)] + +use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis}; +use iota_grpc_client::Client as GrpcClient; +use iota_types::{base_types::ObjectRef, committee::Committee, digests::TransactionDigest}; +use test_cluster::{TestCluster, TestClusterBuilder}; + +pub mod proofs; + +pub struct CheckpointedTransfer { + pub digest: TransactionDigest, + pub gas_object: ObjectRef, +} + +pub async fn start_test_cluster() -> TestCluster { + TestClusterBuilder::new() + .with_num_validators(1) + .with_fullnode_enable_grpc_api(true) + .disable_fullnode_pruning() + .build() + .await +} + +pub fn grpc_client(cluster: &TestCluster) -> GrpcClient { + GrpcClient::new(cluster.grpc_url()).expect("test cluster gRPC client must connect") +} + +pub async fn transfer_tx(cluster: &TestCluster) -> CheckpointedTransfer { + let builder = cluster.test_transaction_builder().await; + let gas_object = builder.gas_object(); + let transaction = builder.transfer_iota(Some(1), cluster.get_address_1()).build(); + let response = cluster.sign_and_execute_transaction(&transaction).await; + let checkpoint = response.checkpoint.expect("transfer transaction must be checkpointed"); + cluster.wait_for_checkpoint(checkpoint, None).await; + let gas_object = cluster + .wallet + .get_object_ref(gas_object.object_id) + .await + .expect("mutated gas object must be available"); + + CheckpointedTransfer { + digest: response.digest, + gas_object, + } +} + +pub async fn staking_tx(cluster: &TestCluster) -> TransactionDigest { + let (sender, mut coins) = cluster.wallet.get_one_account().await.unwrap(); + let gas = coins.pop().expect("funded account must have a gas coin"); + let stake = coins.pop().expect("funded account must have a stake coin"); + let validator = cluster + .swarm + .active_validators() + .next() + .expect("test cluster must have a validator") + .config() + .iota_address(); + let transaction = cluster + .test_transaction_builder_with_gas_object(sender, gas) + .await + .call_staking(stake, validator) + .build(); + let response = cluster.sign_and_execute_transaction(&transaction).await; + let checkpoint = response.checkpoint.expect("staking transaction must be checkpointed"); + cluster.wait_for_checkpoint(checkpoint, None).await; + + response.digest +} + +pub fn genesis_committee(cluster: &TestCluster) -> Committee { + let genesis_path = cluster.swarm.dir().join(IOTA_GENESIS_FILENAME); + Genesis::load(genesis_path) + .expect("test cluster genesis blob must load") + .committee() + .expect("genesis blob must contain a committee") +} + +pub async fn advance_to_epoch(cluster: &TestCluster, target_epoch: u64) -> Vec { + let mut committees = vec![cluster.committee().as_ref().clone()]; + + for epoch in 1..=target_epoch { + cluster.force_new_epoch().await; + let committee = cluster.committee().as_ref().clone(); + assert_eq!(committee.epoch, epoch); + committees.push(committee); + } + + let _ = transfer_tx(cluster).await; + + committees +} diff --git a/poi-rs/tests/utils/proofs.rs b/poi-rs/tests/utils/proofs.rs new file mode 100644 index 0000000..36953e1 --- /dev/null +++ b/poi-rs/tests/utils/proofs.rs @@ -0,0 +1,110 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::{Event, gas::GasCostSummary}; +use iota_types::{ + base_types::ExecutionData, + committee::Committee, + digests::{ChainIdentifier, TransactionDigest}, + effects::{TestEffectsBuilder, TransactionEvents}, + messages_checkpoint::{ + CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, + }, + sdk_types::{Address, Identifier, ObjectId, StructTag}, +}; +use poi_rs::{Proof, ProofTargets, TransactionProof}; + +pub fn execution_data() -> ExecutionData { + FullCheckpointContents::random_for_testing() + .into_iter() + .next() + .expect("test checkpoint contents must include a transaction") +} + +fn signed_checkpoint( + contents: &CheckpointContents, + end_of_epoch_data: Option, +) -> (Committee, CertifiedCheckpointSummary) { + let summary = CheckpointSummary { + epoch: 0, + sequence_number: 0, + network_total_transactions: contents.size() as u64, + content_digest: *contents.digest(), + previous_digest: None, + epoch_rolling_gas_cost_summary: GasCostSummary::default(), + timestamp_ms: 0, + checkpoint_commitments: Vec::new(), + end_of_epoch_data, + version_specific_data: Vec::new(), + }; + let (committee, keypairs) = Committee::new_simple_test_committee(); + let summary = CertifiedCheckpointSummary::new_from_keypairs_for_testing(summary, &keypairs, &committee); + + (committee, summary) +} + +pub fn valid_transaction_proof() -> (Committee, Proof) { + proof_with_targets(ProofTargets::new(), None) +} + +pub fn proof_with_targets(targets: ProofTargets, end_of_epoch_data: Option) -> (Committee, Proof) { + let execution = execution_data(); + let contents = CheckpointContents::new_with_digests_only_for_tests([execution.digests()]); + let (committee, summary) = signed_checkpoint(&contents, end_of_epoch_data); + let chain = ChainIdentifier::from(*summary.digest()); + let proof = Proof::new( + chain, + targets, + summary, + TransactionProof::new(contents, execution.transaction, execution.effects, None), + ); + + (committee, proof) +} + +pub fn proof_with_events(events: TransactionEvents) -> (Committee, TransactionDigest, Proof) { + let mut execution = execution_data(); + let transaction_digest = *execution.transaction.digest(); + execution.effects = TestEffectsBuilder::new(execution.transaction.data()) + .with_events_digest(events.digest()) + .build(); + let contents = CheckpointContents::new_with_digests_only_for_tests([execution.digests()]); + let (committee, summary) = signed_checkpoint(&contents, None); + let chain = ChainIdentifier::from(*summary.digest()); + let proof = Proof::new( + chain, + ProofTargets::new(), + summary, + TransactionProof::new(contents, execution.transaction, execution.effects, Some(events)), + ); + + (committee, transaction_digest, proof) +} + +pub fn 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, + } +} + +pub fn next_epoch_committee(committee: &Committee) -> Committee { + Committee::new(1, committee.voting_rights.iter().cloned().collect()) +} + +pub fn end_of_epoch_data(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, + } +} diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/verifier.rs new file mode 100644 index 0000000..bce4570 --- /dev/null +++ b/poi-rs/tests/verifier.rs @@ -0,0 +1,166 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod utils; + +use iota_types::{ + base_types::dbg_object_id, committee::Committee, effects::TransactionEvents, event::EventID, + messages_checkpoint::CheckpointContents, object::Object, +}; +use poi_rs::{ProofTargets, ProofVerifier, VerifyErrorKind}; +use utils::proofs::{ + end_of_epoch_data, event, execution_data, next_epoch_committee, proof_with_events, proof_with_targets, + valid_transaction_proof, +}; + +#[test] +fn valid_transaction_proof_is_accepted() { + let (committee, proof) = valid_transaction_proof(); + + let result = ProofVerifier::new(&committee).verify(&proof); + + assert!(result.is_ok()); +} + +#[test] +fn transaction_digest_must_match_the_effects() { + let (committee, mut proof) = valid_transaction_proof(); + proof.transaction_proof.effects = execution_data().effects; + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::TransactionDigestMismatch)); +} + +#[test] +fn events_digest_must_match_the_effects() { + let (committee, mut proof) = valid_transaction_proof(); + proof.transaction_proof.events = Some(TransactionEvents(Vec::new())); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::EventsDigestMismatch)); +} + +#[test] +fn checkpoint_contents_must_match_the_signed_summary() { + let (committee, mut proof) = valid_transaction_proof(); + let alternate = execution_data(); + proof.transaction_proof.checkpoint_contents = + CheckpointContents::new_with_digests_only_for_tests([alternate.digests()]); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::CheckpointSummary { .. })); +} + +#[test] +fn transaction_must_be_present_in_the_checkpoint() { + let (committee, mut proof) = valid_transaction_proof(); + let alternate = execution_data(); + proof.transaction_proof.transaction = alternate.transaction; + proof.transaction_proof.effects = alternate.effects; + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint)); +} + +#[test] +fn committee_target_requires_end_of_epoch_data() { + let (committee, _) = Committee::new_simple_test_committee(); + let target = next_epoch_committee(&committee); + let (verifying_committee, proof) = proof_with_targets(ProofTargets::new().set_committee(target), None); + + let error = ProofVerifier::new(&verifying_committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::MissingEndOfEpochCommittee)); +} + +#[test] +fn committee_target_must_match_end_of_epoch_data() { + let (actual, _) = Committee::new_simple_test_committee(); + let actual = next_epoch_committee(&actual); + let (wrong, _) = Committee::new_simple_test_committee_of_size(5); + let wrong = next_epoch_committee(&wrong); + let targets = ProofTargets::new().set_committee(wrong); + let (committee, proof) = proof_with_targets(targets, Some(end_of_epoch_data(&actual))); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::CommitteeMismatch)); +} + +#[test] +fn object_target_must_match_its_reference() { + let object = Object::immutable_for_testing(); + let mut object_ref = object.as_inner().object_ref(); + object_ref.object_id = dbg_object_id(42); + let targets = ProofTargets::new().add_object(object_ref, object); + let (committee, proof) = proof_with_targets(targets, None); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::ObjectReferenceMismatch)); +} + +#[test] +fn object_target_must_appear_in_the_transaction_effects() { + let object = Object::immutable_for_testing(); + let object_ref = object.as_inner().object_ref(); + let targets = ProofTargets::new().add_object(object_ref, object); + let (committee, proof) = proof_with_targets(targets, None); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::ObjectNotFound)); +} + +#[test] +fn event_target_must_match_the_packaged_event() { + let packaged = event(vec![1, 2, 3]); + let target = event(vec![9, 9, 9]); + let (committee, transaction_digest, mut proof) = proof_with_events(TransactionEvents(vec![packaged])); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + proof.target = ProofTargets::new().add_event(event_id, target); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::EventContentsMismatch)); +} + +#[test] +fn event_target_must_belong_to_the_proven_transaction() { + let target = event(vec![1, 2, 3]); + let (committee, _, mut proof) = proof_with_events(TransactionEvents(vec![target.clone()])); + let event_id = EventID { + tx_digest: iota_types::digests::TransactionDigest::new([0xff; 32]), + event_seq: 0, + }; + proof.target = ProofTargets::new().add_event(event_id, target); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!(error.kind, VerifyErrorKind::EventTransactionMismatch)); +} + +#[test] +fn event_sequence_must_exist_in_the_transaction() { + let target = event(vec![1, 2, 3]); + let (committee, transaction_digest, mut proof) = proof_with_events(TransactionEvents(vec![target.clone()])); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 1, + }; + proof.target = ProofTargets::new().add_event(event_id, target); + + let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + + assert!(matches!( + error.kind, + VerifyErrorKind::EventSequenceOutOfBounds { sequence: 1 } + )); +} From 3a13e5c0057630101001241ff3e6042a461bad52 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 14 Jul 2026 18:18:05 +0300 Subject: [PATCH 17/21] feat: Enhance ProofBuilder to support stacking multiple object and event targets, and update related tests --- poi-rs/README.md | 4 + poi-rs/src/builder.rs | 52 +++++++---- poi-rs/src/lib.rs | 2 +- poi-rs/src/proof.rs | 9 ++ poi-rs/src/source.rs | 145 ++++++++++++++++++++--------- poi-rs/tests/proof_builder.rs | 131 +++++++++++++++++++++----- poi-rs/tests/proof_of_inclusion.rs | 58 +++++++++++- poi-rs/tests/utils/mod.rs | 54 ++++++++++- 8 files changed, 363 insertions(+), 92 deletions(-) diff --git a/poi-rs/README.md b/poi-rs/README.md index 16d37d7..21bec66 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -29,6 +29,10 @@ let proof = ProofBuilder::mainnet()? Use `ProofBuilder::testnet()` or `ProofBuilder::devnet()` for the other public networks. Applications can pass a custom `Source` to `ProofBuilder::new(source)` when they use a private node, archive, fixture, or local test cluster. +A builder can stack multiple object and event targets by calling `object()` and `event()` repeatedly or by using the +`objects()` and `events()` batch methods. Every target must belong to the same transaction. The builder ignores exact +duplicates, and the source reuses one transaction proof and one set of checkpoint evidence for the complete target set. + Network selection configures only the proof source. It does not make the returned proof trusted or select an authoritative committee for verification. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index c595eb8..9d734d6 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -13,9 +13,6 @@ pub enum ProofBuilderError { /// No proof target was selected before building. #[error("proof builder requires a target")] MissingTarget, - /// More than one proof target was selected. - #[error("stacked proof targets are not supported yet")] - MultipleTargets, /// The configured source failed to construct the requested proof. #[error("proof source failed")] Source { @@ -76,41 +73,58 @@ impl ProofBuilder { /// Adds a transaction proof target. pub fn transaction(mut self, transaction_digest: TransactionDigest) -> Self { - self.targets.push(SourceTarget::Transaction(transaction_digest)); + self.push_target(SourceTarget::Transaction(transaction_digest)); self } /// Adds an object proof target. pub fn object(mut self, object_ref: ObjectRef) -> Self { - self.targets.push(SourceTarget::Object(object_ref)); + self.push_target(SourceTarget::Object(object_ref)); + self + } + + /// Adds multiple object proof targets. + pub fn objects(mut self, object_refs: impl IntoIterator) -> Self { + for object_ref in object_refs { + self.push_target(SourceTarget::Object(object_ref)); + } self } /// Adds an event proof target. pub fn event(mut self, event_id: EventID) -> Self { - self.targets.push(SourceTarget::Event(event_id)); + self.push_target(SourceTarget::Event(event_id)); + self + } + + /// Adds multiple event proof targets. + pub fn events(mut self, event_ids: impl IntoIterator) -> Self { + for event_id in event_ids { + self.push_target(SourceTarget::Event(event_id)); + } self } /// Builds the requested proof from the configured source. pub async fn build(self) -> Result { - let [target] = self.targets.as_slice() else { - return Err(if self.targets.is_empty() { - ProofBuilderError::MissingTarget - } else { - ProofBuilderError::MultipleTargets - }); - }; - - let proof = match *target { - SourceTarget::Transaction(transaction_digest) => self.source.transaction(transaction_digest).await, - SourceTarget::Object(object_ref) => self.source.object(object_ref).await, - SourceTarget::Event(event_id) => self.source.event(event_id).await, + if self.targets.is_empty() { + return Err(ProofBuilderError::MissingTarget); } - .map_err(|source| ProofBuilderError::Source { source })?; + + let proof = self + .source + .proof(&self.targets) + .await + .map_err(|source| ProofBuilderError::Source { source })?; Ok(proof) } + + fn push_target(&mut self, target: SourceTarget) { + if !self.targets.contains(&target) { + self.targets.push(target); + } + } } #[cfg(test)] diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index b931fba..d81492e 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -27,5 +27,5 @@ pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, }; -pub use source::{Source, SourceError, SourceErrorKind, SourceTarget}; +pub use source::{Source, SourceError, SourceErrorKind, SourceTarget, TransactionMismatch}; pub use target::ProofTargets; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index bbeeffe..c8b87c1 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -295,6 +295,8 @@ impl<'committee> ProofVerifier<'committee> { Ok(()) } + /// Verifies an optional next-epoch committee target against the authenticated + /// end-of-epoch data in the checkpoint summary. fn verify_committee_target( &self, summary: &CertifiedCheckpointSummary, @@ -329,12 +331,15 @@ impl<'committee> ProofVerifier<'committee> { Ok(()) } + /// Verifies that the transaction matches its effects, appears in the + /// authenticated checkpoint contents, and carries the committed events. fn verify_transaction_proof( &self, summary: &CertifiedCheckpointSummary, transaction_proof: &TransactionProof, ) -> Result<(), VerifyError> { let execution_digests = transaction_proof.effects.execution_digests(); + if transaction_proof.transaction.digest() != &execution_digests.transaction { return Err(VerifyError { kind: VerifyErrorKind::TransactionDigestMismatch, @@ -363,6 +368,8 @@ impl<'committee> ProofVerifier<'committee> { Ok(()) } + /// Verifies that every event target belongs to the proven transaction and + /// matches the event committed at its transaction-local sequence number. fn verify_event_targets( &self, targets: &ProofTargets, @@ -405,6 +412,8 @@ impl<'committee> ProofVerifier<'committee> { Ok(()) } + /// Verifies that every object target computes to its claimed reference and + /// appears among the objects changed by the proven transaction. fn verify_object_targets( &self, targets: &ProofTargets, diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 03abe12..3ff537e 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -107,6 +107,25 @@ impl SourceError { } } +/// Transactions involved when stacked proof targets do not share one owner. +#[derive(Debug)] +pub struct TransactionMismatch { + /// Transaction selected by the first proof target. + pub expected: TransactionDigest, + /// Transaction that owns the conflicting target. + pub actual: TransactionDigest, +} + +impl fmt::Display for TransactionMismatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "target belongs to transaction {}, expected transaction {}", + self.actual, self.expected + ) + } +} + /// Kind of proof source failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -144,6 +163,12 @@ pub enum SourceErrorKind { /// The source could not resolve the requested event. #[error("event was not found")] EventNotFound, + /// A requested target belongs to a different transaction than the other targets. + #[error("{mismatch}")] + TargetTransactionMismatch { + /// Conflicting transaction details. + mismatch: Box, + }, /// The transaction response did not expose a checkpoint sequence number. #[error("transaction response is missing checkpoint sequence")] MissingCheckpointSequence { @@ -218,27 +243,12 @@ pub enum SourceErrorKind { /// [`crate::ProofVerifier`]. #[async_trait] pub trait Source { - /// Builds a transaction proof from source data. - /// - /// The returned proof packages the transaction, effects, optional events, - /// certified checkpoint summary, and checkpoint contents. The transaction - /// itself is the authenticated claim, so the proof has no additional object, - /// event, or committee targets. - async fn transaction(&self, transaction_digest: TransactionDigest) -> Result; - - /// Builds an object proof from source data. + /// Builds one proof for a non-empty set of targets. /// - /// The source resolves the object reference to the transaction that last - /// created or mutated the object, builds that transaction proof, and attaches - /// the object as a target. Returned proofs remain untrusted until verified. - async fn object(&self, object_ref: ObjectRef) -> Result; - - /// Builds an event proof from source data. - /// - /// The source uses the transaction digest embedded in the event ID, builds - /// that transaction proof, and attaches the event at the requested sequence - /// as a target. Returned proofs remain untrusted until verified. - async fn event(&self, event_id: EventID) -> Result; + /// All targets must belong to the same transaction. Implementations should + /// reuse the shared transaction and checkpoint evidence when constructing + /// stacked object and event targets. + async fn proof(&self, targets: &[SourceTarget]) -> Result; } /// Proof source backed by an SDK gRPC client. @@ -352,11 +362,32 @@ impl GrpcSource { ) }) } -} -#[async_trait] -impl Source for GrpcSource { - async fn transaction(&self, transaction_digest: TransactionDigest) -> Result { + fn select_transaction( + selected: &mut Option, + target: SourceTarget, + transaction_digest: TransactionDigest, + ) -> Result<(), SourceError> { + if let Some(expected) = selected { + if *expected != transaction_digest { + return Err(SourceError { + target, + kind: SourceErrorKind::TargetTransactionMismatch { + mismatch: Box::new(TransactionMismatch { + expected: *expected, + actual: transaction_digest, + }), + }, + }); + } + } else { + *selected = Some(transaction_digest); + } + + Ok(()) + } + + async fn build_transaction_proof(&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( @@ -523,28 +554,54 @@ 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) - } +#[async_trait] +impl Source for GrpcSource { + async fn proof(&self, targets: &[SourceTarget]) -> Result { + let mut selected_transaction = None; + let mut objects = Vec::new(); + let mut events = Vec::new(); + + for target in targets.iter().copied() { + match target { + SourceTarget::Transaction(transaction_digest) => { + Self::select_transaction(&mut selected_transaction, target, transaction_digest)?; + } + SourceTarget::Object(object_ref) => { + let object = self.fetch_object(object_ref).await?; + Self::select_transaction(&mut selected_transaction, target, object.previous_transaction)?; + objects.push((object_ref, object)); + } + SourceTarget::Event(event_id) => { + Self::select_transaction(&mut selected_transaction, target, event_id.tx_digest)?; + events.push(event_id); + } + } + } + + let transaction_digest = selected_transaction.expect("ProofBuilder only calls Source with non-empty targets"); + let mut proof = self.build_transaction_proof(transaction_digest).await?; + + for (object_ref, object) in objects { + proof.target = proof.target.add_object(object_ref, object); + } + + for event_id in events { + 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); + } - 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) } } diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index 0c536f6..b9a1653 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -3,29 +3,57 @@ mod utils; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; + use async_trait::async_trait; -use iota_types::base_types::ObjectRef; +use iota_types::base_types::dbg_object_id; use iota_types::{digests::TransactionDigest, event::EventID, object::Object}; use poi_rs::{Proof, ProofBuilder, ProofBuilderError, Source, SourceError, SourceErrorKind, SourceTarget}; -use utils::{grpc_client, staking_tx, start_test_cluster}; +use utils::{grpc_client, staking_tx, start_test_cluster, transfer_tx}; struct RejectingSource; #[async_trait] impl Source for RejectingSource { - async fn transaction(&self, transaction_digest: TransactionDigest) -> Result { - Err(SourceError::transaction( - transaction_digest, - SourceErrorKind::TransactionNotFound, - )) + async fn proof(&self, targets: &[SourceTarget]) -> Result { + let target = *targets.first().expect("builder must provide a target"); + Err(match target { + SourceTarget::Transaction(transaction_digest) => { + SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound) + } + SourceTarget::Object(object_ref) => SourceError::object(object_ref, SourceErrorKind::ObjectNotFound), + SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), + _ => panic!("unsupported source target"), + }) } +} - async fn object(&self, object_ref: ObjectRef) -> Result { - Err(SourceError::object(object_ref, SourceErrorKind::ObjectNotFound)) - } +struct RecordingSource { + requests: Arc, + targets: Arc>>, +} - async fn event(&self, event_id: EventID) -> Result { - Err(SourceError::event(event_id, SourceErrorKind::EventNotFound)) +#[async_trait] +impl Source for RecordingSource { + async fn proof(&self, targets: &[SourceTarget]) -> Result { + self.requests.fetch_add(1, Ordering::Relaxed); + self.targets + .lock() + .expect("recorded targets lock must not be poisoned") + .extend_from_slice(targets); + + let target = *targets.first().expect("builder must provide a target"); + Err(match target { + SourceTarget::Transaction(transaction_digest) => { + SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound) + } + SourceTarget::Object(object_ref) => SourceError::object(object_ref, SourceErrorKind::ObjectNotFound), + SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), + _ => panic!("unsupported source target"), + }) } } @@ -54,15 +82,49 @@ async fn builder_without_a_target_is_rejected() { } #[tokio::test] -async fn multiple_targets_are_rejected_until_stacking_is_supported() { - let error = ProofBuilder::new(RejectingSource) - .transaction(TransactionDigest::random()) - .transaction(TransactionDigest::random()) - .build() - .await - .unwrap_err(); - - assert!(matches!(error, ProofBuilderError::MultipleTargets)); +async fn stacked_targets_are_deduplicated_in_one_source_request() { + let transaction_digest = TransactionDigest::random(); + let object_a = Object::immutable_with_id_for_testing(dbg_object_id(1)) + .as_inner() + .object_ref(); + let object_b = Object::immutable_with_id_for_testing(dbg_object_id(2)) + .as_inner() + .object_ref(); + let event_a = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + let event_b = EventID { + tx_digest: transaction_digest, + event_seq: 1, + }; + let requests = Arc::new(AtomicUsize::new(0)); + let targets = Arc::new(Mutex::new(Vec::new())); + + let _ = ProofBuilder::new(RecordingSource { + requests: requests.clone(), + targets: targets.clone(), + }) + .transaction(transaction_digest) + .objects([object_a, object_b, object_a]) + .object(object_b) + .events([event_a, event_b, event_a]) + .event(event_b) + .build() + .await + .unwrap_err(); + + assert_eq!(requests.load(Ordering::Relaxed), 1); + assert_eq!( + *targets.lock().expect("recorded targets lock must not be poisoned"), + vec![ + SourceTarget::Transaction(transaction_digest), + SourceTarget::Object(object_a), + SourceTarget::Object(object_b), + SourceTarget::Event(event_a), + SourceTarget::Event(event_b), + ] + ); } #[tokio::test] @@ -104,9 +166,9 @@ async fn unknown_object_returns_a_fetch_error() { #[tokio::test] async fn event_sequence_outside_the_transaction_is_rejected() { let cluster = start_test_cluster().await; - let transaction_digest = staking_tx(&cluster).await; + let staking = staking_tx(&cluster).await; let event_id = EventID { - tx_digest: transaction_digest, + tx_digest: staking.digest, event_seq: u64::MAX, }; @@ -122,3 +184,26 @@ async fn event_sequence_outside_the_transaction_is_rejected() { assert_eq!(source.target, SourceTarget::Event(event_id)); assert!(matches!(source.kind, SourceErrorKind::EventNotFound)); } + +#[tokio::test] +async fn object_targets_from_different_transactions_are_rejected() { + let cluster = start_test_cluster().await; + let first = transfer_tx(&cluster).await; + let second = transfer_tx(&cluster).await; + + let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .objects([first.gas_object, second.gas_object]) + .build() + .await + .unwrap_err(); + + let ProofBuilderError::Source { source } = error else { + panic!("mixed transactions must return a source error"); + }; + assert_eq!(source.target, SourceTarget::Object(second.gas_object)); + assert!(matches!( + source.kind, + SourceErrorKind::TargetTransactionMismatch { mismatch } + if mismatch.expected == first.digest && mismatch.actual == second.digest + )); +} diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_of_inclusion.rs index 8a12eb3..aa75b07 100644 --- a/poi-rs/tests/proof_of_inclusion.rs +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -5,7 +5,7 @@ mod utils; use iota_types::event::EventID; use poi_rs::{CommitteeResolver, ProofBuilder, ProofVerifier}; -use utils::{grpc_client, staking_tx, start_test_cluster, transfer_tx}; +use utils::{grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; #[tokio::test] async fn transaction_proof_verifies_with_the_resolved_committee() { @@ -52,10 +52,10 @@ async fn object_proof_verifies_with_the_resolved_committee() { #[tokio::test] async fn event_proof_verifies_with_the_resolved_committee() { let cluster = start_test_cluster().await; - let transaction_digest = staking_tx(&cluster).await; + let staking = staking_tx(&cluster).await; let client = grpc_client(&cluster); let event_id = EventID { - tx_digest: transaction_digest, + tx_digest: staking.digest, event_seq: 0, }; @@ -73,3 +73,55 @@ async fn event_proof_verifies_with_the_resolved_committee() { .verify(&proof) .expect("event proof must verify"); } + +#[tokio::test] +async fn multiple_object_targets_share_one_verified_transaction_proof() { + let cluster = start_test_cluster().await; + let transfer = object_transfer_tx(&cluster).await; + let client = grpc_client(&cluster); + + let proof = ProofBuilder::from_grpc_client(client.clone()) + .objects(transfer.objects) + .build() + .await + .expect("stacked object proof must be constructed"); + let committee = CommitteeResolver::node(client) + .resolve(proof.checkpoint_summary.epoch()) + .await + .expect("checkpoint committee must resolve"); + + assert_eq!(proof.transaction_proof.transaction.digest(), &transfer.digest); + assert_eq!(proof.target.objects.len(), 2); + ProofVerifier::new(&committee) + .verify(&proof) + .expect("stacked object proof must verify"); +} + +#[tokio::test] +async fn object_and_event_targets_share_one_verified_transaction_proof() { + let cluster = start_test_cluster().await; + let staking = staking_tx(&cluster).await; + let client = grpc_client(&cluster); + let event_id = EventID { + tx_digest: staking.digest, + event_seq: 0, + }; + + let proof = ProofBuilder::from_grpc_client(client.clone()) + .object(staking.gas_object) + .event(event_id) + .build() + .await + .expect("mixed target proof must be constructed"); + let committee = CommitteeResolver::node(client) + .resolve(proof.checkpoint_summary.epoch()) + .await + .expect("checkpoint committee must resolve"); + + assert_eq!(proof.transaction_proof.transaction.digest(), &staking.digest); + assert_eq!(proof.target.objects.len(), 1); + assert_eq!(proof.target.events.len(), 1); + ProofVerifier::new(&committee) + .verify(&proof) + .expect("mixed target proof must verify"); +} diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs index 5fcb2b7..ae9d6df 100644 --- a/poi-rs/tests/utils/mod.rs +++ b/poi-rs/tests/utils/mod.rs @@ -17,6 +17,16 @@ pub struct CheckpointedTransfer { pub gas_object: ObjectRef, } +pub struct CheckpointedStaking { + pub digest: TransactionDigest, + pub gas_object: ObjectRef, +} + +pub struct CheckpointedObjectTransfer { + pub digest: TransactionDigest, + pub objects: [ObjectRef; 2], +} + pub async fn start_test_cluster() -> TestCluster { TestClusterBuilder::new() .with_num_validators(1) @@ -49,10 +59,42 @@ pub async fn transfer_tx(cluster: &TestCluster) -> CheckpointedTransfer { } } -pub async fn staking_tx(cluster: &TestCluster) -> TransactionDigest { +pub async fn object_transfer_tx(cluster: &TestCluster) -> CheckpointedObjectTransfer { + let (sender, mut coins) = cluster.wallet.get_one_account().await.unwrap(); + let gas = coins.pop().expect("funded account must have a gas coin"); + let object = coins.pop().expect("funded account must have an object to transfer"); + let gas_object_id = gas.object_id; + let transferred_object_id = object.object_id; + let transaction = cluster + .test_transaction_builder_with_gas_object(sender, gas) + .await + .transfer(object, cluster.get_address_1()) + .build(); + let response = cluster.sign_and_execute_transaction(&transaction).await; + let checkpoint = response.checkpoint.expect("object transfer must be checkpointed"); + cluster.wait_for_checkpoint(checkpoint, None).await; + let gas_object = cluster + .wallet + .get_object_ref(gas_object_id) + .await + .expect("mutated gas object must be available"); + let transferred_object = cluster + .wallet + .get_object_ref(transferred_object_id) + .await + .expect("transferred object must be available"); + + CheckpointedObjectTransfer { + digest: response.digest, + objects: [gas_object, transferred_object], + } +} + +pub async fn staking_tx(cluster: &TestCluster) -> CheckpointedStaking { let (sender, mut coins) = cluster.wallet.get_one_account().await.unwrap(); let gas = coins.pop().expect("funded account must have a gas coin"); let stake = coins.pop().expect("funded account must have a stake coin"); + let gas_object_id = gas.object_id; let validator = cluster .swarm .active_validators() @@ -68,8 +110,16 @@ pub async fn staking_tx(cluster: &TestCluster) -> TransactionDigest { let response = cluster.sign_and_execute_transaction(&transaction).await; let checkpoint = response.checkpoint.expect("staking transaction must be checkpointed"); cluster.wait_for_checkpoint(checkpoint, None).await; + let gas_object = cluster + .wallet + .get_object_ref(gas_object_id) + .await + .expect("mutated gas object must be available"); - response.digest + CheckpointedStaking { + digest: response.digest, + gas_object, + } } pub fn genesis_committee(cluster: &TestCluster) -> Committee { From 2e1d26e13c342e526936cfdaec18e90888e9d02d Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 15 Jul 2026 06:31:05 +0300 Subject: [PATCH 18/21] feat: Add chain identifier support in ProofBuilder and related tests --- poi-rs/src/source.rs | 135 +++++++++++++++++++++++++--------- poi-rs/tests/proof_builder.rs | 16 +++- poi-rs/tests/utils/mod.rs | 13 +++- 3 files changed, 126 insertions(+), 38 deletions(-) diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 3ff537e..6d9f2af 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -6,13 +6,13 @@ use std::fmt; use async_trait::async_trait; use iota_grpc_client::{ CheckpointResponse, Client as GrpcClient, ReadMask, - read_mask_fields::{CheckpointResponseField, ObjectField, TransactionField}, + read_mask_fields::{CheckpointResponseField, ObjectField, ServiceInfoField, TransactionField}, }; use iota_grpc_types::v1::transaction::ExecutedTransaction; use iota_sdk_types::{Digest, SignedTransaction}; use iota_types::{ base_types::ObjectRef, - digests::{ChainIdentifier, TransactionDigest}, + digests::{ChainIdentifier, CheckpointDigest, TransactionDigest}, effects::{TransactionEffects, TransactionEffectsAPI}, event::EventID, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, @@ -22,7 +22,7 @@ use iota_types::{ use crate::{BoxError, Proof, ProofTargets, TransactionProof}; -// Minimum gRPC fields needed to package a transaction proof. +// gRPC fields needed to package a transaction proof. const TRANSACTION_PROOF_FIELDS: &[&str] = &[ TransactionField::TRANSACTION_BCS, TransactionField::SIGNATURES, @@ -32,10 +32,13 @@ const TRANSACTION_PROOF_FIELDS: &[&str] = &[ TransactionField::CHECKPOINT, ]; -// Minimum gRPC fields needed to package an object target. +// gRPC fields needed to package an object target. const OBJECT_PROOF_FIELDS: &[&str] = &[ObjectField::BCS]; -// Minimum gRPC fields needed to authenticate checkpoint contents. +// gRPC fields needed to identify the chain. +const CHAIN_IDENTIFIER_FIELDS: &[&str] = &[ServiceInfoField::CHAIN_ID]; + +// gRPC fields needed to authenticate checkpoint contents. const CHECKPOINT_PROOF_FIELDS: &[&str] = &[ CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, CheckpointResponseField::CHECKPOINT_SIGNATURE, @@ -130,6 +133,20 @@ impl fmt::Display for TransactionMismatch { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SourceErrorKind { + /// Fetching the chain identifier from the source failed. + #[error("failed to fetch chain identifier")] + FetchChainIdentifier { + /// Underlying source error. + #[source] + source: BoxError, + }, + /// Reading or converting the chain identifier failed. + #[error("failed to read chain identifier")] + ChainIdentifier { + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, /// Fetching the transaction from the source failed. #[error("failed to fetch transaction")] FetchTransaction { @@ -260,20 +277,47 @@ pub struct GrpcSource { } impl GrpcSource { + /// Wraps an SDK gRPC client as a Proof of Inclusion source. pub(crate) fn new(client: GrpcClient) -> Self { Self { client } } + /// Returns the underlying client for endpoint-selection tests. #[cfg(test)] pub(crate) const fn grpc_client(&self) -> &GrpcClient { &self.client } + /// Fetches the genesis-checkpoint digest that identifies the source chain. + async fn chain_identifier(&self, digest: TransactionDigest) -> Result { + let service_info = self + .client + .get_service_info(Some(ReadMask::from(CHAIN_IDENTIFIER_FIELDS))) + .await + .map_err(|source| { + SourceError::transaction( + digest, + SourceErrorKind::FetchChainIdentifier { + source: Box::new(source), + }, + ) + })?; + let chain_identifier = service_info.body().chain_identifier().map_err(|source| { + SourceError::transaction( + digest, + SourceErrorKind::ChainIdentifier { + source: Box::new(source), + }, + ) + })?; + + Ok(ChainIdentifier::from(CheckpointDigest::new( + chain_identifier.into_inner(), + ))) + } + /// Fetches the executed transaction envelope with the fields needed for inclusion. - async fn fetch_executed_transaction( - &self, - transaction_digest: TransactionDigest, - ) -> Result { + async fn get_transaction(&self, transaction_digest: TransactionDigest) -> Result { let digest = Digest::new(transaction_digest.into_inner()); let transactions = self .client @@ -296,7 +340,7 @@ impl GrpcSource { } /// Fetches the object contents for an exact object reference. - async fn fetch_object(&self, object_ref: ObjectRef) -> Result { + async fn get_object(&self, object_ref: ObjectRef) -> Result { let objects = self .client .get_objects( @@ -338,7 +382,7 @@ impl GrpcSource { } /// Fetches the certified checkpoint summary and contents for an executed transaction. - async fn fetch_checkpoint_with_contents( + async fn get_checkpoint( &self, transaction_digest: TransactionDigest, sequence_number: u64, @@ -363,7 +407,8 @@ impl GrpcSource { }) } - fn select_transaction( + /// Selects the transaction shared by all targets or rejects a conflicting target. + fn ensure_same_transaction( selected: &mut Option, target: SourceTarget, transaction_digest: TransactionDigest, @@ -387,19 +432,11 @@ impl GrpcSource { Ok(()) } - async fn build_transaction_proof(&self, transaction_digest: TransactionDigest) -> Result { - let executed_transaction = self.fetch_executed_transaction(transaction_digest).await?; - let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::MissingCheckpointSequence { - source: Box::new(source), - }, - ) - })?; - let checkpoint = self - .fetch_checkpoint_with_contents(transaction_digest, checkpoint_sequence_number) - .await?; + /// Reads the certified summary and contents from a checkpoint response. + fn parse_checkpoint( + transaction_digest: TransactionDigest, + checkpoint: &CheckpointResponse, + ) -> Result<(CertifiedCheckpointSummary, CheckpointContents), SourceError> { let checkpoint_summary: CertifiedCheckpointSummary = checkpoint .signed_summary() .map_err(|source| { @@ -448,6 +485,16 @@ impl GrpcSource { ) }) })?; + + Ok((checkpoint_summary, checkpoint_contents)) + } + + /// Builds the transaction evidence committed to by the checkpoint contents. + fn build_transaction_proof( + transaction_digest: TransactionDigest, + executed_transaction: &ExecutedTransaction, + checkpoint_contents: CheckpointContents, + ) -> Result { let transaction = executed_transaction .transaction() .map_err(|source| { @@ -547,12 +594,7 @@ impl GrpcSource { None }; - Ok(Proof::new( - ChainIdentifier::from(*checkpoint_summary.digest()), - ProofTargets::new(), - checkpoint_summary, - TransactionProof::new(checkpoint_contents, transaction, effects, events), - )) + Ok(TransactionProof::new(checkpoint_contents, transaction, effects, events)) } } @@ -566,22 +608,43 @@ impl Source for GrpcSource { for target in targets.iter().copied() { match target { SourceTarget::Transaction(transaction_digest) => { - Self::select_transaction(&mut selected_transaction, target, transaction_digest)?; + Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; } SourceTarget::Object(object_ref) => { - let object = self.fetch_object(object_ref).await?; - Self::select_transaction(&mut selected_transaction, target, object.previous_transaction)?; + let object = self.get_object(object_ref).await?; + Self::ensure_same_transaction(&mut selected_transaction, target, object.previous_transaction)?; objects.push((object_ref, object)); } SourceTarget::Event(event_id) => { - Self::select_transaction(&mut selected_transaction, target, event_id.tx_digest)?; + Self::ensure_same_transaction(&mut selected_transaction, target, event_id.tx_digest)?; events.push(event_id); } } } let transaction_digest = selected_transaction.expect("ProofBuilder only calls Source with non-empty targets"); - let mut proof = self.build_transaction_proof(transaction_digest).await?; + let executed_transaction = self.get_transaction(transaction_digest).await?; + let chain_identifier = self.chain_identifier(transaction_digest).await?; + let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingCheckpointSequence { + source: Box::new(source), + }, + ) + })?; + let checkpoint = self + .get_checkpoint(transaction_digest, checkpoint_sequence_number) + .await?; + let (checkpoint_summary, checkpoint_contents) = Self::parse_checkpoint(transaction_digest, &checkpoint)?; + let transaction_proof = + Self::build_transaction_proof(transaction_digest, &executed_transaction, checkpoint_contents)?; + let mut proof = Proof::new( + chain_identifier, + ProofTargets::new(), + checkpoint_summary, + transaction_proof, + ); for (object_ref, object) in objects { proof.target = proof.target.add_object(object_ref, object); diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index b9a1653..03af8e8 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use iota_types::base_types::dbg_object_id; use iota_types::{digests::TransactionDigest, event::EventID, object::Object}; use poi_rs::{Proof, ProofBuilder, ProofBuilderError, Source, SourceError, SourceErrorKind, SourceTarget}; -use utils::{grpc_client, staking_tx, start_test_cluster, transfer_tx}; +use utils::{genesis_chain_identifier, grpc_client, staking_tx, start_test_cluster, transfer_tx}; struct RejectingSource; @@ -145,6 +145,20 @@ async fn unknown_transaction_returns_a_fetch_error() { assert!(matches!(source.kind, SourceErrorKind::FetchTransaction { .. })); } +#[tokio::test] +async fn proof_uses_the_genesis_checkpoint_as_its_chain_identifier() { + let cluster = start_test_cluster().await; + let transfer = transfer_tx(&cluster).await; + + let proof = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .transaction(transfer.digest) + .build() + .await + .expect("transaction proof must be constructed"); + + assert_eq!(proof.chain, genesis_chain_identifier(&cluster)); +} + #[tokio::test] async fn unknown_object_returns_a_fetch_error() { let cluster = start_test_cluster().await; diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs index ae9d6df..1c3a81b 100644 --- a/poi-rs/tests/utils/mod.rs +++ b/poi-rs/tests/utils/mod.rs @@ -7,7 +7,11 @@ use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis}; use iota_grpc_client::Client as GrpcClient; -use iota_types::{base_types::ObjectRef, committee::Committee, digests::TransactionDigest}; +use iota_types::{ + base_types::ObjectRef, + committee::Committee, + digests::{ChainIdentifier, TransactionDigest}, +}; use test_cluster::{TestCluster, TestClusterBuilder}; pub mod proofs; @@ -130,6 +134,13 @@ pub fn genesis_committee(cluster: &TestCluster) -> Committee { .expect("genesis blob must contain a committee") } +pub fn genesis_chain_identifier(cluster: &TestCluster) -> ChainIdentifier { + let genesis_path = cluster.swarm.dir().join(IOTA_GENESIS_FILENAME); + let genesis = Genesis::load(genesis_path).expect("test cluster genesis blob must load"); + + ChainIdentifier::from(*genesis.checkpoint().digest()) +} + pub async fn advance_to_epoch(cluster: &TestCluster, target_epoch: u64) -> Vec { let mut committees = vec![cluster.committee().as_ref().clone()]; From 7b60b9f78e9da513b0365838e81e5540bbf1513f Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 16 Jul 2026 15:37:00 +0300 Subject: [PATCH 19/21] feat: Enhance Proof of Inclusion CLI and underlying logic - Added `clap` dependency for command-line argument parsing with derive feature. - Introduced a new binary `iota-poi` for creating and verifying IOTA Proof of Inclusion proofs. - Implemented command-line interface with subcommands for creating and verifying proofs, including detailed help messages. - Updated `ProofBuilder` to accept object IDs instead of object references, improving clarity and functionality. - Enhanced error handling in `Source` and `Proof` modules to better manage object and event verification. - Added tests for new functionality, ensuring robust handling of object and event targets in proofs. - Updated dependencies in `Cargo.toml` for improved functionality and security. --- Cargo.toml | 3 + poi-rs/Cargo.toml | 13 + poi-rs/src/bin/iota-poi.rs | 581 +++++++++++++++++++++++++++++ poi-rs/src/builder.rs | 19 +- poi-rs/src/proof.rs | 19 +- poi-rs/src/source.rs | 151 +++++--- poi-rs/tests/golden.rs | 5 +- poi-rs/tests/proof_builder.rs | 59 ++- poi-rs/tests/proof_of_inclusion.rs | 8 +- 9 files changed, 774 insertions(+), 84 deletions(-) create mode 100644 poi-rs/src/bin/iota-poi.rs diff --git a/Cargo.toml b/Cargo.toml index 4a331d7..aa2c6de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1.0" async-trait = "0.1" bcs = "0.1" chrono = { version = "0.4", default-features = false } +clap = { version = "4.6.1", features = ["derive"] } hyper = "1" iota-grpc-client = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-client", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11" } iota-grpc-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-types", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11" } @@ -26,12 +27,14 @@ iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_ts" } product_common = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "product_common" } +reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls"] } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } serde_json = { version = "1.0", default-features = false } sha2 = { version = "0.10", default-features = false } strum = { version = "0.27", default-features = false, features = ["std", "derive"] } +tempfile = "3.27.0" thiserror = { version = "2.0", default-features = false } tokio = { version = "1.52.2", default-features = false, features = ["macros", "sync", "rt", "process"] } diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index 5710a77..8e9859b 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -10,17 +10,30 @@ repository.workspace = true rust-version.workspace = true description = "Proof of Inclusion support for the IOTA Notarization Toolkit." +[features] +cli = ["dep:anyhow", "dep:clap", "dep:iota-config", "dep:reqwest", "dep:tempfile"] + [dependencies] +anyhow = { workspace = true, optional = true } async-trait.workspace = true +clap = { workspace = true, optional = true } iota-grpc-client.workspace = true iota-grpc-types.workspace = true +iota-config = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1", optional = true } iota-sdk-types.workspace = true iota-types.workspace = true +reqwest = { workspace = true, optional = true } serde.workspace = true serde_json = { workspace = true, features = ["alloc"] } +tempfile = { workspace = true, optional = true } thiserror.workspace = true tokio.workspace = true [dev-dependencies] iota-config = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } test-cluster = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1", package = "test-cluster" } + +[[bin]] +name = "iota-poi" +path = "src/bin/iota-poi.rs" +required-features = ["cli"] diff --git a/poi-rs/src/bin/iota-poi.rs b/poi-rs/src/bin/iota-poi.rs new file mode 100644 index 0000000..7dc763b --- /dev/null +++ b/poi-rs/src/bin/iota-poi.rs @@ -0,0 +1,581 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![forbid(unsafe_code)] +#![deny(clippy::print_stderr, clippy::print_stdout)] + +use std::{ + fs, + io::{self, Read, Write}, + path::{Path, PathBuf}, + process::ExitCode, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; +use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis, iota_config_dir}; +use iota_grpc_client::Client as GrpcClient; +use iota_sdk_types::ObjectId; +use iota_types::{digests::TransactionDigest, event::EventID}; +use poi_rs::{CommitteeResolver, Proof, ProofBuilder, ProofVerifier}; +use tempfile::NamedTempFile; + +const STDIO_PATH: &str = "-"; +const GENESIS_CACHE_DIR: &str = "iota-poi"; +const GENESIS_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_GENESIS_BLOB_BYTES: usize = 64 * 1024 * 1024; +const MAINNET_GENESIS_URL: &str = "https://dbfiles.mainnet.iota.cafe/genesis.blob"; +const TESTNET_GENESIS_URL: &str = "https://dbfiles.testnet.iota.cafe/genesis.blob"; +const DEVNET_GENESIS_URL: &str = "https://dbfiles.devnet.iota.cafe/genesis.blob"; +const CREATE_EXAMPLES: &str = r#"Examples: + iota-poi create --network mainnet --transaction TRANSACTION_DIGEST + iota-poi create --network testnet --object OBJECT_ID --output proof.json + iota-poi create --grpc-url http://localhost:9000 --event TRANSACTION_DIGEST:EVENT_SEQUENCE + +The selected endpoint supplies untrusted proof material; it does not establish verification trust."#; +const VERIFY_EXAMPLES: &str = r#"Examples: + iota-poi verify --network mainnet proof.json + iota-poi verify --network testnet --genesis trusted-genesis.blob proof.json + iota-poi verify --grpc-url http://localhost:9000 --genesis genesis.blob - + +Known networks download and cache their genesis blob automatically. An explicit --genesis path overrides the managed blob. +The genesis blob is the trust anchor. The selected endpoint only supplies committee-walking data."#; + +#[derive(Debug, Parser)] +#[command( + name = "iota-poi", + version, + about = "Create and verify IOTA Proof of Inclusion proofs", + long_about = "Create portable IOTA Proof of Inclusion proofs and verify them against committee history authenticated from a trusted genesis blob.", + arg_required_else_help = true, + propagate_version = true +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Create a proof from an IOTA gRPC source. + Create(CreateArgs), + /// Verify a proof using genesis-anchored committee history. + Verify(VerifyArgs), +} + +impl Command { + async fn execute(self) -> Result<()> { + match self { + Self::Create(args) => args.execute().await, + Self::Verify(args) => args.execute().await, + } + } +} + +#[derive(Debug, Args)] +#[command( + long_about = "Create a Proof of Inclusion for one transaction and any requested object or event targets that belong to it.", + after_help = CREATE_EXAMPLES, + group( + ArgGroup::new("target") + .required(true) + .multiple(true) + .args(["transaction", "object", "event"]) + ) +)] +struct CreateArgs { + #[command(flatten)] + endpoint: EndpointArgs, + /// Transaction digest to prove. + #[arg(long, value_name = "DIGEST", value_parser = parse_transaction_digest, group = "target")] + transaction: Option, + /// Object ID to prove. The source resolves its latest version unless a transaction or event scopes the proof. May be repeated. + #[arg(long, value_name = "OBJECT_ID", value_parser = parse_object_id, group = "target")] + object: Vec, + /// Event identifier formatted as TRANSACTION_DIGEST:EVENT_SEQUENCE. May be repeated. + #[arg(long, value_name = "EVENT_ID", value_parser = parse_event_id, group = "target")] + event: Vec, + /// Output file. Write JSON to stdout when omitted or set to '-'. + #[arg(short, long, value_name = "PATH")] + output: Option, +} + +impl CreateArgs { + async fn execute(self) -> Result<()> { + let Self { + endpoint, + transaction, + object, + event, + output, + } = self; + let mut builder = ProofBuilder::from_grpc_client(endpoint.client()?); + + if let Some(transaction) = transaction { + builder = builder.transaction(transaction); + } + let proof = builder + .objects(object) + .events(event) + .build() + .await + .context("failed to create proof")?; + let json = proof.to_json_vec().context("failed to encode proof as JSON")?; + + write_output(output.as_deref(), &json) + } +} + +#[derive(Debug, Args)] +#[command( + long_about = "Verify a Proof of Inclusion locally after authenticating the checkpoint committee from a trusted genesis blob.", + after_help = VERIFY_EXAMPLES +)] +struct VerifyArgs { + #[command(flatten)] + endpoint: EndpointArgs, + /// Proof JSON file, or '-' to read from stdin. + #[arg(value_name = "PROOF")] + proof: PathBuf, + /// Trusted genesis blob. Required with --grpc-url; overrides the managed network blob. + #[arg(long, value_name = "PATH", required_unless_present = "network")] + genesis: Option, +} + +impl VerifyArgs { + async fn execute(self) -> Result<()> { + let proof_bytes = read_input(&self.proof)?; + let proof = Proof::from_json_slice(&proof_bytes).context("failed to decode proof JSON")?; + proof.validate().context("proof format is not supported")?; + + let genesis = match self.genesis.as_deref() { + Some(path) => { + Genesis::load(path).with_context(|| format!("failed to load genesis blob '{}'", path.display()))? + } + None => { + download_or_load_genesis( + self.endpoint + .network + .context("a known network or explicit genesis blob is required for verification")?, + ) + .await? + } + }; + let trusted_committee = genesis + .committee() + .context("failed to read committee from genesis blob")?; + let resolver = CommitteeResolver::anchor(self.endpoint.client()?, trusted_committee); + let committee = resolver + .resolve(proof.checkpoint_summary.epoch()) + .await + .context("failed to authenticate the proof checkpoint committee")?; + + ProofVerifier::new(&committee) + .verify(&proof) + .context("proof verification failed")?; + write_stdout(b"valid") + } +} + +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("endpoint") + .required(true) + .multiple(false) + .args(["network", "grpc_url"]) +))] +struct EndpointArgs { + /// Public IOTA network whose default gRPC endpoint should be used. + #[arg(long, value_enum)] + network: Option, + /// Custom IOTA gRPC endpoint. + #[arg(long, value_name = "URL")] + grpc_url: Option, +} + +impl EndpointArgs { + fn client(&self) -> Result { + if let Some(network) = self.network { + return network.client(); + } + if let Some(url) = self.grpc_url.as_deref() { + return GrpcClient::new(url).with_context(|| format!("failed to configure gRPC endpoint '{url}'")); + } + + bail!("an IOTA network or gRPC URL is required") + } +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum Network { + Mainnet, + Testnet, + Devnet, +} + +impl Network { + const fn name(self) -> &'static str { + match self { + Self::Mainnet => "mainnet", + Self::Testnet => "testnet", + Self::Devnet => "devnet", + } + } + + const fn genesis_url(self) -> &'static str { + match self { + Self::Mainnet => MAINNET_GENESIS_URL, + Self::Testnet => TESTNET_GENESIS_URL, + Self::Devnet => DEVNET_GENESIS_URL, + } + } + + fn client(self) -> Result { + match self { + Self::Mainnet => GrpcClient::new_mainnet().context("failed to configure mainnet gRPC endpoint"), + Self::Testnet => GrpcClient::new_testnet().context("failed to configure testnet gRPC endpoint"), + Self::Devnet => GrpcClient::new_devnet().context("failed to configure devnet gRPC endpoint"), + } + } +} + +async fn download_or_load_genesis(network: Network) -> Result { + let path = iota_config_dir() + .context("failed to locate the IOTA configuration directory")? + .join(GENESIS_CACHE_DIR) + .join(network.name()) + .join(IOTA_GENESIS_FILENAME); + + if path.is_file() { + if let Ok(genesis) = Genesis::load(&path) { + return Ok(genesis); + } + } + + let parent = path + .parent() + .context("managed genesis path does not have a parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create genesis cache directory '{}'", parent.display()))?; + + let url = network.genesis_url(); + let response = reqwest::Client::builder() + .timeout(GENESIS_DOWNLOAD_TIMEOUT) + .user_agent(concat!("iota-poi/", env!("CARGO_PKG_VERSION"))) + .build() + .context("failed to configure the genesis download client")? + .get(url) + .send() + .await + .with_context(|| format!("failed to download {} genesis blob from '{url}'", network.name()))? + .error_for_status() + .with_context(|| format!("genesis server rejected the request to '{url}'"))?; + + if response + .content_length() + .is_some_and(|length| length > MAX_GENESIS_BLOB_BYTES as u64) + { + bail!("genesis blob from '{url}' exceeds the {MAX_GENESIS_BLOB_BYTES}-byte size limit"); + } + + let bytes = response + .bytes() + .await + .with_context(|| format!("failed to read genesis blob from '{url}'"))?; + if bytes.len() > MAX_GENESIS_BLOB_BYTES { + bail!("genesis blob from '{url}' exceeds the {MAX_GENESIS_BLOB_BYTES}-byte size limit"); + } + + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create a temporary genesis file in '{}'", parent.display()))?; + temporary + .write_all(&bytes) + .with_context(|| format!("failed to write downloaded {} genesis blob", network.name()))?; + temporary + .as_file() + .sync_all() + .with_context(|| format!("failed to flush downloaded {} genesis blob", network.name()))?; + let genesis = Genesis::load(temporary.path()) + .with_context(|| format!("downloaded {} genesis blob from '{url}' is invalid", network.name()))?; + temporary + .persist(&path) + .map_err(|error| error.error) + .with_context(|| format!("failed to cache genesis blob at '{}'", path.display()))?; + + Ok(genesis) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(error) if is_broken_pipe(&error) => ExitCode::SUCCESS, + Err(error) => { + report_error(&error); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<()> { + Cli::parse().command.execute().await +} + +fn report_error(error: &anyhow::Error) { + let _ = writeln!(io::stderr().lock(), "error: {error:#}"); +} + +fn is_broken_pipe(error: &anyhow::Error) -> bool { + let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(error.as_ref()); + + while let Some(error) = cause { + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe) + { + return true; + } + + cause = error.source(); + } + + false +} + +fn read_input(path: &Path) -> Result> { + if is_stdio(path) { + let mut bytes = Vec::new(); + io::stdin() + .lock() + .read_to_end(&mut bytes) + .context("failed to read proof JSON from stdin")?; + return Ok(bytes); + } + + fs::read(path).with_context(|| format!("failed to read proof JSON from '{}'", path.display())) +} + +fn write_output(path: Option<&Path>, bytes: &[u8]) -> Result<()> { + match path { + None => write_stdout(bytes), + Some(path) if is_stdio(path) => write_stdout(bytes), + Some(path) => write_file_atomically(path, bytes), + } +} + +fn write_stdout(bytes: &[u8]) -> Result<()> { + write_json(io::stdout().lock(), bytes).context("failed to write to stdout") +} + +fn write_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create a temporary output file in '{}'", parent.display()))?; + + write_json(&mut temporary, bytes) + .with_context(|| format!("failed to write proof JSON for '{}'", path.display()))?; + temporary + .as_file() + .sync_all() + .with_context(|| format!("failed to flush proof JSON for '{}'", path.display()))?; + temporary + .persist(path) + .map(|_| ()) + .map_err(|error| error.error) + .with_context(|| format!("failed to atomically replace output file '{}'", path.display())) +} + +fn write_json(mut writer: impl Write, bytes: &[u8]) -> io::Result<()> { + writer.write_all(bytes)?; + writer.write_all(b"\n")?; + writer.flush() +} + +fn is_stdio(path: &Path) -> bool { + path == Path::new(STDIO_PATH) +} + +fn parse_transaction_digest(value: &str) -> Result { + value + .parse() + .map_err(|error| format!("invalid transaction digest '{value}': {error}")) +} + +fn parse_object_id(value: &str) -> Result { + value + .parse::() + .map_err(|error| format!("invalid object ID '{value}': {error}")) +} + +fn parse_event_id(value: &str) -> Result { + let mut parts = value.split(':'); + let (Some(transaction), Some(sequence), None) = (parts.next(), parts.next(), parts.next()) else { + return Err(format!( + "invalid event ID '{value}'; expected TRANSACTION_DIGEST:EVENT_SEQUENCE" + )); + }; + let tx_digest = transaction + .parse::() + .map_err(|error| format!("invalid transaction digest in event ID '{value}': {error}"))?; + let event_seq = sequence + .parse::() + .map_err(|error| format!("invalid event sequence in '{value}': {error}"))?; + + Ok(EventID { tx_digest, event_seq }) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + const DIGEST: &str = "11111111111111111111111111111111"; + const OBJECT_ID: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn create_requires_a_target() { + let error = Cli::try_parse_from(["iota-poi", "create", "--network", "mainnet"]) + .expect_err("create without a target must fail"); + + assert!(error.to_string().contains("--transaction ")); + } + + #[test] + fn create_accepts_mixed_targets() { + let event = format!("{DIGEST}:0"); + let cli = Cli::try_parse_from([ + "iota-poi", + "create", + "--network", + "testnet", + "--transaction", + DIGEST, + "--object", + OBJECT_ID, + "--event", + &event, + ]) + .expect("mixed targets must parse"); + + let Command::Create(args) = cli.command else { + panic!("create command must parse"); + }; + assert!(args.transaction.is_some()); + assert_eq!(args.object.len(), 1); + assert_eq!(args.event.len(), 1); + } + + #[test] + fn endpoint_selection_is_exclusive() { + let error = Cli::try_parse_from([ + "iota-poi", + "create", + "--network", + "mainnet", + "--grpc-url", + "http://localhost:9000", + "--transaction", + DIGEST, + ]) + .expect_err("multiple endpoints must fail"); + + assert!(error.to_string().contains("cannot be used with")); + } + + #[test] + fn known_network_verification_manages_genesis_automatically() { + let cli = Cli::try_parse_from(["iota-poi", "verify", "--network", "mainnet", "proof.json"]) + .expect("known network must not require an explicit genesis blob"); + + let Command::Verify(args) = cli.command else { + panic!("verify command must parse"); + }; + assert!(args.genesis.is_none()); + } + + #[test] + fn custom_endpoint_verification_requires_genesis() { + let error = Cli::try_parse_from([ + "iota-poi", + "verify", + "--grpc-url", + "http://localhost:9000", + "proof.json", + ]) + .expect_err("custom endpoint must require an explicit genesis blob"); + + assert!(error.to_string().contains("--genesis ")); + } + + #[test] + fn known_network_genesis_urls_match_the_iota_light_client() { + assert_eq!(Network::Mainnet.genesis_url(), MAINNET_GENESIS_URL); + assert_eq!(Network::Testnet.genesis_url(), TESTNET_GENESIS_URL); + assert_eq!(Network::Devnet.genesis_url(), DEVNET_GENESIS_URL); + } + + #[test] + fn invalid_event_id_reports_the_required_format() { + let error = Cli::try_parse_from(["iota-poi", "create", "--network", "mainnet", "--event", "not-an-event"]) + .expect_err("invalid event ID must fail"); + + assert!(error.to_string().contains("TRANSACTION_DIGEST:EVENT_SEQUENCE")); + } + + #[test] + fn invalid_object_id_reports_the_invalid_value() { + let error = Cli::try_parse_from([ + "iota-poi", + "create", + "--network", + "mainnet", + "--object", + "not-an-object", + ]) + .expect_err("invalid object ID must fail"); + + assert!(error.to_string().contains("invalid object ID 'not-an-object'")); + } + + #[test] + fn command_help_explains_the_trust_boundary() { + let mut command = Cli::command(); + let create = command + .find_subcommand_mut("create") + .expect("create subcommand must exist") + .render_long_help() + .to_string(); + let verify = command + .find_subcommand_mut("verify") + .expect("verify subcommand must exist") + .render_long_help() + .to_string(); + + assert!(create.contains("does not establish verification trust")); + assert!(verify.contains("genesis blob is the trust anchor")); + } + + #[test] + fn file_output_is_newline_terminated_and_replaced_atomically() { + let directory = tempfile::tempdir().expect("temporary directory must be created"); + let output = directory.path().join("proof.json"); + + write_output(Some(&output), br#"{"version":1}"#).expect("initial proof must be written"); + assert_eq!(fs::read(&output).unwrap(), b"{\"version\":1}\n"); + + write_output(Some(&output), br#"{"version":2}"#).expect("proof must be replaced"); + assert_eq!(fs::read(&output).unwrap(), b"{\"version\":2}\n"); + } + + #[test] + fn broken_pipe_is_treated_as_a_successful_pipeline_shutdown() { + let error = anyhow::Error::new(io::Error::new(io::ErrorKind::BrokenPipe, "reader closed")) + .context("failed to write proof"); + + assert!(is_broken_pipe(&error)); + } +} diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 9d734d6..1cfdd83 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use iota_grpc_client::Client as GrpcClient; -use iota_types::{base_types::ObjectRef, digests::TransactionDigest, event::EventID}; +use iota_sdk_types::ObjectId; +use iota_types::{digests::TransactionDigest, event::EventID}; use crate::{Proof, Source, SourceError, SourceTarget, source::GrpcSource}; @@ -77,16 +78,18 @@ impl ProofBuilder { self } - /// Adds an object proof target. - pub fn object(mut self, object_ref: ObjectRef) -> Self { - self.push_target(SourceTarget::Object(object_ref)); + /// Adds an object proof target by object ID. + /// + /// The source resolves the ID to the exact object reference packaged in the proof. + pub fn object(mut self, object_id: ObjectId) -> Self { + self.push_target(SourceTarget::Object(object_id)); self } - /// Adds multiple object proof targets. - pub fn objects(mut self, object_refs: impl IntoIterator) -> Self { - for object_ref in object_refs { - self.push_target(SourceTarget::Object(object_ref)); + /// Adds multiple object proof targets by object ID. + pub fn objects(mut self, object_ids: impl IntoIterator) -> Self { + for object_id in object_ids { + self.push_target(SourceTarget::Object(object_id)); } self } diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index c8b87c1..35929c6 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -21,24 +21,24 @@ pub struct VersionError { pub version: u16, } -/// Error returned when a proof cannot be serialized. +/// Error returned when a proof cannot be serialized or deserialized. #[derive(Debug, thiserror::Error)] #[non_exhaustive] -#[error("failed to serialize Proof of Inclusion proof")] +#[error("failed to serialize or deserialize Proof of Inclusion proof")] pub struct SerializationError { /// Serialization failure details. #[source] pub kind: SerializationErrorKind, } -/// Kind of proof-serialization failure. +/// Kind of proof serialization or deserialization failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SerializationErrorKind { - /// JSON serialization failed. - #[error("json serialization failed")] + /// JSON serialization or deserialization failed. + #[error("json serialization or deserialization failed")] Json { - /// Underlying JSON serialization error. + /// Underlying JSON serialization or deserialization error. #[source] source: serde_json::Error, }, @@ -241,6 +241,13 @@ impl Proof { }) } + /// Deserializes a proof envelope from JSON bytes. + pub fn from_json_slice(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|source| SerializationError { + kind: SerializationErrorKind::Json { source }, + }) + } + /// Validates proof-format version. pub fn validate(&self) -> Result<(), VersionError> { self.version.validate() diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 6d9f2af..0e4141c 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -9,11 +9,11 @@ use iota_grpc_client::{ read_mask_fields::{CheckpointResponseField, ObjectField, ServiceInfoField, TransactionField}, }; use iota_grpc_types::v1::transaction::ExecutedTransaction; -use iota_sdk_types::{Digest, SignedTransaction}; +use iota_sdk_types::{Digest, ObjectId, SignedTransaction}; use iota_types::{ base_types::ObjectRef, digests::{ChainIdentifier, CheckpointDigest, TransactionDigest}, - effects::{TransactionEffects, TransactionEffectsAPI}, + effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, event::EventID, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, object::Object, @@ -51,8 +51,8 @@ const CHECKPOINT_PROOF_FIELDS: &[&str] = &[ pub enum SourceTarget { /// A transaction proof request. Transaction(TransactionDigest), - /// An object proof request. - Object(ObjectRef), + /// An object proof request identified by object ID. + Object(ObjectId), /// An event proof request. Event(EventID), } @@ -61,7 +61,7 @@ impl fmt::Display for SourceTarget { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Transaction(transaction_digest) => write!(f, "transaction {transaction_digest}"), - Self::Object(object_ref) => write!(f, "object {object_ref:?}"), + Self::Object(object_id) => write!(f, "object {object_id}"), Self::Event(event_id) => write!(f, "event {event_id:?}"), } } @@ -94,9 +94,9 @@ impl SourceError { } /// Creates a source error for a requested object. - pub fn object(object_ref: ObjectRef, kind: SourceErrorKind) -> Self { + pub fn object(object_id: ObjectId, kind: SourceErrorKind) -> Self { Self { - target: SourceTarget::Object(object_ref), + target: SourceTarget::Object(object_id), kind, } } @@ -164,7 +164,7 @@ pub enum SourceErrorKind { #[source] source: BoxError, }, - /// The source returned no object for the requested reference. + /// The source returned no object for the requested ID. #[error("object was not found")] ObjectNotFound, /// Reading or converting the object failed. @@ -174,9 +174,15 @@ pub enum SourceErrorKind { #[source] source: BoxError, }, - /// The returned object does not compute to the requested reference. - #[error("object reference does not match the requested reference")] + /// The returned object does not match the requested ID or transaction effects. + #[error("object reference does not match the requested object")] ObjectReferenceMismatch, + /// The requested object was not changed by the selected transaction. + #[error("object was not changed by transaction {transaction_digest}")] + ObjectNotChangedByTransaction { + /// Transaction selected by the other proof targets. + transaction_digest: TransactionDigest, + }, /// The source could not resolve the requested event. #[error("event was not found")] EventNotFound, @@ -339,18 +345,22 @@ impl GrpcSource { .ok_or_else(|| SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound)) } - /// Fetches the object contents for an exact object reference. - async fn get_object(&self, object_ref: ObjectRef) -> Result { + /// Fetches the latest object or an exact version selected by transaction effects. + async fn get_object( + &self, + object_id: ObjectId, + expected_ref: Option, + ) -> Result<(ObjectRef, Object), SourceError> { let objects = self .client .get_objects( - &[(object_ref.object_id, Some(object_ref.version))], + &[(object_id, expected_ref.map(|object_ref| object_ref.version))], Some(ReadMask::from(OBJECT_PROOF_FIELDS)), ) .await .map_err(|source| { SourceError::object( - object_ref, + object_id, SourceErrorKind::FetchObject { source: Box::new(source), }, @@ -359,26 +369,24 @@ impl GrpcSource { let object: Object = objects .body() .first() - .ok_or_else(|| SourceError::object(object_ref, SourceErrorKind::ObjectNotFound))? + .ok_or_else(|| SourceError::object(object_id, SourceErrorKind::ObjectNotFound))? .object() .map_err(|source| { SourceError::object( - object_ref, + object_id, SourceErrorKind::Object { source: Box::new(source), }, ) })? .into(); + let object_ref = object.as_inner().object_ref(); - if object.as_inner().object_ref() != object_ref { - return Err(SourceError::object( - object_ref, - SourceErrorKind::ObjectReferenceMismatch, - )); + if object_ref.object_id != object_id || expected_ref.is_some_and(|expected| expected != object_ref) { + return Err(SourceError::object(object_id, SourceErrorKind::ObjectReferenceMismatch)); } - Ok(object) + Ok((object_ref, object)) } /// Fetches the certified checkpoint summary and contents for an executed transaction. @@ -489,11 +497,38 @@ impl GrpcSource { Ok((checkpoint_summary, checkpoint_contents)) } + /// Reads transaction effects before resolving transaction-scoped object IDs. + fn parse_effects( + transaction_digest: TransactionDigest, + executed_transaction: &ExecutedTransaction, + ) -> Result { + executed_transaction + .effects() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Effects { + source: Box::new(source), + }, + ) + })? + .effects() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Effects { + source: Box::new(source), + }, + ) + }) + } + /// Builds the transaction evidence committed to by the checkpoint contents. fn build_transaction_proof( transaction_digest: TransactionDigest, executed_transaction: &ExecutedTransaction, checkpoint_contents: CheckpointContents, + effects: TransactionEffects, ) -> Result { let transaction = executed_transaction .transaction() @@ -550,25 +585,6 @@ impl GrpcSource { }, ) })?; - let effects: TransactionEffects = executed_transaction - .effects() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Effects { - source: Box::new(source), - }, - ) - })? - .effects() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Effects { - source: Box::new(source), - }, - ) - })?; let events = if effects.events_digest().is_some() { executed_transaction .events() @@ -602,7 +618,7 @@ impl GrpcSource { impl Source for GrpcSource { async fn proof(&self, targets: &[SourceTarget]) -> Result { let mut selected_transaction = None; - let mut objects = Vec::new(); + let mut object_ids = Vec::new(); let mut events = Vec::new(); for target in targets.iter().copied() { @@ -610,10 +626,8 @@ impl Source for GrpcSource { SourceTarget::Transaction(transaction_digest) => { Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; } - SourceTarget::Object(object_ref) => { - let object = self.get_object(object_ref).await?; - Self::ensure_same_transaction(&mut selected_transaction, target, object.previous_transaction)?; - objects.push((object_ref, object)); + SourceTarget::Object(object_id) => { + object_ids.push(object_id); } SourceTarget::Event(event_id) => { Self::ensure_same_transaction(&mut selected_transaction, target, event_id.tx_digest)?; @@ -622,8 +636,47 @@ impl Source for GrpcSource { } } - let transaction_digest = selected_transaction.expect("ProofBuilder only calls Source with non-empty targets"); - let executed_transaction = self.get_transaction(transaction_digest).await?; + let (transaction_digest, executed_transaction, effects, objects) = + if let Some(transaction_digest) = selected_transaction { + let executed_transaction = self.get_transaction(transaction_digest).await?; + let effects = Self::parse_effects(transaction_digest, &executed_transaction)?; + let changed_objects = effects.all_changed_objects(); + let mut objects = Vec::with_capacity(object_ids.len()); + + for object_id in object_ids { + let object_ref = changed_objects + .iter() + .find_map(|(object_ref, _, _)| (object_ref.object_id == object_id).then_some(*object_ref)) + .ok_or_else(|| { + SourceError::object( + object_id, + SourceErrorKind::ObjectNotChangedByTransaction { transaction_digest }, + ) + })?; + objects.push(self.get_object(object_id, Some(object_ref)).await?); + } + + (transaction_digest, executed_transaction, effects, objects) + } else { + let mut objects = Vec::with_capacity(object_ids.len()); + for object_id in object_ids { + let (object_ref, object) = self.get_object(object_id, None).await?; + Self::ensure_same_transaction( + &mut selected_transaction, + SourceTarget::Object(object_id), + object.previous_transaction, + )?; + objects.push((object_ref, object)); + } + + let transaction_digest = + selected_transaction.expect("ProofBuilder only calls Source with non-empty targets"); + let executed_transaction = self.get_transaction(transaction_digest).await?; + let effects = Self::parse_effects(transaction_digest, &executed_transaction)?; + + (transaction_digest, executed_transaction, effects, objects) + }; + let chain_identifier = self.chain_identifier(transaction_digest).await?; let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { SourceError::transaction( @@ -638,7 +691,7 @@ impl Source for GrpcSource { .await?; let (checkpoint_summary, checkpoint_contents) = Self::parse_checkpoint(transaction_digest, &checkpoint)?; let transaction_proof = - Self::build_transaction_proof(transaction_digest, &executed_transaction, checkpoint_contents)?; + Self::build_transaction_proof(transaction_digest, &executed_transaction, checkpoint_contents, effects)?; let mut proof = Proof::new( chain_identifier, ProofTargets::new(), diff --git a/poi-rs/tests/golden.rs b/poi-rs/tests/golden.rs index 4cf07f3..ea596a7 100644 --- a/poi-rs/tests/golden.rs +++ b/poi-rs/tests/golden.rs @@ -11,13 +11,14 @@ const EVENT: &str = include_str!("fixtures/v1/event.json"); fn assert_version_one_compatibility(fixture: &str) -> Proof { let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); - let proof: Proof = serde_json::from_str(fixture).expect("proof fixture must deserialize"); + let proof = Proof::from_json_slice(fixture.as_bytes()).expect("proof fixture must deserialize"); ProofVerifier::new(&committee) .verify(&proof) .expect("proof fixture must verify offline"); assert_eq!( - serde_json::to_value(&proof).expect("proof fixture must serialize"), + serde_json::from_slice::(&proof.to_json_vec().expect("proof fixture must serialize")) + .expect("serialized proof must be valid JSON"), serde_json::from_str::(fixture).expect("proof fixture must be valid JSON") ); assert_eq!(proof.version(), ProofVersion::CURRENT); diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index 03af8e8..9d8b92c 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use iota_types::base_types::dbg_object_id; use iota_types::{digests::TransactionDigest, event::EventID, object::Object}; use poi_rs::{Proof, ProofBuilder, ProofBuilderError, Source, SourceError, SourceErrorKind, SourceTarget}; -use utils::{genesis_chain_identifier, grpc_client, staking_tx, start_test_cluster, transfer_tx}; +use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; struct RejectingSource; @@ -24,7 +24,7 @@ impl Source for RejectingSource { SourceTarget::Transaction(transaction_digest) => { SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound) } - SourceTarget::Object(object_ref) => SourceError::object(object_ref, SourceErrorKind::ObjectNotFound), + SourceTarget::Object(object_id) => SourceError::object(object_id, SourceErrorKind::ObjectNotFound), SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), _ => panic!("unsupported source target"), }) @@ -50,7 +50,7 @@ impl Source for RecordingSource { SourceTarget::Transaction(transaction_digest) => { SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound) } - SourceTarget::Object(object_ref) => SourceError::object(object_ref, SourceErrorKind::ObjectNotFound), + SourceTarget::Object(object_id) => SourceError::object(object_id, SourceErrorKind::ObjectNotFound), SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), _ => panic!("unsupported source target"), }) @@ -84,12 +84,8 @@ async fn builder_without_a_target_is_rejected() { #[tokio::test] async fn stacked_targets_are_deduplicated_in_one_source_request() { let transaction_digest = TransactionDigest::random(); - let object_a = Object::immutable_with_id_for_testing(dbg_object_id(1)) - .as_inner() - .object_ref(); - let object_b = Object::immutable_with_id_for_testing(dbg_object_id(2)) - .as_inner() - .object_ref(); + let object_a = dbg_object_id(1); + let object_b = dbg_object_id(2); let event_a = EventID { tx_digest: transaction_digest, event_seq: 0, @@ -162,10 +158,10 @@ async fn proof_uses_the_genesis_checkpoint_as_its_chain_identifier() { #[tokio::test] async fn unknown_object_returns_a_fetch_error() { let cluster = start_test_cluster().await; - let object_ref = Object::immutable_for_testing().as_inner().object_ref(); + let object_id = Object::immutable_for_testing().id(); let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) - .object(object_ref) + .object(object_id) .build() .await .unwrap_err(); @@ -173,7 +169,7 @@ async fn unknown_object_returns_a_fetch_error() { let ProofBuilderError::Source { source } = error else { panic!("missing object must return a source error"); }; - assert_eq!(source.target, SourceTarget::Object(object_ref)); + assert_eq!(source.target, SourceTarget::Object(object_id)); assert!(matches!(source.kind, SourceErrorKind::FetchObject { .. })); } @@ -199,14 +195,45 @@ async fn event_sequence_outside_the_transaction_is_rejected() { assert!(matches!(source.kind, SourceErrorKind::EventNotFound)); } +#[tokio::test] +async fn object_outside_the_event_transaction_is_rejected() { + let cluster = start_test_cluster().await; + let transfer = object_transfer_tx(&cluster).await; + let staking = staking_tx(&cluster).await; + let object_id = transfer.objects[1].object_id; + let event_id = EventID { + tx_digest: staking.digest, + event_seq: 0, + }; + + let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .object(object_id) + .event(event_id) + .build() + .await + .unwrap_err(); + + let ProofBuilderError::Source { source } = error else { + panic!("mixed transactions must return a source error"); + }; + assert_eq!(source.target, SourceTarget::Object(object_id)); + assert!(matches!( + source.kind, + SourceErrorKind::ObjectNotChangedByTransaction { transaction_digest } + if transaction_digest == staking.digest + )); +} + #[tokio::test] async fn object_targets_from_different_transactions_are_rejected() { let cluster = start_test_cluster().await; - let first = transfer_tx(&cluster).await; - let second = transfer_tx(&cluster).await; + let first = object_transfer_tx(&cluster).await; + let second = object_transfer_tx(&cluster).await; + let first_object_id = first.objects[1].object_id; + let second_object_id = second.objects[1].object_id; let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) - .objects([first.gas_object, second.gas_object]) + .objects([first_object_id, second_object_id]) .build() .await .unwrap_err(); @@ -214,7 +241,7 @@ async fn object_targets_from_different_transactions_are_rejected() { let ProofBuilderError::Source { source } = error else { panic!("mixed transactions must return a source error"); }; - assert_eq!(source.target, SourceTarget::Object(second.gas_object)); + assert_eq!(source.target, SourceTarget::Object(second_object_id)); assert!(matches!( source.kind, SourceErrorKind::TargetTransactionMismatch { mismatch } diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_of_inclusion.rs index aa75b07..8473ad5 100644 --- a/poi-rs/tests/proof_of_inclusion.rs +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -35,7 +35,7 @@ async fn object_proof_verifies_with_the_resolved_committee() { let client = grpc_client(&cluster); let proof = ProofBuilder::from_grpc_client(client.clone()) - .object(transfer.gas_object) + .object(transfer.gas_object.object_id) .build() .await .expect("object proof must be constructed"); @@ -44,6 +44,7 @@ async fn object_proof_verifies_with_the_resolved_committee() { .await .expect("checkpoint committee must resolve"); + assert_eq!(proof.target.objects[0].0, transfer.gas_object); ProofVerifier::new(&committee) .verify(&proof) .expect("object proof must verify"); @@ -81,7 +82,7 @@ async fn multiple_object_targets_share_one_verified_transaction_proof() { let client = grpc_client(&cluster); let proof = ProofBuilder::from_grpc_client(client.clone()) - .objects(transfer.objects) + .objects(transfer.objects.map(|object_ref| object_ref.object_id)) .build() .await .expect("stacked object proof must be constructed"); @@ -108,7 +109,7 @@ async fn object_and_event_targets_share_one_verified_transaction_proof() { }; let proof = ProofBuilder::from_grpc_client(client.clone()) - .object(staking.gas_object) + .object(staking.gas_object.object_id) .event(event_id) .build() .await @@ -119,6 +120,7 @@ async fn object_and_event_targets_share_one_verified_transaction_proof() { .expect("checkpoint committee must resolve"); assert_eq!(proof.transaction_proof.transaction.digest(), &staking.digest); + assert_eq!(proof.target.objects[0].0, staking.gas_object); assert_eq!(proof.target.objects.len(), 1); assert_eq!(proof.target.events.len(), 1); ProofVerifier::new(&committee) From 11fb1e0a7f216f3135a6fae27c726d899d102791 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 16 Jul 2026 15:46:00 +0300 Subject: [PATCH 20/21] feat: Refactor committee authentication logic and improve error handling --- poi-rs/src/committee.rs | 108 +++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 29 deletions(-) diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 88f5ac0..d579830 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -9,7 +9,8 @@ use iota_grpc_client::{ }; use iota_types::{ committee::{Committee, EpochId}, - messages_checkpoint::{CertifiedCheckpointSummary, EndOfEpochData}, + error::IotaError, + messages_checkpoint::CertifiedCheckpointSummary, }; use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; @@ -377,7 +378,7 @@ impl CommitteeResolver { .await?; let summary = self.certified_checkpoint_summary(target_epoch, sequence_number).await?; - Self::authenticate_and_store_next_committee(target_epoch, current_committee, &summary, cache).await + Self::authenticate_and_store_next_committee(target_epoch, current_committee, summary, cache).await } /// Fetches the checkpoint sequence number that closes an epoch. @@ -458,27 +459,41 @@ impl CommitteeResolver { /// Verifies an end-of-epoch summary before accepting its next committee. fn authenticate_next_committee( current_committee: &Committee, - summary: &CertifiedCheckpointSummary, + summary: CertifiedCheckpointSummary, ) -> Result { let sequence_number = summary.sequence_number; - summary.clone().try_into_verified(current_committee).map_err(|source| { + let summary_epoch = summary.epoch(); + if summary_epoch != current_committee.epoch { + return Err(CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { + epoch: current_committee.epoch, + sequence_number, + source: Box::new(IotaError::WrongEpoch { + expected_epoch: current_committee.epoch, + actual_epoch: summary_epoch, + }), + }); + } + + if summary.end_of_epoch_data.is_none() { + return Err(CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number }); + } + + let next_epoch = summary_epoch + .checked_add(1) + .ok_or(CommitteeResolutionErrorKind::NextEpochOverflow { epoch: summary_epoch })?; + + let verified = summary.try_into_verified(current_committee).map_err(|source| { CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { epoch: current_committee.epoch, sequence_number, source: Box::new(source), } })?; - - let Some(EndOfEpochData { - next_epoch_committee, .. - }) = &summary.end_of_epoch_data - else { - return Err(CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number }); - }; - let next_epoch = summary - .epoch() - .checked_add(1) - .ok_or(CommitteeResolutionErrorKind::NextEpochOverflow { epoch: summary.epoch() })?; + let next_epoch_committee = &verified + .end_of_epoch_data + .as_ref() + .expect("checked before signature verification") + .next_epoch_committee; Ok(Committee::new( next_epoch, @@ -490,7 +505,7 @@ impl CommitteeResolver { async fn authenticate_and_store_next_committee( target_epoch: EpochId, current_committee: &Committee, - summary: &CertifiedCheckpointSummary, + summary: CertifiedCheckpointSummary, cache: &dyn CommitteeCache, ) -> Result { let next_committee = Self::authenticate_next_committee(current_committee, summary) @@ -604,7 +619,7 @@ mod tests { let cache = RecordingCache::default(); let committee = - CommitteeResolver::authenticate_and_store_next_committee(4, ¤t_committee, &summary, &cache) + CommitteeResolver::authenticate_and_store_next_committee(4, ¤t_committee, summary, &cache) .await .unwrap(); @@ -619,7 +634,7 @@ mod tests { let wrong_committee = Committee::new(3, wrong_committee.voting_rights.iter().cloned().collect()); let cache = RecordingCache::default(); - let error = CommitteeResolver::authenticate_and_store_next_committee(4, &wrong_committee, &summary, &cache) + let error = CommitteeResolver::authenticate_and_store_next_committee(4, &wrong_committee, summary, &cache) .await .unwrap_err(); @@ -639,7 +654,25 @@ mod tests { let (current_committee, _, summary) = signed_end_of_epoch_summary(3, false); let cache = RecordingCache::default(); - let error = CommitteeResolver::authenticate_and_store_next_committee(4, ¤t_committee, &summary, &cache) + let error = CommitteeResolver::authenticate_and_store_next_committee(4, ¤t_committee, summary, &cache) + .await + .unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number: 42 } + )); + assert!(cache.stored().is_empty()); + } + + #[tokio::test] + async fn end_of_epoch_structure_is_checked_before_signatures() { + let (_, _, summary) = signed_end_of_epoch_summary(3, false); + 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 cache = RecordingCache::default(); + + let error = CommitteeResolver::authenticate_and_store_next_committee(4, &wrong_committee, summary, &cache) .await .unwrap_err(); @@ -650,19 +683,36 @@ mod tests { assert!(cache.stored().is_empty()); } + #[tokio::test] + async fn wrong_epoch_summary_does_not_advance_or_reach_the_cache() { + let (signing_committee, _, summary) = signed_end_of_epoch_summary(4, true); + let expected_committee = Committee::new(3, signing_committee.voting_rights.iter().cloned().collect()); + let cache = RecordingCache::default(); + + let error = CommitteeResolver::authenticate_and_store_next_committee(4, &expected_committee, summary, &cache) + .await + .unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { + epoch: 3, + sequence_number: 42, + .. + } + )); + assert!(cache.stored().is_empty()); + } + #[tokio::test] async fn overflowing_next_epoch_never_reaches_the_cache() { let (current_committee, _, summary) = signed_end_of_epoch_summary(EpochId::MAX, true); let cache = RecordingCache::default(); - let error = CommitteeResolver::authenticate_and_store_next_committee( - EpochId::MAX, - ¤t_committee, - &summary, - &cache, - ) - .await - .unwrap_err(); + let error = + CommitteeResolver::authenticate_and_store_next_committee(EpochId::MAX, ¤t_committee, summary, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -682,7 +732,7 @@ mod tests { 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(); + 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(); @@ -697,7 +747,7 @@ mod tests { 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(); + CommitteeResolver::authenticate_next_committee(¤t_committee, summary).unwrap(); let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); let resolver = CommitteeResolver::anchor(client, current_committee); let CommitteeResolution::Anchor { cache, .. } = &resolver.mode else { From b98d8a4fd6723f52ff68a16264c6ef5be2c7e1f9 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 20 Jul 2026 10:46:10 +0300 Subject: [PATCH 21/21] feat: Update CLI command name and enhance dependencies for Proof of Inclusion --- poi-rs/Cargo.toml | 7 +- poi-rs/src/bin/{iota-poi.rs => poi.rs} | 263 ++++++------------------- 2 files changed, 58 insertions(+), 212 deletions(-) rename poi-rs/src/bin/{iota-poi.rs => poi.rs} (60%) diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index 8e9859b..2322bd4 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true description = "Proof of Inclusion support for the IOTA Notarization Toolkit." [features] -cli = ["dep:anyhow", "dep:clap", "dep:iota-config", "dep:reqwest", "dep:tempfile"] +cli = ["dep:anyhow", "dep:clap", "dep:iota-config", "dep:reqwest", "serde_json/std"] [dependencies] anyhow = { workspace = true, optional = true } @@ -25,7 +25,6 @@ iota-types.workspace = true reqwest = { workspace = true, optional = true } serde.workspace = true serde_json = { workspace = true, features = ["alloc"] } -tempfile = { workspace = true, optional = true } thiserror.workspace = true tokio.workspace = true @@ -34,6 +33,6 @@ iota-config = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" test-cluster = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1", package = "test-cluster" } [[bin]] -name = "iota-poi" -path = "src/bin/iota-poi.rs" +name = "poi" +path = "src/bin/poi.rs" required-features = ["cli"] diff --git a/poi-rs/src/bin/iota-poi.rs b/poi-rs/src/bin/poi.rs similarity index 60% rename from poi-rs/src/bin/iota-poi.rs rename to poi-rs/src/bin/poi.rs index 7dc763b..1afee34 100644 --- a/poi-rs/src/bin/iota-poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -2,14 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 #![forbid(unsafe_code)] -#![deny(clippy::print_stderr, clippy::print_stdout)] use std::{ fs, - io::{self, Read, Write}, + io::{self, Write}, path::{Path, PathBuf}, - process::ExitCode, - time::Duration, }; use anyhow::{Context, Result, bail}; @@ -19,32 +16,28 @@ use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::ObjectId; use iota_types::{digests::TransactionDigest, event::EventID}; use poi_rs::{CommitteeResolver, Proof, ProofBuilder, ProofVerifier}; -use tempfile::NamedTempFile; -const STDIO_PATH: &str = "-"; -const GENESIS_CACHE_DIR: &str = "iota-poi"; -const GENESIS_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(30); -const MAX_GENESIS_BLOB_BYTES: usize = 64 * 1024 * 1024; +const GENESIS_CACHE_DIR: &str = "poi"; const MAINNET_GENESIS_URL: &str = "https://dbfiles.mainnet.iota.cafe/genesis.blob"; const TESTNET_GENESIS_URL: &str = "https://dbfiles.testnet.iota.cafe/genesis.blob"; const DEVNET_GENESIS_URL: &str = "https://dbfiles.devnet.iota.cafe/genesis.blob"; const CREATE_EXAMPLES: &str = r#"Examples: - iota-poi create --network mainnet --transaction TRANSACTION_DIGEST - iota-poi create --network testnet --object OBJECT_ID --output proof.json - iota-poi create --grpc-url http://localhost:9000 --event TRANSACTION_DIGEST:EVENT_SEQUENCE + poi create --network mainnet --transaction TRANSACTION_DIGEST + poi create --network testnet --object OBJECT_ID --output proof.json + poi create --grpc-url http://localhost:9000 --event TRANSACTION_DIGEST:EVENT_SEQUENCE The selected endpoint supplies untrusted proof material; it does not establish verification trust."#; const VERIFY_EXAMPLES: &str = r#"Examples: - iota-poi verify --network mainnet proof.json - iota-poi verify --network testnet --genesis trusted-genesis.blob proof.json - iota-poi verify --grpc-url http://localhost:9000 --genesis genesis.blob - + poi verify --network mainnet proof.json + poi verify --network testnet --genesis trusted-genesis.blob proof.json + poi verify --grpc-url http://localhost:9000 --genesis genesis.blob - Known networks download and cache their genesis blob automatically. An explicit --genesis path overrides the managed blob. The genesis blob is the trust anchor. The selected endpoint only supplies committee-walking data."#; #[derive(Debug, Parser)] #[command( - name = "iota-poi", + name = "poi", version, about = "Create and verify IOTA Proof of Inclusion proofs", long_about = "Create portable IOTA Proof of Inclusion proofs and verify them against committee history authenticated from a trusted genesis blob.", @@ -121,9 +114,17 @@ impl CreateArgs { .build() .await .context("failed to create proof")?; - let json = proof.to_json_vec().context("failed to encode proof as JSON")?; - write_output(output.as_deref(), &json) + match output.as_deref() { + Some(path) if path != Path::new("-") => { + let file = fs::File::create(path) + .with_context(|| format!("failed to create proof file '{}'", path.display()))?; + serde_json::to_writer_pretty(file, &proof) + .with_context(|| format!("failed to write proof JSON to '{}'", path.display())) + } + _ => serde_json::to_writer_pretty(io::stdout().lock(), &proof) + .context("failed to write proof JSON to stdout"), + } } } @@ -145,8 +146,14 @@ struct VerifyArgs { impl VerifyArgs { async fn execute(self) -> Result<()> { - let proof_bytes = read_input(&self.proof)?; - let proof = Proof::from_json_slice(&proof_bytes).context("failed to decode proof JSON")?; + let proof: Proof = if self.proof == Path::new("-") { + serde_json::from_reader(io::stdin().lock()).context("failed to read proof JSON from stdin")? + } else { + let file = fs::File::open(&self.proof) + .with_context(|| format!("failed to open proof file '{}'", self.proof.display()))?; + serde_json::from_reader(file) + .with_context(|| format!("failed to read proof JSON from '{}'", self.proof.display()))? + }; proof.validate().context("proof format is not supported")?; let genesis = match self.genesis.as_deref() { @@ -154,7 +161,7 @@ impl VerifyArgs { Genesis::load(path).with_context(|| format!("failed to load genesis blob '{}'", path.display()))? } None => { - download_or_load_genesis( + load_genesis( self.endpoint .network .context("a known network or explicit genesis blob is required for verification")?, @@ -174,7 +181,7 @@ impl VerifyArgs { ProofVerifier::new(&committee) .verify(&proof) .context("proof verification failed")?; - write_stdout(b"valid") + writeln!(io::stdout().lock(), "valid").context("failed to write verification result to stdout") } } @@ -240,165 +247,38 @@ impl Network { } } -async fn download_or_load_genesis(network: Network) -> Result { +async fn load_genesis(network: Network) -> Result { let path = iota_config_dir() .context("failed to locate the IOTA configuration directory")? .join(GENESIS_CACHE_DIR) .join(network.name()) .join(IOTA_GENESIS_FILENAME); - if path.is_file() { - if let Ok(genesis) = Genesis::load(&path) { - return Ok(genesis); - } - } - - let parent = path - .parent() - .context("managed genesis path does not have a parent directory")?; - fs::create_dir_all(parent) - .with_context(|| format!("failed to create genesis cache directory '{}'", parent.display()))?; - - let url = network.genesis_url(); - let response = reqwest::Client::builder() - .timeout(GENESIS_DOWNLOAD_TIMEOUT) - .user_agent(concat!("iota-poi/", env!("CARGO_PKG_VERSION"))) - .build() - .context("failed to configure the genesis download client")? - .get(url) - .send() - .await - .with_context(|| format!("failed to download {} genesis blob from '{url}'", network.name()))? - .error_for_status() - .with_context(|| format!("genesis server rejected the request to '{url}'"))?; - - if response - .content_length() - .is_some_and(|length| length > MAX_GENESIS_BLOB_BYTES as u64) - { - bail!("genesis blob from '{url}' exceeds the {MAX_GENESIS_BLOB_BYTES}-byte size limit"); - } + if !path.is_file() { + let parent = path + .parent() + .context("managed genesis path does not have a parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create genesis cache directory '{}'", parent.display()))?; - let bytes = response - .bytes() - .await - .with_context(|| format!("failed to read genesis blob from '{url}'"))?; - if bytes.len() > MAX_GENESIS_BLOB_BYTES { - bail!("genesis blob from '{url}' exceeds the {MAX_GENESIS_BLOB_BYTES}-byte size limit"); + let url = network.genesis_url(); + let bytes = reqwest::get(url) + .await + .with_context(|| format!("failed to download {} genesis blob from '{url}'", network.name()))? + .bytes() + .await + .with_context(|| format!("failed to read genesis blob from '{url}'"))?; + fs::write(&path, bytes).with_context(|| format!("failed to cache genesis blob at '{}'", path.display()))?; } - let mut temporary = NamedTempFile::new_in(parent) - .with_context(|| format!("failed to create a temporary genesis file in '{}'", parent.display()))?; - temporary - .write_all(&bytes) - .with_context(|| format!("failed to write downloaded {} genesis blob", network.name()))?; - temporary - .as_file() - .sync_all() - .with_context(|| format!("failed to flush downloaded {} genesis blob", network.name()))?; - let genesis = Genesis::load(temporary.path()) - .with_context(|| format!("downloaded {} genesis blob from '{url}' is invalid", network.name()))?; - temporary - .persist(&path) - .map_err(|error| error.error) - .with_context(|| format!("failed to cache genesis blob at '{}'", path.display()))?; - - Ok(genesis) + Genesis::load(&path).with_context(|| format!("failed to load genesis blob '{}'", path.display())) } #[tokio::main(flavor = "current_thread")] -async fn main() -> ExitCode { - match run().await { - Ok(()) => ExitCode::SUCCESS, - Err(error) if is_broken_pipe(&error) => ExitCode::SUCCESS, - Err(error) => { - report_error(&error); - ExitCode::FAILURE - } - } -} - -async fn run() -> Result<()> { +async fn main() -> Result<()> { Cli::parse().command.execute().await } -fn report_error(error: &anyhow::Error) { - let _ = writeln!(io::stderr().lock(), "error: {error:#}"); -} - -fn is_broken_pipe(error: &anyhow::Error) -> bool { - let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(error.as_ref()); - - while let Some(error) = cause { - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe) - { - return true; - } - - cause = error.source(); - } - - false -} - -fn read_input(path: &Path) -> Result> { - if is_stdio(path) { - let mut bytes = Vec::new(); - io::stdin() - .lock() - .read_to_end(&mut bytes) - .context("failed to read proof JSON from stdin")?; - return Ok(bytes); - } - - fs::read(path).with_context(|| format!("failed to read proof JSON from '{}'", path.display())) -} - -fn write_output(path: Option<&Path>, bytes: &[u8]) -> Result<()> { - match path { - None => write_stdout(bytes), - Some(path) if is_stdio(path) => write_stdout(bytes), - Some(path) => write_file_atomically(path, bytes), - } -} - -fn write_stdout(bytes: &[u8]) -> Result<()> { - write_json(io::stdout().lock(), bytes).context("failed to write to stdout") -} - -fn write_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> { - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let mut temporary = NamedTempFile::new_in(parent) - .with_context(|| format!("failed to create a temporary output file in '{}'", parent.display()))?; - - write_json(&mut temporary, bytes) - .with_context(|| format!("failed to write proof JSON for '{}'", path.display()))?; - temporary - .as_file() - .sync_all() - .with_context(|| format!("failed to flush proof JSON for '{}'", path.display()))?; - temporary - .persist(path) - .map(|_| ()) - .map_err(|error| error.error) - .with_context(|| format!("failed to atomically replace output file '{}'", path.display())) -} - -fn write_json(mut writer: impl Write, bytes: &[u8]) -> io::Result<()> { - writer.write_all(bytes)?; - writer.write_all(b"\n")?; - writer.flush() -} - -fn is_stdio(path: &Path) -> bool { - path == Path::new(STDIO_PATH) -} - fn parse_transaction_digest(value: &str) -> Result { value .parse() @@ -438,7 +318,7 @@ mod tests { #[test] fn create_requires_a_target() { - let error = Cli::try_parse_from(["iota-poi", "create", "--network", "mainnet"]) + let error = Cli::try_parse_from(["poi", "create", "--network", "mainnet"]) .expect_err("create without a target must fail"); assert!(error.to_string().contains("--transaction ")); @@ -448,7 +328,7 @@ mod tests { fn create_accepts_mixed_targets() { let event = format!("{DIGEST}:0"); let cli = Cli::try_parse_from([ - "iota-poi", + "poi", "create", "--network", "testnet", @@ -472,7 +352,7 @@ mod tests { #[test] fn endpoint_selection_is_exclusive() { let error = Cli::try_parse_from([ - "iota-poi", + "poi", "create", "--network", "mainnet", @@ -488,7 +368,7 @@ mod tests { #[test] fn known_network_verification_manages_genesis_automatically() { - let cli = Cli::try_parse_from(["iota-poi", "verify", "--network", "mainnet", "proof.json"]) + let cli = Cli::try_parse_from(["poi", "verify", "--network", "mainnet", "proof.json"]) .expect("known network must not require an explicit genesis blob"); let Command::Verify(args) = cli.command else { @@ -499,14 +379,8 @@ mod tests { #[test] fn custom_endpoint_verification_requires_genesis() { - let error = Cli::try_parse_from([ - "iota-poi", - "verify", - "--grpc-url", - "http://localhost:9000", - "proof.json", - ]) - .expect_err("custom endpoint must require an explicit genesis blob"); + let error = Cli::try_parse_from(["poi", "verify", "--grpc-url", "http://localhost:9000", "proof.json"]) + .expect_err("custom endpoint must require an explicit genesis blob"); assert!(error.to_string().contains("--genesis ")); } @@ -520,7 +394,7 @@ mod tests { #[test] fn invalid_event_id_reports_the_required_format() { - let error = Cli::try_parse_from(["iota-poi", "create", "--network", "mainnet", "--event", "not-an-event"]) + let error = Cli::try_parse_from(["poi", "create", "--network", "mainnet", "--event", "not-an-event"]) .expect_err("invalid event ID must fail"); assert!(error.to_string().contains("TRANSACTION_DIGEST:EVENT_SEQUENCE")); @@ -528,15 +402,8 @@ mod tests { #[test] fn invalid_object_id_reports_the_invalid_value() { - let error = Cli::try_parse_from([ - "iota-poi", - "create", - "--network", - "mainnet", - "--object", - "not-an-object", - ]) - .expect_err("invalid object ID must fail"); + let error = Cli::try_parse_from(["poi", "create", "--network", "mainnet", "--object", "not-an-object"]) + .expect_err("invalid object ID must fail"); assert!(error.to_string().contains("invalid object ID 'not-an-object'")); } @@ -558,24 +425,4 @@ mod tests { assert!(create.contains("does not establish verification trust")); assert!(verify.contains("genesis blob is the trust anchor")); } - - #[test] - fn file_output_is_newline_terminated_and_replaced_atomically() { - let directory = tempfile::tempdir().expect("temporary directory must be created"); - let output = directory.path().join("proof.json"); - - write_output(Some(&output), br#"{"version":1}"#).expect("initial proof must be written"); - assert_eq!(fs::read(&output).unwrap(), b"{\"version\":1}\n"); - - write_output(Some(&output), br#"{"version":2}"#).expect("proof must be replaced"); - assert_eq!(fs::read(&output).unwrap(), b"{\"version\":2}\n"); - } - - #[test] - fn broken_pipe_is_treated_as_a_successful_pipeline_shutdown() { - let error = anyhow::Error::new(io::Error::new(io::ErrorKind::BrokenPipe, "reader closed")) - .context("failed to write proof"); - - assert!(is_broken_pipe(&error)); - } }