From 19ef8ffa0fee01cdc8427af4ea2e6d93effae3f1 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Jul 2026 10:43:48 +0300 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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)); - } } From e1fd7e3c8fb85055c22b89b90e854597195b9f5a Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 13:26:06 +0300 Subject: [PATCH 22/41] feat: add tests for NodePoiSource and WASM integration - Implement tests for NodePoiSource to validate BCS evidence retrieval. - Create WASM source tests to ensure transaction evidence handling. - Introduce TypeScript configuration for building WASM bindings. - Update Rust dependencies and features for native gRPC support. - Refactor source trait to return decoded transaction and checkpoint evidence. - Enhance proof builder to handle stacked targets and deduplicate requests. --- Cargo.toml | 6 +- bindings/wasm/build/node.js | 27 +- bindings/wasm/poi_wasm/.cargo/config.toml | 5 + bindings/wasm/poi_wasm/.gitignore | 4 + bindings/wasm/poi_wasm/Cargo.toml | 39 + bindings/wasm/poi_wasm/README.md | 85 ++ .../wasm/poi_wasm/examples/service-info.ts | 13 + bindings/wasm/poi_wasm/grpc/buf.gen.yaml | 14 + bindings/wasm/poi_wasm/grpc/iota-ledger.binpb | Bin 0 -> 150329 bytes .../wasm/poi_wasm/grpc/iota-schema.lock.json | 9 + bindings/wasm/poi_wasm/package-lock.json | 1094 +++++++++++++++++ bindings/wasm/poi_wasm/package.json | 39 + bindings/wasm/poi_wasm/rust-toolchain.toml | 5 + .../wasm/poi_wasm/scripts/generate-grpc.mjs | 49 + .../poi_wasm/scripts/update-iota-schema.mjs | 77 ++ bindings/wasm/poi_wasm/src/client.ts | 44 + .../grpc/generated/google/rpc/status_pb.ts | 76 ++ .../grpc/generated/iota/grpc/options_pb.ts | 50 + .../src/grpc/generated/iota/grpc/v1/bcs_pb.ts | 37 + .../generated/iota/grpc/v1/checkpoint_pb.ts | 140 +++ .../grpc/generated/iota/grpc/v1/epoch_pb.ts | 222 ++++ .../grpc/generated/iota/grpc/v1/event_pb.ts | 109 ++ .../grpc/generated/iota/grpc/v1/filter_pb.ts | 668 ++++++++++ .../iota/grpc/v1/ledger_service_pb.ts | 764 ++++++++++++ .../grpc/generated/iota/grpc/v1/object_pb.ts | 70 ++ .../generated/iota/grpc/v1/signatures_pb.ts | 83 ++ .../generated/iota/grpc/v1/transaction_pb.ts | 208 ++++ .../grpc/generated/iota/grpc/v1/types_pb.ts | 244 ++++ bindings/wasm/poi_wasm/src/index.ts | 15 + bindings/wasm/poi_wasm/src/lib.rs | 16 + bindings/wasm/poi_wasm/src/node-poi-source.ts | 263 ++++ bindings/wasm/poi_wasm/src/node_source.rs | 444 +++++++ bindings/wasm/poi_wasm/src/proof.rs | 78 ++ bindings/wasm/poi_wasm/src/versioned.rs | 28 + bindings/wasm/poi_wasm/tests/client.test.ts | 119 ++ .../poi_wasm/tests/node-poi-source.test.ts | 189 +++ .../wasm/poi_wasm/tests/wasm-source.test.ts | 41 + bindings/wasm/poi_wasm/tsconfig.build.json | 14 + bindings/wasm/poi_wasm/tsconfig.json | 18 + poi-rs/Cargo.toml | 21 +- poi-rs/README.md | 12 +- poi-rs/src/builder.rs | 170 ++- poi-rs/src/lib.rs | 8 +- poi-rs/src/source.rs | 481 +++----- poi-rs/tests/proof_builder.rs | 108 +- 45 files changed, 5856 insertions(+), 350 deletions(-) create mode 100644 bindings/wasm/poi_wasm/.cargo/config.toml create mode 100644 bindings/wasm/poi_wasm/.gitignore create mode 100644 bindings/wasm/poi_wasm/Cargo.toml create mode 100644 bindings/wasm/poi_wasm/README.md create mode 100644 bindings/wasm/poi_wasm/examples/service-info.ts create mode 100644 bindings/wasm/poi_wasm/grpc/buf.gen.yaml create mode 100644 bindings/wasm/poi_wasm/grpc/iota-ledger.binpb create mode 100644 bindings/wasm/poi_wasm/grpc/iota-schema.lock.json create mode 100644 bindings/wasm/poi_wasm/package-lock.json create mode 100644 bindings/wasm/poi_wasm/package.json create mode 100644 bindings/wasm/poi_wasm/rust-toolchain.toml create mode 100644 bindings/wasm/poi_wasm/scripts/generate-grpc.mjs create mode 100644 bindings/wasm/poi_wasm/scripts/update-iota-schema.mjs create mode 100644 bindings/wasm/poi_wasm/src/client.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/google/rpc/status_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/options_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/bcs_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/checkpoint_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/epoch_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/event_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/filter_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/ledger_service_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/object_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/signatures_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/transaction_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/types_pb.ts create mode 100644 bindings/wasm/poi_wasm/src/index.ts create mode 100644 bindings/wasm/poi_wasm/src/lib.rs create mode 100644 bindings/wasm/poi_wasm/src/node-poi-source.ts create mode 100644 bindings/wasm/poi_wasm/src/node_source.rs create mode 100644 bindings/wasm/poi_wasm/src/proof.rs create mode 100644 bindings/wasm/poi_wasm/src/versioned.rs create mode 100644 bindings/wasm/poi_wasm/tests/client.test.ts create mode 100644 bindings/wasm/poi_wasm/tests/node-poi-source.test.ts create mode 100644 bindings/wasm/poi_wasm/tests/wasm-source.test.ts create mode 100644 bindings/wasm/poi_wasm/tsconfig.build.json create mode 100644 bindings/wasm/poi_wasm/tsconfig.json diff --git a/Cargo.toml b/Cargo.toml index aa2c6de..068fffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,11 @@ rust-version = "1.85" [workspace] resolver = "2" members = ["audit-trail-rs", "examples", "notarization-rs", "poi-rs"] -exclude = ["bindings/wasm/notarization_wasm", "bindings/wasm/audit_trail_wasm"] +exclude = [ + "bindings/wasm/notarization_wasm", + "bindings/wasm/audit_trail_wasm", + "bindings/wasm/poi_wasm", +] [workspace.dependencies] anyhow = "1.0" diff --git a/bindings/wasm/build/node.js b/bindings/wasm/build/node.js index 95c58bd..2ad06fa 100644 --- a/bindings/wasm/build/node.js +++ b/bindings/wasm/build/node.js @@ -4,6 +4,7 @@ const { lintAll } = require("./lints"); const generatePackage = require("./utils/generatePackage"); const artifact = process.argv[2]; +const skipFetchPolyfill = process.argv.includes("--skip-fetch-polyfill"); const RELEASE_FOLDER = path.join(__dirname, "..", artifact, "node"); const entryFilePathNode = path.join(RELEASE_FOLDER, `${artifact}.js`); @@ -12,10 +13,11 @@ console.log(`[build/node.js] Processing entryFile '${entryFilePathNode}' for art lintAll(entryFileNode); -// Add node-fetch polyfill (https://github.com/seanmonstar/reqwest/issues/910). -let changedFileNode = entryFileNode.replace( - "let imports = {};", - `if (!globalThis.fetch) { +if (!skipFetchPolyfill) { + // Add node-fetch polyfill (https://github.com/seanmonstar/reqwest/issues/910). + const changedFileNode = entryFileNode.replace( + "let imports = {};", + `if (!globalThis.fetch) { const fetch = require('node-fetch') globalThis.Headers = fetch.Headers globalThis.Request = fetch.Request @@ -23,15 +25,16 @@ let changedFileNode = entryFileNode.replace( globalThis.fetch = fetch } let imports = {};`, -); + ); -fs.writeFileSync( - entryFilePathNode, - changedFileNode, -); -console.log( - `[build/node.js] Added node-fetch polyfill to entryFile '${entryFilePathNode}'. Starting generatePackage().`, -); + fs.writeFileSync( + entryFilePathNode, + changedFileNode, + ); + console.log(`[build/node.js] Added node-fetch polyfill to entryFile '${entryFilePathNode}'.`); +} else { + console.log(`[build/node.js] Skipped node-fetch polyfill for artifact '${artifact}'.`); +} // Generate `package.json`. const newPackage = generatePackage({ diff --git a/bindings/wasm/poi_wasm/.cargo/config.toml b/bindings/wasm/poi_wasm/.cargo/config.toml new file mode 100644 index 0000000..18a827b --- /dev/null +++ b/bindings/wasm/poi_wasm/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "wasm32-unknown-unknown" + +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/bindings/wasm/poi_wasm/.gitignore b/bindings/wasm/poi_wasm/.gitignore new file mode 100644 index 0000000..a706174 --- /dev/null +++ b/bindings/wasm/poi_wasm/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.cache/ +target/ +node/ diff --git a/bindings/wasm/poi_wasm/Cargo.toml b/bindings/wasm/poi_wasm/Cargo.toml new file mode 100644 index 0000000..7002a6a --- /dev/null +++ b/bindings/wasm/poi_wasm/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "poi_wasm" +version = "0.1.0-alpha" +authors = ["IOTA Stiftung"] +edition = "2024" +homepage = "https://www.iota.org" +keywords = ["iota", "proof", "inclusion", "wasm"] +license = "Apache-2.0" +publish = false +readme = "README.md" +repository = "https://github.com/iotaledger/notarization" +rust-version = "1.85" +description = "Node.js WASM bindings for the IOTA Proof of Inclusion Package." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +async-trait = { version = "0.1", default-features = false } +bcs = "0.1.6" +console_error_panic_hook = "0.1" +iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11", default-features = false, features = ["serde"] } +iota-types = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } +js-sys = "=0.3.85" +poi-rs = { path = "../../../poi-rs", default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6.5" +wasm-bindgen = "=0.2.108" +wasm-bindgen-futures = "=0.4.58" + +[target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] +getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } + +[profile.release] +lto = true +opt-level = "s" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md new file mode 100644 index 0000000..e8419e3 --- /dev/null +++ b/bindings/wasm/poi_wasm/README.md @@ -0,0 +1,85 @@ +# Proof of Inclusion Node.js Package + +This package generates a typed Node.js client for IOTA's `LedgerService` and +connects it to `poi-rs` compiled as WebAssembly. + +The generated client uses: + +- protobuf definitions pinned to the same `iota-rust-sdk` revision as the Rust + workspace; +- Protobuf-ES generated messages and service descriptors; +- ConnectRPC's native Node.js gRPC transport over HTTP/2. + +## Schema workflow + +[`grpc/iota-schema.lock.json`](grpc/iota-schema.lock.json) records the approved +repository, exact Git revision and SHA-256 digest of the committed Buf image. +Normal generation does not access the network. + +To intentionally download a different upstream schema: + +```sh +npm run grpc:schema:update -- +``` + +To regenerate the TypeScript client from the committed schema image: + +```sh +npm run grpc:generate +``` + +Review the lock file, Buf image and generated TypeScript changes together. + +## Source usage + +```ts +import { NodePoiSource } from "./src/index.js"; + +const source = new NodePoiSource("https://grpc.testnet.iota.cafe:443"); +const chainIdentifier = await source.chainIdentifier(); +const transaction = await source.transaction(transactionDigest); +const object = await source.object(objectId, version); +const checkpoint = await source.checkpoint(42n); +``` + +`NodePoiSource` returns only opaque BCS bytes and checkpoint sequence numbers. +The WASM `Source` adapter decodes those values into existing IOTA Rust types, +then delegates target resolution and proof construction to `poi-rs`. + +## Proof construction + +```ts +import { NodePoiSource, ProofBuilder } from "./src/index.js"; + +const source = new NodePoiSource("https://grpc.testnet.iota.cafe:443"); +const proof = await new ProofBuilder(source) + .transaction(transactionDigest) + .build(); + +console.log(proof.toJSON()); +``` + +The same builder also exposes `object(objectId)` and +`event(transactionDigest, eventSequence)`. All 64-bit values use JavaScript +`bigint`. + +The lower-level generated client remains available through +`createIotaGrpcClient` when direct access to another `LedgerService` method is +needed. + +## Verification + +```sh +npm install +npm run verify +``` + +Verification regenerates the Node.js protobuf client from the committed schema +image, builds `poi-rs` for `wasm32-unknown-unknown`, type-checks the TypeScript +boundary, and runs the tests. The tests use an in-memory generated service +implementation and do not require a running IOTA node. To query a live +endpoint: + +```sh +npm run example:service-info -- https://grpc.testnet.iota.cafe:443 +``` diff --git a/bindings/wasm/poi_wasm/examples/service-info.ts b/bindings/wasm/poi_wasm/examples/service-info.ts new file mode 100644 index 0000000..036e23d --- /dev/null +++ b/bindings/wasm/poi_wasm/examples/service-info.ts @@ -0,0 +1,13 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { NodePoiSource } from "../src/index.js"; + +const endpoint = process.argv[2] ?? "https://grpc.testnet.iota.cafe:443"; +const source = new NodePoiSource(endpoint); +const chainIdentifier = await source.chainIdentifier(); + +console.log({ + endpoint, + chainIdentifier: Buffer.from(chainIdentifier).toString("hex"), +}); diff --git a/bindings/wasm/poi_wasm/grpc/buf.gen.yaml b/bindings/wasm/poi_wasm/grpc/buf.gen.yaml new file mode 100644 index 0000000..f7e5db9 --- /dev/null +++ b/bindings/wasm/poi_wasm/grpc/buf.gen.yaml @@ -0,0 +1,14 @@ +version: v2 +clean: true + +plugins: + - local: protoc-gen-es + out: src/grpc/generated + opt: + - target=ts + - import_extension=js + include_imports: true + +inputs: + - binary_image: grpc/iota-ledger.binpb + diff --git a/bindings/wasm/poi_wasm/grpc/iota-ledger.binpb b/bindings/wasm/poi_wasm/grpc/iota-ledger.binpb new file mode 100644 index 0000000000000000000000000000000000000000..4b1c09ac790fb0c9c6784df1ea92964e4f53038a GIT binary patch literal 150329 zcmeFa3v^t^dFRP~5Ckqr5NuKs)p}jEL=lugfCTk2W!V8ikb+DCOaqiGc^$9`G(bp= zZWs-Sq7^xb<8d52iC=N-*zsG(>Yb&Z6_IL z$Lq(LS2p|me^qtwZ2;86nar6pD;|mLy0>oCS6_Yg)%U8eqW3%)#fz=hVxvC1vf5f} zU0h!nURbI(=4Y2{?I(xqS!uVc;Y!q~+#2PHd~jQ4lF^na`E5h za-YZ-PMGqx}0T8;R``ocnewH+UhU0Vm+@qBHq7B4l|>Z@~?0ID7@v{sjEYf(JbTDh{i zw0LPP9yxO4e*Zx{(VQEKHq6@#li z7++pmyA-!p?cdfqa4xszmll@hYHDFHuC3N%dR|^yTdU8m%B!uN?;@{kd}kVC}fI5Fnas&DZ1Q_4XPgU#kJBTCH7dJyoYg zkFh9jw$_&B>Vv#oYR3)6s1~w4GSW_PK*U_5wzOPd1(RC?L1!6U1RxmW{5q&g;D}N{ z{zh;_ng_Ky-3DkT z)Xbx!)$y3$=c-eWOq?7)8J~EJm*er+)Vas16Q>`Vi65Feb8@^o9gj|)hVN znW^e@6jw&4`LtqhMkgPO$6tG{IzBxePgUcIv**rC&>GEGM<-_{#-|74iOI1u=TA;d zo*s;8A)cI?iQ+R8XD4Q;H#0S8-ERIao;nqu9j}f(L^Go&CeBREJZ2r9nwXhXXQ!sB zQ9K%-8?DYvjGaF-T8+=0ub!Kl9*;G$lM~ZpXGSN^j-MQ&fBKBa9~qyViKibLJ#(gW zpiw;a=;U}+&}WAkpBM+q(GzFJ)fHVoMx)j7u^A00{WS*i0PxIU6i=TUADiG8_!tKb zqt(X-eWTOkKYX5Ayo^tdo*g|sJ{=Fd5LTF&vGdjOvw|8_OrJk7Ju@+Ler7yAJvDXG zkUc$KePm*6eENa-%#_f7Dn36w4j?B-XGX1S+5la=X88M+f*FQB%C{#C!&8$Qt!QO@s`{9k6&`E`2IEH`8s|9^J?XGDDuhf!9Ah)tYIF## zFwyBa+M=2;gIe6K8zXLCS-#k6 zu;Ok1k8MvM)CLpRpRO&hH0og^us6r!%EC~s;<$499_AWpcUxa=!a6Ui`l1lW zzUsCB5ao7V|GF8kuOLUjfJ=CAJxvh!$`?{b`^2rK&JbSjo zKifp4(H{gyI2<9(J?ij4C7V*%zM!mMmpo0KyD;!oT`Pal`WLp*ia=&M+AVRIde^9a} znh^^L4g|&>0_rGE5Eq(~YHb?Ua2OvnBgO0u>tKC8f;?co5^c3gX}i58i#_jH_*B4o z+m=gaE&*;Mn@5^4w8fTqJ~JmN-nyM1GIk@z z+W|Ut92a)FsfO)2j_V=x$1(|!bns(ga6V=l*n0G_O=vq_Z?3i0=PseOy7iktN9QZG zwiF9ZMGGCK-kw80bzgf}JqT`24CF<$7EsU$9;-Cj-yQFKD!a*uWD*9lnJcBk(X7=r z<9?7ed^uo4B&UhivOpg)LNjpcPeba$_QoC@4Y~PAW@ji)_y(BU^bPu$w#2|JJ+*G; zlJNx-lP+vzlN+|(bck1LNeInQpueSO%3*`^@cbjn{sKS#p0p6u^x zdL+{{XlMqxB!k)|CtG;&_H{_S7b}AjeCH~!2NCv_FbJ6^0>Q;ZPR?w~n}(i%gamTc zu@A;s7)OCnVt8tF5Zgs6kTHin(^+lA8(2}RdS;rv7biOSodj9G;foUlq0g}?TYgO> zLd*nWM+^?(#BZ3`#+=~dX(tg@ga(f}L|I#Po_^gfKsTFZ9N(6&=!cTBMdam&5QgFN z)Yu45vfI0TH!!Qg1S7Q|Nr{%*Yc(2DRX<;;CCx^cww8fw!4g&JunzE%m%5h7_CAX4u;4mpHiJ+5dQc^}*;}Sf6lc1CGD`VKqj1##W z0@|?n9)w^|KDOZ)6acc`yj(+Z$!yWmSUg*tgNjz4!is3{ z`rVw@9!46jH7?h#gpKct7V*QMiWx@m_NdmfNZLwDh9X{O>-G0}(Cx%%F$O!9go<-Q4*Z!d{vU2Z}2yP~dZ7u3JDo)3Rc%UWiL>n(c zTxx0iHBD9UOL}66x7~&byV-_2IN{1EDG$xeoHNOl8Z%V3vS6^a6^J$?mpicrWmEQp z3>-26+uHYQ{oLpb7Ly>ILb~U$#AFw98{N6RaK-i5hAx|!h#Aa)Dw;vrHdl#6l0UT9 zEv!TS4#91&7n|BV9@J*KIb~|hS%e7*Y!gjWtVKo{SRpmjv}qPPhQgqV2AY^DF$ivw zVvX}VOL)7!B7xLl6oP9lFV|3P)e*{rY_^Qcgl>Ig5*-RStk_6-tu-ubD`a%JzIu3$ z>Zp#jWf7v)V%SWJCqj{?Gr0`vt<9DZVQQyONhpz%6FoS*JGa_mJ=Rm@_dIJycwCJi zjE_DLHpO$7q*t!k-w(zk{<+^OLtFM~qF#F(Vai@<)UM3h=(A1w_qF+X)_a@Y(pGIY z;H6rr+1b#{UhoiKu*KbIwVu?>w2?`JG+}#50*2q0D;m;J78q}qirxHSs+~JW32mof~Hly(b;3GOcF&ourY$jooxwY_!!*HS2*qMVXFH)f{|d z6&2i}#H>3lRc*&l$g2V*tM$coR3cwtC|Smib!?)H0CSkYX*1!S_2!;rB-RN=h4$L4 z``r}AzK_Jv+m{~?^Hl%N9vWPr$gl}PO{SYchxL|dutc%YIFx#HL}b1$9p5Pvtdl?9fwgu-e4c=qY0 z?5yB;1V2h}viWgtkkIH=99-&~PR@>2PoJ0B|3bRm2_$_bt#ZA&jCNGJ*r+qU z-l$OAmMay%o$KBc?TWS+x8=7LOU3Wx@=-Jx6;-s-jpdtdR>kERVrFs!ru@g-RlPT9 zi>AMmD_s|wE~r?(;bNEZgJb{v`-Nh6ZvVFED!*8o zd+OKLPxtUZ>etp!_wbI?udSc%;lb3et)K4UK&U*QpWb^U8DFD?<1Ri^udO7H6b)2qV-muOP$57F z*^_}ZTXGz%;OTXkSd-`|L9$t|n@lQ;iUQ|Qqg-s%AS$OBTrj!beHdRlv$E8rS=vzR zuOE2+5IN1~u+=nee+DG2T1+0DnofP>$dRLm?Z25LM~>Tnk89|!;LqWsBZrS2of$cH z{GM0v-;kc!pT~#d6Ih3sNc4T?+qqtBh}JqWby&um?RE2IF>s2~T)a;2=GTQ##jmTL z8jIp%$Bw-s9n0m*mxq?>YYP_7xVkVm%>Sw}wD$DcA;lL4OM~}2jXQ;3N}>N5A3cte zw~U{+o>>bvV!~1;UK?K!W(E#fY=dIsW`f8W>xBu+UL8xyLrCnio~8%v^U3pP&Kx?l zWj&+!fg`+3VH$ZMn6RCx-PXcZ$7@gd9y_YvvG&wpYyhdtA81HV z4Xr(;0Y2Y|)~piB`y4TiN1>jM5gxnh2p?T)9vg`-oUX4;U%}nFJi}l{+ov$G=vQX2 zrzXyf&k!vZFRb}tUhSJZ7S;l!=ZU1c_iivWhb`d2cwk__Jvp?nHZ*_vAsEL=h(fR|;b9mk@Xog`!v$zkE0m#Tcvu@xt*_v?)X{r4FC#Y# zw5HFG-h21mci(sHUYhYFgpzEg-}B9-r#-I|GuyZrvE%9zyD3x}9PseMLuCZ**L z48V&ua$nO8{g9`7Ve-(>MYTN8fr5Kp00k4~Pgxtn5s(?Rf65kd8R$FeJnukLxLGfe{? zkmThM%YG~+6W%L`%Z$}$F}kql@W7iQW!ffg*f|>#JUE*bRMcsyb|&gzDbn`yl6D{0 zl_VGTwI%1^z~f^8Xr))4k<{?);b)eKAiKoB@bYJ8p3&C*?D1!iAov>&@a*e`o{_L5 ze*5g}AII|MjOOlZHROFU^`~W1mHpmH0)*<}`dM03pa-4=nW$Y)gRyl*jEU=vvU+pI zE$hle1;S4ibMJ#5Ls@$? zYMzFp48g9Q5zLVX25sWUml$ZU|9MohYnd!*oxvubP$Rm&max{rf^6Q+1eO)9x#QM~?F7_vk0YfT=%rI*6HG$jK!UFuq zgtb#hgNZAnGIHeTecBt2-ZOLL=<#Dmjvu{e=*Usf=ct=4M&&SFF)=4d-B#23$4!<< z&G+CNr!`5nT0k)7X(CcqSU(c{vw~VJ*5(*O&_pisc6wrW^k{;#Jj7@SxE)`&)|!}} znzltYa3~e&k`o+5%dI!U+-oM=)tiUUPY+{XX%9bIzc`$NGF+{5qJyE;hfgLZQh80c8X|+bfC#pr zHZEjF1k^(-jEbGC$*^^HX>GZ2oBaykLSZ;aooiPw0v_d^jSn7p?C^o* z!w2SP4m@=Hz}e#mriTtJJbny!Lo#)be5oJ*V@Ca`I-?c5eBcj0RDxf z8M=Uk;EOhJ71?iIKj2Q)aH!@9zB=q6G>1OZx>#qsTpO;fEZLlBSgK_s8g|n@yt#QB zs*vmYh~h)SW9yC%Ch+sPNjtBajWt2PbAl{z(Yt#BX7>c}fo zbEUN^XRBsV3itfF^Mn&QhS!JHR8!b<)AbAu_{{?ub~3=@0q`+*7BY~6XijUQ%h0tg zw!4`fiGdM?_%zcto#_Aqlgh3IV&rTSsD&No&&?UC>!AN%rd|9%nzIDmIm2N0u7F?A z{mxwfo=pyxKN^az-{@e$DRed0%Dquxw0Wg;ebg;K)$ICe!@Mdz)g3CIUv0>%V%RXR z%Fe1QoOo*MBY@pB7ThU=4h)XOJ^$f9pj)~4V$b}2dC+-LKK`;7IJC*l33Bakz-abF0DzJM}+a(q@DdF({H8}psa1F~ zvh4bb1TRU|EBGgd*PHr>nC>e<8gk|g6SWD2T!;xyZ7i*E#C^RP{4s?5&%5L4*pR*W zjSavlPbM7-OtmRc1yIJ8eop;KhxJ_($m^q_=y~j}yHs6&PPJM9(ZYl^G&JPxw`#K1 zyy8pz8d7Vgi0B)jYB1qa-!(x<>3}Mow?WRd2JTR;LuuerQd99!0Y+@=&kLh8rxyVv zfCKK3(EGf=VXlmuTSuo&f>}u1{=VmlJ zO1++NLpwP>JyxAK$Khno;u-aD_K??Z;xpf4mV`JmU}I%|Ua(KMJUJRI9)B>tW98xy za6dyk9{=hIhvw>P%xivAVX}TX+bL6`*S9_xHo@J+d%7~e8x zqf500v<*rzZM|SWE)GysnXFgO9L#RSZ_7TCa1M7~jALSsbQHH5#RqL&JDmXyzdfWJ z8VU#=vKI;)Jg8vFgj;cB=%VA8H0MKDRu^ll2HexbSBBnLac6I?m`bXTdNir$GVWD}0! z2l?aBWH#a?6ct_6>8o&c$LHM{3}AOd^|7ILtOG?Dw?y)Z;D-MuI{*Z;e6en(Q_uc< z_7DXPX1J?UpD+B7puG?br(t(dqhahrVI_tTO-(V|fln(Vol;TAe(465dyZTujOm%M zXfQCzV!5!iN+hb`W{Y@Y;Xl00Migt|B`sA9Q(7m0kGkv`_f{HojqYAdL5!LNL(f{&0$MrV-)E^Kr`pQH_o110oXV; zP=P;2na-l-49xjpY63rvHo}x$YAHNj^OfmIR)#Ru&Roc0eK*to;72m4G0U5C?S7+t zU5Y+itV1Wg@#OWdd%X@L8xmjgOmsGtnG73=X7cFJ$kCB@8rq9d%>q>mKi1tFOiS33 zKAg*UM}NOJSkx$9TmIEvOzSMsBl=*B5oDD_Ep}79*$y=vre5 z1vpdIMowj`#Y1b0(%Iyp1T`^ywJE!B$U$JAC)R%>4cX6IO*|Q#xW@3wX$;oU(4dB5 zv{o4+X4)SSHy=TL-mLu-@;xYc#*+NhYs-Wex8bN@7zYrndPTk-oD=Tcp-gRAX(z~AF(zb< zuz%b9fT{^)ac@&|bnxr|F)j>wgK7qwd`RST(l!8yL`(xLAd9g0)k+o!n^kfsnr$eP zpx{oOsZDwm`k*FD!;}DJ9)|23bEr#Qc z9F8Df9+!|0SdWDbYu)Ug%;R!q+GX0Lomv1!zn^L?Va9DT-LY@lNYb4r;}E<{aV5H8 zJGD!AOz2LYL zXL0OAbsNbLb1%sPj)QfkDySd`2Ph<-^u31|Qb8_XYPdKkH%)lJmgaEad6prCXtgdz zfD1&@$dcTyW^aa0-KnOOoG#WEmzqsM<9#igw=vv>rtO^N%-+b6kz?jH#xW`9Bs6vz zeg;_9wzcbs7qO+|{HV0VdDZ6-4=N)wlDrqYl3X%vtB1arO(T8;kj|A94?s@ZU+T*% z;KnVeLj*r4N1BfN8x*EkCapW+By*zAAN!)mz|Apae>m5Z92~%q{o&lcYl2-HUjH%q z4IT|{14^axL}E0riT0tbRO}JOckph{Fl&~hEIo5AbODC*UdWvOv0R5a{bO61(?8K4 zUFX>B@aoFkFpi+Lb^L1Tu@rg!%Zu{WZSIwYsB79jE0v<6vZo|kog3Z8= z;oTQYL(z_TWn62t_ZLXN*E_N=WS6t3wIcBTi$wuw6cyeuV72amcuxa_$U;%?k zVzx};I4Y3{$zv%120uwM%TWP5!P_f7wc3f%>4|B?*heR39#Sr>G@qJJDRy!~`MWIX zmvUykhMZ(53N9y|kKPc7TEQqsAZwb;L>cr+POx&asO8RCcWMCq*Vq|3G?tevcbst^ zr86PBjWGkAL)tJRRw0%z5^VKv+bhFaVOyzC+IE{ahwV$VUFoN@kzKQGG8p6Z(>=U4 zHL>ZZdw89<&|!?zPxo;DwuAOL`MdPfJ-j|O#_8wp6wK~ctU%u7Un`glcfqZo3r-|3 z5ddMP#+J5dGL-GG$_>StPm>>*tM)o0*W`mZO|6nYN(5w~Ew0vlaNYU1Z%O{f#R_Oagq6 z@q-Cb!X^Y6uPiAkj@AM0@!D}GFR+y6*~|RMoOa;0HuuQ$ByAyDRti!;FoG<}YqPAw z;_zL5+UA-?k2a)BgpaWoU2d&D>6WQ>OD|)*PABP3*4s;q&G_^>+gv)nxkhV!J}@>` zx*`DD!>GR09kxE~v~bWID&2k2PItm>I|TANwN+WcUhUp$qRB|A0O zm%W(PS|G+E<@G~jAn5g*9)J+vDMWUlduJ#WDDCd|CEVhLyU=9*wTlg)=3V(~%3sN$ z$Rr^shZkygihaWjY=jZKjdQAbsZCTA`+yP0>XzT80YENhp4cPFX4*p`%LMAV0)(x+mFWy8$i^o^noDz4Z8QsbK1TpNng}ZtZRDhVf_U`kD$rnq7VU8kRv6+H3 z4Cn7EH$aOr7l}eW4I|4)OrNW*V*EDQ#18rgq9jt<4tKK*-8pWUrKEjLVORwwKN?rF zg$6ycsU$$=yXd?3<^UET58rY}C;{^Dp*uJE+`swHiknmILY@}3)LOlo@BI&R(H?~u zL-fApbbYOKe^iuF#ZvC}j@%x|KH2j-zp%Z%YG2&>O!trf>p%SKT>nq#@A7i#Nmnl2 zO?c!2lNov$dg`d2%&ME&^f9r1YqNF$L3?re@D^Wn_6@Y-Ie$IV6^)(!hg1EuT|~S<4i?bb=qOJ>TXMU-0xmwDvw1wJtfVGlJ*x(Hj6jSQ#z|| zj(Yu9GkjDUje5jjTnpQ+h0QZPPK}MNbn7$U1P5O&U&j6}YvAA}5T@PxjctVQO8B>u z`QE?78ac43kyEm4Zfqj`u5@p-!{MmCzngQny(2en(L|#@<&xM`L*>#$)bEm$&1%D$ zU1~10_V-u=w`^))+9JTjcPFS;?Q>{(s&3aBfT`lAA8&Wsapuj*&%hfbbM!ji@W%7wzi6 z!Hey>@<+w!nl0Ndy*Ap1Q8yevV@e#?+5Zj4xHi)*fs|=&2Vr$@M-%R0>9MFclatUd z=#dvU^XiBX}2T9o~g2 z@U91jcW_*{bEEaTm#30)31;KHtg~h_W}DZ?JN?5)l+CtuOVsN)EqYx2Y1@UjR~_@6 z&=XDj6ZrH3yRz=}z`DOL+;^)rv18=pNnb)IzV{W(p&Jven{YILSQpOo!*S$`qzo8|mK;^V{hf zn0W2@$$Rha@9KO)ogJlJq#seQC#I&(^miw1Dwp}>>HeOi&C}JX^XK}bq=mC&pC^TT ze{WLb#A7q#)BQV>CIl8;>`J;ApFDrIzpsRG)OwsgC9tTmd&46j?N3@ECAwPK)A4{B zrOH^e-N+Df?>jSkV*JePlx1@tJp=BNXVvj@1X3iRDL}dUAwh*vn33#in?4Nncv(u z%QI&!1M-F`tNO39LZl;@EmRbzYi)$oZDeR9N&&W9`RROg&6Ztl3HD7W+T+4BT>II-po3{u~)n8`}p<+ zEZ<%r=DuB zo}F`XCv>vUsxZd->a2ygHDz4TzSC1J>D`oGjp2K5>rLsNXs>;-j8}7s46A1JXd`m7 z&jiT!sK!}eK@hdkJ#MnkLLlNS=6%LJi`8tF=#ESK_o;ys`Te=-^{RNV z{6kbK9gnWHR=^=Bn#JWY_vGyQ+QR+&Z^-s<5P}!#jH$|bYLup#34*0n^i+EmTLHZwYi@4V#*BqU z^M%i&y}v&T?4G$f8|>1YUw>^@$2BRkvLD)R9WLiXBjF&pjZK{#pPim~ zd>r8rS&z$s#%HVNxnbw*cz+?IDZr=i?7xe@@8~~R6?!%iW#zktsISvhPN9vZrik{W@`_PX(vA3 z<&=f0`%>yP4C6_7mrZchmrAFjt|!{owiE0&8#OxGet6m%?|FE7c5OH*)y-QS2tB8a}F~-YAFdOB{q-jzQY*cInZm-`kQ|sCJ7>8zDgY$Kdlr=Pk@9-AC2kz7=0? zcdU(TUdq}yAMN(GrCEOhWB;}G{R;pT>`m^}Mzzm3GVRKLdv><+;}|5Jx!n&wYoHgy zdNBt2&hx0;Noi({@-O70zRrQMm`Y*BFa)}&xOu;5ukHxg0@!j>{&R;m~BUo^FB+|@gz&W>Gu zi_OZJB>oHCtTHRD^1tMwKCkQsip^rI@=`F?DW%_fwHf)(A@^2(u@LR*u>WkJm#aee zhG-A*S<5R#Q4@cGp1|d4`-O(Sk#pSTmY)p7BlG=DLvNZQW z?BQEDFp&scIE{-s*en=t@y(Dl{ByW{t1kKm%LV9lhhkQ}S4ayNBS-q&Ife$KlDiG? zDFtY(`N~?jMdEGjp1`LF$yiHjhrBMC+H!dns{P?jQX6Z-e2Z9~x^Go1IfwVup1B5P zIB}*k6Y@-5-C6B*&s-IJNVP?xYe^>)8gjpN#*as(G{kB~01G(Y{xcxl0tJKX!$%GkC%bqKM&jvxTZNLP&}i$B!gJz~0n%lF^p?8DxG;G9q=C^>Do&jnp9BQoWg3h0 zfn0PQN1132zq~_*<^2lMZQsbz%}P?*dDVYvC>d`O55$Kj4sSvU7fgFtfb87p*lQ>y z&|DJ$=xC+%Ce&T*sUor^Y++I=MZa4*VN-}?f`xAU7al2Yc0j`<5}%<2FwCp|SPDj; zdP_g0|NdYiN>Jjwz;`_#O1z;dLB<^!Wb7;LJ~KXfdgh^7ZqqgoA;9=K+3hkJch_iq| zKcK&Xjd|H_5bpRuuKNSIo=@cVl+bD(J2O7(ptZT`EA`kj4%OVf|IZ9xf3~Aq@?d3} zFa1_7y58&fv+m~P*{6~a8}l{vjf_9vqhd4-0o7ung*M* zpwZyHrqu7B%uJnQAdifnNx{C(!?&@)^2doO-PC>MCs?0X0f9aL)|2C>M$ey_nVo*@ z?1`x}vs{zyFUAfVG8-6sRSF<`7s}2?@X(~t5zkDGjh@jca3_16b8M!+_;7c3&Ie`u zH_)yZK>0MS4~=zsR{)wX&Y$j%*?=6N~Sjt1LIEKU$9S zXxYv?d3)z>|9s3pR%|~xjW`M{S9z!_S*@qqA6_Jtk4M*sc6Igx*ZO?=ks_*ls@M4n zC;j{I?Val#=Oh+Ob$?F6PDN~SPQpc-)4UH;PnCo6om(7|=)`EMHT=_jw8tFTl9H3y z^|MiAmy$Wr)UCmpMBU+OvIA}_ z?T8A~<1=gzwh>K?PO~rkPCnWZxCdL^xI62Z*|hfJa_|q`j@AE`9f%Fd!*kWCnW>Tf zuWx>Mtp6K32)f%DKG#`n80&^dclWc~T!Sc-^gKN?s?fxDPHHC&kB`n0_ct>;HF@SS z29S9%l6k?_7F`#<&_x#aWnO;xXS+7OzCZK&Bb#5pBJ=vAyxxn=)kAr9^tJte&7U22 zo{#!Bijb1*IwU}0ecOhoCgd>`1guP5ClL1MPrPaC?4bRr+uF{=v>(bnet7etWQ+df zfYPu$p?1#CACKy|Z>@Z~)#6gmA!|tooXE-CAh84*-otsL0)f;9dq4)YlyrYik09z905cwS%40S;i!)~jyE5ZJ{v{-eBW`HU;nqUVY;qXW4(6`GaCH3Ca#FYhbUHehUMz`U-#O+u zHn+;bega2A%9lGg0A|NUJw)P&E@y=W_4u~MOi%3BS{N2-do+fnUf6vFG>+svv z!f~{0@f98V|h29>tFk&{b0o!V@8n4%dB=`-QYi=cK@8Hz4A^bmm3Suj0dr8(J z?&g5i!a3c6WR7kV>IyR&^(j>{k7IxL0CIP;-H^aaA_KxI*T_8so6-HY*>lS_ChiDv z`w%-7$u;O**M+$)6t3qoO(k^*hqjCdx+nxKn*5=5hM&v~9R42Iw%_MyxO3adkoQ49 zUE+qj(j=_<=^hSlJ8Ct_`=Foh;o)uf*~0^+f_}P(LnM13>qPhMrGkFChr?;s3H@{r zk8Qg#`Y~$ulnVSTuX)_Az8_2R`Vb?kSQYq>FUva;)|`;8iIhkMTV(Al5*Z<-rS54L5nKdW^Sg@axB;2;2HEV7G2L{=e%!hPM>`!nxc5?r{SgZUx# z8U?(*F1rFp~i!WX;~zewr_URvq8@;j9JA=|Qt z&3bSdSFUKm!~JQ7wlZ!WXcaCf(j3f-O1~?5|CQ4 z4v~bBJnDxLsY%o0@lD<@GK1IdbNTB$CJSKeTvD+}#q;^T=rfK2SXk#Ft&iv8@gRI#ZVOAXc2O65_{3uK z4jpllfCpdJy3){CTCk7_8d;Q7cIW{OCH^v`5mAOf2q$W>eF*g|{wr``)`P!uNV^1b z_TknlR82+EHMz{#ifrVoOZae-VHX+A`8|Q2w=ye?c}*V42a?%N#Yd8gT~vH5 zUy9Bfe&898<;&%Vo`>vxCKQo9gR&d!^?SXk3}o}`4Z-QHX9;Xwbotmd#7L1KN)OnnN3#)sN)MzG62OetH#fAK?=r&}oXs)#ViEY0?M)lj2WymBXpCpE3K`^O| z^uzbs2*t6V=)OT{F6Q#u2%lslBtMHvD%?=H%l1%H-UdYr@CtW9>?)hMNWp8$9FwYt zc33J;_F`Fbt8+LYrtPAISIYG!cG$Wl_N4;7y@B3-*m~pD8}b9?ljypfF?Rh)I&pbi zyilmM?xYl#e$lfu!j7CN!LXV*?d@l-8AxS zuQ6!a7t4RPawvR8BhL~UgBxhcd`0~ed*WDc6B1r#u?$(Mg_R`W@)zbGIjrCe_ zxcv2;6Cy3PC#Vycq>Nmk^Q@l7l60a5rb(~!X6%J5l#q?FU0V=Ey2y6uNfIhyX;7N$ zCa9B_Y9tqAcV6X2Z-f=ZJb!Hj0XpO*)rM=Du9&)1GiR;UQn#3C_6NEQ&ms0J0JMdLnw*JFZZ!^tEM*KzJ$u#JvLFW5wS>Bh&_(LHkccvw zx1DzD94yC8BXSHgL%K0vWZvGHyWu8Z+{L`TPxB_thI!j%#kl+((R!k^1oG;bxc_Pq zxC_F6U#_pVZx_&XY?e8N{-OwRWd83qN%w%GE5u}g_ zioclazRsG-OOh&nP>2`1qG+WX!b_&`gE>EiyyVjlD&j;_ND&A=m_tHxMI@x+FQqL} zmy%ni=&g+9mvS&rU)SEo2SSmSeksSbAnq-KkaElinDSO~D?FbkCm=7AmNc9X=itTu zQ(F3P&NJhD9wDdr(X=IAQo766Ezr_ObL>&>QzR6MH{215R{2mE05XblAqSXT$?^Pr zzDP?S%dt_pmXI4LvQzmYEqyF^*I?8q6q8@I>l3;B-^~@=>w>WQ2|^{ze8Ov+l27J} zJEI$-F4fnhP`;_sD^EnYicrQDd&wMqk}%cubK3i4Zf~YN`u}9^h8vR_nyKfq9q+(fprv0)VM2DHSiaTwgYZJ} z_5%@zg+vp%QrEBL^8a0~Q2r1%lPUw7SY2>o#%s03n3!NvKyjOV)jKw`Pm`3)!8#)Y zwr$+V1-cs8i;yuUXH$yh0qFT`O0lMW6+f5lfG;R-NwL(XgU@9<5Wt^H zDV7>^@VS(AAw3p%(}IX2wD71wM1$b z@lyhnDIO@Vr_<)-DRU!+o3iCN^kyP6}cnL0; zAauc!&0fF*a|P2mw+0|I)FIa(!sYWyRQ}t!-mtAm?EUTBjbW)o0Px$nL(&KGC|O;< zliT+HatQogzTfRkyGMd_9{A{dT#8U7?|`-$JMa#@1n zvDO%q`ul;%?E(G&es1seX$|20{T$|n@QL{T9|)i~dVu(Xa;5x)0c01R98Rxh3SMvt zfGHeCnEw8VM4}}Lw{syONTN~$`~n%Z&FG@9&FZh_GVp7W{y}aRkwShzTBLuF+m`_s z`2Qdm-H)emVcbv=k{k!9CjHV`2ATAB(?l= zjgx3UM>*8s!7b&_=cH&TUy2fy&+8TuXx%K;8lER4)Q^M_Y)wbxs28mA4Q`lOBR-S) zePd>!UZ`=e8TLP~PiXD9tm3#iF2RUuDe-P>4`4{hj!};Qh0)U(A==ndxR!;P6kET6eQiAZi9c` zuY#tX84!jepvw$MGT@i81A?>hfC1emR#Vu<&hRI>;_k{mfUwi#XpI~!ckr0tq)Ke0 zf0EnP7qHMHCe!(VC14L62h$S&iZ~?uvsfh6DV{2SB4;Ks4U0Rs8&U*U+sEURQ}N`~ zOniQNd?Zb@~U(MZmTl7QftH}27uX6csBGMf(?&`99uwdnWC(jI4Zf}N25o&^0=&q=_{#Usj zo>3IpP}zO>o0RRy&`&C(g}V^W zMR1N;n8=+>w4WSUVr1Q7rHN?~5zTJwAnpX8rsPD? zo;Aw2{#_tOJrFVe`#_BLz{-vot;`qyUc?xh5V`)9zPRiGr2^%>_HEd`{k6@N$bEU$0_5_y!)H0dIOWa=&PS9ozg_0zUk0p>; z&l%7p&i=o`q4Z(_5A9Ljm%xsXng@O@s)Aj5>e=7PXHd*~rknzSW0waR6iXd?hfw@KqNmubur>2wU-{QD8rK#NBfjVY z9r2e%f4UjUKE2xFiZ+ulO~k?AD``wZmGk*~Z>XNw&89vw&1{znkxuW_J16VvvCY?p zc|$DjaF`yNynORq@68bg4LdjerkaBE2t z4WhkbQ*(qK5Sv2RE^i-p@g*+mqvi$AxX4}RP6%HJ+i7Mo;~EE%d(yQk#qVcC0D~UW zXqS|)#gTfZ-dtR}v^A~+{op~T`redYaH1FAsj0iow}*>_^1kw3R`q4==j^)vGOLuw z;9sX!1}vGT-<72qyatrhesEp$fUx@j$u7Qlca|=(S5r<$x{EK~o$pTxwT~~}lP~T` zD7nwd&W0kw?caG$dlDc&E!TdD=Wteqn{zGcb@mC&LIiQR(UwkP0 zg=&0MU;L4@3V9x=!Gr6{zwK1Y$(l4%5$u2~yjhb#7UI#8CCF%OIG{-C zLD5VSpTHhtG6h8}&-uJF%TR)WedKMD{7TCWOwJS zLVn!k-bwnAxrqs!L!eh64bS1u6mTbKJ-2 zVV9rdb9oF5ZYc6|{FY#Ux1$)SjpFU)H#>@fNH`!f7YIe?SZXfl3nB@j0(J#3HVMi0 z;h2r5AoFM=1_=`cNlVQK4;7iFDkAubrKTp1)oQpbF;wX2C(-l3jlH3hc03?UI6+~x z6^9xbsV>*9Fomu&N#z?F8cXUsuwv7PO@5DG#Rcah_q&)G*Jy&zF7WwVDLy5h{#M@G z#fy1)dwwflaiJSU^Y(mR_BXIey&AMXIwek|FG12%k^+qvYk!=l29YEH~5V z^Mrgkl8~F@_cVlm?}mVAOz~FEsm4k(5T^#;0D;+gosQ$Vrozr_bJMAbG`v7o$xH4Q z+SQn(+!o_qiz~`IVyqLQaN$fAQ4reKYSx(S73i?()7b?l@QQ)>&;?oM#E^6{khs%1 zXaf_oi+nO<@xtH-KG2NZCqtC>T(euUv>Ia^|40zm4dQ+;oqMSbznAiXZV>l-`I`gc zGHOSkJo zNCGK{)c9?UGHq>tJOEOHj1;$Xsm_r>$VdyyMc`}(j?qJUa3jBJbfvm(-}LATSn|sY z!ATAxI=zfZE_C5~;NH&Z&L?o}`qM1Yu$bM^9>qBEMdOMCY2L*YF8UPoY{*v>FSAu+ ziSXdVy`7dQ`&I?@t>H&11KQ8H_ZuBzo^>L@dmaX)B#c6Up&ICV?1xD}28=F9~+vXD6$ zi<(RDQj9)$n$OGe!c` zdm|atK`qES)4HVnAX%}AJx;qNO1At)x`tSg7-{&K2=mG0b<(2<0(-fJuNCI_{L4IMNNrKuO+oo;;ItG=MM_RO zqeRzV4LqA7pMEtcC=}`Xt3esqu7K_0*V2}#OUdzUUc$4#7I-#Ad+T{NMOylrc=kaC z(N*gDdVbqa7IK`J%EYSsOKCk9)kq{hd_7Nyx<=AvBl$+?pCTsnFcM0PT{eU!_pzzerKP{f``A>9wDcEwVpBgOak5L^@9*UE-_IAyAIWbKu@b~f+t9N? zEA9aX`;8F~a916rMuIZ=AC@9mnBVhi9jeG**b#hs`SwLBAHnq$7Ht`iw1(7eIsqK-=v=UtI-L(ts{a7SfTa* zkoPVjiVXEXrR;6GIWPwN=VxryF=$p%U`FQLi*}o2OWjCK>F7~y6iH2_00t!RqPH$?C%AxZvGp@ z?$8LRdUL_Y?ob4PHy3>D4n+WXbAc$yqp(U+dA+r;?H#bn+p?_EVFoth-rf#&x=UR@SJ?IoVCUKn>}b}>h2aq*MdarS-A;?|HiW!0bWRb=bwCIuhVjmT z5Q=>I&VUe#bpFnOkZwcByV91ZOUcE!@seS@tI+GFfug;=)y23`q@{Nie2iPSA>=)2 zOCW@jbI9|OmflnF(fSl=={*G>_ehbJ-cum%QFG5p?&8lEINGMk?pCP7&lh@k+V)9_ z53@yjv7&v5*7UwYUe}fLVZ?{;EA;M-bg-<4K*y$dcXK1w32 zjnqYAW~}q)#3e?U`YM0pjGb}BsBfI5K6%ocR^dQlIh#-jHwBd#O+HAm&z8D z2rsrsQUNeV^^tT`n*5IxI5pnGN-iS2#~Ad-3qI(QA}xKqaJ{2XidOkHC(ZR3gMLmejT@qA zm(qRZ`(jc;yVS~T=?meY0=6h_f5Y|eFBuEZJ#O00Qb?-|=DC8m&r$@0=L-D+1aaQy z3MmMfgPtqgagQIH!j(UjLPSfHHbZ0#Um=SdAo9;QLL~d&r&5T-ia(V?Bv$;X6r$}w z^eI8~nLgG8RO%#BRO5B|;n;Mg~lO*oorw#^6g!G^|b3gT6H!=~R@NH`a+V~>C2jX`z4*J8zh4(sAvqwWiF7Nq=vszx-Ms{OdIUfCDBi1s3*eJknM$O zClnzk!&p#-Ygm+}rdu2=-!Ds|PBc$Lf$mvR99muHlG5` zgmlR~xJKqyTNs#RzzD6UC{T16^)b;~fSq(wu`h9@m6n?Yy_bn4oOX1Pg_r9bmS3W# zLJY&9GHflR=`iY8X)(3dsToAt7#}eJ=)`{Hba3a8N3RxOnbyT$4egrh@z84$1Y)!T zL1JQb1`8MlbiiEAUE-)F2o*gw8m-Ii;}JQrWCsrw9b{(241w7a{({qj9f&E$Mujec zE6_!RwN{l~tF^iaRld<44T^{bhK^saLJwHrF|4?CH)x1-I7B?gK-`Y+Il>>cvUUl} z2{sMot$T&2!fYGPIdY}7E~M9+ZH}N)6GZBfw-~tDVH#pTv(~ghk+4e-0aSg}QG+Er zINZizW80h!-<>69rDCEDt*Iwj%Plto6DfYZh7yQz<}m70!^4R?dzA*>;`+UWI5J@<$$5W#DhN)R;LLg?603D24H*)@4Jx;w0r8 zYx#9iXAz%obGs!lYFdnZ>|<;uYA6}LAJUK=;FMzOBl{A}21;?}9BYU+(gqAHH@!0H zudi9)X38$w>lY7uZm4iy$8g5nikNalh$Lkx{^0#;P5gkAY>v;?pRR%Fx)w+LQWNm0 z5tj_)HcxD7#9Rz84lZyfIe{H(wXMMMk5zaA2EdC!x=VZrGJ>tI7<}*m615Q}qu?_z z%)O*a_T>6VXv8TdvTOaLLib+V&?6HI|5zLPcO}eGE)^->R{pA@5w#t@v>+KB`gK?aO9&`5A+McDx+WZZOKa$L0HTl(5> zA^~MAh|m~2nnY|+&U}3-oi9oDUn-QGs>WO9>r2wzU1b>uzoPl#`ftq_qCLe2%I_~| z>G=RK?|xVRZjO|7(o;9nV>1wlodwyhBhbhlVF|!pP9V3ZNn}h>>=_MQpUup9QoNS7 zHOgH}CO)pSR{)O03Lilr0R%zmPHonk(H8J!Xm=q3cHqjU&XAbiWlRSTwJs~?iCc*s z-@3tu;58y&l{8cm7oVodxb*zmGS9hi3O%9$p^56K3%r$BFyp4@r$W9Wv|WiK(-dv{%;C9T%ab6+aC7q zLb1orRM-Oo>kGxc%CV$33v;lvMAn{Y%ByK;XqII*NbF{uw?<%|-!AOjl{^sZ{Z4^E z=2IB^Rl}7_i2+#g`rYcRFJjIzA@GNn)?vi2{k8_(9n3Wg7+M%cK6np@*9;oH- z7KpJ8JyC)3HI;j_1A=RF%p0;jOHe>0L- z;`qK6t&DKw`Logk;U!kU?-zVb1TTT<`-T0^UPF-y|9;_M;7PJnd_Uz$3S9atHT*Rv zH_#NtO8I}zr=r$r1wm&8Ef%5W)lHf33kzE8YAxcr2|jb&J=>@cK;iHL8bF2zBx#yW zu01TpK2{#dtPm3*bB(=OIQ|pMMC{mDk#X6`B%XN7+ECLUlnd~hP%ijp?TA+L_(imhygcld1|VN43T zxK45$OWYR0jJ1aVkKtxDuAUpiD~`!SwhyKPk)Dszo=|K+3N{YuT()@qJLRR>2u3_0 z4~6hkn|D$1sM|glnu5hH$SuAnJMEREmC><$0vW091Ji9c z*cd?!Ti?`~!^UMsVG^K@38OWKGY5=iq4pGv2>{%>_JAW#Xyfs~uop;P%VN~2vsXtc z#$)VA?4KYK29<>DwcKU_-ls6mShH@2b-Fy`A~IU+I!gxbK!c1tNer~x&aqK3yV}kZ zl2l~dPZz!U6(WNqf4Z26HHvKe>0%<*>@XTk#2N*Ik2TEH&CVm|rm* zcO|!!*ENY8;-rX9+Lr6^R@DzQnz8I6X+W!ztFM?;X~G+gNs2ME7hih47}C-fXx?^| zb!c(^flyuF6E?{qvJlKo*BClYv-Nwj^%Pc$lhcrt5-L+P%X`x~lh*&c#`?Cf7UJ+Y5qNvc+7e9F{PP%^MYjL&C3pvII0!$rq6|v`;9pPV;5k2Bn3l9fj&|!ITu)z z-vt;SDU#aILDdHs&j}2R!jAHs`k`o1*r}N(p1!=akT7ZOO-B574pB7JVZbpdZHz%! zJgY^lfOTv~B5tDg`IZ2FW&d0yBp+m&8X@&q8v$eTlV*!o3dpd#R*xOmTQ_`=X+|$G z8xv5N_+rA6&s>W-;1;}jBA{b)KS+taYBxzQL6htQpU&VupQ(D*!?4M3)fy>H^zbe>&$E`6=XHh&*{!Gqf{|vr`KY#0qrb)u3BUw1fmOzut z(t&8j$R8cQ5Sybsx=1D^<+eIm3bSsDIoO`RC!_oDNDzUUK8Pz0Z!FZx6i z6v5}`Q+)0NpI;C@f7Iw{pOuc6o7zjIETNDDXRxy}!OgimVe6zw5sXx;(L6bs%XPF6 z^t3YNTt8COobM-_v>?jw1M)8veI`7LK>mfI&!9vR$iGk|gVImsB%MMvUn~+(_{PN5 z?i^XNvA?wR34cSdE!V;qi$2#vq+AP>+}S%yh}2&!l54@V35oUpFcrUL*vS^DYenU< z;zr^>EN=Voc9+Xa^4K31yWQEvUh^n@S=06>+nG1Yr6R>wmH*Lp?PZB+)9tY6nBic> z(O|Mst?bEfO;MuRyPyLYKL3S{+eG2S#dBDKHLft1PTxrlnqhAc3ay){j>`;1?=c*b z{z$qwNV4~$>x|JGF&M!DQzA!RFYOZeXXpgZWl1Y0 z?pt0XXG{emHb0rDTvR-k1TUr}rqvEle4JE2)oR7h$Vh@)-5Gg(L%JArR+yUwa-Erf z(6If0tDB-QzFYe+ms;AJXQPf7;}k4!9CgW|Ktlk}48rXPG6DiDx1TT6AxVQ&e~;jr(@!_)lMzuNVD&+YL@H7Yy(b6?Kl+X1Ux94b^hg`mr!IK ze7V@~BxH*4gfAC;a3MwXk}nsr{z`1<&07?|Rta}0YL)Qq*NWsb`qNx|(mBs%@Mr@2Q7k>}rTXdvIH6>>k>SOe$FS7kXK?gk4R-*^naMDC z5cjndabh)JEB3pvWKaR(zE&J@+36^PxUUuOIqAQZH}9K5++RAx!Ivl=E&m^>b|2PN z(2nc#|A`fRO@r^`C2I9vsTXBAe7}%ZAC^RdI~kgIib&I(()*+P^muoAm zUIJ&#VQQNKEyzfU%OUY%oq}zoPh1!WytTIS%GcDd%%C?8#*gUc6a9RZp3mB+@Y18O z%Nb_gL=RahL)2E>Xtg+%sMF$RJx^Kn+1kn}xzEN8EYGyFjGN{i$Q`UYt%0T1gQYB# z9tgeGAIH_Gk^t-@(jSaRJky7{>rcWbI|14-I+A?{uZ#t1v2!Uz@ls1E{KXsDH3GVg z73q5e|3PzDCQE`_U`)@zX4Rx69b98>u;psuc*9ySlB-eOcVzDvqxf}5Q;yyvgky~H zS6jIh-85v!R}vqRht|2QLr9X{cBSA4JQV8JO`}LCMMog4ggPdtX2!`Lmpx|YoN(}8 zy3KeU@H>vWBZWbdVT_#7KAy3g3kX^i?WlQNNwY-T(D)!w-nEn|4G4{2yG+{cXiVx=2Q1?Y5%g**lr?BEeqR| ziwXUU@X=Sx>!65B8`3lc8BAO=3uz1YG5>G_ht3nS!$!yOhd)h)dndt)9Q-`%{Yy|JP3#5^ZF(dZ9VLqV)f&xWf_-)7{TZ0p^ zomxC%Z4plmQNR~hTioa8B0mYAS%*E*ujbvGMSwklQu623F(xSfKla`P%&x1t^Q}A7 z9ZqSyrO8*ed{ve$$tsmBS)SwptE7@$wxm+3N(Lu^Qb{UH!kVa385~}}@CeWbY{uX) z2FPnnpdkq)4#7#9gqRKufi}ZS2>D1HNGCv`8#;7&X{Vuo|F!l$=iC}(leXW-`x-uf zsMfiAuRX23_S$ROq?hxqXzF~r)7m(gqCGR`f$;c&a_4eCdLbQ=Up7sfzqm~v(C-k03v{-Z%W#xWdMsuQ#*L5zrI#8sn+^_4r&W!~W#whW+&KrizB|@P6eB=I5 zTGrJEMLKp#?$T)n_>)WWv23~(nDk(0xFj<}O!tGG;gSrad zWtYPPbc)~#9)10lMFoZStn?(#JW+YW=J4z_i;%o<^dcK#d{lBorBaN37Mp~(MnOOJ z%P(%3GX@NduEgiA)FpYq{)`Rg#$7|hyPs=ZIeZuy9p-_L=e9OzRNM~b*||~nv$i;V z>oE<1$fmZ((wFpzORHX%gjEN=e5T8k$jof=Uw=8}pvWC#;dpkqa8z3wG z-p-Iq2@o26Z)b>~14JZ#Z|9Y};7_c-yl{TCv-ogl$@mj6m4CG}B=Q0@Z12bCFu529 z7yCf2{Lb>?=HgT*%Y)e3z4BbK0ubZ-J40?;eUQBT;Z6d&9xMeM{$XMBoJ&7N1g6heMUD@<#jImPEv7@!7VrlC2%{hC@i99+mC7EED@dd6{Hog~Fx zAY&npE?`iNq-I$q@oktyW8s8v4vOp)brtg^xl1_jPyt ztE%;lZ(N1(feXKqmN&Tji zG@~XIrEwo8XXZLQOl$+IALTI?QbynCC4s8d9rxg5nU|hfY+RENJU)EVVnW8*JPctk zq??0!iFrv=KZe&RTZiJn{Vq2WFPx?*mSka!I>$lxq82i@;S zc?i%Jd5P;k0pT~lpYa>%D?TNPc&pT*3q(MGS2kbWX=BBDtq*x2RaIb!ck z`O;YTv_58)6i-zL*=^>cX`Vunwpb$emV1HutgGJD+j-Dl4B^}aA|5W`EINj6v3-~b z^6dtGx_Uc7IN@LRm%&y@KTL(cNF##}tDi=;8^b_u8yWLs!3=ik8Rn94ZamIGqOQu8 zThhNL^5Nh+a>CME%C_ry0guB8;Sa_JNL6oaC>UpP%kacVete-)IeCUFA)LX3sIq1` zSw@uKIzz65Efq0rhfx-hQ&{BQLIi#kx1^h9*E;bkkT;~cu? zvz!4v1V~<4eW-_z_YH~;CNjd7`1q;LVEBYoA?#0ehL^1XA?#0el7~T>9Y7A!Pj#-@ z8V*u2Vt%@__{H=f)vow-XVpDL7m$BaC+lZA@f2NehoEJ|1a34}2198oL-^Fi1J%=E zex@_Hf(n&zKPFTg11g6bWHLF(isV)!98@Wb_e`0O<&kNWU&te4hXT)mEX1SbkZCu6 zLCAbcq|W-5_WD~A+v`ji;HKuQAUoHPn%d8I4a*V^s*~eCcjDx|cnj_w|2YoH;3*yb zV}g!iv(__gbzPSS3G`HMI&pTz46-*wiRS1Zm6JwJyuIxCe7rSd=Q`$r(RWZ#ft3k}vb5Y0A#F(xOs_dtnyk>CF-$Y&OvG4ey18gE z#zzpAn!1;#zq=J{YWf&&4q1?tJW#-8Y6sa0&`}CWg?0}>J5efPwK=b=>2kK+o4g(5=0mvmmv{8H}0^&p+jc)Emm2l6)S ziEi)l^kI}xb|OsPCFdc@eHeS9p3hw%(I(34DMS=mS~Q~77#Eraxu9z7ikM~I_Ds`% zP9AhJA~h5ZM>p#Y#`FzE(H^3oHO6>FYTh3PU^=jLmai98LLR|4JT@k5CNC)1&7E3e z)MaSU$oa_hqz6sgA%nl4q~~^oBKi}jgAyh_{%a4A>ZPzimYT)=X%Ze=yf(F=n~q%f zXfy}ivO*`=Xg$axrswiQl`X!D;pvCLsX^bv ziUZ&+Io+@{;q6=9Zh-i%5?UG*p6#3QIKuDDrZRT2hkEqP`yC>89V4sEwT?B*M03h2 z#&&~8nHcYZwsEL2PGivuVTOEew7t)svj|cqGq6fzsBdL5lysBd>I_L@7nmONTb&_E zEFeeCw>rbaXA4aI_+7DCy>ZS-1lI+;runV{y@*pkW-Ap!d>|x<;mA3nrl1Xr;fS-S z+o<C2wT=vw-+CpuTUx_&{ig0A$5&gMQuj4>0bBmUQpDtd_U9}Ebj`i zf!jUria(je+=i3LX~566aNUoGI#FH9ORRtZJBj^NIy!N9nzW+MmtVPJZ{z8Szyt1m>2P`6$vcJ`2Fyh@WR95-RWBMS$^JV!O4reu_KzLcc zh8lf&g=ZRK8x#P}P)dY+c_n3a^6sdKrni1o(>5poye=zXA4sGaYp9#A&-aS80M2?jt8WrcC)bjg4DiM2 zK=m%kn<}u`IC2r;gZYs|vcTEM^)AVS`H{<;;Fl`p6=@szB>-m&rsp+&sgi1J6`S(b zY)eoY$P+;>uq4HAt%NLYfZ%>>CFCgsWEtLCA)3aNy~^$Q_Db=cm7we`=}P+cN_e4m zK}cQr4tRlgO&@r5w^H_(`m6vm_>Rg#e=x&7fZ`pMa zE~5`P(pSoBdX~0E@r<%7gI_{CB+-hnw7*;lZ|m6ytF$bvoRHAWFITYi`mv*!mv?1z zM92$}$8#(w>XpiORYK}AKybaQ5*``{1k<}Jo8w8T;E#ud$qaN0ibQ_^{=`6+8t9l3 z=JTOSFwg-qpAS`nfew)Qe5itfZnro%jG^+rO7VB$!rM`V`td4nF5J*KfOSkobIS{1 z&uGSV0gFC_@klM{;CWw0spv#lrVom$zNq&-Xhx*Uhu-EN`THKhn>g}xxPP!;k#j!1 zcgM^woD8~TqfQFCQ;+7u$xHRa6_qS2e(nZS59fexf_}>x*ga_H^WK45cJ8{knodjt|Vl2P@0hhyBF|=KF&c z{On>Cxz>G1Q=@mXG&9ry?&Y1VQKmbanB1I@QsfMGmYmVgr{thr&FqHmX{=p%jW~3; z;h}&U00YJNq+$vr2Bkfr^B>B0Ud#BQeCM@|Ka}shj)8~O`Fld=;f27xO;ux7 zk5z(UACS&`tP%|SickA^<(V<;FEGRY$1B0G52&_+VIK(c|9Az%{%RZy7I5@GS}88< zS=5Trq|Z04sL9H5XneF1yqy-9Lt`EUk%3;8(SD*5LQNN#Lt`GKtby`%U)ZR8veNOz z;7y=u`eY?|6D%|?{F(Hy1V+VOA$t;Uk^Cg&Ly`$o0-b#(9+rSi;Ai4t2?(8irV>&s zEj07#W0B|qQS(=xzIU)eNM|04L=R{U1fmC|Gmk~0S2W}oo*|-NXhi>oNc4beD-b;p z^!Ei3z36XIqw=Sfj_(KhQxM^wRzmi!MMi&L)_fg;NdPv=K&|F5ejt)9#3?;Ofb-Q{ zH2K%H;k+XJb?2|jVhJRnbpvB@PUbdxBVnw&`s%*vN5K?eg!cfMR%vO50C1b(>^ zUKv_stNFNk_n&kj1tueY1y$9bW~wSPUu8$o)2ykoW6u&Vp^@W~8qWob#h5?zIv;uc zloVAKkPu`whRGZ~p3RYn?D0y_Q}HWv^tdii+8%&hupY0Vr&O2VDWT0iV{ide>lgc-;NQwr)q2@=6|9g08x_jiX z(wTpVM+hLD`G+Vs0n(X&h)0M5!2j_XIzkrN5%Q1m2mw@E z;Rpd@v;Si}LKZhF|5WLCW7QoZ70dlkm6`_uF191&2b!<%i%A3XP_LNu*Jm(kS0z8= ztlH0;f0<<=Tn;$(p3Jgf$BvCCYONVKtM!ApsR5a$A4Ikd$in_0vTZ=v_7B9i#l;uf zN%yj9+{D0WfvZ2+#8}|NsmPSDZ(ddnN)tevqM$SZWO`p#4V!qe9bK=~eEymfCDaUf zW%Hd7W$S1^oeq#r-CterZv24OP&l;~8ykJCdLcFnh=#oS^s!M8LJqI3hU+IFoq26FNaBEW z=C##u{ZuZBH&maQ>*r!)qi?8&>nEVv3TzYz!h1tCNa9Nxl{Zy8J{Z`jvO>J68ls+; z7#n?C$^n2$+EV?A+BD+;ObJW=w#WehnZUPI0|x+P$=_BDaScn1qduhh{Bt?d0;5!| zlEf(&<{1-`J)>t*XylnbkU}$@M%y@e_v3Ctp!puTb;k6~t*x*%Pk+wq_VRy9-_=2> z+;USjj$GzkBcCC1$SL>_PwZ0@iiJbLORoCyvu948Ifjph`8*y!Lm;tyOt>lGI2Rn; zOdC5*egz(J#S(1Md@ZuVd1AQuz?z$lsd&niclbKd50yb5cLY7IIllyte$VEKW0ZQ- zKdtu+;=#+(vcDgS%o5O+I^354p^JyAAypM1%<`dXNL97O4vP0hHU)^5{ApxUAcSV$ z7uggbeS2SIQ-JjCeUVKmYs?3pAvU$d*whCin*vl@flUE1?hiyZwX{+B^=ilG1DjIL zmS3;dmfPX7)Y#M`ny)|AwgN_bscl8r`Jn8-$WFn@>lxiu-o`V%se<3w|9*Rlb%huo ziF*nVDtM$C_7os=`$*hVfb6M9w5Pr!X=|xsVm?+a{!z7*=eB_?l%;#KXVBej4wxHY zKIV~!huE#7qX*Yx*HTg~QuOo$S-d7DpXFth)$^!jyG;ESEtc29 zHiBR0Ed!(SOFL`~OD*YX4`nuoHOYVdz`@+_^e!|P3&9XAE^DUm^?{+0(IA{MV&>;# z)!=0SXcP7a)!=1d9~xy~H}e|ImivQh@G{T`v8_L>mb;TnuzlDEgID!*n@6|3|Ce^o zEJRiaG=ErK;qDFkAP)2KYI#v|bwEP_;O?HwTG`kop+s@`-BI{Z5t| z5B{`Jn+5|I$M2trKGT`#!;~<+pN^srAk+KlDEa_0y`PSv&r(Z@@Y#5#0XE7&Pn!k< zgmmV!@k|4xGoOuT8lbfoL~kVk``j~hrY*HI?Q`)=15{h#Oap?9KBqJ7)g`pxjYj3m z)sFvMEj0fb(+YdrGv}o7C6ZxyxH%}hv38oXJ2}px%QN1eXO8g#4KB)~Zb4KKlwwK< zIDN4Ljgu%oo4QYisQeg@zhlP%PdsIr*$5}eUR*f9WAT#7&PZws0+l&U*)u=t zU{*HdkHpD$RDPuroWveI1y$?A?Kh8lyl3+W!w?Zg?P)EK&R{Sh(p8F^sR;`lq)7$3 z9X)eyb9`$7QP^F~PfU%aDTU-mYL*QGXLGglC_$7J-+;Fw$5BueKqZ_^l6~7~K+&`I zA)oI-sgehn``#C~ex~QHGiQ2wJyY;=dw~y+Pxc)i|2e*?Gxq6Ju1M=&N)s&aY}~rZubt*(=iz;QVUT*fgw{e2InbuBc&s`?@f_UD60JDQv36_00#|haeZO zF*k9MOI%@M>|PPI?8Bm6?ldG`VE^Mnz(I<~BdQ~NPYhRxqtp1r5-sMjr+vJdA4 zT_Go9E=Reb*$IVK_*is#`Fb^kOwn2EZAh95$k4uCz0^+ClYAV%U2UXQ!SwAa4{`p5j*V_K-_?$f z)(g!i;7rPvF~A*nN))0fe1cpoebolZkuIfrsFY`Xiz#ZRcpz&Mu~Yg(dr61{L^kOUc1_XLdV)~G zt88Rzvxgu+K_H3V=CZJYqvM|D&Lu*k=czG0<)PXZS#P#nc;wU(qK79>oS~hat-Gcb zy^}eF5u0KO);Mn5p-c0TO5|)4u;2!ERE}C4o+P^iH;>ul(3{=*{S3j%O4K%v;+}R) zryKXLp{M6<#yy?b`p5uR#IetcIlH z|BRY-Qf4h0uwt8d{lO%vPqLm3dRG z{7dP|@P#4|W-C*uX=UE5d5=hx*z_&6^3Jdjgk!wDR(w~w5So*>*Q$OG?hY{&@2DX; z__PvKf%|i3;7M&DTr5|A3D0C3z? z!1jS{=yNG+#k;eGU<^R-PZvU$v3J+Po6z0X{<~{yuZ@*ihmg(44S3sJO%_|3NT3@tl^yF(@NL_D1I$l2tF7b z7Xp|Xzm_dT$)@VpvpvKIl#AIO0=5sq2aiTsh~LoChCacgfzy>O8INxN00R5qD=7pY z&Q_910Q%i@C3Tbia4k5tcblN|;aYI01Y{*YTtt5{k z(!bxzR+0})$-~)7mb8+;T`T`ix{`dM$Val3EZItaR6EGG6dx$@NVbyYM&D>x2N&l`ELJaglEBUcndWKk}6L363fT{7Z zT2M2ohSvHIv~sZx3IO}9!wT4k5ZT^s=D?3<3&9wGK9w$nta%@=g}@|AgZ<;R)&B4m zAPe#FTHlti5PC5FAXt>{gYZXa*Ehh5MYvrL7iFU-7Yvum1 zGL=T&Uzsqt5}kW)e^RO0bk$n;|$42}$z0@QfC7Bc;GTjI5^W-G%Ofc|T`GRm9w z)mm3P-ZiAJ*4FsDfmCK?zFNELYWk}~hB#1aU-Mtnm8ocDzLs)t3TQ|CO|}qx0ONnE zl{bb-yNiIeg96aJeYP>>;K(aSp|Kx70CB$ zxO`h1r4EVq|E*Sjc|C}j`T~akR;#y?rAb{Ff_VRrwaR*G!3zBoPEfTU5P{$)82^X(3y1t_RC#}JooS8O8@j&cti+&?1Y1gUC5vT>PZ$!^3Q z4h2E330nOpkxuqWbA82h?)lDJ(_9fxGT2>M(QTI)I|eSJaVjj8ExU#|sGw90P0uimvif?&U|ewAm^q%uqP%6g?5cnBBa$}8)&*Vn7wda)`V@~V0* zFhyYTkXP4hJz?+SyuvlBUVBZw9(E>QXybu8Z+AJruiD;yZM}AT_JI-Eix1bsz13_1Z>k43oNn0yfILnfh^lX@ zFV}$pyX@o^OXcRnFpQ--0QkU(Pna`PcKG;P<~#FaH*wdeDe7obpHN#b(cP%NvmTV4Q=b(JcA` zc8b${Bpahcaf~{UKB6&R7x4W~z1S@sGvs*u+_|$;JGN{&2K`Tyd-BYwEu^k9yMjL_ zu6N{7i%9p{ttjB%sW0>d+(0yw2jj0FD54)*hNZXO@#pBr-XXI}uyU{$EJ>DLD^Q@B zx}e<>b}B&yu4=c3QPZrLrofsq>#P}Prp!qt%OhhM{elKZ;R&fuo`R^GLNYuHT4T3P zkG-;)RO$Ch>FpiLBjCH}r zeC~*z{*94wnrr1Igcrb;JY4bA1nCaN1Z4NnvGuWft(p9W>>kT>ZarEr{>OT$`A*HA za$MUWr3oXT{WO_&BW~~JxU3;B;AvlYjV3XjPzHSB97!?a2biJK;&8--sJ$t(h!d|C zQZUd*Q>}Ss#_`rM@{Xj9Vf$%Yu8=n0`FG4!7K(|wK^V+J3svt`6y08N|qiF&x&+Xv+QdbsNt z3=jN7Jp^3a2T**X9s;iI1AOok^>7=~2kBuxDV+P!P}v8Aw>EEz$GK5(BwXf-fd}#n zL8urAG%zEpYz(o}(&T)yzR->K)&PC_WPMd{IxOk^KUv>$MfyQ1iBGFf2ST6NhQOPe zH=n;x>urvoQm@R$obQ!({-^7!Tn(pgnw_~G-bLpF%=oNogVhMvNJg#$V$c#Ux8Fx&gfiDWvOJzh_rl|m2-#lkpcD?>}YWh@+yoU6{ zA}jylk&}3~-7|q{)N`sq@d}q^<3Z=V#cPttK!}r$yTvFajcPWpjC3yt;x`|QFV?%9 zG1DdMQ@0yP02#m+>%mmC%uGdJ(g0rJ17IEj*Ee6}1CTX<0krd&r`Gv2KPL9o5FD%_ zDp#8%F6%pPdnYV1cGe(7Wnan$p^5!cJspH7>`NNNmkXRO%drdBJHDP2n$O4Xa)k8w zyz;`4$ku`v!+zW2;M26MQ)i|p$wX#vml;F#2Tiam>>*24X2(8YPn>CS(o+Uo!=b0g z*5I~p^}Q4J))Lw8bT~aR~GvmD{Hu6u4%i|Tjt!?~&>$z5Rlp(#4 zH!x-oj6082JUpXfFHbm8R~1i=V_Ix&h@+R60#%Om9-UR?s#wKr;v0O%L-~o`yJnT& z8q1qI#|D-r6h78_d{*Hr)56oI4s-Z#*h*nfoTq8p%r=PAW}*yEFS5>yl$j>?wee*| z5Slo77o4~cUmbBI6*`Txj^;RhA)8zo6Fkt0K+HLP;+)daj!FpRKNBIvyv-shj}jBM zLONp;ONC5s&@!eXrNHwM2}2reWqe>M{9Pk=MgjRAB%_qbS#Ws}$R^x!ITn$T;oaTZ zaF!IdCk}%&2x_92G&^A`@=DMPdluuec&{+|rok0yTB)(q>4G%4=^B06tQiLoMfRll zrh3v@6e6EUO^!lb=&j8bl(D(Bk7Rx)Bo#;WE?HXB!l^8d)>fQQ&%4V3q0=yCdd|%s zL&2iWdl6#h%>ERr5<(}f;Ukku=$j5-kGUdDJKdf-Gesf_9i?VRFr%Cmq|- zo-nWWenxBaW^I3TEFq~>#4J2E7hxI1@{Cc~Xv|tqLbLHB$Hi{_FtPJ)j#0OhD*p7g zy&0D%aoit#VHTzj2daWvMV635DC@N1JqkQATdSiJ6Fqi&f`DEGQ??w(`3Z~;X)dx0 z%i)P*BytoP(f1stbY^6#r<=#=gk4*2l2vHCO>ltY;PjE&{)0|lvTkM!2$_`9T9V|p8QnZd#4e%+Wa%B4}g&ChDsdlRE4;ztWELYkE;MK38rz z3U7f-tbjzIz99M7OCcMs+YtjNbgEwS98-}f! zG83i8IOpD6BW-J(GbUc(!%S|sUZ!X8OzAq`b0o9v$^^`|nNiI9KO6bBcI11smuFNB z8{D5P6Y9AN^_d7VtM6M4eH4A73~6QUxi-yD++_9=|G*{l zJ;_C`{M8rTqCcQL|9NuASvz3mLvQmBQF!@bs72xIK5`D0r*zL86Fh;+4Xzaht{p54 zf*bb$B@pw!8Nl?Z(X}5XG z)Gy9243B1xAB8uYwZWu~ph`P>VhT26PYzh0*r>|6AD2olq#wwyoiqa<$giDr10Tq* z9qvJV$gN#LSr$K#Upr}>zM{2z&w{Xal;Fe0<}WS~aT~3U*iBm}oKn+ChLp`j$Kbe~ zi6`*_&!4w;&}vv6gOKa^N*Lt_0(B1tt zJ?x)&q0Na=uIc!4auauzv(uI>f56ok+*oz+rUwhWgqYi?o3OKYR~UYta4A4j^ZtC8 z7=by^@|lB`2Rqw-3;GaZ3i$C#d(9J(V6Ijv5?kE~3Bfvji{yJ|WzN$zA`k+OP-tFE zv1B?oN6+wr05>vI+#ntV1iQn=Na;kp7onVt5a~r-1Rpk%4vlk@{s)_T5hOaW@DLcMul@ZAo;s5P2IgfA_Mj)!OF8)1ihnu~D!pSNW=3 z-s(qiy*u_gWo2rqb-5T?7&h80V;U)W+eL*Uk;sFv(bu|si*<88%-{^jVK3b0V}-P% zPOttW9lbhT6~BS#?b^U@<5V-!cpNDX;VVf9Cy^8&9q(6?Wo}2d4;)8dNr**=Rph4k zmpW^t)>jD%0B3@PA`<>Gk5e89f0-25 zP!#R1N<4nzEQEdTg%fC5Qf)Sr!`)L%QEG-6`f+h=t`?k)x1j3MgPljygFI?lb#dZN z@FOSeEVHxF-c`|okha1Jk}2G?(9)p5a#0o)ncsY~d~u;tPFWr8q;ewoc2aZ8_6oZ> zen-0T7csejI0GfcOR)*;Jk5VF0oNUcqYoiZx&~9bNEAj-qO&UPt4 zL!prNey&?AtBgXl%!T+nN${2fq?_MKRyZCKwEfxcGRPVGyh%!l6Q&*$&MI!M!dtRG4<7P76c&Kyl#wbXNvCx#Le2tX9& z;C8vKZ*NdY_vv~Qwz74IE%Lo6S=t8%@x3Tn+6M;xy(n4Q2PDhyMaj}XK=0p+lBK@r zR`7idd?<_?q`>Q%&zd=I5$M#t9OkFss>oqMxSM2R8G1=qxUAX-X6Pkd-R?+bA8h>itUcdX zQyzdX>ngvxtF!r>)^K)9QOmQ2g9_7w;o}o8^K8GQZR7a?NaN;P__D70QusN}U8!&3 zE4nI4vIn=e@Ci?@uF7JvdNX;*A+TDm8L?q^m=M%#cdZ&7qXB>W&N<&&Mhll{-}0K75Ho?R4u< zILHr~5O7GLg#03Mva$}vvZA?P%F2q~-rQB} z4tV(y$RdD2B>H)C*NT;&|Ho3fR#?@M{FfwIc>=*;%dyF`N4B_JJLN}uqplzN^h2|y zrwmU{q&{$4e3P9$$77wg(mQ@CSsFE%jc^Q1?`u2G~fgz7~U-j54 zO0B|%`tXuacW116U!kz$r;`LtFwEgMU7qkTO%5UNnt^wgiN#T7VeN|?|eBr9}vY|C+5`rRt($ zZ}SO>#`c7Ccy~>5xe+y_9M5Da`OPiPMjOn0u-C9o++pv4aq*7X_{r&ssU6hQE5p6v z-!TXY1*9IS5pU?lGRdF(pZ|d247-DC0Cu!`RHP?udCIEz`?2WZ+xsEM6xL)j2P3w4 z>)Tkmf^6L=<08XjSh7bBO#Oxh#O`lQkY=lsnIMwf{x0x$;}ZY7#NU@~vcHkNN)2Z2 zbKEO#KA%VS?-dR0%tF_GdHYch0H1xpi1iUWlHGC(BT5 zjo*s`0h!20A%~aN23d5C+)a#AI5e_?4fz0KUH|lnllc32G7H`n4wz2Kz!)zPQ9~?w zBC4EpZkf-d<_>P8aU=Pa8sts1zcs!km-*k!K?51W32eD&T18+SrR7zhCM3X8ld1Wk zriSm$;^obMD45D*JeNb_`d#{T@G{jSCc$ z5_zDqS!^xl)?i{ZGfZSN5EHz&)FeXQzB7@{bsG;?j6YtPa@_x3OjcwZ_nxg=4j+jO zcS*`{`|jD=$M@!Zyt!w6Qtdx7wR`;Bc%zY&_15^R0-SWgu>HNG_R@z}l{LKEo@}>(i-n2H^#7&&zETK)J9Qt>SKopaN``dek_AGiJRCURbPhoR$R%SjstPKY?RmZ9#;nws7&1S(E=~_2iqB=$pCNePKaw?HE=$ zol@yC_E(bf7$1A?N*1*tooeh!lB|~?FKy`Bc3C^}?DxL5%EP&uI~GKedELXS3O$FD z3yr~#bHeXDc1(}Cay5Qh$8y^v6_IASN5%bcngbSx`0o3fL{b#-|Uk8?LRMS@;H zDnR=if?n88&;kS1!b-bgr4PyX)F`@}6Z*{sE*~5rSrfSpBsy4S6b5L>UCxUUV&7ozCOjtdLT3+0p+jss0oHZp0lG)qXhrIk2&+TYCBsxV^( zX8{7^PZt!gH11HSBIPwo0@V+MW+rnrlPT^Xff`^#{D}^)CRal!NMoxn@a|I(B&!LV zznKmr9$ySSHn(rFt656;S%-bKqd%17pA)b&gxxR@9RU{;3Z&kWZ@AP711b7I1gaB+ z>(kqw&|3xb1N6q9=xtA!UVZ{&PncdHdfRjPrdgcrH7k=*LZM?>QnU|mS zY)+~>j~?YZH^qbGRSd2AGn_6F%s3O-lxst#nHfCVSe?{eOEY%jXsA*14}(W@RW45| zyHDsr=f;Ypa@76`r2~{J`=vs%*zpg`+e25Z#!WMu>DqQ-JIC|E$9}<4({8|48y6*2 z&U(3ottxV8mTxT##}E4XnzU28oOEq#&V}0taBFcmbh({fFG!NHbSC|e z=0oneJy|%4+&wye><%u@XC@mM!|W+x^IowT>Dsn>uEs;P;kp$UL|UteY{Z=B8DxSP z1vmOJ-WxGw_2IgIp~aqGDJ9i_hGnbq?)AyEV2uf@>I(%ME0fCfwf2+dNV!U7OsL69KoiuH3fSRG48o+&fSgJG%~#AHmg*i&Xg4g22Q* z|5v2KWyVQ4P~n{mku1YO1@Bx)80{`UnwEAfFEp=7dzY#=X5+LH8WYP6qP0>g=e;JPdbu68snE3Sw?fn!Kh!=NkC}8kQxwJ0A((*;Ey*hz0 zlEZPOb%LkH)5EdUemG*+<8Zu$!?Bm6Tvk4S9_|vOQo3Ipx zm;s{6OGy3`Ao_d>xqlqe9sg2Vx!hWz;HAYi&5Nv+z^>%)Hd-6pme&_ALLSmc!Ya!4mPJ(t8H)&7hOs_vLd&X_PKU?o=tVnSfzav2OB^a+?5nA1(7qX%i;4P!M#Rc$HV^uo(1pT;b?Y z2ABE*I+AiQ<}JmVp;O8Z0Pha~1-I0F%7H+)g%l`42jkh+akHZ%HA88eZN_rBQsahr zW${@h>jDtJUCB;ThsZKPQ`^ZPB}ooQfNvK73U0?_5DL=NcD9Q{T9vEPY81SR=b*z+ zg6hr)$Vox9t6&8Fn{Mz|r`1@Zt4Vhiexlmd_1G<}?WL5!SQFhW$jkq6Da8mC(6 zrnFijAXek$-O^2IH6GqA-9$AJBwEvcorGdX8cKg4x~lPn@f3{?XP-6MCKns824L-zB$qWOHPhUWQo8qIkx20d>p zBujUt{Ti7*g_GI6IRRKYrSl8G2z}1SBsm8ShXlp*YB&mWXgD&#I8}C#m$Z@fZkId9 zP5sz(@4rGUS@#+QB~MKdbIuKA%w4`s>MkSREs8(vx0o$W)nHP^<5tsccaW}am$%EB z89DT2ZG;8VeI-;Ds=Y}~3N@6`Q>#ig^_!jot~i}sVbbct_MF6oVpCbIlJwrS?W%Tk zsaj&H?|eF{8d?NS2JnDG77cuS-%GRDV}e$IsJ;)UUf#is!bMFLR~Q zuK!;sjU*Ctpd=mZO*)2EbanJ3pW(AKq%Da|e^5s`avZ%sY7sMbkcc!g3Rxzlv@to^ zygE%JMMl7PH2dW=?o7NpLVRgj6Pu|5-`sAAbiwuVUKeIUXp$q69OCxP7fj zPLbW*4}nEEGI7?__||-g`NRFy{U5VFp-eh)_e87z#JMA9Bs5R92I%?Z#Plh-A|_O)rP3!j>yH1ZxmyRFg8f6R4~v z!=nU2rle3#lc%;EpPU(I=-mr0aw(inR5rWCCBL$+D)D=(HuWPL}3a&@LzKPaYms$APC8?c?{o^|J zfQJ^>ry6gL$=Gt;? zxN1|GHm^mc7=EK?*OJ-MAx*~hG;_T*L&58d8=HQ>&B1+KHKU1|qft%A^<938!1G(U zE^>+nqS5Pd`}RhqLb!=WZ?HxYCvGZkYHqOw&ovrX;Lv$nl~&;)K9B-TdVxkU)Q2>)&plxL1<*>ZI~Qqt(-M~m0U1i2V^I%p0d6~?Qw6rRPXaOU;W`&Ig{dh-of>3c6sz*orWvD z{Wz!*?#=q(cf`#eazg&Fqtk206B$(`p2=|bw5m}=aSB>1EUD0KJg^L00?+n4?6J5-jgGN#}qhtMhG-!K5A~1}B(Vo*drc*_)2>XH_-$TazWYi;TJ7?U)H_K{s$7$CKjl zA}WoTukMIsJ7HoeEw*ngW7MV=D09EZhjMc2+^+OQRe97P@?Osum!YqGYk7E)erPX! zRnkf4CeN*s!B3i|nGU0)kt^TYIqXH%$kwEU7yT+30?%KmGOU8Ca%Iw;I_Zub8=qnk zalBk5TVeUu>hRL^$6Lmy4(KOUI*_E)Duo23y-KFTu5CT%qfFlfx7LOiMMCi}V9eNO zf5x<=;Yb(0Uj#OD?d;>sWiU0$jJYSqmkak@|TuHwU&vO zay(lc#LIAL5h!8EFD>#0?ME20OG~5JYF=d`*mYOCakKyA)YffRUc0sBXLAegpGjhA z7qTl1GG~LAg93hJoW|1C5kkgk@P2^*CYf_NZC`C|Q*b#Jq>Zh6&RCKHKl-^NB4wtn z@Wjp(nK$__ZBMq5y!xX7%F(^Y%k4xEhJtirI{}311P04%I&Sn%$P#u<@FMG^ zrE5bcI=vGDg#Z&JYqNv3(QwU{xufX*YXB_pj zx5b}W#_PL$2a_0BzqK~U-#~Ibv9gXtm%AI%p1{p+D8_=itKAUpYWAQ(>4r6aCj z?bwm~{SZF(rr%SFh#3jMtgD_FTA_K~b@^9#&{?|@Q%s7BFZbJ^+iV}G7W1~i2ki}n z(rxRu$DuK)w_S5>fOPTRo30E$?G3b9(R*{Hz3Iwm-|r2y$!{tdXtScdS=yH(F~G@| zpFpxNMWTJXFK9dX4J7-5wxgmqF-k)z5~>XqU9ZMZ3}-0tDIkyxUFwVP!J>sc?l;#AUW8(d6stK6_+Fz&eS)!%U94)ES@3v_PiOteRmIQ zT%S}dJ|f;%FPSAId%?avitq5N=y0gg{HdfX_j!(6UT^+}+O=)PEacoarYCZ@zn%{l zlTQ0c&yv$TPB(_&!4+4=;ze_a@cSuZ<)!gZd9RHBt(!?hbzS)i6zTG>X(8qb<`D|% z8dp~#|LTSC?KA(yd#oGLDsn()=+&O1A2;kZAK~Q~edc@Y7oYfgL+T3Xfxb5<@pT5g7wQX)WDp<6|fmn7wtO=)&xnsK2gIUzvwkxiWT9rDQnDzSrfepd#l$W` zt_6KL3=_d&69u5RQT)-l0iVi`p zkujw;mF$UiPlBTyChZ-=wXHpAu|;|j7%aC$DOo+$8gFEJoS{9O2@jNskdszKC=B@) z8J8+%-C-g~lg1@`l9}{GDl2_w_rY~HbhYD6ge zL?$@+$6To9bcqd^yER`e3mbEBN(s(N1f$)kgNaX=V3FtBToznG5!reWvsoxK6+$iPn>q!d54cJJJ`^Ok{; z)`lmW6&7aK!Qp{@>NQg_a-;K-m&+~EGvf#KnU2Sx{n_HSgBpUuoL^qth*Z8JQyU!xVR z3=9q5DYTjgTY=uzvu_>XI}5$vXKSY>WCY^aHJUF*gU|{KosF}#f8dtAgSQOq-!-8A z3<=G%2S)}rvS5Ss?H1wGG(USMjY23iEYhG~ERp`skuHk10=g+X((W1 zFpvwCMh@<}HOx8W@1f7Fj#c^#A*`q4V(EWs7yF<7@sf@`_OXl0H-GxabsfF-5$Q~S z`o~MdM=s`B4TZd(rKmFr|4i0fjUfL+>&)?L$$9VB<;|kn- zsMI>-2ks%`jcIZ}KTml}mt0UpX-Y?<;9(fc6zo|C0*uJTJCd+jN>_#I!e5Tw3!e&! zvvoqpU~4vr&M8v&=p>&W#~r;lZJhJOtevDJ2%%rpGcN!nV!339aa-ps7f@e&#?=$2 zN!38|45IVtSc=|Vdu|)SZ0*BQ>{yD32IP`M)!>M2_dg}RI&soCsU3o8anC^{Mi>%x zP#^z?332D}ShILEtdu#3;cMY?m3GNkBc*N|f%s>jR@rnd=%|$mUE^NNC0UpHZNZNn zZ65Oc_Tn-#aGP##JG#Bz)x}W&F+x=>Z}+L6M+F)k)Kh ztC)TxU1j_MiR!3)N0cxFm!8Q<(naV{JKxHM>4^(xJ)x4;P3nOgQEv2b3oOWi{8`1l zrRFljCm}oSIpuX9qYO2k6{3RCH?q<@d5Uwr>dV7-XZa#xIfvtxpqDneKKzKpbU-^7 zxg)bHEq9_UwFZh(tM20EV!8L@#vtHA-22;5hQxhwB%z|pcDd%z{U3G(wF+4}Kst~j zMbj}b8>uh(jGn3k11VDJhz3%m(jDA_Z?=LR3i|1m@+yKI+IkwOX9ia38Ey%#+tgIm z;JRIqgMVp|uFCHMNWg{oU9@3!@-r6LJ_pi045ry@czx7$!;ZU&M zXXLg&N!(aVwAPPN{)C*d_A-gfIz46>YxNbLyZ~`~rOVKZ_R22T>AO95<$AX%xjlE~_N&6==v0<*_W!hQ zb2zNVe*6L@(O|o$x)90S|6lZ5vd!wCg13yfrPZk&T2jgCAX-_j#?l)4{aoueKdr%j zdY~2fOq}cS5It;{3}sZeBRc1po;U0Igz6o$nbRqk!!=!Q3!rHhcgL;Av`yze~qq?PfM3+soQ?^pbzphA1^q(ac(&3GrrRdy$*;R%?c8 z>xx(5*?>=So|ZkIX1>XkKqNq|OFfn)5v^Mn#~_Jl-IZ>B>d*t*<|DnynxfkJ;+4%c z@opu)AFipQk+?s-m$Klj?}~kv1!w(Qx08UDM%HiLmR#)jpG`!SHoLiJ#G8j<(rF$= z`%NJ{c^>a)D+U+X9L&cBHy;aBXOPSpha972(lqgYOE75y(9bPFQ~;u%TY{(nL_fF8 zAu9acNOD12Mvc2poJ8vwWQFdwlp6li(=0FNCLS2Ab|0kB zye}|?-3{e)y*-dD4EsGy!d2}V zN#@Cc5b6xik*?&`g`_F=8@Tq)(?^{V2K?B1vO_;~{JfG>PL7fTZxEs+g2_yk2~pXg za-u}fO-Z*pW78&iDD>j|xjQmcJ%3zIy7F^=O;X}m3H`ogPWou4D*C7z#8j5Jw+GR2 zo{DW`V--)|i}Y4It>jSLc!GoB+_l?@gW74e#tk#X%Nf3PPDFiebfBqq5v*}MS$T>n z9E;1wX*w2G!OeVI=A4H4X#m{IgmET?G^Ub^tfdK)Xy<6pO@se}_AU@!TgO?A4kwFq zjhYzk!tD&-*f2}`BBG3%7|+5KzdmR5BX2>k*mF2p24#$90bPL-}osW4YN zl1{0ioIfwxwr)o4WUIrrukVNCUTz2TTBX*gtK z;zJJqZxxc|A`=OqyIlMX?L1@5kL)|OyMibjE|`Vk^b0q*ta~}`XkoyyTF-{$LYq2l zxIRp7fF?V8=Q9lCDqlHr_T-6ky9h)*U!i?^a{Ro7S_BX%YGdm7d~K~uR_!CMQS(Vc zp!rI@AX#?sEFpeJX)f328TWR-@SpdzVK2Ac+p|&6xUcqnp`0viZw&8pv4OE8ta~7$ z88PQ~xqve#anN#2)kQUp=Os(diBqr^AG*}ZuVe2b|_P~5V=wa=1BbwhZR4B+ zpNo3H6zev~5vj0P4lEXiJW@gVF(WJ{uFE`x2Es03(Yir|4f;0i)D|4%=kD8F6sf+2 zx;fIyPKoQ2Ry&7rNl%T-C3{B6sJCmh3?&tfqabsR&nY*WR z9ZdAp1Q%6L1($iM&vE8hb;OrjW{EFz|{wAk!`UY(^G-!=H9?_E)kd)r&xULa*~&eE&lWYV1!IkK>dh15@B57LU=jZEhLT*~8#bryK^fDAx90NDNp#;tY1~FpV(v zZF~_lu@Z{TP804{dHS~}y|GZ&D`ZrjiBy+AKT$!%dtq0yC8v9}M542~=*Hd@Jr_&1CU>>oKeGPY}I9}XB}l;1ma%fW%MJGL$% zWplD%VE^v1p*>>*2Znatx}=jE$V%Keb`R~7=fK#==+4oBv4aQjFBn)-x$R|Fzx44x z{pZD{W}%Q&8=a+&zvqvidgcHt#;E-t1SOHl_b?(+Oe`~=3u zE8X242z9_>UPI`0wMBQw$^z=0*fks>=It4Z=u&qshv~AQt_SD1@~gmliO=`j#|L1GfW& z_O<3~Lnd6sCiH~%Lu`UTweJB4>(JMzeG5QXS9GUZ0C21evC%-ZzmC}G>u8?zZ7l&~rI zFMCkJrr?qfM8`I5a-|_QFO*TeVU5iK)(Z~DD4TI_Py+H39qA1!P#}=>q5}Q*GTsr| zu&v|jLi3}A-1ww~)4Qst(cm&t<5$tbZ;owxZyw*(76~Lb;{LV=dp}9xPw^BvIZDiQ zA1a|7k1qq`;)k33_~9c*ckI}AuWLjO;Y5IDS~z*s;?SNmXZS+vAt6QDh-{~`D(~2# z@_`7XeB4%BZF8-#j4Q)jhp-HRni-G1xC&Ps5rKteT-BCh?y9yFb5~)677^lmdYvq~ zDG|y6#HJ;(4Cy7Wt94uZ724ks+7Cey0`(l3<)pizcrc%n?uOtdLgWJ*n86p}*VxSf~cd{t%2tlmRFQ$ho$`Q1a6NX8F}Q@^r0COy&sz zI8O0_fk5RRt1u80{RAC=^Y-$Z%Er09e0}p{MQp-)0w=6xRN2c*7gIfMDW2lz z$@jZI!Wy?qJJm;h3uJGMVy7kV32J+9>fDRm`8O2pk45|O-1DPJ>T$E_L~K#1ct{L! zRy_>u8kgm!@xf`E+TAO~G$0+*`Oxi*-Pk!B9fllUnG7>8MtA$gS4W5?yZyQy0g{d2 z05ku0KJ)OU1Ld8~Kc6}C(X}?ucg_Z@*Bl%+wYbD74-82rt-x^bVVj>5#bIY7R0^IM zYZ6?=W=$^PI#0_?2J0IRa_#71=YG^BZd(&E!~+)-m0-L<)|dm&x;a2Hsf?CaCXacX zlR>@Bj~8a_hPl?{$Nenu;ffoNu8KikvJ#CFCd2JohO}f{Nd{GsHbsVe3B~ z*m~7t-2|#dQZ&L+em0!NKmuI00APN9He_J|0^`pHNdt(k{VbA(BtahQJJfOXsY-%1 z@Ei&~tI@OZ(6gHNOrUyJvz{FZJp*c#j)a~8(6b|Xd!w}9xy^DkP%v1)Er zr#hxB2GJasPIa2R7#x?*aHfw$$E7pHh0Q&9XxcRh3pk#E*tersz3*OgtU}Wi-i(pR z?XMf=?%2T*D0n=b=~|%v14*DdTU;a|LXZH<^eTXB$5~PqdqMs7f}-We@1FB; zTQGJ~@EifQx3IQJqx_D~aq*j^dOw!p_`u*SB) z#`fxh-4DtF8)cx)HW(lfy}H0HBa~%quP$ur4ItNy^8E!COw+OeiWYQH{E-X9{RM6s zUX_gjh!7S)Fx_833Ud%whVla$CN7vj7snsL^gxD5?L1IuG~=&edY}L&P!@)Tjmql_ z9dBg5ds_R(&ymD$yk%ODO#d6bUZ4Wa&O)%gzQCIq1~64`h*P!Dr^;ZxDzvCkd9cv& zW^iAW;?5MnGte#q?SqBd#fEkfXn!f9UF2vD7TU#)%G(MZ?`T7tsexypT@2c{MYM}S z`}T--v7T4ZH^<-qWq1Ax@FD z{4G5AGGTEh7HMniLK@i4k@Vv2c{PJ7Szb%(L4)qRKMu1&cm8ecPQ$xnu*k6?MpXV_ zBu7Alt~P=Lf*E|UfcH}<3*HYFaDNIQjue(3aBd7({4KV;n=xu?_7Y(mXkHS_(%FX#JU`=AxeJzmyHFNiRaMFY`LKZuTLoL-gNUil-1 zvhHoF$_55rItj}!Vh@1p$2 zg<{240nngsn`a;p{c)k{gbm14{&4{*M(k~Qqw?v3bjYx`aL`+rPHs?PYb^)Mrwb$p z)z(^0cm5=HXSsJLz`O{rt@YVL@ta|50aN$0aceENt@W4|ehV}M*eC<7eJXkwjg|_a zd#rGQA9{cc=&=G$cmAt(-{*y0N}lD~GeGk0i%Qn{)W4y+RY;L*e||nguKoE%A(!rC zx%MZ}W#`q$I9m1Ni-il;hE8hlez8E32LDxi_sq=7J1Hm3y_c9_r9+b< zNApUMJTd2z&ck!jCRt9O_TUR5mP{hnRmOWP-t~BttmqOweq553|5~MsUqj?eo@E_o zCjqT*ZjBwDx&P>b+=|akE0waGp}o1ZHEz&g=#Di!{tS; zz(8xxcaDa7VEx+yiS7aj*1s)qqj8YO9?IV+louz@F|1r)fmSzfi(|-9jx=QSU*7bJ zcz2yH;dn6IIKNKGuK1Wi!(4bly~N&Iru%ie@r}Z&W-cV8q9>4Bh2(duGdw-RG+7K{ zqutZfe|kDSJ(cNW$jE>ko%J+R+mGPqgBdcbr+A1T`5gvJvY$4sq!sqsBsoL9XHyLv zFMa6I`*cPSi2oQpkYd>#WxXbO+o#GNsc^xMRir{{pR8r}Nxk%_@B}X53HHe?oO7Hp z3p*M}Yzj~CVa}hIUl(l77#^K6RYHhOkQq{3KbRqJKx3XdX-S%B-OA!*KkPNZ(O$*@3=ldGBquw1@}m&@ufsRDEBcN-j^@$GCoIQ>!qoixO68=+Y(zZ zMbeX%h0V0Q;{sV(G7s)R5U`Wz!o57a;wl&J743f70@Zu$nDlp5&@liBaN{cggI*O( zV?gv~Rd`nkh(WKq@S@!Q^(V|fx;9zU=BJ(GAi-JN9zaPSb=Z;uICRrlWv#xCywU&OCsg2bJ?ngqvFzb7qnb~ zzFb`ni+o)j7kk^D5H4DBr1kB{(hT2I?N10RCv)x;dxlGA*!j+yrxcB7l>+ReUPO)|Uh+*5DNXIHS59XjstzyoS{bICUlQlhSZ=z^ zUo>0QekINh%a$WRE0s-~i3#ZhS#)FDc>9+oi^tk~-t#iDaL+-)Q;oyPa=+)|{FUoi zlrLuZK;}yH&4F%sS;RG(?Te{w__zJSau-4F{IL>2{Do+6b-?@}BArhJX-KDKLl_G$bD--GohS zS-~q4YwhwgSf6tY~|n;{w`I3P}t!de7NYNFu!CFh7X| zb3vN~b3vN~a{&^}RW89?Bxj|N^3_iy-OQYFbfcK@qD?B+T$Fp4V@;T`;MFBC6KmEo zlJs4Uwe!8pksDXwdST*R09qR+&IO>gZSQidZF`qP0fOnf9P5}kk~2x_o87>?^GEIp zh$jXL0x6{Lrl zgF3bT8^R+p@>YvZS;1Q^I%VO626f82N{`5xw;Io*%>9T=ek$x#2vbch#@!pH8i=NQ z!&CzyYxahzMt4eU3M^FiNoi66t|0rQ(jFN77~XX3Xg_9-O$0(k8jNqQ00~dppd5Gf z=E~hnG#Aw?J4Oo4vGyJ8mto!q!d!|IR^)ehX`MU+?V3Y?9&gH+I(kE=a{ll>%Aw0o zjp!RC+vb(QH%ktB+XHzA2R(u6J$`pg!Zl37WlVxO=v`A>k1k*oCC9mp<+kX-#L2tD zBvXztUlaTpfoSZSm1Zp!4#s><$XHskN!HUU6NaCH*B5s-pOa4V+|O2#MJO7M(w(UyrcdXx-y_coP{&udv}C z%F~!ncvJs+LbUH(0e#n@Fhb7IPpFS@aUP8BoBxlc@CAYUy4b9b5XhZK?WsQUx zlTX^7;3>frF`IiXa!+3%%^21dr(wlz4>FqvZ88m5+q~80GCoHbvMR;QF1eR`eE)@4 z!=##m(dK3&r76XD4~9EzNf+nRV2JSsqC0~j#=B(sb(QHC*9g{@YI_O1CUF{D>fq@U zFKVTdmbi>~X(ow1)y~MnYj0q3G(l(fUg`nbKy+p=0opz{iUJ=>37l#}q*uPeEFZL< zx8G0so|Be$zB!N+(@@AxP%6sfXK1~Ln*-78P{?*%Dk`(@FwOSc9Ke8vi#y1yFM|us zx-1!4R-`mbqcCsVC`n7hY1?uV8eZ?Y5NM6Ihp)YkgWa5m4|X69N_U}EIvAW|l%o8O zzy*Uhut3ccPQG;aIdoSZs^$&+oZ@xyC)#~ZmuoWIH~2Xh`R<|9v?M0eos6Rv2qAZ)MZSt{G<5PDBy~C5gbQDlbo;^bT zS2KCJT4H6^w=?8~BWGPe+5X^cMwa2{_6KJ@5w>HT@r#&NsMD?;3A$Xb)c#Yr_4rRu zqWPJjjd6BlHG2*wOKzDsck9IX$#chtozFE$r*ZB$nVycHIY|()ZX&uR>cSkq?wjI% zmR&0><4gJ@ReHXKW+8`H4>V3q5GCD==XLI3lrevrENy00EVCZvPyx3kZHM92@C9B6`o3s1Xzc#seEX6fv zn>;zSoY9UrxelJb>r9-=8>Gq}XBMHupr4rixQk_`-bV@Mn5dxfqx!pjg7r2^im2zk zoym%s?S(1c!7I4OF%u{Ql1Ea`(8BoQ?><4k#B;P)wbGvbh$pKIvMH=Hg$`=k)7Pv@ zMhis(e)RZ+emei6N?)Nja?ZP`NB(DbXBT5vb=~nfhr?xjhWYTdjm^cFVQ_*mW@;N- zKroSPFa{hV@K7Tp)bZFJ!_@O(&z*_upvE+WIv-%Z6jx9Lh0;7wB@MJnM5;s)5)yrg zghVB(R6^nbiIx{0cpxH``unfF_BrR?@r;cN5~?&0v+g-(@3r?{d+m?4)_>&}pQ`^0 z#qVKWb9?TtcuT<@#znD+Qf!LH;`7G*BCv;P_3+5IFuaZI9VZC1kTARE$jGj9VR&h4 ze5Wi;h3v{NTcD8QoT3~Mc$}uf${e$7z&^AJuY!5T z+5WS!*mfE*?$1}9Qp$^7-nJ@VIlMsYwtY2Uz0mX6db>CYEgCqScZZh739;iWgSZjZ zGK{;mW0YemXhQ{t5A2PB<6fn5zA>w83m0!y+5!e!*OF+x37=?2hHuNU7Z z+eQZJ_^!{rwwT9->S>0_AF7h5wWyA88##9Zl&XVB@`~yphVO!jk-s1bT(b!@Dw(6d zJvwYKId&V_&HdsMp5GoG5Ak%{QSd2SJ5~}G8|0=jS7qBS%$K9nPF4QE!%2RA9P{Sv z_zX6)S{NShl!Z)Ybb`NJUrQ9Cpfde3xgOEXS)K<&;3hAJSIUvmbYSJ8+JW!nb#!Dg zM6MriY&YPw1-i~NL@IGtH0PlWJ2`+3D+{l2N1QM8Hz7+-ki+WLf0PmK$iv0a8ENM- zseD>EM}rI4sxXD1R&Rnzn$=8SBtasgXd} zugH7jp>{Z=)UYY5{cKNs@XcAvPP(a>o7&|lqh8C2qiyHpbF)=X2lLEl(9`#}^lNiC z?D&1dbB5?FDD!HMIv9c0klXTZzhdxmLxB6~Bf8Job$i3d0*E@zLp!xCAce9GcC&dty%esa@gHXty;NlV^OwJ zU8H0^*2|Pwl%sUQ8sv$Q>4RW)1i9b*SxVhb*rR<+v5P1w5RH5C&R8F(%~fFm8gAmz zj8o(s)nOWE*G1v2q3}|x3Muz|jl1(jMWxCuJD>V$Thf?lWg(X8wC%&n(?wwUj2s=G z8reVa2#_5tv)gsVk1K3(0}wT3c>~a*=0595rNV&@wc#k+dfjoa-BCF=z8~3CO|RXx zu^lzoGGp2o3{k=9sp{JJn_Axe4ZCmj`mv_=yn*;SWjIX&3^~n{6_Ov43G8J!o3RkB({+R!eeyN;FR!7{O z&W^SAFUE~Uo;lh4T7%aKrjYa9@fD*+w|kP9Q;B87ky%YnCGtAKB+}X)%k|{dWHz?OT&m z`xO*`sYP$|?ebc0q~LscpT(Q%8O$%gatr81Dwh`<>FvaK-Z?u;#x_~XbPK0!kn)JX zZxkPVaD>LVo}T9Z96N?Gr-3OTb9qeH$-Zc&tekz?IgOgdFslM8Z!BButjI9j*t}CV zAfEAPWqON05-TffeUZ|$#b)l-i}dHptIP>j6ostZbh8%%u`0dWKa&M>Rc%?MC}h3 zZX^q(vB;yw~Bh+#xb*n-Q_qZpWpv2kD?M+r69Pc|D_ zU?q!!w&ADd7#P?Xrh~8&Ydwi=eCzS#YP)1=~a&WGc4GK|ABlz+EOJG zAe2@}qlddwf?;J$lHZo?pryJq6dsdslJ$%n!dkEVI)c_1;c7zV#D-x(-OP)jRfTUB zBRS~y=`hO%>s9cpLBiQ(AI0N)L&w)bw`)3VaK{|^c5n1UKPq==RNY`)%|( zl!y(D8>$i+Y+iB3PfS@d!F|{P>C1yOz?0W zJjluqC1NvH{_W9vrhF-$5MG0a^=ys=C`F`H2!k#Sa$pv~7IQTz*b#`+bp<=>pX&&_ z2<%8fTnD_mdJ(UwEgjoJFN&8fgoMlDEcd1xycgZQNu&zE{fHK9r3F7!U=pOLs~dkw zWN!1I$J!p1zgox_R@veZ$E6goiYd^YIDQ!5tV~WJaD0g174QJ-qPW<*d!prR-Kh4D z>-H7aAvaM9$;nw`3U6)kn%e3P1;J}-D-H!}H%?49b?gi6mO{J9(Fe4YZ1;A%kC8|f zjz}M)Y&ItL(@>B(<2n7d)NGJIt#EDy`;FBG!N(s>eQU9;z=U9imdm}fnhplXW?xjUA6NoA%Y`#JMHwe-@)5S zS&{!Xw3IjRAPvr=N&hXgD2J;wc(T*Aq-O}tn7^U$u0N$w*^l6cyb&Y)_n)G~@v(!W z{d*?pxXIBOV7>O}1~L3A7qR9PI<9DyME#D!p+%y8hvx$iPEvPx(x%`fr8LdEZ1_;< zLo|vUmDP;capv1F?Hy^tDeJLT;`_;52OA4$0cwDkn87)CP7M{jXh|I^c#)^|0N?jApYF8j6zrW9X*W$5G)g)DmAI`XP&s5V z96=iv#^gd!&Q%3P$7qjmZ#{~?oQ>`+#9z)v!|-*B@kMin@%p*`w0unk{E+<|F(m49 zIjF(GaKV8#7{lDR1?8eG#W21BJFhuwBU)f7w`?~N&;v{JH~^q z)M$>fV>YiO*gD|4(mjzxssX_h=^zbwSsKJ#kq)LeG@cbs)Zy7Nwku&9vnQNhZ{5Eq zHf1_kbv5s-JBvjkHkqEStP?i=wAA~ahvT3RnraN8!(jtNGQidzE8h=rXGQKBcxAv3 zdT1LIEj~DSZEO~!bnyBu)-Oc*PIgR(evvdyI*_F>1gE@T979l~`UTWL2KF%hA`dOY z5ImfQEIAO=d$=nk@`Iwp!$ej4XViO`pz1EhqgFq6rsF}tQ8fq>q8Z=%2t07sD|YZe zktzX*KPo=r6&pa{ku-u_sp2C=%6L_Lq+!PbeMZGc+zS@;KME24&bQ#+umB%m;UAl4 z;Xlj8K8y@Th;=?>>%u}97kTQ1-v?a5K*8?=zQ{wt?*r~N2s$AA9)sT}C547Dcr3lD z@x9768)Ny{#Oy?b$iY4N+jtBkQ2W@oazYXQlfwx2ui0?>w*IU7H*CE2_H9@A-esmL zjA6FP|In~2wcka9%}#o}b=>!^*`)7%T(o2y^H~UoH0D@+O?*aUj$Ns3q|nD|-%UDx zz_1IZK%vp!O*)qbf=KI!B4$B=7mIY3ohQtKhm-XCj#&WBw+~0m0>c=~{+h^XYT<2m zHKIbSyFrdYUJ|1D4RZ_<_g@7|pQnMyjwhMUg8Gzlp#oX~l#v%)6yIgbbv!YoM&Xs! zew1`P?u`MDEc;PH5QACg3%gvY6&sT|6hpJRxfj`ZfpTr@917iUN=1*bwCkYxXkbQ5u%3et_Eq9Jm28Ep+@d+qpuOu6`M-q-+Np88-Ne#%WQH>c&8B|bX zP*n43;%W?%PhX8{3^1`@Bpv_ijS*1TFCz3-H;wsaY>Wei87+poO`czi@(fuAc_vOLgl%dqY$ciJy{g=F(eInT@48}st?{s$nz`2 zmmc*2)FmLk+{HrPejfHalJRBw zs5Vz#EsgtK)J{E$SAlfx1ZixzcIvUS!CT@{VNsBRD@t!|+^rxP?sbBLfkyPuNRy+q zg2BeCQ7IgfII4Nj%KrW}d^VvA6!Lf%*gv{#Fh6a!Oo2sqz|_+fCIq#@;#;i<6u&v$w_Ku(61wK;6J8b0HRkLHw+k6CBXnN!Qv~J{H~SAvPfgfX zevXa_fsfUdNBrSV4ZX9}Tj$cV#Sfx>fP}>lqJDrhE?htKnhN_+EJ{B> z)-$LtOg~To%zYU310>9S81(}r%zYU3L$5;cvX8{vAGG^}HuCbue<%HSTdD(FN6V!p z*m1&@dW4}W4-HMB{Z&+3^WfN_LnvBl-09JA5~BBSo#`JR9Z}%$BzG!=^;N!hkEybd z_`}339{`ff;;th4qq9wMsAis8?pv&VV^jOb_8CMc+W-*WAZD;qDPsg4XLg+@$DIB{ zQ_V501HLZNbZdK8*a1M&ZyzOn0UHdGe)}k5gY_zBZ1%DG4I~xy8+{IWW#eRW3g+C8 zt=-BZocKv!!m3_kTFyo%b^R>^#!bPjWp;{<>({TJz&>~T*reSlV91#@Bi&~Ge!T^q zOv}8Zsjr1^-#Cdc?B*#bL225L%lR)&`*A|pmp`SAAL}R;NUjL8Pt-UFEzd_1Z&e`u&1;sr84_JZxV3&cR@reJ?F|4>rs2 zODNFHmf3O6P9PYIcUdk)uQ3>{QSNmyO;|vxMV*%FfXP!7!9W~OyO)$dz{T-&>1E{) zaD6-_7}>fXrhb_EEsUf5I?ZtC1n@|jJ{g;2CxAy%(6uC*eRhC(G|hCe$=l)h(up*awO*g{F4Q-?T=m?EbmNXl^6826ww+FDmOqxVEf1fv zAVP(!sy)##4I$LkY~?=JgNwB%9$Tn|!GA9GdoW0= z)sHQZaQIwm*TSsu_1X)mc|}Rm>%93wiqD1-bo`64cap literal 0 HcmV?d00001 diff --git a/bindings/wasm/poi_wasm/grpc/iota-schema.lock.json b/bindings/wasm/poi_wasm/grpc/iota-schema.lock.json new file mode 100644 index 0000000..8b58c59 --- /dev/null +++ b/bindings/wasm/poi_wasm/grpc/iota-schema.lock.json @@ -0,0 +1,9 @@ +{ + "repository": "https://github.com/iotaledger/iota-rust-sdk", + "revision": "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11", + "protoRoot": "crates/iota-sdk-grpc-types/proto", + "entrypoints": [ + "iota/grpc/v1/ledger_service.proto" + ], + "imageSha256": "sha256:1edb00622210b6685be1f9d0c979cd926671856c146f7db91ea196436d410b29" +} diff --git a/bindings/wasm/poi_wasm/package-lock.json b/bindings/wasm/poi_wasm/package-lock.json new file mode 100644 index 0000000..8f43d16 --- /dev/null +++ b/bindings/wasm/poi_wasm/package-lock.json @@ -0,0 +1,1094 @@ +{ + "name": "@iota/poi-wasm", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@iota/poi-wasm", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@connectrpc/connect": "2.1.2", + "@connectrpc/connect-node": "2.1.2" + }, + "devDependencies": { + "@bufbuild/buf": "1.72.0", + "@bufbuild/protoc-gen-es": "2.13.0", + "@types/node": "22.20.1", + "rimraf": "6.0.1", + "tsx": "4.23.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@bufbuild/buf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.72.0.tgz", + "integrity": "sha512-BwBKTX/WXkhAhqWJGrEKnqU03/4tK1O0OozSlwUMBCOEo8pLL3xu3M24RT3+umExEeM0wjlANO6axqGWMqtt4Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.72.0", + "@bufbuild/buf-darwin-x64": "1.72.0", + "@bufbuild/buf-linux-aarch64": "1.72.0", + "@bufbuild/buf-linux-armv7": "1.72.0", + "@bufbuild/buf-linux-x64": "1.72.0", + "@bufbuild/buf-win32-arm64": "1.72.0", + "@bufbuild/buf-win32-x64": "1.72.0" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-rKHRvjwAThapxIoOn92vIoTjYSz5FmRemDRLU4BYT4T6QWMEC13PM3/pPnqVgsNKZ5aW7iYDm9ztnisEqSi5yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.72.0.tgz", + "integrity": "sha512-4TQ1AGft8sGspNg9NMsEjsKKis7nGaVV8tZLnNa3cKUBmx22gwOnB6VRhgKWwjf+BDqr85lUEzQ6wHCboNUutg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.72.0.tgz", + "integrity": "sha512-cbIsUcgM5bHhbZWcDaAXqaYOAi8N0c0u+NiDydwVmZ04Et3s1EZ3TDqfQDRzwvoBPDP+lsO6YuTRXX6nI28x4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.72.0.tgz", + "integrity": "sha512-v/bXVsFL8YNm2HgosGb9r3+nAt4jQiUc3r3JipYuiVY3DAJZAjoEvcak6/BkxQMTEQz9Zb8gRRlule9IFkbc5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.72.0.tgz", + "integrity": "sha512-4xHGXEjqFxo1wX1zMGq4CzhYt5++nrj4C7k30j+YmGtvqCnipfdSe+V6kknBYRfYswVZEUwUbQOh6pnMTcGcrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.72.0.tgz", + "integrity": "sha512-WH7ClsoB9A0e/5fFhx0DLqLzillYPRdHBhlwzihgvjGci0bBdyJVHSQGf0B9uspCMU6sn6W/N1S9/2vvQBNMug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.72.0.tgz", + "integrity": "sha512-X3eWqFzhDmu8CYQZz+Fu7i+PgH+yUl8UwJ5+x+bhZRYAIdcijikthodk60c5u/qq42m1Z2XAnAGyp/mTf7IffA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz", + "integrity": "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.13.0.tgz", + "integrity": "sha512-ylI1vrLksdnXrVZRs9xGxmrQxKGhUm6pPszv26kqBvNiO3qPTktk+hgfwbLISBY4M/reShkT2dFLGT9fbydBXg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@bufbuild/protoplugin": "2.13.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.13.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.13.0.tgz", + "integrity": "sha512-32eMChKaL/A8Hh5AfMmXSdnuyznN85uoEjoyWiWeRrvtQOtpqX/v1R9PDe0g9vMIgzznK9inMT3CUaal0kjLUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", + "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.2.tgz", + "integrity": "sha512-+i/aAOpsI8sIx1mbYp6d99zvxaUSF6t/jP9Ux9maAmjsZPgmIQ3JuIeYi0zJIP9zlCnBlJjkpPosshCgdRuThQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/bindings/wasm/poi_wasm/package.json b/bindings/wasm/poi_wasm/package.json new file mode 100644 index 0000000..d5f1ca4 --- /dev/null +++ b/bindings/wasm/poi_wasm/package.json @@ -0,0 +1,39 @@ +{ + "name": "@iota/poi-wasm", + "version": "0.0.0", + "private": true, + "description": "Node.js WASM bindings for the IOTA Proof of Inclusion Package.", + "license": "Apache-2.0", + "type": "module", + "files": [ + "node/*" + ], + "scripts": { + "build:src:nodejs": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", + "prebundle:nodejs": "rimraf node", + "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/poi_wasm.wasm --typescript --target nodejs --out-dir node && node ../build/node poi_wasm --skip-fetch-polyfill && tsc --project ./tsconfig.build.json", + "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/poi_wasm_bg.wasm -o node/poi_wasm_bg.wasm", + "grpc:schema:update": "node scripts/update-iota-schema.mjs", + "grpc:generate": "node scripts/generate-grpc.mjs", + "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts", + "verify": "npm run grpc:generate && npm run build:nodejs && npm run typecheck && npm test", + "example:service-info": "tsx examples/service-info.ts" + }, + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@connectrpc/connect": "2.1.2", + "@connectrpc/connect-node": "2.1.2" + }, + "devDependencies": { + "@bufbuild/buf": "1.72.0", + "@bufbuild/protoc-gen-es": "2.13.0", + "@types/node": "22.20.1", + "rimraf": "6.0.1", + "tsx": "4.23.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/bindings/wasm/poi_wasm/rust-toolchain.toml b/bindings/wasm/poi_wasm/rust-toolchain.toml new file mode 100644 index 0000000..825d39b --- /dev/null +++ b/bindings/wasm/poi_wasm/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "stable" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] +profile = "minimal" diff --git a/bindings/wasm/poi_wasm/scripts/generate-grpc.mjs b/bindings/wasm/poi_wasm/scripts/generate-grpc.mjs new file mode 100644 index 0000000..6e0e01c --- /dev/null +++ b/bindings/wasm/poi_wasm/scripts/generate-grpc.mjs @@ -0,0 +1,49 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const lock = JSON.parse( + await readFile(resolve(packageRoot, "grpc/iota-schema.lock.json"), "utf8"), +); +const image = await readFile(resolve(packageRoot, "grpc/iota-ledger.binpb")); +const actualDigest = `sha256:${createHash("sha256").update(image).digest("hex")}`; + +if (lock.imageSha256 !== actualDigest) { + throw new Error( + `IOTA schema image digest mismatch: expected ${lock.imageSha256}, received ${actualDigest}`, + ); +} + +await run("buf", ["generate", "--template", "grpc/buf.gen.yaml"]); + +function run(command, args) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: packageRoot, + env: { + ...process.env, + BUF_CACHE_DIR: resolve(packageRoot, ".cache/buf"), + }, + stdio: "inherit", + shell: false, + }); + + child.once("error", (error) => { + reject(new Error(`failed to start ${command}: ${error.message}`, { cause: error })); + }); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(); + } else { + reject(new Error(`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`)); + } + }); + }); +} + diff --git a/bindings/wasm/poi_wasm/scripts/update-iota-schema.mjs b/bindings/wasm/poi_wasm/scripts/update-iota-schema.mjs new file mode 100644 index 0000000..0c0cd50 --- /dev/null +++ b/bindings/wasm/poi_wasm/scripts/update-iota-schema.mjs @@ -0,0 +1,77 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const lockPath = resolve(packageRoot, "grpc/iota-schema.lock.json"); +const imagePath = resolve(packageRoot, "grpc/iota-ledger.binpb"); +const temporaryImagePath = `${imagePath}.tmp`; +const lock = JSON.parse(await readFile(lockPath, "utf8")); +const revision = process.argv[2] ?? lock.revision; + +if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new Error("IOTA SDK revision must be a complete 40-character lowercase Git commit"); +} + +if (lock.repository !== "https://github.com/iotaledger/iota-rust-sdk") { + throw new Error(`refusing to download schema from unapproved repository: ${lock.repository}`); +} + +const archive = `${lock.repository}/archive/${revision}.tar.gz`; +const input = `${archive}#strip_components=1,subdir=${lock.protoRoot}`; +const args = ["build", input, "--timeout", "60s", "--output", temporaryImagePath]; + +for (const entrypoint of lock.entrypoints) { + args.push("--path", entrypoint); +} + +await rm(temporaryImagePath, { force: true }); + +try { + await run("buf", args); + + const image = await readFile(temporaryImagePath); + const imageSha256 = `sha256:${createHash("sha256").update(image).digest("hex")}`; + + await rename(temporaryImagePath, imagePath); + await writeFile( + lockPath, + `${JSON.stringify({ ...lock, revision, imageSha256 }, null, 2)}\n`, + ); + + console.log(`Updated IOTA gRPC schema to ${revision}`); + console.log(`Buf image: ${imageSha256}`); +} finally { + await rm(temporaryImagePath, { force: true }); +} + +function run(command, args) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: packageRoot, + env: { + ...process.env, + BUF_CACHE_DIR: resolve(packageRoot, ".cache/buf"), + }, + stdio: "inherit", + shell: false, + }); + + child.once("error", (error) => { + reject(new Error(`failed to start ${command}: ${error.message}`, { cause: error })); + }); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(); + } else { + reject(new Error(`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`)); + } + }); + }); +} + diff --git a/bindings/wasm/poi_wasm/src/client.ts b/bindings/wasm/poi_wasm/src/client.ts new file mode 100644 index 0000000..e7cd605 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/client.ts @@ -0,0 +1,44 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { createClient, type Client, type Transport } from "@connectrpc/connect"; +import { createGrpcTransport } from "@connectrpc/connect-node"; + +import { LedgerService } from "./grpc/generated/iota/grpc/v1/ledger_service_pb.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_READ_MAX_BYTES = 128 * 1024 * 1024; + +export type IotaGrpcClient = Client; + +export interface IotaGrpcClientOptions { + defaultTimeoutMs?: number; + readMaxBytes?: number; + transport?: Transport; +} + +/** + * Creates a Node.js gRPC client from the generated IOTA LedgerService + * descriptor. + */ +export function createIotaGrpcClient( + endpoint: string, + options: IotaGrpcClientOptions = {}, +): IotaGrpcClient { + const baseUrl = endpoint.trim().replace(/\/+$/, ""); + + if (!baseUrl) { + throw new Error("IOTA gRPC endpoint must not be empty"); + } + + const transport = + options.transport ?? + createGrpcTransport({ + baseUrl, + defaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, + readMaxBytes: options.readMaxBytes ?? DEFAULT_READ_MAX_BYTES, + }); + + return createClient(LedgerService, transport); +} + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/google/rpc/status_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/google/rpc/status_pb.ts new file mode 100644 index 0000000..e48529c --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/google/rpc/status_pb.ts @@ -0,0 +1,76 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file google/rpc/status.proto (package google.rpc, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Any } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_any } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file google/rpc/status.proto. + */ +export const file_google_rpc_status: GenFile = /*@__PURE__*/ + fileDesc("Chdnb29nbGUvcnBjL3N0YXR1cy5wcm90bxIKZ29vZ2xlLnJwYyJOCgZTdGF0dXMSDAoEY29kZRgBIAEoBRIPCgdtZXNzYWdlGAIgASgJEiUKB2RldGFpbHMYAyADKAsyFC5nb29nbGUucHJvdG9idWYuQW55QmEKDmNvbS5nb29nbGUucnBjQgtTdGF0dXNQcm90b1ABWjdnb29nbGUuZ29sYW5nLm9yZy9nZW5wcm90by9nb29nbGVhcGlzL3JwYy9zdGF0dXM7c3RhdHVz+AEBogIDUlBDYgZwcm90bzM", [file_google_protobuf_any]); + +/** + * The `Status` type defines a logical error model that is suitable for + * different programming environments, including REST APIs and RPC APIs. It is + * used by [gRPC](https://github.com/grpc). Each `Status` message contains + * three pieces of data: error code, error message, and error details. + * + * You can find out more about this error model and how to work with it in the + * [API Design Guide](https://cloud.google.com/apis/design/errors). + * + * @generated from message google.rpc.Status + */ +export type Status = Message<"google.rpc.Status"> & { + /** + * The status code, which should be an enum value of + * [google.rpc.Code][google.rpc.Code]. + * + * @generated from field: int32 code = 1; + */ + code: number; + + /** + * A developer-facing error message, which should be in English. Any + * user-facing error message should be localized and sent in the + * [google.rpc.Status.details][google.rpc.Status.details] field, or localized + * by the client. + * + * @generated from field: string message = 2; + */ + message: string; + + /** + * A list of messages that carry the error details. There is a common set of + * message types for APIs to use. + * + * @generated from field: repeated google.protobuf.Any details = 3; + */ + details: Any[]; +}; + +/** + * Describes the message google.rpc.Status. + * Use `create(StatusSchema)` to create a new message. + */ +export const StatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_google_rpc_status, 0); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/options_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/options_pb.ts new file mode 100644 index 0000000..89877de --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/options_pb.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/options.proto (package iota.grpc, syntax proto3) +/* eslint-disable */ + +import type { GenExtension, GenFile } from "@bufbuild/protobuf/codegenv2"; +import { extDesc, fileDesc } from "@bufbuild/protobuf/codegenv2"; +import type { FieldOptions, MessageOptions } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_descriptor } from "@bufbuild/protobuf/wkt"; + +/** + * Describes the file iota/grpc/options.proto. + */ +export const file_iota_grpc_options: GenFile = /*@__PURE__*/ + fileDesc("Chdpb3RhL2dycGMvb3B0aW9ucy5wcm90bxIJaW90YS5ncnBjOlEKEW1lc3NhZ2VfYWNjZXNzb3JzEh8uZ29vZ2xlLnByb3RvYnVmLk1lc3NhZ2VPcHRpb25zGNCGAyABKAlSEG1lc3NhZ2VBY2Nlc3NvcnOIAQE6WgoWZmllbGRfbWFza190cmFuc3BhcmVudBIfLmdvb2dsZS5wcm90b2J1Zi5NZXNzYWdlT3B0aW9ucxjShgMgASgIUhRmaWVsZE1hc2tUcmFuc3BhcmVudIgBATpLCg9maWVsZF9hY2Nlc3NvcnMSHS5nb29nbGUucHJvdG9idWYuRmllbGRPcHRpb25zGNGGAyABKAlSDmZpZWxkQWNjZXNzb3JziAEBYgZwcm90bzM", [file_google_protobuf_descriptor]); + +/** + * Default accessor types to generate for all fields in this message. + * Valid values: getter, getter_opt, set, with, mut, mut_opt, all + * Example: "with" generates with_field() for all fields + * Field-level field_accessors annotations override this default. + * + * @generated from extension: optional string message_accessors = 50000; + */ +export const message_accessors: GenExtension = /*@__PURE__*/ + extDesc(file_iota_grpc_options, 0); + +/** + * When true, parent fields pointing to this message will skip this wrapper + * layer in field_info (read_mask paths) and FieldPathBuilders. The wrapper + * must contain exactly one repeated or map field. + * + * @generated from extension: optional bool field_mask_transparent = 50002; + */ +export const field_mask_transparent: GenExtension = /*@__PURE__*/ + extDesc(file_iota_grpc_options, 1); + +/** + * Comma-separated list of accessor types to generate for this field. + * Valid values: getter, getter_opt, set, with, mut, mut_opt, all + * Example: "set,with" generates set_field() and with_field() + * Overrides message-level message_accessors option. + * + * @generated from extension: optional string field_accessors = 50001; + */ +export const field_accessors: GenExtension = /*@__PURE__*/ + extDesc(file_iota_grpc_options, 2); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/bcs_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/bcs_pb.ts new file mode 100644 index 0000000..0bee6d3 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/bcs_pb.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/bcs.proto (package iota.grpc.v1.bcs, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/bcs.proto. + */ +export const file_iota_grpc_v1_bcs: GenFile = /*@__PURE__*/ + fileDesc("ChZpb3RhL2dycGMvdjEvYmNzLnByb3RvEhBpb3RhLmdycGMudjEuYmNzIiEKB0Jjc0RhdGESDAoEZGF0YRgBIAEoDDoIgrUYBHdpdGhiBnByb3RvMw", [file_iota_grpc_options]); + +/** + * BCS-serialized data container + * + * @generated from message iota.grpc.v1.bcs.BcsData + */ +export type BcsData = Message<"iota.grpc.v1.bcs.BcsData"> & { + /** + * @generated from field: bytes data = 1; + */ + data: Uint8Array; +}; + +/** + * Describes the message iota.grpc.v1.bcs.BcsData. + * Use `create(BcsDataSchema)` to create a new message. + */ +export const BcsDataSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_bcs, 0); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/checkpoint_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/checkpoint_pb.ts new file mode 100644 index 0000000..900f4c1 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/checkpoint_pb.ts @@ -0,0 +1,140 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/checkpoint.proto (package iota.grpc.v1.checkpoint, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { BcsData } from "./bcs_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { ValidatorAggregatedSignature } from "./signatures_pb.js"; +import { file_iota_grpc_v1_signatures } from "./signatures_pb.js"; +import type { Digest } from "./types_pb.js"; +import { file_iota_grpc_v1_types } from "./types_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/checkpoint.proto. + */ +export const file_iota_grpc_v1_checkpoint: GenFile = /*@__PURE__*/ + fileDesc("Ch1pb3RhL2dycGMvdjEvY2hlY2twb2ludC5wcm90bxIXaW90YS5ncnBjLnYxLmNoZWNrcG9pbnQijgEKEUNoZWNrcG9pbnRTdW1tYXJ5Ei8KBmRpZ2VzdBgBIAEoCzIaLmlvdGEuZ3JwYy52MS50eXBlcy5EaWdlc3RIAIgBARIrCgNiY3MYAiABKAsyGS5pb3RhLmdycGMudjEuYmNzLkJjc0RhdGFIAYgBAToIgrUYBHdpdGhCCQoHX2RpZ2VzdEIGCgRfYmNzIo8BChJDaGVja3BvaW50Q29udGVudHMSLwoGZGlnZXN0GAEgASgLMhouaW90YS5ncnBjLnYxLnR5cGVzLkRpZ2VzdEgAiAEBEisKA2JjcxgCIAEoCzIZLmlvdGEuZ3JwYy52MS5iY3MuQmNzRGF0YUgBiAEBOgiCtRgEd2l0aEIJCgdfZGlnZXN0QgYKBF9iY3MixAIKCkNoZWNrcG9pbnQSHAoPc2VxdWVuY2VfbnVtYmVyGAEgASgESACIAQESQAoHc3VtbWFyeRgCIAEoCzIqLmlvdGEuZ3JwYy52MS5jaGVja3BvaW50LkNoZWNrcG9pbnRTdW1tYXJ5SAGIAQESQgoIY29udGVudHMYAyABKAsyKy5pb3RhLmdycGMudjEuY2hlY2twb2ludC5DaGVja3BvaW50Q29udGVudHNIAogBARJNCglzaWduYXR1cmUYBCABKAsyNS5pb3RhLmdycGMudjEuc2lnbmF0dXJlcy5WYWxpZGF0b3JBZ2dyZWdhdGVkU2lnbmF0dXJlSAOIAQE6CIK1GAR3aXRoQhIKEF9zZXF1ZW5jZV9udW1iZXJCCgoIX3N1bW1hcnlCCwoJX2NvbnRlbnRzQgwKCl9zaWduYXR1cmViBnByb3RvMw", [file_iota_grpc_options, file_iota_grpc_v1_bcs, file_iota_grpc_v1_signatures, file_iota_grpc_v1_types]); + +/** + * A header for a checkpoint on the IOTA blockchain. + * + * On the IOTA network, checkpoints define the history of the blockchain. They are quite similar to + * the concept of blocks used by other blockchains like Bitcoin or Ethereum. The IOTA blockchain, + * however, forms checkpoints after transaction execution has already happened to provide a + * certified history of the chain, instead of being formed before execution. + * + * Checkpoints commit to a variety of state, including but not limited to: + * - The hash of the previous checkpoint. + * - The set of transaction digests, their corresponding effects digests, as well as the set of + * user signatures that authorized its execution. + * - The objects produced by a transaction. + * - The set of live objects that make up the current state of the chain. + * - On epoch transitions, the next validator committee. + * + * `CheckpointSummary`s themselves don't directly include all of the previous information but they + * are the top-level type by which all the information is committed to transitively via cryptographic + * hashes included in the summary. `CheckpointSummary`s are signed and certified by a quorum of + * the validator committee in a given epoch to allow verification of the chain's state. + * + * @generated from message iota.grpc.v1.checkpoint.CheckpointSummary + */ +export type CheckpointSummary = Message<"iota.grpc.v1.checkpoint.CheckpointSummary"> & { + /** + * The digest of this CheckpointSummary. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 1; + */ + digest?: Digest | undefined; + + /** + * This CheckpointSummary serialized as BCS. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 2; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.checkpoint.CheckpointSummary. + * Use `create(CheckpointSummarySchema)` to create a new message. + */ +export const CheckpointSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_checkpoint, 0); + +/** + * The committed to contents of a checkpoint. + * + * @generated from message iota.grpc.v1.checkpoint.CheckpointContents + */ +export type CheckpointContents = Message<"iota.grpc.v1.checkpoint.CheckpointContents"> & { + /** + * The digest of this CheckpointContents. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 1; + */ + digest?: Digest | undefined; + + /** + * This CheckpointContents serialized as BCS. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 2; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.checkpoint.CheckpointContents. + * Use `create(CheckpointContentsSchema)` to create a new message. + */ +export const CheckpointContentsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_checkpoint, 1); + +/** + * @generated from message iota.grpc.v1.checkpoint.Checkpoint + */ +export type Checkpoint = Message<"iota.grpc.v1.checkpoint.Checkpoint"> & { + /** + * The height of this checkpoint. + * + * @generated from field: optional uint64 sequence_number = 1; + */ + sequenceNumber?: bigint | undefined; + + /** + * The `CheckpointSummary` for this checkpoint. + * + * @generated from field: optional iota.grpc.v1.checkpoint.CheckpointSummary summary = 2; + */ + summary?: CheckpointSummary | undefined; + + /** + * The `CheckpointContents` for this checkpoint. + * + * @generated from field: optional iota.grpc.v1.checkpoint.CheckpointContents contents = 3; + */ + contents?: CheckpointContents | undefined; + + /** + * An aggregated quorum signature from the validator committee that + * certified this checkpoint. + * + * @generated from field: optional iota.grpc.v1.signatures.ValidatorAggregatedSignature signature = 4; + */ + signature?: ValidatorAggregatedSignature | undefined; +}; + +/** + * Describes the message iota.grpc.v1.checkpoint.Checkpoint. + * Use `create(CheckpointSchema)` to create a new message. + */ +export const CheckpointSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_checkpoint, 2); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/epoch_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/epoch_pb.ts new file mode 100644 index 0000000..89cad73 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/epoch_pb.ts @@ -0,0 +1,222 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/epoch.proto (package iota.grpc.v1.epoch, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { BcsData } from "./bcs_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/epoch.proto. + */ +export const file_iota_grpc_v1_epoch: GenFile = /*@__PURE__*/ + fileDesc("Chhpb3RhL2dycGMvdjEvZXBvY2gucHJvdG8SEmlvdGEuZ3JwYy52MS5lcG9jaCJsChhWYWxpZGF0b3JDb21taXR0ZWVNZW1iZXISFwoKcHVibGljX2tleRgBIAEoDEgAiAEBEhMKBndlaWdodBgCIAEoBEgBiAEBOgiCtRgEd2l0aEINCgtfcHVibGljX2tleUIJCgdfd2VpZ2h0ImQKGVZhbGlkYXRvckNvbW1pdHRlZU1lbWJlcnMSPQoHbWVtYmVycxgBIAMoCzIsLmlvdGEuZ3JwYy52MS5lcG9jaC5WYWxpZGF0b3JDb21taXR0ZWVNZW1iZXI6CIK1GAR3aXRoIo0BChJWYWxpZGF0b3JDb21taXR0ZWUSEgoFZXBvY2gYASABKARIAIgBARJDCgdtZW1iZXJzGAIgASgLMi0uaW90YS5ncnBjLnYxLmVwb2NoLlZhbGlkYXRvckNvbW1pdHRlZU1lbWJlcnNIAYgBAToIgrUYBHdpdGhCCAoGX2Vwb2NoQgoKCF9tZW1iZXJzIpYBChRQcm90b2NvbEZlYXR1cmVGbGFncxJCCgVmbGFncxgBIAMoCzIzLmlvdGEuZ3JwYy52MS5lcG9jaC5Qcm90b2NvbEZlYXR1cmVGbGFncy5GbGFnc0VudHJ5GiwKCkZsYWdzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgIOgI4AToMgrUYBHdpdGiQtRgBIqEBChJQcm90b2NvbEF0dHJpYnV0ZXMSSgoKYXR0cmlidXRlcxgBIAMoCzI2LmlvdGEuZ3JwYy52MS5lcG9jaC5Qcm90b2NvbEF0dHJpYnV0ZXMuQXR0cmlidXRlc0VudHJ5GjEKD0F0dHJpYnV0ZXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBOgyCtRgEd2l0aJC1GAEi9gEKDlByb3RvY29sQ29uZmlnEh0KEHByb3RvY29sX3ZlcnNpb24YASABKARIAIgBARJECg1mZWF0dXJlX2ZsYWdzGAIgASgLMiguaW90YS5ncnBjLnYxLmVwb2NoLlByb3RvY29sRmVhdHVyZUZsYWdzSAGIAQESPwoKYXR0cmlidXRlcxgDIAEoCzImLmlvdGEuZ3JwYy52MS5lcG9jaC5Qcm90b2NvbEF0dHJpYnV0ZXNIAogBAToIgrUYBHdpdGhCEwoRX3Byb3RvY29sX3ZlcnNpb25CEAoOX2ZlYXR1cmVfZmxhZ3NCDQoLX2F0dHJpYnV0ZXMisgQKBUVwb2NoEhIKBWVwb2NoGAEgASgESACIAQESPgoJY29tbWl0dGVlGAIgASgLMiYuaW90YS5ncnBjLnYxLmVwb2NoLlZhbGlkYXRvckNvbW1pdHRlZUgBiAEBEjgKEGJjc19zeXN0ZW1fc3RhdGUYAyABKAsyGS5pb3RhLmdycGMudjEuYmNzLkJjc0RhdGFIAogBARIdChBmaXJzdF9jaGVja3BvaW50GAQgASgESAOIAQESHAoPbGFzdF9jaGVja3BvaW50GAUgASgESASIAQESLgoFc3RhcnQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAWIAQESLAoDZW5kGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgGiAEBEiAKE3JlZmVyZW5jZV9nYXNfcHJpY2UYCCABKARIB4gBARJACg9wcm90b2NvbF9jb25maWcYCSABKAsyIi5pb3RhLmdycGMudjEuZXBvY2guUHJvdG9jb2xDb25maWdICIgBAToIgrUYBHdpdGhCCAoGX2Vwb2NoQgwKCl9jb21taXR0ZWVCEwoRX2Jjc19zeXN0ZW1fc3RhdGVCEwoRX2ZpcnN0X2NoZWNrcG9pbnRCEgoQX2xhc3RfY2hlY2twb2ludEIICgZfc3RhcnRCBgoEX2VuZEIWChRfcmVmZXJlbmNlX2dhc19wcmljZUISChBfcHJvdG9jb2xfY29uZmlnYgZwcm90bzM", [file_google_protobuf_timestamp, file_iota_grpc_options, file_iota_grpc_v1_bcs]); + +/** + * A member of a validator committee. + * + * @generated from message iota.grpc.v1.epoch.ValidatorCommitteeMember + */ +export type ValidatorCommitteeMember = Message<"iota.grpc.v1.epoch.ValidatorCommitteeMember"> & { + /** + * The 96-byte Bls12381 public key for this validator. + * + * @generated from field: optional bytes public_key = 1; + */ + publicKey?: Uint8Array | undefined; + + /** + * voting weight this validator possesses. + * + * @generated from field: optional uint64 weight = 2; + */ + weight?: bigint | undefined; +}; + +/** + * Describes the message iota.grpc.v1.epoch.ValidatorCommitteeMember. + * Use `create(ValidatorCommitteeMemberSchema)` to create a new message. + */ +export const ValidatorCommitteeMemberSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 0); + +/** + * @generated from message iota.grpc.v1.epoch.ValidatorCommitteeMembers + */ +export type ValidatorCommitteeMembers = Message<"iota.grpc.v1.epoch.ValidatorCommitteeMembers"> & { + /** + * @generated from field: repeated iota.grpc.v1.epoch.ValidatorCommitteeMember members = 1; + */ + members: ValidatorCommitteeMember[]; +}; + +/** + * Describes the message iota.grpc.v1.epoch.ValidatorCommitteeMembers. + * Use `create(ValidatorCommitteeMembersSchema)` to create a new message. + */ +export const ValidatorCommitteeMembersSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 1); + +/** + * The validator set for a particular epoch. + * + * @generated from message iota.grpc.v1.epoch.ValidatorCommittee + */ +export type ValidatorCommittee = Message<"iota.grpc.v1.epoch.ValidatorCommittee"> & { + /** + * The epoch where this committee governs. + * + * @generated from field: optional uint64 epoch = 1; + */ + epoch?: bigint | undefined; + + /** + * The committee members. + * + * @generated from field: optional iota.grpc.v1.epoch.ValidatorCommitteeMembers members = 2; + */ + members?: ValidatorCommitteeMembers | undefined; +}; + +/** + * Describes the message iota.grpc.v1.epoch.ValidatorCommittee. + * Use `create(ValidatorCommitteeSchema)` to create a new message. + */ +export const ValidatorCommitteeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 2); + +/** + * @generated from message iota.grpc.v1.epoch.ProtocolFeatureFlags + */ +export type ProtocolFeatureFlags = Message<"iota.grpc.v1.epoch.ProtocolFeatureFlags"> & { + /** + * @generated from field: map flags = 1; + */ + flags: { [key: string]: boolean }; +}; + +/** + * Describes the message iota.grpc.v1.epoch.ProtocolFeatureFlags. + * Use `create(ProtocolFeatureFlagsSchema)` to create a new message. + */ +export const ProtocolFeatureFlagsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 3); + +/** + * @generated from message iota.grpc.v1.epoch.ProtocolAttributes + */ +export type ProtocolAttributes = Message<"iota.grpc.v1.epoch.ProtocolAttributes"> & { + /** + * @generated from field: map attributes = 1; + */ + attributes: { [key: string]: string }; +}; + +/** + * Describes the message iota.grpc.v1.epoch.ProtocolAttributes. + * Use `create(ProtocolAttributesSchema)` to create a new message. + */ +export const ProtocolAttributesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 4); + +/** + * @generated from message iota.grpc.v1.epoch.ProtocolConfig + */ +export type ProtocolConfig = Message<"iota.grpc.v1.epoch.ProtocolConfig"> & { + /** + * @generated from field: optional uint64 protocol_version = 1; + */ + protocolVersion?: bigint | undefined; + + /** + * @generated from field: optional iota.grpc.v1.epoch.ProtocolFeatureFlags feature_flags = 2; + */ + featureFlags?: ProtocolFeatureFlags | undefined; + + /** + * @generated from field: optional iota.grpc.v1.epoch.ProtocolAttributes attributes = 3; + */ + attributes?: ProtocolAttributes | undefined; +}; + +/** + * Describes the message iota.grpc.v1.epoch.ProtocolConfig. + * Use `create(ProtocolConfigSchema)` to create a new message. + */ +export const ProtocolConfigSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 5); + +/** + * @generated from message iota.grpc.v1.epoch.Epoch + */ +export type Epoch = Message<"iota.grpc.v1.epoch.Epoch"> & { + /** + * @generated from field: optional uint64 epoch = 1; + */ + epoch?: bigint | undefined; + + /** + * The committee governing this epoch. + * + * @generated from field: optional iota.grpc.v1.epoch.ValidatorCommittee committee = 2; + */ + committee?: ValidatorCommittee | undefined; + + /** + * Snapshot of IOTA's SystemState (`0x3::iota_system::SystemState`) at the + * beginning of the epoch, for past epochs, or the current state for the + * current epoch. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs_system_state = 3; + */ + bcsSystemState?: BcsData | undefined; + + /** + * @generated from field: optional uint64 first_checkpoint = 4; + */ + firstCheckpoint?: bigint | undefined; + + /** + * @generated from field: optional uint64 last_checkpoint = 5; + */ + lastCheckpoint?: bigint | undefined; + + /** + * @generated from field: optional google.protobuf.Timestamp start = 6; + */ + start?: Timestamp | undefined; + + /** + * @generated from field: optional google.protobuf.Timestamp end = 7; + */ + end?: Timestamp | undefined; + + /** + * Reference gas price denominated in NANOS + * + * @generated from field: optional uint64 reference_gas_price = 8; + */ + referenceGasPrice?: bigint | undefined; + + /** + * @generated from field: optional iota.grpc.v1.epoch.ProtocolConfig protocol_config = 9; + */ + protocolConfig?: ProtocolConfig | undefined; +}; + +/** + * Describes the message iota.grpc.v1.epoch.Epoch. + * Use `create(EpochSchema)` to create a new message. + */ +export const EpochSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_epoch, 6); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/event_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/event_pb.ts new file mode 100644 index 0000000..e779fa8 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/event_pb.ts @@ -0,0 +1,109 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/event.proto (package iota.grpc.v1.event, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Value } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_struct } from "@bufbuild/protobuf/wkt"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { BcsData } from "./bcs_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { Address, ObjectId } from "./types_pb.js"; +import { file_iota_grpc_v1_types } from "./types_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/event.proto. + */ +export const file_iota_grpc_v1_event: GenFile = /*@__PURE__*/ + fileDesc("Chhpb3RhL2dycGMvdjEvZXZlbnQucHJvdG8SEmlvdGEuZ3JwYy52MS5ldmVudCKeAwoFRXZlbnQSKwoDYmNzGAEgASgLMhkuaW90YS5ncnBjLnYxLmJjcy5CY3NEYXRhSACIAQESNQoKcGFja2FnZV9pZBgCIAEoCzIcLmlvdGEuZ3JwYy52MS50eXBlcy5PYmplY3RJZEgBiAEBEhMKBm1vZHVsZRgDIAEoCUgCiAEBEjAKBnNlbmRlchgEIAEoCzIbLmlvdGEuZ3JwYy52MS50eXBlcy5BZGRyZXNzSAOIAQESFwoKZXZlbnRfdHlwZRgFIAEoCUgEiAEBEjQKDGJjc19jb250ZW50cxgGIAEoCzIZLmlvdGEuZ3JwYy52MS5iY3MuQmNzRGF0YUgFiAEBEjIKDWpzb25fY29udGVudHMYByABKAsyFi5nb29nbGUucHJvdG9idWYuVmFsdWVIBogBAToIgrUYBHdpdGhCBgoEX2Jjc0INCgtfcGFja2FnZV9pZEIJCgdfbW9kdWxlQgkKB19zZW5kZXJCDQoLX2V2ZW50X3R5cGVCDwoNX2Jjc19jb250ZW50c0IQCg5fanNvbl9jb250ZW50cyJBCgZFdmVudHMSKQoGZXZlbnRzGAEgAygLMhkuaW90YS5ncnBjLnYxLmV2ZW50LkV2ZW50OgyCtRgEd2l0aJC1GAFiBnByb3RvMw", [file_google_protobuf_struct, file_iota_grpc_options, file_iota_grpc_v1_bcs, file_iota_grpc_v1_types]); + +/** + * An event. + * + * @generated from message iota.grpc.v1.event.Event + */ +export type Event = Message<"iota.grpc.v1.event.Event"> & { + /** + * This Event serialized as BCS. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 1; + */ + bcs?: BcsData | undefined; + + /** + * Package ID of the top-level function invoked by a `MoveCall` command that triggered this + * event to be emitted. + * + * @generated from field: optional iota.grpc.v1.types.ObjectId package_id = 2; + */ + packageId?: ObjectId | undefined; + + /** + * Module name of the top-level function invoked by a `MoveCall` command that triggered this + * event to be emitted. + * + * @generated from field: optional string module = 3; + */ + module?: string | undefined; + + /** + * Address of the account that sent the transaction where this event was emitted. + * + * @generated from field: optional iota.grpc.v1.types.Address sender = 4; + */ + sender?: Address | undefined; + + /** + * The type of the event emitted. + * + * @generated from field: optional string event_type = 5; + */ + eventType?: string | undefined; + + /** + * BCS serialized bytes of the event. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs_contents = 6; + */ + bcsContents?: BcsData | undefined; + + /** + * JSON rendering of the event. + * + * @generated from field: optional google.protobuf.Value json_contents = 7; + */ + jsonContents?: Value | undefined; +}; + +/** + * Describes the message iota.grpc.v1.event.Event. + * Use `create(EventSchema)` to create a new message. + */ +export const EventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_event, 0); + +/** + * A list of events. + * + * @generated from message iota.grpc.v1.event.Events + */ +export type Events = Message<"iota.grpc.v1.event.Events"> & { + /** + * @generated from field: repeated iota.grpc.v1.event.Event events = 1; + */ + events: Event[]; +}; + +/** + * Describes the message iota.grpc.v1.event.Events. + * Use `create(EventsSchema)` to create a new message. + */ +export const EventsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_event, 1); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/filter_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/filter_pb.ts new file mode 100644 index 0000000..df7e03f --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/filter_pb.ts @@ -0,0 +1,668 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/filter.proto (package iota.grpc.v1.filter, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_iota_grpc_options } from "../options_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { Address, ObjectId, ObjectReference } from "./types_pb.js"; +import { file_iota_grpc_v1_types } from "./types_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/filter.proto. + */ +export const file_iota_grpc_v1_filter: GenFile = /*@__PURE__*/ + fileDesc("Chlpb3RhL2dycGMvdjEvZmlsdGVyLnByb3RvEhNpb3RhLmdycGMudjEuZmlsdGVyIk0KDkFsbEV2ZW50RmlsdGVyEjEKB2ZpbHRlcnMYASADKAsyIC5pb3RhLmdycGMudjEuZmlsdGVyLkV2ZW50RmlsdGVyOgiCtRgEd2l0aCJNCg5BbnlFdmVudEZpbHRlchIxCgdmaWx0ZXJzGAEgAygLMiAuaW90YS5ncnBjLnYxLmZpbHRlci5FdmVudEZpbHRlcjoIgrUYBHdpdGgiTAoOTm90RXZlbnRGaWx0ZXISMAoGZmlsdGVyGAEgASgLMiAuaW90YS5ncnBjLnYxLmZpbHRlci5FdmVudEZpbHRlcjoIgrUYBHdpdGgiRwoNQWRkcmVzc0ZpbHRlchIsCgdhZGRyZXNzGAEgASgLMhsuaW90YS5ncnBjLnYxLnR5cGVzLkFkZHJlc3M6CIK1GAR3aXRoIngKGk1vdmVQYWNrYWdlQW5kTW9kdWxlRmlsdGVyEjAKCnBhY2thZ2VfaWQYASABKAsyHC5pb3RhLmdycGMudjEudHlwZXMuT2JqZWN0SWQSEwoGbW9kdWxlGAIgASgJSACIAQE6CIK1GAR3aXRoQgkKB19tb2R1bGUiMwoTTW92ZUV2ZW50VHlwZUZpbHRlchISCgpzdHJ1Y3RfdGFnGAEgASgJOgiCtRgEd2l0aCLrAwoLRXZlbnRGaWx0ZXISMgoDYWxsGAEgASgLMiMuaW90YS5ncnBjLnYxLmZpbHRlci5BbGxFdmVudEZpbHRlckgAEjIKA2FueRgCIAEoCzIjLmlvdGEuZ3JwYy52MS5maWx0ZXIuQW55RXZlbnRGaWx0ZXJIABI3CghuZWdhdGlvbhgDIAEoCzIjLmlvdGEuZ3JwYy52MS5maWx0ZXIuTm90RXZlbnRGaWx0ZXJIABI0CgZzZW5kZXIYBCABKAsyIi5pb3RhLmdycGMudjEuZmlsdGVyLkFkZHJlc3NGaWx0ZXJIABJSChdtb3ZlX3BhY2thZ2VfYW5kX21vZHVsZRgFIAEoCzIvLmlvdGEuZ3JwYy52MS5maWx0ZXIuTW92ZVBhY2thZ2VBbmRNb2R1bGVGaWx0ZXJIABJYCh1tb3ZlX2V2ZW50X3BhY2thZ2VfYW5kX21vZHVsZRgGIAEoCzIvLmlvdGEuZ3JwYy52MS5maWx0ZXIuTW92ZVBhY2thZ2VBbmRNb2R1bGVGaWx0ZXJIABJDCg9tb3ZlX2V2ZW50X3R5cGUYByABKAsyKC5pb3RhLmdycGMudjEuZmlsdGVyLk1vdmVFdmVudFR5cGVGaWx0ZXJIADoIgrUYBHdpdGhCCAoGZmlsdGVyIlkKFEFsbFRyYW5zYWN0aW9uRmlsdGVyEjcKB2ZpbHRlcnMYASADKAsyJi5pb3RhLmdycGMudjEuZmlsdGVyLlRyYW5zYWN0aW9uRmlsdGVyOgiCtRgEd2l0aCJZChRBbnlUcmFuc2FjdGlvbkZpbHRlchI3CgdmaWx0ZXJzGAEgAygLMiYuaW90YS5ncnBjLnYxLmZpbHRlci5UcmFuc2FjdGlvbkZpbHRlcjoIgrUYBHdpdGgiWAoUTm90VHJhbnNhY3Rpb25GaWx0ZXISNgoGZmlsdGVyGAEgASgLMiYuaW90YS5ncnBjLnYxLmZpbHRlci5UcmFuc2FjdGlvbkZpbHRlcjoIgrUYBHdpdGgiVwoWVHJhbnNhY3Rpb25LaW5kc0ZpbHRlchIzCgVraW5kcxgBIAMoDjIkLmlvdGEuZ3JwYy52MS5maWx0ZXIuVHJhbnNhY3Rpb25LaW5kOgiCtRgEd2l0aCJTCg5PYmplY3RJZEZpbHRlchI3CgpvYmplY3RfcmVmGAEgASgLMiMuaW90YS5ncnBjLnYxLnR5cGVzLk9iamVjdFJlZmVyZW5jZToIgrUYBHdpdGgilwEKFU1vdmVDYWxsQ29tbWFuZEZpbHRlchIwCgpwYWNrYWdlX2lkGAEgASgLMhwuaW90YS5ncnBjLnYxLnR5cGVzLk9iamVjdElkEhMKBm1vZHVsZRgCIAEoCUgAiAEBEhUKCGZ1bmN0aW9uGAMgASgJSAGIAQE6CIK1GAR3aXRoQgkKB19tb2R1bGVCCwoJX2Z1bmN0aW9uIigKHFRyYW5zZmVyT2JqZWN0c0NvbW1hbmRGaWx0ZXI6CIK1GAR3aXRoIiMKF1NwbGl0Q29pbnNDb21tYW5kRmlsdGVyOgiCtRgEd2l0aCIjChdNZXJnZUNvaW5zQ29tbWFuZEZpbHRlcjoIgrUYBHdpdGgiIAoUUHVibGlzaENvbW1hbmRGaWx0ZXI6CIK1GAR3aXRoIiQKGE1ha2VNb3ZlVmVjQ29tbWFuZEZpbHRlcjoIgrUYBHdpdGgiZgoUVXBncmFkZUNvbW1hbmRGaWx0ZXISNQoKcGFja2FnZV9pZBgBIAEoCzIcLmlvdGEuZ3JwYy52MS50eXBlcy5PYmplY3RJZEgAiAEBOgiCtRgEd2l0aEINCgtfcGFja2FnZV9pZCKBBAoNQ29tbWFuZEZpbHRlchI/Cgltb3ZlX2NhbGwYASABKAsyKi5pb3RhLmdycGMudjEuZmlsdGVyLk1vdmVDYWxsQ29tbWFuZEZpbHRlckgAEk0KEHRyYW5zZmVyX29iamVjdHMYAiABKAsyMS5pb3RhLmdycGMudjEuZmlsdGVyLlRyYW5zZmVyT2JqZWN0c0NvbW1hbmRGaWx0ZXJIABJDCgtzcGxpdF9jb2lucxgDIAEoCzIsLmlvdGEuZ3JwYy52MS5maWx0ZXIuU3BsaXRDb2luc0NvbW1hbmRGaWx0ZXJIABJDCgttZXJnZV9jb2lucxgEIAEoCzIsLmlvdGEuZ3JwYy52MS5maWx0ZXIuTWVyZ2VDb2luc0NvbW1hbmRGaWx0ZXJIABI8CgdwdWJsaXNoGAUgASgLMikuaW90YS5ncnBjLnYxLmZpbHRlci5QdWJsaXNoQ29tbWFuZEZpbHRlckgAEkYKDW1ha2VfbW92ZV92ZWMYBiABKAsyLS5pb3RhLmdycGMudjEuZmlsdGVyLk1ha2VNb3ZlVmVjQ29tbWFuZEZpbHRlckgAEjwKB3VwZ3JhZGUYByABKAsyKS5pb3RhLmdycGMudjEuZmlsdGVyLlVwZ3JhZGVDb21tYW5kRmlsdGVySAA6CIK1GAR3aXRoQggKBmZpbHRlciIyChVFeGVjdXRpb25TdGF0dXNGaWx0ZXISDwoHc3VjY2VzcxgBIAEoCDoIgrUYBHdpdGgihAUKEVRyYW5zYWN0aW9uRmlsdGVyEjgKA2FsbBgBIAEoCzIpLmlvdGEuZ3JwYy52MS5maWx0ZXIuQWxsVHJhbnNhY3Rpb25GaWx0ZXJIABI4CgNhbnkYAiABKAsyKS5pb3RhLmdycGMudjEuZmlsdGVyLkFueVRyYW5zYWN0aW9uRmlsdGVySAASPQoIbmVnYXRpb24YAyABKAsyKS5pb3RhLmdycGMudjEuZmlsdGVyLk5vdFRyYW5zYWN0aW9uRmlsdGVySAASSAoRdHJhbnNhY3Rpb25fa2luZHMYBCABKAsyKy5pb3RhLmdycGMudjEuZmlsdGVyLlRyYW5zYWN0aW9uS2luZHNGaWx0ZXJIABJGChBleGVjdXRpb25fc3RhdHVzGAUgASgLMiouaW90YS5ncnBjLnYxLmZpbHRlci5FeGVjdXRpb25TdGF0dXNGaWx0ZXJIABI0CgZzZW5kZXIYBiABKAsyIi5pb3RhLmdycGMudjEuZmlsdGVyLkFkZHJlc3NGaWx0ZXJIABI2CghyZWNlaXZlchgHIAEoCzIiLmlvdGEuZ3JwYy52MS5maWx0ZXIuQWRkcmVzc0ZpbHRlckgAEj4KD2FmZmVjdGVkX29iamVjdBgIIAEoCzIjLmlvdGEuZ3JwYy52MS5maWx0ZXIuT2JqZWN0SWRGaWx0ZXJIABI1Cgdjb21tYW5kGAkgASgLMiIuaW90YS5ncnBjLnYxLmZpbHRlci5Db21tYW5kRmlsdGVySAASMQoFZXZlbnQYCiABKAsyIC5pb3RhLmdycGMudjEuZmlsdGVyLkV2ZW50RmlsdGVySAA6CIK1GAR3aXRoQggKBmZpbHRlciqNAQoPVHJhbnNhY3Rpb25LaW5kEgoKBlNZU1RFTRAAEhAKDFBST0dSQU1NQUJMRRABEgsKB0dFTkVTSVMQAhIgChxDT05TRU5TVVNfQ09NTUlUX1BST0xPR1VFX1YxEAMSEAoMRU5EX09GX0VQT0NIEAUSGwoXUkFORE9NTkVTU19TVEFURV9VUERBVEUQBmIGcHJvdG8z", [file_iota_grpc_options, file_iota_grpc_v1_bcs, file_iota_grpc_v1_types]); + +/** + * Logical AND of several filters. + * + * @generated from message iota.grpc.v1.filter.AllEventFilter + */ +export type AllEventFilter = Message<"iota.grpc.v1.filter.AllEventFilter"> & { + /** + * @generated from field: repeated iota.grpc.v1.filter.EventFilter filters = 1; + */ + filters: EventFilter[]; +}; + +/** + * Describes the message iota.grpc.v1.filter.AllEventFilter. + * Use `create(AllEventFilterSchema)` to create a new message. + */ +export const AllEventFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 0); + +/** + * Logical OR of several filters. + * + * @generated from message iota.grpc.v1.filter.AnyEventFilter + */ +export type AnyEventFilter = Message<"iota.grpc.v1.filter.AnyEventFilter"> & { + /** + * @generated from field: repeated iota.grpc.v1.filter.EventFilter filters = 1; + */ + filters: EventFilter[]; +}; + +/** + * Describes the message iota.grpc.v1.filter.AnyEventFilter. + * Use `create(AnyEventFilterSchema)` to create a new message. + */ +export const AnyEventFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 1); + +/** + * Logical NOT of a filter. + * + * @generated from message iota.grpc.v1.filter.NotEventFilter + */ +export type NotEventFilter = Message<"iota.grpc.v1.filter.NotEventFilter"> & { + /** + * @generated from field: iota.grpc.v1.filter.EventFilter filter = 1; + */ + filter?: EventFilter | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.NotEventFilter. + * Use `create(NotEventFilterSchema)` to create a new message. + */ +export const NotEventFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 2); + +/** + * Filter by address. + * + * @generated from message iota.grpc.v1.filter.AddressFilter + */ +export type AddressFilter = Message<"iota.grpc.v1.filter.AddressFilter"> & { + /** + * @generated from field: iota.grpc.v1.types.Address address = 1; + */ + address?: Address | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.AddressFilter. + * Use `create(AddressFilterSchema)` to create a new message. + */ +export const AddressFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 3); + +/** + * Filter by Move package + module (optional). + * + * @generated from message iota.grpc.v1.filter.MovePackageAndModuleFilter + */ +export type MovePackageAndModuleFilter = Message<"iota.grpc.v1.filter.MovePackageAndModuleFilter"> & { + /** + * @generated from field: iota.grpc.v1.types.ObjectId package_id = 1; + */ + packageId?: ObjectId | undefined; + + /** + * @generated from field: optional string module = 2; + */ + module?: string | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.MovePackageAndModuleFilter. + * Use `create(MovePackageAndModuleFilterSchema)` to create a new message. + */ +export const MovePackageAndModuleFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 4); + +/** + * Filter by the given Move event struct name (struct tag). + * For example, if the event is defined in `0xabcd::MyModule`, and named + * `Foo`, then the struct tag is `0xabcd::MyModule::Foo`. + * + * @generated from message iota.grpc.v1.filter.MoveEventTypeFilter + */ +export type MoveEventTypeFilter = Message<"iota.grpc.v1.filter.MoveEventTypeFilter"> & { + /** + * @generated from field: string struct_tag = 1; + */ + structTag: string; +}; + +/** + * Describes the message iota.grpc.v1.filter.MoveEventTypeFilter. + * Use `create(MoveEventTypeFilterSchema)` to create a new message. + */ +export const MoveEventTypeFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 5); + +/** + * Filter for events. + * + * @generated from message iota.grpc.v1.filter.EventFilter + */ +export type EventFilter = Message<"iota.grpc.v1.filter.EventFilter"> & { + /** + * @generated from oneof iota.grpc.v1.filter.EventFilter.filter + */ + filter: { + /** + * Logical AND of several filters. + * + * @generated from field: iota.grpc.v1.filter.AllEventFilter all = 1; + */ + value: AllEventFilter; + case: "all"; + } | { + /** + * Logical OR of several filters. + * + * @generated from field: iota.grpc.v1.filter.AnyEventFilter any = 2; + */ + value: AnyEventFilter; + case: "any"; + } | { + /** + * Logical NOT of a filter. + * + * @generated from field: iota.grpc.v1.filter.NotEventFilter negation = 3; + */ + value: NotEventFilter; + case: "negation"; + } | { + /** + * Filter by sender address. + * + * @generated from field: iota.grpc.v1.filter.AddressFilter sender = 4; + */ + value: AddressFilter; + case: "sender"; + } | { + /** + * Return events emitted in a specified Move package + module (optional). + * If the event is defined in PackageA::ModuleA but emitted in a tx with PackageB::ModuleB, + * filtering `MovePackageAndModule` by PackageB::ModuleB returns the event. + * Filtering `MoveEventPackageAndModule` by PackageA::ModuleA returns the event too. + * + * @generated from field: iota.grpc.v1.filter.MovePackageAndModuleFilter move_package_and_module = 5; + */ + value: MovePackageAndModuleFilter; + case: "movePackageAndModule"; + } | { + /** + * Return events with the given Move package + module (optional) where the event struct is + * defined. If the event is defined in PackageA::ModuleA but emitted in a tx + * with PackageB::ModuleB, filtering `MoveEventPackageAndModule` by PackageA::ModuleA returns the + * event. Filtering `MovePackageAndModule` by PackageB::ModuleB returns the event too. + * + * @generated from field: iota.grpc.v1.filter.MovePackageAndModuleFilter move_event_package_and_module = 6; + */ + value: MovePackageAndModuleFilter; + case: "moveEventPackageAndModule"; + } | { + /** + * Return events with the given Move event struct name (struct tag). + * For example, if the event is defined in `0xabcd::MyModule`, and named + * `Foo`, then the struct tag is `0xabcd::MyModule::Foo`. + * + * @generated from field: iota.grpc.v1.filter.MoveEventTypeFilter move_event_type = 7; + */ + value: MoveEventTypeFilter; + case: "moveEventType"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.filter.EventFilter. + * Use `create(EventFilterSchema)` to create a new message. + */ +export const EventFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 6); + +/** + * Logical AND of several filters. + * + * @generated from message iota.grpc.v1.filter.AllTransactionFilter + */ +export type AllTransactionFilter = Message<"iota.grpc.v1.filter.AllTransactionFilter"> & { + /** + * @generated from field: repeated iota.grpc.v1.filter.TransactionFilter filters = 1; + */ + filters: TransactionFilter[]; +}; + +/** + * Describes the message iota.grpc.v1.filter.AllTransactionFilter. + * Use `create(AllTransactionFilterSchema)` to create a new message. + */ +export const AllTransactionFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 7); + +/** + * Logical OR of several filters. + * + * @generated from message iota.grpc.v1.filter.AnyTransactionFilter + */ +export type AnyTransactionFilter = Message<"iota.grpc.v1.filter.AnyTransactionFilter"> & { + /** + * @generated from field: repeated iota.grpc.v1.filter.TransactionFilter filters = 1; + */ + filters: TransactionFilter[]; +}; + +/** + * Describes the message iota.grpc.v1.filter.AnyTransactionFilter. + * Use `create(AnyTransactionFilterSchema)` to create a new message. + */ +export const AnyTransactionFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 8); + +/** + * Logical NOT of a filter. + * + * @generated from message iota.grpc.v1.filter.NotTransactionFilter + */ +export type NotTransactionFilter = Message<"iota.grpc.v1.filter.NotTransactionFilter"> & { + /** + * @generated from field: iota.grpc.v1.filter.TransactionFilter filter = 1; + */ + filter?: TransactionFilter | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.NotTransactionFilter. + * Use `create(NotTransactionFilterSchema)` to create a new message. + */ +export const NotTransactionFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 9); + +/** + * Filter by transaction kinds (any of the specified kinds). + * + * @generated from message iota.grpc.v1.filter.TransactionKindsFilter + */ +export type TransactionKindsFilter = Message<"iota.grpc.v1.filter.TransactionKindsFilter"> & { + /** + * @generated from field: repeated iota.grpc.v1.filter.TransactionKind kinds = 1; + */ + kinds: TransactionKind[]; +}; + +/** + * Describes the message iota.grpc.v1.filter.TransactionKindsFilter. + * Use `create(TransactionKindsFilterSchema)` to create a new message. + */ +export const TransactionKindsFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 10); + +/** + * Filter by input object ID. + * + * @generated from message iota.grpc.v1.filter.ObjectIdFilter + */ +export type ObjectIdFilter = Message<"iota.grpc.v1.filter.ObjectIdFilter"> & { + /** + * @generated from field: iota.grpc.v1.types.ObjectReference object_ref = 1; + */ + objectRef?: ObjectReference | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.ObjectIdFilter. + * Use `create(ObjectIdFilterSchema)` to create a new message. + */ +export const ObjectIdFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 11); + +/** + * Filter by move package, module (optional) and function (optional). + * + * @generated from message iota.grpc.v1.filter.MoveCallCommandFilter + */ +export type MoveCallCommandFilter = Message<"iota.grpc.v1.filter.MoveCallCommandFilter"> & { + /** + * @generated from field: iota.grpc.v1.types.ObjectId package_id = 1; + */ + packageId?: ObjectId | undefined; + + /** + * @generated from field: optional string module = 2; + */ + module?: string | undefined; + + /** + * @generated from field: optional string function = 3; + */ + function?: string | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.MoveCallCommandFilter. + * Use `create(MoveCallCommandFilterSchema)` to create a new message. + */ +export const MoveCallCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 12); + +/** + * Match a TransferObjects command. + * + * @generated from message iota.grpc.v1.filter.TransferObjectsCommandFilter + */ +export type TransferObjectsCommandFilter = Message<"iota.grpc.v1.filter.TransferObjectsCommandFilter"> & { +}; + +/** + * Describes the message iota.grpc.v1.filter.TransferObjectsCommandFilter. + * Use `create(TransferObjectsCommandFilterSchema)` to create a new message. + */ +export const TransferObjectsCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 13); + +/** + * Match a SplitCoins command. + * + * @generated from message iota.grpc.v1.filter.SplitCoinsCommandFilter + */ +export type SplitCoinsCommandFilter = Message<"iota.grpc.v1.filter.SplitCoinsCommandFilter"> & { +}; + +/** + * Describes the message iota.grpc.v1.filter.SplitCoinsCommandFilter. + * Use `create(SplitCoinsCommandFilterSchema)` to create a new message. + */ +export const SplitCoinsCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 14); + +/** + * Match a MergeCoins command. + * + * @generated from message iota.grpc.v1.filter.MergeCoinsCommandFilter + */ +export type MergeCoinsCommandFilter = Message<"iota.grpc.v1.filter.MergeCoinsCommandFilter"> & { +}; + +/** + * Describes the message iota.grpc.v1.filter.MergeCoinsCommandFilter. + * Use `create(MergeCoinsCommandFilterSchema)` to create a new message. + */ +export const MergeCoinsCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 15); + +/** + * Match a Publish command. + * + * @generated from message iota.grpc.v1.filter.PublishCommandFilter + */ +export type PublishCommandFilter = Message<"iota.grpc.v1.filter.PublishCommandFilter"> & { +}; + +/** + * Describes the message iota.grpc.v1.filter.PublishCommandFilter. + * Use `create(PublishCommandFilterSchema)` to create a new message. + */ +export const PublishCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 16); + +/** + * Match a MakeMoveVec command. + * + * @generated from message iota.grpc.v1.filter.MakeMoveVecCommandFilter + */ +export type MakeMoveVecCommandFilter = Message<"iota.grpc.v1.filter.MakeMoveVecCommandFilter"> & { +}; + +/** + * Describes the message iota.grpc.v1.filter.MakeMoveVecCommandFilter. + * Use `create(MakeMoveVecCommandFilterSchema)` to create a new message. + */ +export const MakeMoveVecCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 17); + +/** + * Match an Upgrade command. Optionally filter by the specific package being upgraded. + * + * @generated from message iota.grpc.v1.filter.UpgradeCommandFilter + */ +export type UpgradeCommandFilter = Message<"iota.grpc.v1.filter.UpgradeCommandFilter"> & { + /** + * @generated from field: optional iota.grpc.v1.types.ObjectId package_id = 1; + */ + packageId?: ObjectId | undefined; +}; + +/** + * Describes the message iota.grpc.v1.filter.UpgradeCommandFilter. + * Use `create(UpgradeCommandFilterSchema)` to create a new message. + */ +export const UpgradeCommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 18); + +/** + * Filter by command type. + * + * @generated from message iota.grpc.v1.filter.CommandFilter + */ +export type CommandFilter = Message<"iota.grpc.v1.filter.CommandFilter"> & { + /** + * @generated from oneof iota.grpc.v1.filter.CommandFilter.filter + */ + filter: { + /** + * @generated from field: iota.grpc.v1.filter.MoveCallCommandFilter move_call = 1; + */ + value: MoveCallCommandFilter; + case: "moveCall"; + } | { + /** + * @generated from field: iota.grpc.v1.filter.TransferObjectsCommandFilter transfer_objects = 2; + */ + value: TransferObjectsCommandFilter; + case: "transferObjects"; + } | { + /** + * @generated from field: iota.grpc.v1.filter.SplitCoinsCommandFilter split_coins = 3; + */ + value: SplitCoinsCommandFilter; + case: "splitCoins"; + } | { + /** + * @generated from field: iota.grpc.v1.filter.MergeCoinsCommandFilter merge_coins = 4; + */ + value: MergeCoinsCommandFilter; + case: "mergeCoins"; + } | { + /** + * @generated from field: iota.grpc.v1.filter.PublishCommandFilter publish = 5; + */ + value: PublishCommandFilter; + case: "publish"; + } | { + /** + * @generated from field: iota.grpc.v1.filter.MakeMoveVecCommandFilter make_move_vec = 6; + */ + value: MakeMoveVecCommandFilter; + case: "makeMoveVec"; + } | { + /** + * @generated from field: iota.grpc.v1.filter.UpgradeCommandFilter upgrade = 7; + */ + value: UpgradeCommandFilter; + case: "upgrade"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.filter.CommandFilter. + * Use `create(CommandFilterSchema)` to create a new message. + */ +export const CommandFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 19); + +/** + * Filter by transaction execution status. + * Set `success` to `true` to match successful transactions, + * or `false` to match failed transactions (cancelled, execution error, etc.). + * + * @generated from message iota.grpc.v1.filter.ExecutionStatusFilter + */ +export type ExecutionStatusFilter = Message<"iota.grpc.v1.filter.ExecutionStatusFilter"> & { + /** + * @generated from field: bool success = 1; + */ + success: boolean; +}; + +/** + * Describes the message iota.grpc.v1.filter.ExecutionStatusFilter. + * Use `create(ExecutionStatusFilterSchema)` to create a new message. + */ +export const ExecutionStatusFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 20); + +/** + * Filter for transactions. + * + * @generated from message iota.grpc.v1.filter.TransactionFilter + */ +export type TransactionFilter = Message<"iota.grpc.v1.filter.TransactionFilter"> & { + /** + * @generated from oneof iota.grpc.v1.filter.TransactionFilter.filter + */ + filter: { + /** + * Logical AND of several filters. + * + * @generated from field: iota.grpc.v1.filter.AllTransactionFilter all = 1; + */ + value: AllTransactionFilter; + case: "all"; + } | { + /** + * Logical OR of several filters. + * + * @generated from field: iota.grpc.v1.filter.AnyTransactionFilter any = 2; + */ + value: AnyTransactionFilter; + case: "any"; + } | { + /** + * Logical NOT of a filter. + * + * @generated from field: iota.grpc.v1.filter.NotTransactionFilter negation = 3; + */ + value: NotTransactionFilter; + case: "negation"; + } | { + /** + * Filter transactions of any given kind in the filter. + * + * @generated from field: iota.grpc.v1.filter.TransactionKindsFilter transaction_kinds = 4; + */ + value: TransactionKindsFilter; + case: "transactionKinds"; + } | { + /** + * Filter by transaction execution success/failure. + * + * @generated from field: iota.grpc.v1.filter.ExecutionStatusFilter execution_status = 5; + */ + value: ExecutionStatusFilter; + case: "executionStatus"; + } | { + /** + * Filter by sender address. + * + * @generated from field: iota.grpc.v1.filter.AddressFilter sender = 6; + */ + value: AddressFilter; + case: "sender"; + } | { + /** + * Filter by recipient address. The recipient is determined by + * checking the owners of mutated and unwrapped objects. + * + * @generated from field: iota.grpc.v1.filter.AddressFilter receiver = 7; + */ + value: AddressFilter; + case: "receiver"; + } | { + /** + * Filter for transactions that touch this object. + * + * @generated from field: iota.grpc.v1.filter.ObjectIdFilter affected_object = 8; + */ + value: ObjectIdFilter; + case: "affectedObject"; + } | { + /** + * Filter by command type. + * + * @generated from field: iota.grpc.v1.filter.CommandFilter command = 9; + */ + value: CommandFilter; + case: "command"; + } | { + /** + * Filter transactions that contain events matching the given event filter. + * + * @generated from field: iota.grpc.v1.filter.EventFilter event = 10; + */ + value: EventFilter; + case: "event"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.filter.TransactionFilter. + * Use `create(TransactionFilterSchema)` to create a new message. + */ +export const TransactionFilterSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_filter, 21); + +/** + * @generated from enum iota.grpc.v1.filter.TransactionKind + */ +export enum TransactionKind { + /** + * `SYSTEM` can be used to filter for all types of system transactions. + * + * @generated from enum value: SYSTEM = 0; + */ + SYSTEM = 0, + + /** + * @generated from enum value: PROGRAMMABLE = 1; + */ + PROGRAMMABLE = 1, + + /** + * @generated from enum value: GENESIS = 2; + */ + GENESIS = 2, + + /** + * @generated from enum value: CONSENSUS_COMMIT_PROLOGUE_V1 = 3; + */ + CONSENSUS_COMMIT_PROLOGUE_V1 = 3, + + /** + * @generated from enum value: END_OF_EPOCH = 5; + */ + END_OF_EPOCH = 5, + + /** + * @generated from enum value: RANDOMNESS_STATE_UPDATE = 6; + */ + RANDOMNESS_STATE_UPDATE = 6, +} + +/** + * Describes the enum iota.grpc.v1.filter.TransactionKind. + */ +export const TransactionKindSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_iota_grpc_v1_filter, 0); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/ledger_service_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/ledger_service_pb.ts new file mode 100644 index 0000000..d263966 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/ledger_service_pb.ts @@ -0,0 +1,764 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/ledger_service.proto (package iota.grpc.v1.ledger_service, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { FieldMask, Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_field_mask, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Status } from "../../../google/rpc/status_pb.js"; +import { file_google_rpc_status } from "../../../google/rpc/status_pb.js"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { Checkpoint } from "./checkpoint_pb.js"; +import { file_iota_grpc_v1_checkpoint } from "./checkpoint_pb.js"; +import type { Epoch } from "./epoch_pb.js"; +import { file_iota_grpc_v1_epoch } from "./epoch_pb.js"; +import type { Events } from "./event_pb.js"; +import { file_iota_grpc_v1_event } from "./event_pb.js"; +import type { EventFilter, TransactionFilter } from "./filter_pb.js"; +import { file_iota_grpc_v1_filter } from "./filter_pb.js"; +import type { Object$ } from "./object_pb.js"; +import { file_iota_grpc_v1_object } from "./object_pb.js"; +import type { ExecutedTransaction, ExecutedTransactions } from "./transaction_pb.js"; +import { file_iota_grpc_v1_transaction } from "./transaction_pb.js"; +import type { Digest, ObjectReference } from "./types_pb.js"; +import { file_iota_grpc_v1_types } from "./types_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/ledger_service.proto. + */ +export const file_iota_grpc_v1_ledger_service: GenFile = /*@__PURE__*/ + fileDesc("CiFpb3RhL2dycGMvdjEvbGVkZ2VyX3NlcnZpY2UucHJvdG8SG2lvdGEuZ3JwYy52MS5sZWRnZXJfc2VydmljZSJIChBHZXRIZWFsdGhSZXF1ZXN0EhkKDHRocmVzaG9sZF9tcxgBIAEoBEgAiAEBOgiCtRgEd2l0aEIPCg1fdGhyZXNob2xkX21zIrUBChFHZXRIZWFsdGhSZXNwb25zZRInChpleGVjdXRlZF9jaGVja3BvaW50X2hlaWdodBgBIAEoBEgAiAEBEisKHmVzdGltYXRlZF92YWxpZGF0b3JfbGF0ZW5jeV9tcxgCIAEoDUgBiAEBOgiCtRgEd2l0aEIdChtfZXhlY3V0ZWRfY2hlY2twb2ludF9oZWlnaHRCIQofX2VzdGltYXRlZF92YWxpZGF0b3JfbGF0ZW5jeV9tcyJjChVHZXRTZXJ2aWNlSW5mb1JlcXVlc3QSMgoJcmVhZF9tYXNrGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0gAiAEBOgiCtRgEd2l0aEIMCgpfcmVhZF9tYXNrIpQEChZHZXRTZXJ2aWNlSW5mb1Jlc3BvbnNlEjEKCGNoYWluX2lkGAEgASgLMhouaW90YS5ncnBjLnYxLnR5cGVzLkRpZ2VzdEgAiAEBEhIKBWNoYWluGAIgASgJSAGIAQESEgoFZXBvY2gYAyABKARIAogBARInChpleGVjdXRlZF9jaGVja3BvaW50X2hlaWdodBgEIAEoBEgDiAEBEkYKHWV4ZWN1dGVkX2NoZWNrcG9pbnRfdGltZXN0YW1wGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgEiAEBEigKG2xvd2VzdF9hdmFpbGFibGVfY2hlY2twb2ludBgGIAEoBEgFiAEBEjAKI2xvd2VzdF9hdmFpbGFibGVfY2hlY2twb2ludF9vYmplY3RzGAcgASgESAaIAQESEwoGc2VydmVyGAggASgJSAeIAQE6CIK1GAR3aXRoQgsKCV9jaGFpbl9pZEIICgZfY2hhaW5CCAoGX2Vwb2NoQh0KG19leGVjdXRlZF9jaGVja3BvaW50X2hlaWdodEIgCh5fZXhlY3V0ZWRfY2hlY2twb2ludF90aW1lc3RhbXBCHgocX2xvd2VzdF9hdmFpbGFibGVfY2hlY2twb2ludEImCiRfbG93ZXN0X2F2YWlsYWJsZV9jaGVja3BvaW50X29iamVjdHNCCQoHX3NlcnZlciJmCg1PYmplY3RSZXF1ZXN0EjwKCm9iamVjdF9yZWYYASABKAsyIy5pb3RhLmdycGMudjEudHlwZXMuT2JqZWN0UmVmZXJlbmNlSACIAQE6CIK1GAR3aXRoQg0KC19vYmplY3RfcmVmIlgKDk9iamVjdFJlcXVlc3RzEjwKCHJlcXVlc3RzGAEgAygLMiouaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLk9iamVjdFJlcXVlc3Q6CIK1GAR3aXRoIvABChFHZXRPYmplY3RzUmVxdWVzdBJCCghyZXF1ZXN0cxgBIAEoCzIrLmlvdGEuZ3JwYy52MS5sZWRnZXJfc2VydmljZS5PYmplY3RSZXF1ZXN0c0gAiAEBEjIKCXJlYWRfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tIAYgBARIjChZtYXhfbWVzc2FnZV9zaXplX2J5dGVzGAMgASgNSAKIAQE6CIK1GAR3aXRoQgsKCV9yZXF1ZXN0c0IMCgpfcmVhZF9tYXNrQhkKF19tYXhfbWVzc2FnZV9zaXplX2J5dGVzInYKDE9iamVjdFJlc3VsdBItCgZvYmplY3QYASABKAsyGy5pb3RhLmdycGMudjEub2JqZWN0Lk9iamVjdEgAEiMKBWVycm9yGAIgASgLMhIuZ29vZ2xlLnJwYy5TdGF0dXNIADoIgrUYBHdpdGhCCAoGcmVzdWx0ImwKEkdldE9iamVjdHNSZXNwb25zZRI6CgdvYmplY3RzGAEgAygLMikuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLk9iamVjdFJlc3VsdBIQCghoYXNfbmV4dBgCIAEoCDoIgrUYBHdpdGgiWgoSVHJhbnNhY3Rpb25SZXF1ZXN0Ei8KBmRpZ2VzdBgBIAEoCzIaLmlvdGEuZ3JwYy52MS50eXBlcy5EaWdlc3RIAIgBAToIgrUYBHdpdGhCCQoHX2RpZ2VzdCJiChNUcmFuc2FjdGlvblJlcXVlc3RzEkEKCHJlcXVlc3RzGAEgAygLMi8uaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLlRyYW5zYWN0aW9uUmVxdWVzdDoIgrUYBHdpdGgi+gEKFkdldFRyYW5zYWN0aW9uc1JlcXVlc3QSRwoIcmVxdWVzdHMYASABKAsyMC5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuVHJhbnNhY3Rpb25SZXF1ZXN0c0gAiAEBEjIKCXJlYWRfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tIAYgBARIjChZtYXhfbWVzc2FnZV9zaXplX2J5dGVzGAMgASgNSAKIAQE6CIK1GAR3aXRoQgsKCV9yZXF1ZXN0c0IMCgpfcmVhZF9tYXNrQhkKF19tYXhfbWVzc2FnZV9zaXplX2J5dGVzIpsBChFUcmFuc2FjdGlvblJlc3VsdBJNChRleGVjdXRlZF90cmFuc2FjdGlvbhgBIAEoCzItLmlvdGEuZ3JwYy52MS50cmFuc2FjdGlvbi5FeGVjdXRlZFRyYW5zYWN0aW9uSAASIwoFZXJyb3IYAiABKAsyEi5nb29nbGUucnBjLlN0YXR1c0gAOgiCtRgEd2l0aEIICgZyZXN1bHQiggEKF0dldFRyYW5zYWN0aW9uc1Jlc3BvbnNlEksKE3RyYW5zYWN0aW9uX3Jlc3VsdHMYASADKAsyLi5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuVHJhbnNhY3Rpb25SZXN1bHQSEAoIaGFzX25leHQYAiABKAg6CIK1GAR3aXRoIsADChRHZXRDaGVja3BvaW50UmVxdWVzdBIQCgZsYXRlc3QYASABKAhIABIZCg9zZXF1ZW5jZV9udW1iZXIYAiABKARIABIsCgZkaWdlc3QYAyABKAsyGi5pb3RhLmdycGMudjEudHlwZXMuRGlnZXN0SAASMgoJcmVhZF9tYXNrGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0gBiAEBEkgKE3RyYW5zYWN0aW9uc19maWx0ZXIYBSABKAsyJi5pb3RhLmdycGMudjEuZmlsdGVyLlRyYW5zYWN0aW9uRmlsdGVySAKIAQESPAoNZXZlbnRzX2ZpbHRlchgGIAEoCzIgLmlvdGEuZ3JwYy52MS5maWx0ZXIuRXZlbnRGaWx0ZXJIA4gBARIjChZtYXhfbWVzc2FnZV9zaXplX2J5dGVzGAcgASgNSASIAQE6CIK1GAR3aXRoQg8KDWNoZWNrcG9pbnRfaWRCDAoKX3JlYWRfbWFza0IWChRfdHJhbnNhY3Rpb25zX2ZpbHRlckIQCg5fZXZlbnRzX2ZpbHRlckIZChdfbWF4X21lc3NhZ2Vfc2l6ZV9ieXRlcyLEBAoYU3RyZWFtQ2hlY2twb2ludHNSZXF1ZXN0EiIKFXN0YXJ0X3NlcXVlbmNlX251bWJlchgBIAEoBEgAiAEBEiAKE2VuZF9zZXF1ZW5jZV9udW1iZXIYAiABKARIAYgBARIyCglyZWFkX21hc2sYAyABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrSAKIAQESSAoTdHJhbnNhY3Rpb25zX2ZpbHRlchgEIAEoCzImLmlvdGEuZ3JwYy52MS5maWx0ZXIuVHJhbnNhY3Rpb25GaWx0ZXJIA4gBARI8Cg1ldmVudHNfZmlsdGVyGAUgASgLMiAuaW90YS5ncnBjLnYxLmZpbHRlci5FdmVudEZpbHRlckgEiAEBEh8KEmZpbHRlcl9jaGVja3BvaW50cxgGIAEoCEgFiAEBEiEKFHByb2dyZXNzX2ludGVydmFsX21zGAcgASgNSAaIAQESIwoWbWF4X21lc3NhZ2Vfc2l6ZV9ieXRlcxgIIAEoDUgHiAEBOgiCtRgEd2l0aEIYChZfc3RhcnRfc2VxdWVuY2VfbnVtYmVyQhYKFF9lbmRfc2VxdWVuY2VfbnVtYmVyQgwKCl9yZWFkX21hc2tCFgoUX3RyYW5zYWN0aW9uc19maWx0ZXJCEAoOX2V2ZW50c19maWx0ZXJCFQoTX2ZpbHRlcl9jaGVja3BvaW50c0IXChVfcHJvZ3Jlc3NfaW50ZXJ2YWxfbXNCGQoXX21heF9tZXNzYWdlX3NpemVfYnl0ZXMi/QMKDkNoZWNrcG9pbnREYXRhEjkKCmNoZWNrcG9pbnQYASABKAsyIy5pb3RhLmdycGMudjEuY2hlY2twb2ludC5DaGVja3BvaW50SAASTwoVZXhlY3V0ZWRfdHJhbnNhY3Rpb25zGAIgASgLMi4uaW90YS5ncnBjLnYxLnRyYW5zYWN0aW9uLkV4ZWN1dGVkVHJhbnNhY3Rpb25zSAASLAoGZXZlbnRzGAMgASgLMhouaW90YS5ncnBjLnYxLmV2ZW50LkV2ZW50c0gAEkgKCHByb2dyZXNzGAQgASgLMjQuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkNoZWNrcG9pbnREYXRhLlByb2dyZXNzSAASSwoKZW5kX21hcmtlchgFIAEoCzI1LmlvdGEuZ3JwYy52MS5sZWRnZXJfc2VydmljZS5DaGVja3BvaW50RGF0YS5FbmRNYXJrZXJIABo8CghQcm9ncmVzcxImCh5sYXRlc3Rfc2Nhbm5lZF9zZXF1ZW5jZV9udW1iZXIYASABKAQ6CIK1GAR3aXRoGkcKCUVuZE1hcmtlchIcCg9zZXF1ZW5jZV9udW1iZXIYASABKARIAIgBAToIgrUYBHdpdGhCEgoQX3NlcXVlbmNlX251bWJlcjoIgrUYBHdpdGhCCQoHcGF5bG9hZCJ7Cg9HZXRFcG9jaFJlcXVlc3QSEgoFZXBvY2gYASABKARIAIgBARIyCglyZWFkX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrSAGIAQE6CIK1GAR3aXRoQggKBl9lcG9jaEIMCgpfcmVhZF9tYXNrIlUKEEdldEVwb2NoUmVzcG9uc2USLQoFZXBvY2gYASABKAsyGS5pb3RhLmdycGMudjEuZXBvY2guRXBvY2hIAIgBAToIgrUYBHdpdGhCCAoGX2Vwb2NoMr4GCg1MZWRnZXJTZXJ2aWNlEmoKCUdldEhlYWx0aBItLmlvdGEuZ3JwYy52MS5sZWRnZXJfc2VydmljZS5HZXRIZWFsdGhSZXF1ZXN0Gi4uaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkdldEhlYWx0aFJlc3BvbnNlEnkKDkdldFNlcnZpY2VJbmZvEjIuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkdldFNlcnZpY2VJbmZvUmVxdWVzdBozLmlvdGEuZ3JwYy52MS5sZWRnZXJfc2VydmljZS5HZXRTZXJ2aWNlSW5mb1Jlc3BvbnNlEm8KCkdldE9iamVjdHMSLi5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuR2V0T2JqZWN0c1JlcXVlc3QaLy5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuR2V0T2JqZWN0c1Jlc3BvbnNlMAESfgoPR2V0VHJhbnNhY3Rpb25zEjMuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkdldFRyYW5zYWN0aW9uc1JlcXVlc3QaNC5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuR2V0VHJhbnNhY3Rpb25zUmVzcG9uc2UwARJxCg1HZXRDaGVja3BvaW50EjEuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkdldENoZWNrcG9pbnRSZXF1ZXN0GisuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkNoZWNrcG9pbnREYXRhMAESeQoRU3RyZWFtQ2hlY2twb2ludHMSNS5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuU3RyZWFtQ2hlY2twb2ludHNSZXF1ZXN0GisuaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkNoZWNrcG9pbnREYXRhMAESZwoIR2V0RXBvY2gSLC5pb3RhLmdycGMudjEubGVkZ2VyX3NlcnZpY2UuR2V0RXBvY2hSZXF1ZXN0Gi0uaW90YS5ncnBjLnYxLmxlZGdlcl9zZXJ2aWNlLkdldEVwb2NoUmVzcG9uc2ViBnByb3RvMw", [file_google_protobuf_field_mask, file_google_protobuf_timestamp, file_google_rpc_status, file_iota_grpc_options, file_iota_grpc_v1_checkpoint, file_iota_grpc_v1_epoch, file_iota_grpc_v1_event, file_iota_grpc_v1_filter, file_iota_grpc_v1_object, file_iota_grpc_v1_transaction, file_iota_grpc_v1_types]); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetHealthRequest + */ +export type GetHealthRequest = Message<"iota.grpc.v1.ledger_service.GetHealthRequest"> & { + /** + * Optional threshold in milliseconds. The node is considered healthy only if + * the latest executed checkpoint timestamp is within this many milliseconds of + * the current system time. If not provided, the server applies a default + * threshold of 5 seconds. + * + * @generated from field: optional uint64 threshold_ms = 1; + */ + thresholdMs?: bigint | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetHealthRequest. + * Use `create(GetHealthRequestSchema)` to create a new message. + */ +export const GetHealthRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 0); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetHealthResponse + */ +export type GetHealthResponse = Message<"iota.grpc.v1.ledger_service.GetHealthResponse"> & { + /** + * Checkpoint height of the most recently executed checkpoint. + * + * @generated from field: optional uint64 executed_checkpoint_height = 1; + */ + executedCheckpointHeight?: bigint | undefined; + + /** + * Estimated validator latency in milliseconds. + * Reserved for future use. + * + * @generated from field: optional uint32 estimated_validator_latency_ms = 2; + */ + estimatedValidatorLatencyMs?: number | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetHealthResponse. + * Use `create(GetHealthResponseSchema)` to create a new message. + */ +export const GetHealthResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 1); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetServiceInfoRequest + */ +export type GetServiceInfoRequest = Message<"iota.grpc.v1.ledger_service.GetServiceInfoRequest"> & { + /** + * Mask specifying which ServiceInfo fields to read. + * If no mask is specified, defaults to `chain_id,epoch,checkpoint_height`. + * + * @generated from field: optional google.protobuf.FieldMask read_mask = 1; + */ + readMask?: FieldMask | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetServiceInfoRequest. + * Use `create(GetServiceInfoRequestSchema)` to create a new message. + */ +export const GetServiceInfoRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 2); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetServiceInfoResponse + */ +export type GetServiceInfoResponse = Message<"iota.grpc.v1.ledger_service.GetServiceInfoResponse"> & { + /** + * The chain identifier of the chain that this node is on. + * + * The chain identifier is the digest of the genesis checkpoint, the + * checkpoint with sequence number 0. + * + * @generated from field: optional iota.grpc.v1.types.Digest chain_id = 1; + */ + chainId?: Digest | undefined; + + /** + * Human-readable name of the chain that this node is on. + * + * This is intended to be a human-readable name like `mainnet`, `testnet`, and so on. + * + * @generated from field: optional string chain = 2; + */ + chain?: string | undefined; + + /** + * Current epoch of the node based on its highest executed checkpoint. + * + * @generated from field: optional uint64 epoch = 3; + */ + epoch?: bigint | undefined; + + /** + * Checkpoint height of the most recently executed checkpoint. + * + * @generated from field: optional uint64 executed_checkpoint_height = 4; + */ + executedCheckpointHeight?: bigint | undefined; + + /** + * Unix timestamp of the most recently executed checkpoint. + * + * @generated from field: optional google.protobuf.Timestamp executed_checkpoint_timestamp = 5; + */ + executedCheckpointTimestamp?: Timestamp | undefined; + + /** + * The lowest checkpoint for which checkpoints and transaction data are available. + * + * @generated from field: optional uint64 lowest_available_checkpoint = 6; + */ + lowestAvailableCheckpoint?: bigint | undefined; + + /** + * The lowest checkpoint for which object data is available. + * + * @generated from field: optional uint64 lowest_available_checkpoint_objects = 7; + */ + lowestAvailableCheckpointObjects?: bigint | undefined; + + /** + * Software version of the service. Similar to the `server` http header. + * + * @generated from field: optional string server = 8; + */ + server?: string | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetServiceInfoResponse. + * Use `create(GetServiceInfoResponseSchema)` to create a new message. + */ +export const GetServiceInfoResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 3); + +/** + * @generated from message iota.grpc.v1.ledger_service.ObjectRequest + */ +export type ObjectRequest = Message<"iota.grpc.v1.ledger_service.ObjectRequest"> & { + /** + * Required. The `ObjectId` of the requested object. + * If no version is specified, and the object is live, then the latest + * version of the object is returned. + * + * @generated from field: optional iota.grpc.v1.types.ObjectReference object_ref = 1; + */ + objectRef?: ObjectReference | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.ObjectRequest. + * Use `create(ObjectRequestSchema)` to create a new message. + */ +export const ObjectRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 4); + +/** + * @generated from message iota.grpc.v1.ledger_service.ObjectRequests + */ +export type ObjectRequests = Message<"iota.grpc.v1.ledger_service.ObjectRequests"> & { + /** + * @generated from field: repeated iota.grpc.v1.ledger_service.ObjectRequest requests = 1; + */ + requests: ObjectRequest[]; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.ObjectRequests. + * Use `create(ObjectRequestsSchema)` to create a new message. + */ +export const ObjectRequestsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 5); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetObjectsRequest + */ +export type GetObjectsRequest = Message<"iota.grpc.v1.ledger_service.GetObjectsRequest"> & { + /** + * @generated from field: optional iota.grpc.v1.ledger_service.ObjectRequests requests = 1; + */ + requests?: ObjectRequests | undefined; + + /** + * Mask specifying which fields to read. + * If no mask is specified, defaults to `object_id,version,digest`. + * + * @generated from field: optional google.protobuf.FieldMask read_mask = 2; + */ + readMask?: FieldMask | undefined; + + /** + * Optional maximum message size the client can receive (1MB - 128MB) + * If not specified, server uses default chunking threshold (4MB) + * + * @generated from field: optional uint32 max_message_size_bytes = 3; + */ + maxMessageSizeBytes?: number | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetObjectsRequest. + * Use `create(GetObjectsRequestSchema)` to create a new message. + */ +export const GetObjectsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 6); + +/** + * @generated from message iota.grpc.v1.ledger_service.ObjectResult + */ +export type ObjectResult = Message<"iota.grpc.v1.ledger_service.ObjectResult"> & { + /** + * @generated from oneof iota.grpc.v1.ledger_service.ObjectResult.result + */ + result: { + /** + * @generated from field: iota.grpc.v1.object.Object object = 1; + */ + value: Object$; + case: "object"; + } | { + /** + * @generated from field: google.rpc.Status error = 2; + */ + value: Status; + case: "error"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.ObjectResult. + * Use `create(ObjectResultSchema)` to create a new message. + */ +export const ObjectResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 7); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetObjectsResponse + */ +export type GetObjectsResponse = Message<"iota.grpc.v1.ledger_service.GetObjectsResponse"> & { + /** + * @generated from field: repeated iota.grpc.v1.ledger_service.ObjectResult objects = 1; + */ + objects: ObjectResult[]; + + /** + * @generated from field: bool has_next = 2; + */ + hasNext: boolean; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetObjectsResponse. + * Use `create(GetObjectsResponseSchema)` to create a new message. + */ +export const GetObjectsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 8); + +/** + * @generated from message iota.grpc.v1.ledger_service.TransactionRequest + */ +export type TransactionRequest = Message<"iota.grpc.v1.ledger_service.TransactionRequest"> & { + /** + * Required. The digest of the requested transaction. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 1; + */ + digest?: Digest | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.TransactionRequest. + * Use `create(TransactionRequestSchema)` to create a new message. + */ +export const TransactionRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 9); + +/** + * @generated from message iota.grpc.v1.ledger_service.TransactionRequests + */ +export type TransactionRequests = Message<"iota.grpc.v1.ledger_service.TransactionRequests"> & { + /** + * @generated from field: repeated iota.grpc.v1.ledger_service.TransactionRequest requests = 1; + */ + requests: TransactionRequest[]; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.TransactionRequests. + * Use `create(TransactionRequestsSchema)` to create a new message. + */ +export const TransactionRequestsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 10); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetTransactionsRequest + */ +export type GetTransactionsRequest = Message<"iota.grpc.v1.ledger_service.GetTransactionsRequest"> & { + /** + * @generated from field: optional iota.grpc.v1.ledger_service.TransactionRequests requests = 1; + */ + requests?: TransactionRequests | undefined; + + /** + * Mask specifying which fields to read. + * If no mask is specified, defaults to `transaction.digest`. + * + * @generated from field: optional google.protobuf.FieldMask read_mask = 2; + */ + readMask?: FieldMask | undefined; + + /** + * Optional maximum message size the client can receive (1MB - 128MB) + * If not specified, server uses default chunking threshold (4MB) + * + * @generated from field: optional uint32 max_message_size_bytes = 3; + */ + maxMessageSizeBytes?: number | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetTransactionsRequest. + * Use `create(GetTransactionsRequestSchema)` to create a new message. + */ +export const GetTransactionsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 11); + +/** + * @generated from message iota.grpc.v1.ledger_service.TransactionResult + */ +export type TransactionResult = Message<"iota.grpc.v1.ledger_service.TransactionResult"> & { + /** + * @generated from oneof iota.grpc.v1.ledger_service.TransactionResult.result + */ + result: { + /** + * @generated from field: iota.grpc.v1.transaction.ExecutedTransaction executed_transaction = 1; + */ + value: ExecutedTransaction; + case: "executedTransaction"; + } | { + /** + * @generated from field: google.rpc.Status error = 2; + */ + value: Status; + case: "error"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.TransactionResult. + * Use `create(TransactionResultSchema)` to create a new message. + */ +export const TransactionResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 12); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetTransactionsResponse + */ +export type GetTransactionsResponse = Message<"iota.grpc.v1.ledger_service.GetTransactionsResponse"> & { + /** + * @generated from field: repeated iota.grpc.v1.ledger_service.TransactionResult transaction_results = 1; + */ + transactionResults: TransactionResult[]; + + /** + * @generated from field: bool has_next = 2; + */ + hasNext: boolean; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetTransactionsResponse. + * Use `create(GetTransactionsResponseSchema)` to create a new message. + */ +export const GetTransactionsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 13); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetCheckpointRequest + */ +export type GetCheckpointRequest = Message<"iota.grpc.v1.ledger_service.GetCheckpointRequest"> & { + /** + * @generated from oneof iota.grpc.v1.ledger_service.GetCheckpointRequest.checkpoint_id + */ + checkpointId: { + /** + * If set to true, the latest checkpoint is requested. + * + * @generated from field: bool latest = 1; + */ + value: boolean; + case: "latest"; + } | { + /** + * The sequence number of the requested checkpoint. + * + * @generated from field: uint64 sequence_number = 2; + */ + value: bigint; + case: "sequenceNumber"; + } | { + /** + * The digest of the requested checkpoint. + * + * @generated from field: iota.grpc.v1.types.Digest digest = 3; + */ + value: Digest; + case: "digest"; + } | { case: undefined; value?: undefined }; + + /** + * Mask specifying which fields to read. + * If no mask is specified, defaults to `summary`. + * + * @generated from field: optional google.protobuf.FieldMask read_mask = 4; + */ + readMask?: FieldMask | undefined; + + /** + * if no filter is passed, all transactions are included (if mentioned in the read_mask) + * + * @generated from field: optional iota.grpc.v1.filter.TransactionFilter transactions_filter = 5; + */ + transactionsFilter?: TransactionFilter | undefined; + + /** + * if no filter is passed, all events are included (if mentioned in the read_mask) + * + * @generated from field: optional iota.grpc.v1.filter.EventFilter events_filter = 6; + */ + eventsFilter?: EventFilter | undefined; + + /** + * Optional maximum message size the client can receive (1MB - 128MB) + * If not specified, server uses default chunking threshold (4MB) + * + * @generated from field: optional uint32 max_message_size_bytes = 7; + */ + maxMessageSizeBytes?: number | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetCheckpointRequest. + * Use `create(GetCheckpointRequestSchema)` to create a new message. + */ +export const GetCheckpointRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 14); + +/** + * @generated from message iota.grpc.v1.ledger_service.StreamCheckpointsRequest + */ +export type StreamCheckpointsRequest = Message<"iota.grpc.v1.ledger_service.StreamCheckpointsRequest"> & { + /** + * if no start sequence number is provided, streaming starts from the latest checkpoint + * + * @generated from field: optional uint64 start_sequence_number = 1; + */ + startSequenceNumber?: bigint | undefined; + + /** + * if no end sequence number is provided, streaming continues forever + * + * @generated from field: optional uint64 end_sequence_number = 2; + */ + endSequenceNumber?: bigint | undefined; + + /** + * Mask specifying which fields to read. + * If no mask is specified, defaults to `summary`. + * + * @generated from field: optional google.protobuf.FieldMask read_mask = 3; + */ + readMask?: FieldMask | undefined; + + /** + * if no filter is passed, all transactions are included (if mentioned in the read_mask) + * + * @generated from field: optional iota.grpc.v1.filter.TransactionFilter transactions_filter = 4; + */ + transactionsFilter?: TransactionFilter | undefined; + + /** + * if no filter is passed, all events are included (if mentioned in the read_mask) + * + * @generated from field: optional iota.grpc.v1.filter.EventFilter events_filter = 5; + */ + eventsFilter?: EventFilter | undefined; + + /** + * When true, checkpoints with no matching transactions or events are skipped entirely. + * At least one of transactions_filter or events_filter must be set. + * A Progress message is sent periodically to indicate liveness and scan position. + * + * @generated from field: optional bool filter_checkpoints = 6; + */ + filterCheckpoints?: boolean | undefined; + + /** + * Progress message interval in milliseconds when filter_checkpoints is enabled. + * Defaults to 2000ms. Minimum value is 500ms; lower values are clamped. + * + * @generated from field: optional uint32 progress_interval_ms = 7; + */ + progressIntervalMs?: number | undefined; + + /** + * Optional maximum message size the client can receive (1MB - 128MB) + * If not specified, server uses default chunking threshold (4MB) + * + * @generated from field: optional uint32 max_message_size_bytes = 8; + */ + maxMessageSizeBytes?: number | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.StreamCheckpointsRequest. + * Use `create(StreamCheckpointsRequestSchema)` to create a new message. + */ +export const StreamCheckpointsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 15); + +/** + * @generated from message iota.grpc.v1.ledger_service.CheckpointData + */ +export type CheckpointData = Message<"iota.grpc.v1.ledger_service.CheckpointData"> & { + /** + * @generated from oneof iota.grpc.v1.ledger_service.CheckpointData.payload + */ + payload: { + /** + * @generated from field: iota.grpc.v1.checkpoint.Checkpoint checkpoint = 1; + */ + value: Checkpoint; + case: "checkpoint"; + } | { + /** + * @generated from field: iota.grpc.v1.transaction.ExecutedTransactions executed_transactions = 2; + */ + value: ExecutedTransactions; + case: "executedTransactions"; + } | { + /** + * @generated from field: iota.grpc.v1.event.Events events = 3; + */ + value: Events; + case: "events"; + } | { + /** + * @generated from field: iota.grpc.v1.ledger_service.CheckpointData.Progress progress = 4; + */ + value: CheckpointData_Progress; + case: "progress"; + } | { + /** + * @generated from field: iota.grpc.v1.ledger_service.CheckpointData.EndMarker end_marker = 5; + */ + value: CheckpointData_EndMarker; + case: "endMarker"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.CheckpointData. + * Use `create(CheckpointDataSchema)` to create a new message. + */ +export const CheckpointDataSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 16); + +/** + * @generated from message iota.grpc.v1.ledger_service.CheckpointData.Progress + */ +export type CheckpointData_Progress = Message<"iota.grpc.v1.ledger_service.CheckpointData.Progress"> & { + /** + * The sequence number of the latest scanned checkpoint. + * + * @generated from field: uint64 latest_scanned_sequence_number = 1; + */ + latestScannedSequenceNumber: bigint; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.CheckpointData.Progress. + * Use `create(CheckpointData_ProgressSchema)` to create a new message. + */ +export const CheckpointData_ProgressSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 16, 0); + +/** + * @generated from message iota.grpc.v1.ledger_service.CheckpointData.EndMarker + */ +export type CheckpointData_EndMarker = Message<"iota.grpc.v1.ledger_service.CheckpointData.EndMarker"> & { + /** + * The height of this checkpoint. + * + * @generated from field: optional uint64 sequence_number = 1; + */ + sequenceNumber?: bigint | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.CheckpointData.EndMarker. + * Use `create(CheckpointData_EndMarkerSchema)` to create a new message. + */ +export const CheckpointData_EndMarkerSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 16, 1); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetEpochRequest + */ +export type GetEpochRequest = Message<"iota.grpc.v1.ledger_service.GetEpochRequest"> & { + /** + * The requested epoch. + * If no epoch is provided the current epoch will be returned. + * + * @generated from field: optional uint64 epoch = 1; + */ + epoch?: bigint | undefined; + + /** + * Mask specifying which fields to read. + * If no mask is specified, defaults to `epoch`. + * + * @generated from field: optional google.protobuf.FieldMask read_mask = 2; + */ + readMask?: FieldMask | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetEpochRequest. + * Use `create(GetEpochRequestSchema)` to create a new message. + */ +export const GetEpochRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 17); + +/** + * @generated from message iota.grpc.v1.ledger_service.GetEpochResponse + */ +export type GetEpochResponse = Message<"iota.grpc.v1.ledger_service.GetEpochResponse"> & { + /** + * @generated from field: optional iota.grpc.v1.epoch.Epoch epoch = 1; + */ + epoch?: Epoch | undefined; +}; + +/** + * Describes the message iota.grpc.v1.ledger_service.GetEpochResponse. + * Use `create(GetEpochResponseSchema)` to create a new message. + */ +export const GetEpochResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_ledger_service, 18); + +/** + * @generated from service iota.grpc.v1.ledger_service.LedgerService + */ +export const LedgerService: GenService<{ + /** + * Check the health of the node. + * + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.GetHealth + */ + getHealth: { + methodKind: "unary"; + input: typeof GetHealthRequestSchema; + output: typeof GetHealthResponseSchema; + }, + /** + * Query the service for general information about its current state. + * + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.GetServiceInfo + */ + getServiceInfo: { + methodKind: "unary"; + input: typeof GetServiceInfoRequestSchema; + output: typeof GetServiceInfoResponseSchema; + }, + /** + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.GetObjects + */ + getObjects: { + methodKind: "server_streaming"; + input: typeof GetObjectsRequestSchema; + output: typeof GetObjectsResponseSchema; + }, + /** + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.GetTransactions + */ + getTransactions: { + methodKind: "server_streaming"; + input: typeof GetTransactionsRequestSchema; + output: typeof GetTransactionsResponseSchema; + }, + /** + * Checkpoint operations + * + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.GetCheckpoint + */ + getCheckpoint: { + methodKind: "server_streaming"; + input: typeof GetCheckpointRequestSchema; + output: typeof CheckpointDataSchema; + }, + /** + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.StreamCheckpoints + */ + streamCheckpoints: { + methodKind: "server_streaming"; + input: typeof StreamCheckpointsRequestSchema; + output: typeof CheckpointDataSchema; + }, + /** + * @generated from rpc iota.grpc.v1.ledger_service.LedgerService.GetEpoch + */ + getEpoch: { + methodKind: "unary"; + input: typeof GetEpochRequestSchema; + output: typeof GetEpochResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_iota_grpc_v1_ledger_service, 0); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/object_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/object_pb.ts new file mode 100644 index 0000000..4e33014 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/object_pb.ts @@ -0,0 +1,70 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/object.proto (package iota.grpc.v1.object, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { BcsData } from "./bcs_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { ObjectReference } from "./types_pb.js"; +import { file_iota_grpc_v1_types } from "./types_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/object.proto. + */ +export const file_iota_grpc_v1_object: GenFile = /*@__PURE__*/ + fileDesc("Chlpb3RhL2dycGMvdjEvb2JqZWN0LnByb3RvEhNpb3RhLmdycGMudjEub2JqZWN0IogBCgZPYmplY3QSOwoJcmVmZXJlbmNlGAEgASgLMiMuaW90YS5ncnBjLnYxLnR5cGVzLk9iamVjdFJlZmVyZW5jZUgAiAEBEisKA2JjcxgCIAEoCzIZLmlvdGEuZ3JwYy52MS5iY3MuQmNzRGF0YUgBiAEBQgwKCl9yZWZlcmVuY2VCBgoEX2JjcyI9CgdPYmplY3RzEiwKB29iamVjdHMYASADKAsyGy5pb3RhLmdycGMudjEub2JqZWN0Lk9iamVjdDoEkLUYAWIGcHJvdG8z", [file_iota_grpc_options, file_iota_grpc_v1_bcs, file_iota_grpc_v1_types]); + +/** + * An object on the IOTA blockchain. + * + * @generated from message iota.grpc.v1.object.Object + */ +export type Object$ = Message<"iota.grpc.v1.object.Object"> & { + /** + * Reference to this object. + * + * @generated from field: optional iota.grpc.v1.types.ObjectReference reference = 1; + */ + reference?: ObjectReference | undefined; + + /** + * This Object serialized as BCS. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 2; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.object.Object. + * Use `create(ObjectSchema)` to create a new message. + */ +export const ObjectSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_object, 0); + +/** + * A list of objects. + * + * @generated from message iota.grpc.v1.object.Objects + */ +export type Objects = Message<"iota.grpc.v1.object.Objects"> & { + /** + * @generated from field: repeated iota.grpc.v1.object.Object objects = 1; + */ + objects: Object$[]; +}; + +/** + * Describes the message iota.grpc.v1.object.Objects. + * Use `create(ObjectsSchema)` to create a new message. + */ +export const ObjectsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_object, 1); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/signatures_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/signatures_pb.ts new file mode 100644 index 0000000..979bbd5 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/signatures_pb.ts @@ -0,0 +1,83 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/signatures.proto (package iota.grpc.v1.signatures, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { BcsData } from "./bcs_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/signatures.proto. + */ +export const file_iota_grpc_v1_signatures: GenFile = /*@__PURE__*/ + fileDesc("Ch1pb3RhL2dycGMvdjEvc2lnbmF0dXJlcy5wcm90bxIXaW90YS5ncnBjLnYxLnNpZ25hdHVyZXMiTgoNVXNlclNpZ25hdHVyZRIrCgNiY3MYASABKAsyGS5pb3RhLmdycGMudjEuYmNzLkJjc0RhdGFIAIgBAToIgrUYBHdpdGhCBgoEX2JjcyJaCg5Vc2VyU2lnbmF0dXJlcxI6CgpzaWduYXR1cmVzGAEgAygLMiYuaW90YS5ncnBjLnYxLnNpZ25hdHVyZXMuVXNlclNpZ25hdHVyZToMgrUYBHdpdGiQtRgBIl0KHFZhbGlkYXRvckFnZ3JlZ2F0ZWRTaWduYXR1cmUSKwoDYmNzGAEgASgLMhkuaW90YS5ncnBjLnYxLmJjcy5CY3NEYXRhSACIAQE6CIK1GAR3aXRoQgYKBF9iY3NiBnByb3RvMw", [file_iota_grpc_options, file_iota_grpc_v1_bcs]); + +/** + * A signature from a user. + * + * @generated from message iota.grpc.v1.signatures.UserSignature + */ +export type UserSignature = Message<"iota.grpc.v1.signatures.UserSignature"> & { + /** + * This signature serialized as as BCS. + * + * When provided as input this will support both the form that is length + * prefixed as well as not length prefixed. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 1; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.signatures.UserSignature. + * Use `create(UserSignatureSchema)` to create a new message. + */ +export const UserSignatureSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_signatures, 0); + +/** + * A list of user signatures. + * + * @generated from message iota.grpc.v1.signatures.UserSignatures + */ +export type UserSignatures = Message<"iota.grpc.v1.signatures.UserSignatures"> & { + /** + * @generated from field: repeated iota.grpc.v1.signatures.UserSignature signatures = 1; + */ + signatures: UserSignature[]; +}; + +/** + * Describes the message iota.grpc.v1.signatures.UserSignatures. + * Use `create(UserSignaturesSchema)` to create a new message. + */ +export const UserSignaturesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_signatures, 1); + +/** + * / An aggregated signature from multiple validators. + * + * @generated from message iota.grpc.v1.signatures.ValidatorAggregatedSignature + */ +export type ValidatorAggregatedSignature = Message<"iota.grpc.v1.signatures.ValidatorAggregatedSignature"> & { + /** + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 1; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.signatures.ValidatorAggregatedSignature. + * Use `create(ValidatorAggregatedSignatureSchema)` to create a new message. + */ +export const ValidatorAggregatedSignatureSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_signatures, 2); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/transaction_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/transaction_pb.ts new file mode 100644 index 0000000..a2df99b --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/transaction_pb.ts @@ -0,0 +1,208 @@ +// Copyright (c) Mysten Labs, Inc. +// Modifications Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/transaction.proto (package iota.grpc.v1.transaction, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { BcsData } from "./bcs_pb.js"; +import { file_iota_grpc_v1_bcs } from "./bcs_pb.js"; +import type { Events } from "./event_pb.js"; +import { file_iota_grpc_v1_event } from "./event_pb.js"; +import type { Objects } from "./object_pb.js"; +import { file_iota_grpc_v1_object } from "./object_pb.js"; +import type { UserSignatures } from "./signatures_pb.js"; +import { file_iota_grpc_v1_signatures } from "./signatures_pb.js"; +import type { Digest } from "./types_pb.js"; +import { file_iota_grpc_v1_types } from "./types_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/transaction.proto. + */ +export const file_iota_grpc_v1_transaction: GenFile = /*@__PURE__*/ + fileDesc("Ch5pb3RhL2dycGMvdjEvdHJhbnNhY3Rpb24ucHJvdG8SGGlvdGEuZ3JwYy52MS50cmFuc2FjdGlvbiKIAQoLVHJhbnNhY3Rpb24SLwoGZGlnZXN0GAEgASgLMhouaW90YS5ncnBjLnYxLnR5cGVzLkRpZ2VzdEgAiAEBEisKA2JjcxgCIAEoCzIZLmlvdGEuZ3JwYy52MS5iY3MuQmNzRGF0YUgBiAEBOgiCtRgEd2l0aEIJCgdfZGlnZXN0QgYKBF9iY3MijwEKElRyYW5zYWN0aW9uRWZmZWN0cxIvCgZkaWdlc3QYASABKAsyGi5pb3RhLmdycGMudjEudHlwZXMuRGlnZXN0SACIAQESKwoDYmNzGAIgASgLMhkuaW90YS5ncnBjLnYxLmJjcy5CY3NEYXRhSAGIAQE6CIK1GAR3aXRoQgkKB19kaWdlc3RCBgoEX2JjcyKVAQoRVHJhbnNhY3Rpb25FdmVudHMSLwoGZGlnZXN0GAEgASgLMhouaW90YS5ncnBjLnYxLnR5cGVzLkRpZ2VzdEgAiAEBEi8KBmV2ZW50cxgCIAEoCzIaLmlvdGEuZ3JwYy52MS5ldmVudC5FdmVudHNIAYgBAToIgrUYBHdpdGhCCQoHX2RpZ2VzdEIJCgdfZXZlbnRzIuIEChNFeGVjdXRlZFRyYW5zYWN0aW9uEj8KC3RyYW5zYWN0aW9uGAEgASgLMiUuaW90YS5ncnBjLnYxLnRyYW5zYWN0aW9uLlRyYW5zYWN0aW9uSACIAQESQAoKc2lnbmF0dXJlcxgCIAEoCzInLmlvdGEuZ3JwYy52MS5zaWduYXR1cmVzLlVzZXJTaWduYXR1cmVzSAGIAQESQgoHZWZmZWN0cxgDIAEoCzIsLmlvdGEuZ3JwYy52MS50cmFuc2FjdGlvbi5UcmFuc2FjdGlvbkVmZmVjdHNIAogBARJACgZldmVudHMYBCABKAsyKy5pb3RhLmdycGMudjEudHJhbnNhY3Rpb24uVHJhbnNhY3Rpb25FdmVudHNIA4gBARIXCgpjaGVja3BvaW50GAUgASgESASIAQESMgoJdGltZXN0YW1wGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgFiAEBEjgKDWlucHV0X29iamVjdHMYByABKAsyHC5pb3RhLmdycGMudjEub2JqZWN0Lk9iamVjdHNIBogBARI5Cg5vdXRwdXRfb2JqZWN0cxgIIAEoCzIcLmlvdGEuZ3JwYy52MS5vYmplY3QuT2JqZWN0c0gHiAEBOgiCtRgEd2l0aEIOCgxfdHJhbnNhY3Rpb25CDQoLX3NpZ25hdHVyZXNCCgoIX2VmZmVjdHNCCQoHX2V2ZW50c0INCgtfY2hlY2twb2ludEIMCgpfdGltZXN0YW1wQhAKDl9pbnB1dF9vYmplY3RzQhEKD19vdXRwdXRfb2JqZWN0cyJuChRFeGVjdXRlZFRyYW5zYWN0aW9ucxJMChVleGVjdXRlZF90cmFuc2FjdGlvbnMYASADKAsyLS5pb3RhLmdycGMudjEudHJhbnNhY3Rpb24uRXhlY3V0ZWRUcmFuc2FjdGlvbjoIgrUYBHdpdGhiBnByb3RvMw", [file_google_protobuf_timestamp, file_iota_grpc_options, file_iota_grpc_v1_bcs, file_iota_grpc_v1_event, file_iota_grpc_v1_object, file_iota_grpc_v1_signatures, file_iota_grpc_v1_types]); + +/** + * A transaction. + * + * @generated from message iota.grpc.v1.transaction.Transaction + */ +export type Transaction = Message<"iota.grpc.v1.transaction.Transaction"> & { + /** + * The digest of this Transaction. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 1; + */ + digest?: Digest | undefined; + + /** + * This Transaction serialized as BCS. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 2; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.transaction.Transaction. + * Use `create(TransactionSchema)` to create a new message. + */ +export const TransactionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_transaction, 0); + +/** + * The effects of executing a transaction. + * + * @generated from message iota.grpc.v1.transaction.TransactionEffects + */ +export type TransactionEffects = Message<"iota.grpc.v1.transaction.TransactionEffects"> & { + /** + * The digest of this TransactionEffects. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 1; + */ + digest?: Digest | undefined; + + /** + * This TransactionEffects serialized as BCS. + * + * @generated from field: optional iota.grpc.v1.bcs.BcsData bcs = 2; + */ + bcs?: BcsData | undefined; +}; + +/** + * Describes the message iota.grpc.v1.transaction.TransactionEffects. + * Use `create(TransactionEffectsSchema)` to create a new message. + */ +export const TransactionEffectsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_transaction, 1); + +/** + * iota.grpc.v1.event.Events emitted during the successful execution of a transaction. + * + * @generated from message iota.grpc.v1.transaction.TransactionEvents + */ +export type TransactionEvents = Message<"iota.grpc.v1.transaction.TransactionEvents"> & { + /** + * The digest of this TransactionEvents. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 1; + */ + digest?: Digest | undefined; + + /** + * List of events emitted by a transaction. + * + * @generated from field: optional iota.grpc.v1.event.Events events = 2; + */ + events?: Events | undefined; +}; + +/** + * Describes the message iota.grpc.v1.transaction.TransactionEvents. + * Use `create(TransactionEventsSchema)` to create a new message. + */ +export const TransactionEventsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_transaction, 2); + +/** + * A transaction that has been executed, along with its signatures, effects, events and objects. + * + * @generated from message iota.grpc.v1.transaction.ExecutedTransaction + */ +export type ExecutedTransaction = Message<"iota.grpc.v1.transaction.ExecutedTransaction"> & { + /** + * The transaction itself. + * + * @generated from field: optional iota.grpc.v1.transaction.Transaction transaction = 1; + */ + transaction?: Transaction | undefined; + + /** + * List of user signatures that are used to authorize the + * execution of this transaction. + * + * @generated from field: optional iota.grpc.v1.signatures.UserSignatures signatures = 2; + */ + signatures?: UserSignatures | undefined; + + /** + * The `TransactionEffects` for this transaction. + * + * @generated from field: optional iota.grpc.v1.transaction.TransactionEffects effects = 3; + */ + effects?: TransactionEffects | undefined; + + /** + * The `TransactionEvents` for this transaction. + * + * This field might be empty, even if it was explicitly requested, if the + * transaction didn't produce any events. + * `iota.types.TransactionEffects.events_digest` is populated if the + * transaction produced any events. + * + * @generated from field: optional iota.grpc.v1.transaction.TransactionEvents events = 4; + */ + events?: TransactionEvents | undefined; + + /** + * The sequence number for the checkpoint that includes this transaction. + * + * @generated from field: optional uint64 checkpoint = 5; + */ + checkpoint?: bigint | undefined; + + /** + * The Unix timestamp of the checkpoint that includes this transaction. + * + * @generated from field: optional google.protobuf.Timestamp timestamp = 6; + */ + timestamp?: Timestamp | undefined; + + /** + * Set of input objects used by this transaction. + * + * @generated from field: optional iota.grpc.v1.object.Objects input_objects = 7; + */ + inputObjects?: Objects | undefined; + + /** + * Set of output objects produced by this transaction. + * + * @generated from field: optional iota.grpc.v1.object.Objects output_objects = 8; + */ + outputObjects?: Objects | undefined; +}; + +/** + * Describes the message iota.grpc.v1.transaction.ExecutedTransaction. + * Use `create(ExecutedTransactionSchema)` to create a new message. + */ +export const ExecutedTransactionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_transaction, 3); + +/** + * @generated from message iota.grpc.v1.transaction.ExecutedTransactions + */ +export type ExecutedTransactions = Message<"iota.grpc.v1.transaction.ExecutedTransactions"> & { + /** + * @generated from field: repeated iota.grpc.v1.transaction.ExecutedTransaction executed_transactions = 1; + */ + executedTransactions: ExecutedTransaction[]; +}; + +/** + * Describes the message iota.grpc.v1.transaction.ExecutedTransactions. + * Use `create(ExecutedTransactionsSchema)` to create a new message. + */ +export const ExecutedTransactionsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_transaction, 4); + diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/types_pb.ts b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/types_pb.ts new file mode 100644 index 0000000..0749f76 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/types_pb.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts,import_extension=js" +// @generated from file iota/grpc/v1/types.proto (package iota.grpc.v1.types, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_iota_grpc_options } from "../options_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file iota/grpc/v1/types.proto. + */ +export const file_iota_grpc_v1_types: GenFile = /*@__PURE__*/ + fileDesc("Chhpb3RhL2dycGMvdjEvdHlwZXMucHJvdG8SEmlvdGEuZ3JwYy52MS50eXBlcyIkCgdBZGRyZXNzEg8KB2FkZHJlc3MYASABKAw6CIK1GAR3aXRoIicKCE9iamVjdElkEhEKCW9iamVjdF9pZBgBIAEoDDoIgrUYBHdpdGgiIgoGRGlnZXN0Eg4KBmRpZ2VzdBgBIAEoDDoIgrUYBHdpdGgivQEKD09iamVjdFJlZmVyZW5jZRI0CglvYmplY3RfaWQYASABKAsyHC5pb3RhLmdycGMudjEudHlwZXMuT2JqZWN0SWRIAIgBARIUCgd2ZXJzaW9uGAIgASgESAGIAQESLwoGZGlnZXN0GAMgASgLMhouaW90YS5ncnBjLnYxLnR5cGVzLkRpZ2VzdEgCiAEBOgiCtRgEd2l0aEIMCgpfb2JqZWN0X2lkQgoKCF92ZXJzaW9uQgkKB19kaWdlc3QiQAoNVHlwZVRhZ1ZlY3RvchIvCgppbm5lcl90eXBlGAEgASgLMhsuaW90YS5ncnBjLnYxLnR5cGVzLlR5cGVUYWciIwoNVHlwZVRhZ1N0cnVjdBISCgpzdHJ1Y3RfdGFnGAEgASgJIrsCCgdUeXBlVGFnEhIKCGJvb2xfdGFnGAEgASgISAASEAoGdThfdGFnGAIgASgISAASEQoHdTE2X3RhZxgDIAEoCEgAEhEKB3UzMl90YWcYBCABKAhIABIRCgd1NjRfdGFnGAUgASgISAASEgoIdTEyOF90YWcYBiABKAhIABISCgh1MjU2X3RhZxgHIAEoCEgAEhUKC2FkZHJlc3NfdGFnGAggASgISAASFAoKc2lnbmVyX3RhZxgJIAEoCEgAEjcKCnZlY3Rvcl90YWcYCiABKAsyIS5pb3RhLmdycGMudjEudHlwZXMuVHlwZVRhZ1ZlY3RvckgAEjcKCnN0cnVjdF90YWcYCyABKAsyIS5pb3RhLmdycGMudjEudHlwZXMuVHlwZVRhZ1N0cnVjdEgAQgoKCHR5cGVfdGFnIjoKCFR5cGVUYWdzEi4KCXR5cGVfdGFncxgBIAMoCzIbLmlvdGEuZ3JwYy52MS50eXBlcy5UeXBlVGFnYgZwcm90bzM", [file_iota_grpc_options]); + +/** + * 32-byte address type for IOTA account addresses. + * + * @generated from message iota.grpc.v1.types.Address + */ +export type Address = Message<"iota.grpc.v1.types.Address"> & { + /** + * @generated from field: bytes address = 1; + */ + address: Uint8Array; +}; + +/** + * Describes the message iota.grpc.v1.types.Address. + * Use `create(AddressSchema)` to create a new message. + */ +export const AddressSchema: GenMessage
= /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 0); + +/** + * 32-byte identifier for on-chain objects and packages. + * + * @generated from message iota.grpc.v1.types.ObjectId + */ +export type ObjectId = Message<"iota.grpc.v1.types.ObjectId"> & { + /** + * @generated from field: bytes object_id = 1; + */ + objectId: Uint8Array; +}; + +/** + * Describes the message iota.grpc.v1.types.ObjectId. + * Use `create(ObjectIdSchema)` to create a new message. + */ +export const ObjectIdSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 1); + +/** + * Generic 32-byte digest (used for transactions, objects, etc.) + * + * @generated from message iota.grpc.v1.types.Digest + */ +export type Digest = Message<"iota.grpc.v1.types.Digest"> & { + /** + * @generated from field: bytes digest = 1; + */ + digest: Uint8Array; +}; + +/** + * Describes the message iota.grpc.v1.types.Digest. + * Use `create(DigestSchema)` to create a new message. + */ +export const DigestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 2); + +/** + * Reference to an object. + * + * @generated from message iota.grpc.v1.types.ObjectReference + */ +export type ObjectReference = Message<"iota.grpc.v1.types.ObjectReference"> & { + /** + * The object id of this object. + * + * @generated from field: optional iota.grpc.v1.types.ObjectId object_id = 1; + */ + objectId?: ObjectId | undefined; + + /** + * The version of this object. + * + * @generated from field: optional uint64 version = 2; + */ + version?: bigint | undefined; + + /** + * The digest of this object. + * + * @generated from field: optional iota.grpc.v1.types.Digest digest = 3; + */ + digest?: Digest | undefined; +}; + +/** + * Describes the message iota.grpc.v1.types.ObjectReference. + * Use `create(ObjectReferenceSchema)` to create a new message. + */ +export const ObjectReferenceSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 3); + +/** + * @generated from message iota.grpc.v1.types.TypeTagVector + */ +export type TypeTagVector = Message<"iota.grpc.v1.types.TypeTagVector"> & { + /** + * @generated from field: iota.grpc.v1.types.TypeTag inner_type = 1; + */ + innerType?: TypeTag | undefined; +}; + +/** + * Describes the message iota.grpc.v1.types.TypeTagVector. + * Use `create(TypeTagVectorSchema)` to create a new message. + */ +export const TypeTagVectorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 4); + +/** + * @generated from message iota.grpc.v1.types.TypeTagStruct + */ +export type TypeTagStruct = Message<"iota.grpc.v1.types.TypeTagStruct"> & { + /** + * @generated from field: string struct_tag = 1; + */ + structTag: string; +}; + +/** + * Describes the message iota.grpc.v1.types.TypeTagStruct. + * Use `create(TypeTagStructSchema)` to create a new message. + */ +export const TypeTagStructSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 5); + +/** + * @generated from message iota.grpc.v1.types.TypeTag + */ +export type TypeTag = Message<"iota.grpc.v1.types.TypeTag"> & { + /** + * @generated from oneof iota.grpc.v1.types.TypeTag.type_tag + */ + typeTag: { + /** + * @generated from field: bool bool_tag = 1; + */ + value: boolean; + case: "boolTag"; + } | { + /** + * @generated from field: bool u8_tag = 2; + */ + value: boolean; + case: "u8Tag"; + } | { + /** + * @generated from field: bool u16_tag = 3; + */ + value: boolean; + case: "u16Tag"; + } | { + /** + * @generated from field: bool u32_tag = 4; + */ + value: boolean; + case: "u32Tag"; + } | { + /** + * @generated from field: bool u64_tag = 5; + */ + value: boolean; + case: "u64Tag"; + } | { + /** + * @generated from field: bool u128_tag = 6; + */ + value: boolean; + case: "u128Tag"; + } | { + /** + * @generated from field: bool u256_tag = 7; + */ + value: boolean; + case: "u256Tag"; + } | { + /** + * @generated from field: bool address_tag = 8; + */ + value: boolean; + case: "addressTag"; + } | { + /** + * @generated from field: bool signer_tag = 9; + */ + value: boolean; + case: "signerTag"; + } | { + /** + * @generated from field: iota.grpc.v1.types.TypeTagVector vector_tag = 10; + */ + value: TypeTagVector; + case: "vectorTag"; + } | { + /** + * @generated from field: iota.grpc.v1.types.TypeTagStruct struct_tag = 11; + */ + value: TypeTagStruct; + case: "structTag"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message iota.grpc.v1.types.TypeTag. + * Use `create(TypeTagSchema)` to create a new message. + */ +export const TypeTagSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 6); + +/** + * @generated from message iota.grpc.v1.types.TypeTags + */ +export type TypeTags = Message<"iota.grpc.v1.types.TypeTags"> & { + /** + * @generated from field: repeated iota.grpc.v1.types.TypeTag type_tags = 1; + */ + typeTags: TypeTag[]; +}; + +/** + * Describes the message iota.grpc.v1.types.TypeTags. + * Use `create(TypeTagsSchema)` to create a new message. + */ +export const TypeTagsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_iota_grpc_v1_types, 7); + diff --git a/bindings/wasm/poi_wasm/src/index.ts b/bindings/wasm/poi_wasm/src/index.ts new file mode 100644 index 0000000..50bc989 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/index.ts @@ -0,0 +1,15 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export { + createIotaGrpcClient, + type IotaGrpcClient, + type IotaGrpcClientOptions, +} from "./client.js"; +export { LedgerService } from "./grpc/generated/iota/grpc/v1/ledger_service_pb.js"; +export { + NodePoiSource, + type CheckpointEvidence, + type TransactionEvidence, +} from "./node-poi-source.js"; +export { Proof, ProofBuilder } from "../node/poi_wasm.js"; diff --git a/bindings/wasm/poi_wasm/src/lib.rs b/bindings/wasm/poi_wasm/src/lib.rs new file mode 100644 index 0000000..e818942 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/lib.rs @@ -0,0 +1,16 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod node_source; +mod proof; +mod versioned; + +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + console_error_panic_hook::set_once(); +} + +#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)] +const NODE_SOURCE_IMPORT: &str = r#" +import type { NodePoiSource } from "../src/node-poi-source.js"; +"#; diff --git a/bindings/wasm/poi_wasm/src/node-poi-source.ts b/bindings/wasm/poi_wasm/src/node-poi-source.ts new file mode 100644 index 0000000..3d08fca --- /dev/null +++ b/bindings/wasm/poi_wasm/src/node-poi-source.ts @@ -0,0 +1,263 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import type { Status } from "./grpc/generated/google/rpc/status_pb.js"; +import type { ExecutedTransaction } from "./grpc/generated/iota/grpc/v1/transaction_pb.js"; +import { + createIotaGrpcClient, + type IotaGrpcClient, + type IotaGrpcClientOptions, +} from "./client.js"; + +const CHAIN_IDENTIFIER_FIELDS = ["chain_id"]; +const OBJECT_PROOF_FIELDS = ["bcs"]; +const TRANSACTION_PROOF_FIELDS = [ + "transaction.bcs", + "signatures", + "effects.bcs", + "events.digest", + "events.events.bcs", + "checkpoint", +]; +const CHECKPOINT_PROOF_FIELDS = [ + "checkpoint.summary.bcs", + "checkpoint.signature", + "checkpoint.contents.bcs", +]; + +/** + * Serialized transaction data needed by `poi-rs` to build a proof. + * + * These values are opaque BCS bytes. Node.js fetches them, while Rust remains + * responsible for decoding and validating them. + */ +export interface TransactionEvidence { + transactionBcs: Uint8Array; + signaturesBcs: Uint8Array[]; + effectsBcs: Uint8Array; + eventsBcs?: Uint8Array[]; + checkpointSequenceNumber: bigint; +} + +/** + * Serialized checkpoint data needed by `poi-rs` to authenticate a transaction. + */ +export interface CheckpointEvidence { + summaryBcs: Uint8Array; + signatureBcs: Uint8Array; + contentsBcs: Uint8Array; +} + +/** + * Node.js implementation of the ledger reads required by Proof of Inclusion. + * + * The generated client owns gRPC and protobuf. This class narrows its responses + * to BCS evidence that can cross the JavaScript/WASM boundary. + */ +export class NodePoiSource { + readonly #client: IotaGrpcClient; + + public constructor(endpoint: string, options: IotaGrpcClientOptions = {}) { + this.#client = createIotaGrpcClient(endpoint, options); + } + + public async chainIdentifier(): Promise { + const response = await this.#client.getServiceInfo({ + readMask: { paths: CHAIN_IDENTIFIER_FIELDS }, + }); + + return required(response.chainId?.digest, "service info chain_id"); + } + + public async transaction( + digest: Uint8Array, + ): Promise { + let transaction: ExecutedTransaction | undefined; + + for await (const response of this.#client.getTransactions({ + requests: { + requests: [{ digest: { digest } }], + }, + readMask: { paths: TRANSACTION_PROOF_FIELDS }, + })) { + for (const result of response.transactionResults) { + if (result.result.case === "error") { + throw statusError("getTransactions", result.result.value); + } + + if (result.result.case !== "executedTransaction") { + throw new Error( + "getTransactions returned a result without a transaction or error", + ); + } + + if (transaction) { + throw new Error( + "getTransactions returned more than one transaction for one digest", + ); + } + + transaction = result.result.value; + } + } + + if (!transaction) { + return undefined; + } + + const signatures = required( + transaction.signatures, + "transaction signatures", + ); + const transactionEvents = transaction.events?.events?.events; + + return { + transactionBcs: required( + transaction.transaction?.bcs?.data, + "transaction BCS", + ), + signaturesBcs: signatures.signatures.map((signature, index) => + required(signature.bcs?.data, `transaction signature ${index} BCS`), + ), + effectsBcs: required(transaction.effects?.bcs?.data, "effects BCS"), + eventsBcs: transactionEvents?.map((event, index) => + required(event.bcs?.data, `transaction event ${index} BCS`), + ), + checkpointSequenceNumber: required( + transaction.checkpoint, + "transaction checkpoint sequence number", + ), + }; + } + + public async object( + objectId: Uint8Array, + version?: bigint, + ): Promise { + let objectBcs: Uint8Array | undefined; + + for await (const response of this.#client.getObjects({ + requests: { + requests: [ + { + objectRef: { + objectId: { objectId }, + version, + }, + }, + ], + }, + readMask: { paths: OBJECT_PROOF_FIELDS }, + })) { + for (const result of response.objects) { + if (result.result.case === "error") { + throw statusError("getObjects", result.result.value); + } + + if (result.result.case !== "object") { + throw new Error( + "getObjects returned a result without an object or error", + ); + } + + if (objectBcs) { + throw new Error("getObjects returned more than one object for one ID"); + } + + objectBcs = required(result.result.value.bcs?.data, "object BCS"); + } + } + + return objectBcs; + } + + public async checkpoint( + sequenceNumber: bigint, + ): Promise { + let checkpoint: CheckpointEvidence | undefined; + let reachedEnd = false; + + for await (const response of this.#client.getCheckpoint({ + checkpointId: { + case: "sequenceNumber", + value: sequenceNumber, + }, + readMask: { paths: CHECKPOINT_PROOF_FIELDS }, + })) { + if (response.payload.case === "checkpoint") { + if (checkpoint) { + throw new Error( + "getCheckpoint returned more than one checkpoint for one sequence number", + ); + } + + const value = response.payload.value; + + if ( + value.sequenceNumber !== undefined && + value.sequenceNumber !== sequenceNumber + ) { + throw new Error( + `getCheckpoint returned sequence number ${value.sequenceNumber}, expected ${sequenceNumber}`, + ); + } + + checkpoint = { + summaryBcs: required( + value.summary?.bcs?.data, + "checkpoint summary BCS", + ), + signatureBcs: required( + value.signature?.bcs?.data, + "checkpoint signature BCS", + ), + contentsBcs: required( + value.contents?.bcs?.data, + "checkpoint contents BCS", + ), + }; + } else if (response.payload.case === "endMarker") { + const returnedSequenceNumber = response.payload.value.sequenceNumber; + + if ( + returnedSequenceNumber !== undefined && + returnedSequenceNumber !== sequenceNumber + ) { + throw new Error( + `getCheckpoint ended at sequence number ${returnedSequenceNumber}, expected ${sequenceNumber}`, + ); + } + + reachedEnd = true; + } + } + + if (!checkpoint) { + throw new Error( + `getCheckpoint returned no checkpoint for sequence number ${sequenceNumber}`, + ); + } + + if (!reachedEnd) { + throw new Error( + `getCheckpoint did not finish sequence number ${sequenceNumber}`, + ); + } + + return checkpoint; + } +} + +function required(value: T | null | undefined, field: string): T { + if (value === undefined || value === null) { + throw new Error(`IOTA gRPC response is missing ${field}`); + } + + return value; +} + +function statusError(method: string, status: Status): Error { + const details = status.message ? `: ${status.message}` : ""; + + return new Error(`${method} failed with gRPC status ${status.code}${details}`); +} diff --git a/bindings/wasm/poi_wasm/src/node_source.rs b/bindings/wasm/poi_wasm/src/node_source.rs new file mode 100644 index 0000000..ebe1196 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/node_source.rs @@ -0,0 +1,444 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::{error::Error, fmt}; + +use async_trait::async_trait; +use iota_sdk_types::{ObjectId, Version}; +use iota_sdk_types::{SignedCheckpointSummary, SignedTransaction, Transaction, TransactionEffects, UserSignature}; +use iota_types::{ + digests::TransactionDigest, + effects::{TransactionEffectsAPI, TransactionEvents}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, +}; +use iota_types::{ + digests::{ChainIdentifier, CheckpointDigest}, + object::Object, +}; +use js_sys::{Promise, Uint8Array}; +use poi_rs::Source; +use poi_rs::{SourceCheckpoint, SourceError, SourceErrorKind, SourceTransaction}; +use serde::Deserialize; +use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; +use wasm_bindgen_futures::JsFuture; + +use crate::versioned::VersionedObject; +use crate::versioned::{VersionedCheckpointSummary, VersionedEvent, VersionedValidatorAggregatedSignature}; + +#[wasm_bindgen] +extern "C" { + /// JavaScript source that owns the generated Node.js gRPC client. + #[wasm_bindgen(typescript_type = "NodePoiSource")] + pub type NodePoiSource; + + #[wasm_bindgen(method, catch, structural, js_name = chainIdentifier)] + fn chain_identifier_js(this: &NodePoiSource) -> Result; + + #[wasm_bindgen(method, catch, structural, js_name = transaction)] + fn transaction_js(this: &NodePoiSource, digest: Uint8Array) -> Result; + + #[wasm_bindgen(method, catch, structural, js_name = object)] + fn object_js(this: &NodePoiSource, object_id: Uint8Array, version: Option) -> Result; + + #[wasm_bindgen(method, catch, structural, js_name = checkpoint)] + fn checkpoint_js(this: &NodePoiSource, sequence_number: u64) -> Result; +} + +pub(crate) struct WasmSource { + source: NodePoiSource, +} + +impl WasmSource { + pub(crate) fn new(source: NodePoiSource) -> Self { + Self { source } + } + + async fn await_method(result: Result) -> Result { + let promise = result.map_err(BridgeError::from_js)?; + JsFuture::from(promise).await.map_err(BridgeError::from_js) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsTransactionEvidence { + transaction_bcs: Vec, + signatures_bcs: Vec>, + effects_bcs: Vec, + events_bcs: Option>>, + checkpoint_sequence_number: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsCheckpointEvidence { + summary_bcs: Vec, + signature_bcs: Vec, + contents_bcs: Vec, +} + +#[async_trait(?Send)] +impl Source for WasmSource { + async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { + let value = Self::await_method(self.source.chain_identifier_js()) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchChainIdentifier { + source: Box::new(source), + }, + ) + })?; + let bytes = Uint8Array::new(&value).to_vec(); + let digest = bytes.try_into().map_err(|bytes: Vec| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::ChainIdentifier { + source: Box::new(BridgeError(format!( + "chain identifier must contain 32 bytes, received {}", + bytes.len() + ))), + }, + ) + })?; + + Ok(ChainIdentifier::from(CheckpointDigest::new(digest))) + } + + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + let digest = Uint8Array::from(transaction_digest.as_ref()); + let value = Self::await_method(self.source.transaction_js(digest)) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchTransaction { + source: Box::new(source), + }, + ) + })?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + + let evidence: JsTransactionEvidence = serde_wasm_bindgen::from_value(value).map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(BridgeError(source.to_string())), + }, + ) + })?; + + decode_transaction(transaction_digest, evidence).map(Some) + } + + async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { + let object_id_bytes = Uint8Array::from(object_id.as_ref()); + let value = Self::await_method( + self.source + .object_js(object_id_bytes, version.map(|version| version.as_u64())), + ) + .await + .map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::FetchObject { + source: Box::new(source), + }, + ) + })?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + + let bytes = Uint8Array::new(&value).to_vec(); + let versioned: VersionedObject = decode_bcs(&bytes).map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::Object { + source: Box::new(source), + }, + ) + })?; + let VersionedObject::V1(object) = versioned; + + Ok(Some(object.into())) + } + + async fn checkpoint( + &self, + transaction_digest: TransactionDigest, + sequence_number: u64, + ) -> Result { + let value = Self::await_method(self.source.checkpoint_js(sequence_number)) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) + })?; + let evidence: JsCheckpointEvidence = serde_wasm_bindgen::from_value(value).map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(BridgeError(source.to_string())), + }, + ) + })?; + + decode_checkpoint(transaction_digest, evidence) + } +} + +fn decode_transaction( + transaction_digest: TransactionDigest, + evidence: JsTransactionEvidence, +) -> Result { + let transaction: Transaction = decode_bcs(&evidence.transaction_bcs).map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let signatures = evidence + .signatures_bcs + .iter() + .map(|bytes| decode_bcs::(bytes)) + .collect::, _>>() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + })?; + let transaction = SignedTransaction { + transaction, + signatures, + } + .try_into() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let effects: TransactionEffects = decode_bcs(&evidence.effects_bcs).map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Effects { + source: Box::new(source), + }, + ) + })?; + let events = if effects.events_digest().is_some() { + let events_bcs = evidence.events_bcs.ok_or_else(|| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingEvents { + source: Box::new(BridgeError( + "transaction effects commit to events but eventsBcs is missing".to_owned(), + )), + }, + ) + })?; + let events = events_bcs + .iter() + .map(|bytes| { + let VersionedEvent::V1(event) = decode_bcs::(bytes)?; + Ok(event) + }) + .collect::, bcs::Error>>() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Events { + source: Box::new(source), + }, + ) + })?; + + Some(TransactionEvents(events)) + } else { + None + }; + + Ok(SourceTransaction { + transaction, + effects, + events, + checkpoint_sequence_number: evidence.checkpoint_sequence_number, + }) +} + +fn decode_checkpoint( + transaction_digest: TransactionDigest, + evidence: JsCheckpointEvidence, +) -> Result { + let VersionedCheckpointSummary::V1(summary) = decode_bcs(&evidence.summary_bcs).map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })?; + let VersionedValidatorAggregatedSignature::V1(signature) = + decode_bcs(&evidence.signature_bcs).map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })?; + let summary: CertifiedCheckpointSummary = SignedCheckpointSummary { + checkpoint: summary, + signature, + } + .try_into() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })?; + let contents = decode_bcs::(&evidence.contents_bcs) + .and_then(|contents| { + CheckpointContents::try_from(contents).map_err(|source| bcs::Error::Custom(source.to_string())) + }) + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) + })?; + + Ok(SourceCheckpoint { summary, contents }) +} + +fn decode_bcs(bytes: &[u8]) -> Result +where + T: for<'de> Deserialize<'de>, +{ + bcs::from_bytes(bytes) +} + +#[derive(Debug)] +struct BridgeError(String); + +impl BridgeError { + fn from_js(value: JsValue) -> Self { + let message = value + .dyn_ref::() + .map(js_sys::Error::message) + .and_then(|message| message.as_string()) + .or_else(|| value.as_string()) + .unwrap_or_else(|| format!("{value:?}")); + Self(message) + } +} + +impl fmt::Display for BridgeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for BridgeError {} + +#[cfg(test)] +mod tests { + use iota_sdk_types::{ + CheckpointContents as SdkCheckpointContents, SignedCheckpointSummary as SdkSignedCheckpointSummary, + SignedTransaction as SdkSignedTransaction, + }; + use poi_rs::Proof; + + use super::*; + + #[test] + fn decodes_the_grpc_bcs_evidence_into_existing_iota_types() { + let proof = Proof::from_json_slice(include_bytes!("../../../../poi-rs/tests/fixtures/v1/event.json")) + .expect("fixture must deserialize"); + let transaction_digest = *proof.transaction_proof.transaction.digest(); + let signed_transaction: SdkSignedTransaction = proof + .transaction_proof + .transaction + .clone() + .try_into() + .expect("transaction must convert to SDK types"); + let events_bcs = proof.transaction_proof.events.as_ref().map(|events| { + events + .0 + .iter() + .cloned() + .map(|event| bcs::to_bytes(&VersionedEvent::V1(event)).expect("event must serialize")) + .collect() + }); + let transaction = decode_transaction( + transaction_digest, + JsTransactionEvidence { + transaction_bcs: bcs::to_bytes(&signed_transaction.transaction).expect("transaction must serialize"), + signatures_bcs: signed_transaction + .signatures + .iter() + .map(|signature| bcs::to_bytes(signature).expect("signature must serialize")) + .collect(), + effects_bcs: bcs::to_bytes(&proof.transaction_proof.effects).expect("effects must serialize"), + events_bcs, + checkpoint_sequence_number: proof.checkpoint_summary.sequence_number, + }, + ) + .expect("transaction evidence must decode"); + + assert_eq!(transaction.transaction, proof.transaction_proof.transaction); + assert_eq!(transaction.effects, proof.transaction_proof.effects); + assert_eq!(transaction.events, proof.transaction_proof.events); + + let signed_summary: SdkSignedCheckpointSummary = proof + .checkpoint_summary + .clone() + .try_into() + .expect("checkpoint summary must convert to SDK types"); + let contents = SdkCheckpointContents::try_from(proof.transaction_proof.checkpoint_contents.clone()) + .expect("checkpoint contents must convert to SDK types"); + let checkpoint = decode_checkpoint( + transaction_digest, + JsCheckpointEvidence { + summary_bcs: bcs::to_bytes(&VersionedCheckpointSummary::V1(signed_summary.checkpoint)) + .expect("checkpoint summary must serialize"), + signature_bcs: bcs::to_bytes(&VersionedValidatorAggregatedSignature::V1(signed_summary.signature)) + .expect("checkpoint signature must serialize"), + contents_bcs: bcs::to_bytes(&contents).expect("checkpoint contents must serialize"), + }, + ) + .expect("checkpoint evidence must decode"); + + assert_eq!( + bcs::to_bytes(&checkpoint.summary).expect("decoded checkpoint summary must serialize"), + bcs::to_bytes(&proof.checkpoint_summary).expect("fixture checkpoint summary must serialize") + ); + assert_eq!(checkpoint.contents, proof.transaction_proof.checkpoint_contents); + } +} diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs new file mode 100644 index 0000000..cce6e62 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -0,0 +1,78 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::error::Error; + +use iota_sdk_types::ObjectId; +use iota_types::{digests::TransactionDigest, event::EventID}; +use js_sys::Uint8Array; +use poi_rs::{Proof, ProofBuilder}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; + +use crate::node_source::{NodePoiSource, WasmSource}; + +/// Proof of Inclusion evidence constructed by `poi-rs`. +#[wasm_bindgen(js_name = Proof)] +pub struct WasmProof(Proof); + +#[wasm_bindgen(js_class = Proof)] +impl WasmProof { + /// Serializes this proof as JSON. + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> Result { + let bytes = self.0.to_json_vec().map_err(error_to_js)?; + String::from_utf8(bytes).map_err(error_to_js) + } +} + +/// Builds Proof of Inclusion evidence with a JavaScript `NodePoiSource`. +#[wasm_bindgen(js_name = ProofBuilder)] +pub struct WasmProofBuilder(ProofBuilder); + +#[wasm_bindgen(js_class = ProofBuilder)] +impl WasmProofBuilder { + /// Creates a builder backed by the provided Node.js gRPC source. + #[wasm_bindgen(constructor)] + pub fn new(source: NodePoiSource) -> Self { + Self(ProofBuilder::new(WasmSource::new(source))) + } + + /// Adds a transaction target. + pub fn transaction(self, transaction_digest: Uint8Array) -> Result { + let digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).map_err(error_to_js)?; + Ok(Self(self.0.transaction(digest))) + } + + /// Adds an object target. + pub fn object(self, object_id: Uint8Array) -> Result { + let object_id = ObjectId::from_bytes(object_id.to_vec()).map_err(error_to_js)?; + Ok(Self(self.0.object(object_id))) + } + + /// Adds an event target. + pub fn event(self, transaction_digest: Uint8Array, event_sequence: u64) -> Result { + let tx_digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).map_err(error_to_js)?; + Ok(Self(self.0.event(EventID { + tx_digest, + event_seq: event_sequence, + }))) + } + + /// Fetches the requested evidence and constructs the proof. + pub async fn build(self) -> Result { + self.0.build().await.map(WasmProof).map_err(error_to_js) + } +} + +fn error_to_js(error: impl Error) -> JsValue { + let mut message = error.to_string(); + let mut source = error.source(); + + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + + js_sys::Error::new(&message).into() +} diff --git a/bindings/wasm/poi_wasm/src/versioned.rs b/bindings/wasm/poi_wasm/src/versioned.rs new file mode 100644 index 0000000..9751eb7 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/versioned.rs @@ -0,0 +1,28 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::Object; +use iota_sdk_types::{CheckpointSummary, Event, ValidatorAggregatedSignature}; +use serde::{Deserialize, Serialize}; + +// These one-variant envelopes match the BCS version discriminants used by the +// IOTA gRPC API. The inner values remain the canonical iota-sdk-types values. +#[derive(Deserialize, Serialize)] +pub(crate) enum VersionedObject { + V1(Object), +} + +#[derive(Deserialize, Serialize)] +pub(crate) enum VersionedEvent { + V1(Event), +} + +#[derive(Deserialize, Serialize)] +pub(crate) enum VersionedCheckpointSummary { + V1(CheckpointSummary), +} + +#[derive(Deserialize, Serialize)] +pub(crate) enum VersionedValidatorAggregatedSignature { + V1(ValidatorAggregatedSignature), +} diff --git a/bindings/wasm/poi_wasm/tests/client.test.ts b/bindings/wasm/poi_wasm/tests/client.test.ts new file mode 100644 index 0000000..e109633 --- /dev/null +++ b/bindings/wasm/poi_wasm/tests/client.test.ts @@ -0,0 +1,119 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { create } from "@bufbuild/protobuf"; +import { createRouterTransport } from "@connectrpc/connect"; + +import { createIotaGrpcClient } from "../src/client.js"; +import { + CheckpointDataSchema, + GetObjectsResponseSchema, + GetServiceInfoResponseSchema, + GetTransactionsResponseSchema, + LedgerService, +} from "../src/grpc/generated/iota/grpc/v1/ledger_service_pb.js"; + +test("creates the generated LedgerService client", async () => { + const requests = { + serviceInfo: false, + objects: false, + transactions: false, + checkpoint: false, + }; + const transport = createRouterTransport((router) => { + router.service(LedgerService, { + getServiceInfo(request) { + requests.serviceInfo = true; + assert.deepEqual(request.readMask?.paths, ["chain_id"]); + + return create(GetServiceInfoResponseSchema, { + chainId: { digest: new Uint8Array(32).fill(0xab) }, + }); + }, + async *getObjects(request) { + requests.objects = true; + assert.deepEqual(request.readMask?.paths, ["bcs"]); + + yield create(GetObjectsResponseSchema, { + objects: [], + hasNext: false, + }); + }, + async *getTransactions(request) { + requests.transactions = true; + assert.deepEqual(request.readMask?.paths, ["transaction.bcs"]); + + yield create(GetTransactionsResponseSchema, { + transactionResults: [], + hasNext: false, + }); + }, + async *getCheckpoint(request) { + requests.checkpoint = true; + assert.deepEqual(request.checkpointId, { + case: "sequenceNumber", + value: 42n, + }); + + yield create(CheckpointDataSchema, { + payload: { + case: "endMarker", + value: { sequenceNumber: 42n }, + }, + }); + }, + }); + }); + const client = createIotaGrpcClient("http://unused.test/", { transport }); + + const serviceInfo = await client.getServiceInfo({ + readMask: { paths: ["chain_id"] }, + }); + const objects = await collect( + client.getObjects({ + readMask: { paths: ["bcs"] }, + }), + ); + const transactions = await collect( + client.getTransactions({ + readMask: { paths: ["transaction.bcs"] }, + }), + ); + const checkpoint = await collect( + client.getCheckpoint({ + checkpointId: { + case: "sequenceNumber", + value: 42n, + }, + }), + ); + + assert.deepEqual(serviceInfo.chainId?.digest, new Uint8Array(32).fill(0xab)); + assert.equal(objects.length, 1); + assert.equal(transactions.length, 1); + assert.equal(checkpoint[0]?.payload.case, "endMarker"); + assert.deepEqual(requests, { + serviceInfo: true, + objects: true, + transactions: true, + checkpoint: true, + }); +}); + +test("rejects an empty endpoint", () => { + assert.throws(() => createIotaGrpcClient(" "), /endpoint must not be empty/); +}); + +async function collect(stream: AsyncIterable): Promise { + const values = []; + + for await (const value of stream) { + values.push(value); + } + + return values; +} + diff --git a/bindings/wasm/poi_wasm/tests/node-poi-source.test.ts b/bindings/wasm/poi_wasm/tests/node-poi-source.test.ts new file mode 100644 index 0000000..5414d5b --- /dev/null +++ b/bindings/wasm/poi_wasm/tests/node-poi-source.test.ts @@ -0,0 +1,189 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { create } from "@bufbuild/protobuf"; +import { createRouterTransport } from "@connectrpc/connect"; + +import { + CheckpointDataSchema, + GetObjectsResponseSchema, + GetServiceInfoResponseSchema, + GetTransactionsResponseSchema, + LedgerService, +} from "../src/grpc/generated/iota/grpc/v1/ledger_service_pb.js"; +import { NodePoiSource } from "../src/node-poi-source.js"; + +test("returns the BCS evidence needed by poi-rs", async () => { + const chainId = bytes(0x01); + const transactionDigest = bytes(0x02); + const transactionBcs = bytes(0x03); + const signatureBcs = bytes(0x04); + const effectsBcs = bytes(0x05); + const eventBcs = bytes(0x06); + const objectId = bytes(0x07); + const objectBcs = bytes(0x08); + const summaryBcs = bytes(0x09); + const checkpointSignatureBcs = bytes(0x0a); + const contentsBcs = bytes(0x0b); + + const transport = createRouterTransport((router) => { + router.service(LedgerService, { + getServiceInfo(request) { + assert.deepEqual(request.readMask?.paths, ["chain_id"]); + + return create(GetServiceInfoResponseSchema, { + chainId: { digest: chainId }, + }); + }, + async *getTransactions(request) { + assert.deepEqual(request.readMask?.paths, [ + "transaction.bcs", + "signatures", + "effects.bcs", + "events.digest", + "events.events.bcs", + "checkpoint", + ]); + assert.deepEqual( + request.requests?.requests[0]?.digest?.digest, + transactionDigest, + ); + + yield create(GetTransactionsResponseSchema, { + transactionResults: [ + { + result: { + case: "executedTransaction", + value: { + transaction: { bcs: { data: transactionBcs } }, + signatures: { + signatures: [{ bcs: { data: signatureBcs } }], + }, + effects: { bcs: { data: effectsBcs } }, + events: { + events: { + events: [{ bcs: { data: eventBcs } }], + }, + }, + checkpoint: 42n, + }, + }, + }, + ], + }); + }, + async *getObjects(request) { + assert.deepEqual(request.readMask?.paths, ["bcs"]); + assert.deepEqual( + request.requests?.requests[0]?.objectRef?.objectId?.objectId, + objectId, + ); + assert.equal( + request.requests?.requests[0]?.objectRef?.version, + 7n, + ); + + yield create(GetObjectsResponseSchema, { + objects: [ + { + result: { + case: "object", + value: { bcs: { data: objectBcs } }, + }, + }, + ], + }); + }, + async *getCheckpoint(request) { + assert.deepEqual(request.checkpointId, { + case: "sequenceNumber", + value: 42n, + }); + assert.deepEqual(request.readMask?.paths, [ + "checkpoint.summary.bcs", + "checkpoint.signature", + "checkpoint.contents.bcs", + ]); + + yield create(CheckpointDataSchema, { + payload: { + case: "checkpoint", + value: { + sequenceNumber: 42n, + summary: { bcs: { data: summaryBcs } }, + signature: { bcs: { data: checkpointSignatureBcs } }, + contents: { bcs: { data: contentsBcs } }, + }, + }, + }); + yield create(CheckpointDataSchema, { + payload: { + case: "endMarker", + value: { sequenceNumber: 42n }, + }, + }); + }, + }); + }); + const source = new NodePoiSource("http://unused.test", { transport }); + + assert.deepEqual(await source.chainIdentifier(), chainId); + assert.deepEqual(await source.transaction(transactionDigest), { + transactionBcs, + signaturesBcs: [signatureBcs], + effectsBcs, + eventsBcs: [eventBcs], + checkpointSequenceNumber: 42n, + }); + assert.deepEqual(await source.object(objectId, 7n), objectBcs); + assert.deepEqual(await source.checkpoint(42n), { + summaryBcs, + signatureBcs: checkpointSignatureBcs, + contentsBcs, + }); +}); + +test("returns undefined when a transaction or object is not returned", async () => { + const transport = createRouterTransport((router) => { + router.service(LedgerService, { + async *getTransactions() { + yield create(GetTransactionsResponseSchema); + }, + async *getObjects() { + yield create(GetObjectsResponseSchema); + }, + }); + }); + const source = new NodePoiSource("http://unused.test", { transport }); + + assert.equal(await source.transaction(bytes(0x01)), undefined); + assert.equal(await source.object(bytes(0x02)), undefined); +}); + +test("rejects incomplete checkpoint evidence", async () => { + const transport = createRouterTransport((router) => { + router.service(LedgerService, { + async *getCheckpoint() { + yield create(CheckpointDataSchema, { + payload: { + case: "endMarker", + value: { sequenceNumber: 42n }, + }, + }); + }, + }); + }); + const source = new NodePoiSource("http://unused.test", { transport }); + + await assert.rejects( + source.checkpoint(42n), + /returned no checkpoint for sequence number 42/, + ); +}); + +function bytes(value: number): Uint8Array { + return new Uint8Array(32).fill(value); +} diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts new file mode 100644 index 0000000..e762616 --- /dev/null +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -0,0 +1,41 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ProofBuilder, type NodePoiSource } from "../src/index.js"; + +test("the WASM builder reads transaction evidence from NodePoiSource", async () => { + const transactionDigest = new Uint8Array(32).fill(0x2a); + let requestedDigest: Uint8Array | undefined; + const source = { + async transaction(digest: Uint8Array) { + requestedDigest = digest; + + return { + // Deliberately invalid BCS: the test is proving that the WASM adapter + // reached this source and attempted Rust-side decoding. + transactionBcs: new Uint8Array([0xff]), + signaturesBcs: [], + effectsBcs: new Uint8Array([0xff]), + checkpointSequenceNumber: 7n, + }; + }, + } as unknown as NodePoiSource; + + await assert.rejects( + new ProofBuilder(source).transaction(transactionDigest).build(), + /failed to read signed transaction/, + ); + assert.deepEqual(requestedDigest, transactionDigest); +}); + +test("the WASM builder validates digest lengths before fetching", () => { + const source = {} as NodePoiSource; + + assert.throws( + () => new ProofBuilder(source).transaction(new Uint8Array(31)), + /transaction digest must contain 32 bytes/, + ); +}); diff --git a/bindings/wasm/poi_wasm/tsconfig.build.json b/bindings/wasm/poi_wasm/tsconfig.build.json new file mode 100644 index 0000000..a394da7 --- /dev/null +++ b/bindings/wasm/poi_wasm/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": "./node", + "rootDir": "./src", + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts"], + "exclude": ["examples", "node", "tests"] +} diff --git a/bindings/wasm/poi_wasm/tsconfig.json b/bindings/wasm/poi_wasm/tsconfig.json new file mode 100644 index 0000000..d47edcd --- /dev/null +++ b/bindings/wasm/poi_wasm/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] +} + diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index 2322bd4..948c70f 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -11,14 +11,26 @@ 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", "serde_json/std"] +default = ["native-grpc"] +native-grpc = ["dep:iota-grpc-client", "dep:iota-grpc-types"] +cli = [ + "native-grpc", + "dep:anyhow", + "dep:clap", + "dep:iota-config", + "dep:reqwest", + "serde_json/std", + "tokio/macros", + "tokio/process", + "tokio/rt", +] [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-grpc-client = { workspace = true, optional = true } +iota-grpc-types = { workspace = true, optional = 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 @@ -26,11 +38,12 @@ reqwest = { workspace = true, optional = true } serde.workspace = true serde_json = { workspace = true, features = ["alloc"] } thiserror.workspace = true -tokio.workspace = true +tokio = { version = "1.52.2", default-features = false, features = ["sync"] } [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" } +tokio = { version = "1.52.2", default-features = false, features = ["macros", "process", "rt", "sync"] } [[bin]] name = "poi" diff --git a/poi-rs/README.md b/poi-rs/README.md index 21bec66..5344efe 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -31,11 +31,18 @@ Use `ProofBuilder::testnet()` or `ProofBuilder::devnet()` for the other public n 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. +duplicates and reuses one transaction and one checkpoint for the complete target set. + +`Source` is the transport boundary: it fetches decoded transaction, object, checkpoint, and chain evidence. +`ProofBuilder` owns target resolution, consistency checks, and proof construction, so custom sources do not reimplement +that logic. Network selection configures only the proof source. It does not make the returned proof trusted or select an authoritative committee for verification. +The default `native-grpc` feature provides `GrpcSource`, the public-network constructors, and `CommitteeResolver`. +WASM packages can disable default features and supply a JavaScript-backed `Source` without compiling native gRPC. + ## Proof Model A `Proof` contains three layers of evidence: @@ -80,7 +87,8 @@ 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. - `ProofBuilder`: Network-aware or custom-source proof construction. -- `Source`: Extensible boundary for gRPC nodes, archives, fixtures, and other proof sources. +- `Source`: Ledger-read boundary for gRPC nodes, JavaScript clients, archives, fixtures, and other evidence sources. +- `SourceTransaction` and `SourceCheckpoint`: Transport-independent decoded evidence returned by a `Source`. - `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 index 1cfdd83..4bf41da 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -1,11 +1,18 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#[cfg(feature = "native-grpc")] use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::ObjectId; -use iota_types::{digests::TransactionDigest, event::EventID}; +use iota_types::{ + base_types::ObjectRef, digests::TransactionDigest, effects::TransactionEffectsExt, event::EventID, object::Object, +}; -use crate::{Proof, Source, SourceError, SourceTarget, source::GrpcSource}; +#[cfg(feature = "native-grpc")] +use crate::source::GrpcSource; +use crate::{ + Proof, ProofTargets, Source, SourceError, SourceErrorKind, SourceTarget, TransactionMismatch, TransactionProof, +}; /// Error returned when a proof cannot be constructed by [`ProofBuilder`]. #[derive(Debug, thiserror::Error)] @@ -26,12 +33,14 @@ pub enum ProofBuilderError { /// 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`]. +/// With the `native-grpc` feature enabled, SDK gRPC clients can be adapted +/// through `ProofBuilder::from_grpc_client`. pub struct ProofBuilder { source: S, targets: Vec, } +#[cfg(feature = "native-grpc")] impl ProofBuilder { /// Creates a proof builder connected to the public IOTA mainnet gRPC endpoint. /// @@ -114,15 +123,161 @@ impl ProofBuilder { return Err(ProofBuilderError::MissingTarget); } - let proof = self - .source - .proof(&self.targets) + self.build_proof() .await - .map_err(|source| ProofBuilderError::Source { source })?; + .map_err(|source| ProofBuilderError::Source { source }) + } + + async fn build_proof(&self) -> Result { + let mut selected_transaction = None; + let mut object_ids = Vec::new(); + let mut events = Vec::new(); + + for target in self.targets.iter().copied() { + match target { + SourceTarget::Transaction(transaction_digest) => { + Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; + } + 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)?; + events.push(event_id); + } + } + } + + let (transaction_digest, transaction, objects) = if let Some(transaction_digest) = selected_transaction { + let transaction = self.fetch_transaction(transaction_digest).await?; + let changed_objects = transaction.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.fetch_object(object_id, Some(object_ref)).await?); + } + + (transaction_digest, transaction, objects) + } else { + let mut objects = Vec::with_capacity(object_ids.len()); + + for object_id in object_ids { + let (object_ref, object) = self.fetch_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 builds a proof for non-empty targets"); + let transaction = self.fetch_transaction(transaction_digest).await?; + + (transaction_digest, transaction, objects) + }; + + let chain_identifier = self.source.chain_identifier(transaction_digest).await?; + let checkpoint = self + .source + .checkpoint(transaction_digest, transaction.checkpoint_sequence_number) + .await?; + let transaction_proof = TransactionProof::new( + checkpoint.contents, + transaction.transaction, + transaction.effects, + transaction.events, + ); + 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); + } + + 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); + } Ok(proof) } + async fn fetch_transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result { + self.source + .transaction(transaction_digest) + .await? + .ok_or_else(|| SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound)) + } + + async fn fetch_object( + &self, + object_id: ObjectId, + expected_ref: Option, + ) -> Result<(ObjectRef, Object), SourceError> { + let object = self + .source + .object(object_id, expected_ref.map(|object_ref| object_ref.version)) + .await? + .ok_or_else(|| SourceError::object(object_id, SourceErrorKind::ObjectNotFound))?; + let object_ref = object.as_inner().object_ref(); + + 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_ref, object)) + } + + fn ensure_same_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(()) + } + fn push_target(&mut self, target: SourceTarget) { if !self.targets.contains(&target) { self.targets.push(target); @@ -131,6 +286,7 @@ impl ProofBuilder { } #[cfg(test)] +#[cfg(feature = "native-grpc")] mod tests { use super::*; diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index d81492e..d6fa7f7 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -12,6 +12,7 @@ pub mod builder; /// Verified committee lineage caches for anchored resolution. pub mod cache; /// Committee resolution for checkpoint verification. +#[cfg(feature = "native-grpc")] pub mod committee; /// Proof data types and offline verification. pub mod proof; @@ -22,10 +23,15 @@ pub mod target; pub use builder::{ProofBuilder, ProofBuilderError}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; +#[cfg(feature = "native-grpc")] pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, }; -pub use source::{Source, SourceError, SourceErrorKind, SourceTarget, TransactionMismatch}; +#[cfg(feature = "native-grpc")] +pub use source::GrpcSource; +pub use source::{ + Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, SourceTransaction, TransactionMismatch, +}; pub use target::ProofTargets; diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 0e4141c..26aac5d 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -4,46 +4,28 @@ use std::fmt; use async_trait::async_trait; +#[cfg(feature = "native-grpc")] use iota_grpc_client::{ CheckpointResponse, Client as GrpcClient, ReadMask, read_mask_fields::{CheckpointResponseField, ObjectField, ServiceInfoField, TransactionField}, }; +#[cfg(feature = "native-grpc")] use iota_grpc_types::v1::transaction::ExecutedTransaction; -use iota_sdk_types::{Digest, ObjectId, SignedTransaction}; +#[cfg(feature = "native-grpc")] +use iota_sdk_types::{Digest, SignedTransaction}; +use iota_sdk_types::{ObjectId, Version}; +#[cfg(feature = "native-grpc")] +use iota_types::{digests::CheckpointDigest, effects::TransactionEffectsAPI}; use iota_types::{ - base_types::ObjectRef, - digests::{ChainIdentifier, CheckpointDigest, TransactionDigest}, - effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, + digests::{ChainIdentifier, TransactionDigest}, + effects::{TransactionEffects, TransactionEvents}, event::EventID, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, object::Object, transaction::Transaction, }; -use crate::{BoxError, Proof, ProofTargets, TransactionProof}; - -// 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, -]; - -// gRPC fields needed to package an object target. -const OBJECT_PROOF_FIELDS: &[&str] = &[ObjectField::BCS]; - -// 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, - CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, -]; +use crate::BoxError; /// Source target requested by the caller. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -259,29 +241,70 @@ pub enum SourceErrorKind { }, } -/// Source boundary for building Proof of Inclusion envelopes. +/// Decoded transaction evidence returned by a [`Source`]. /// -/// 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] +/// This type contains IOTA domain values rather than transport-specific gRPC or +/// protobuf messages. +pub struct SourceTransaction { + /// Signed transaction being authenticated. + pub transaction: Transaction, + /// Effects produced by executing the transaction. + pub effects: TransactionEffects, + /// Events emitted by the transaction, when present. + pub events: Option, + /// Sequence number of the checkpoint that includes the transaction. + pub checkpoint_sequence_number: u64, +} + +/// Decoded checkpoint evidence returned by a [`Source`]. +/// +/// The certified summary authenticates the checkpoint contents used by the +/// transaction proof. +pub struct SourceCheckpoint { + /// Certified checkpoint summary. + pub summary: CertifiedCheckpointSummary, + /// Contents committed to by the checkpoint summary. + pub contents: CheckpointContents, +} + +/// Ledger-read boundary used by [`crate::ProofBuilder`]. +/// +/// Implementations may fetch evidence from native gRPC, a JavaScript client, +/// archive storage, fixtures, or another source. Proof assembly and target +/// validation remain centralized in [`crate::ProofBuilder`]. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait Source { - /// Builds one proof for a non-empty set of targets. - /// - /// 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; + /// Fetches the genesis-checkpoint digest that identifies the source chain. + async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result; + + /// Fetches and decodes one executed transaction. + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError>; + + /// Fetches and decodes an object, optionally at an exact version. + async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError>; + + /// Fetches and decodes one certified checkpoint and its contents. + async fn checkpoint( + &self, + transaction_digest: TransactionDigest, + sequence_number: u64, + ) -> Result; } /// Proof source backed by an SDK gRPC client. /// /// Applications normally construct this source through the network and client /// convenience constructors on [`crate::ProofBuilder`]. +#[cfg(feature = "native-grpc")] pub struct GrpcSource { client: GrpcClient, } +#[cfg(feature = "native-grpc")] impl GrpcSource { /// Wraps an SDK gRPC client as a Proof of Inclusion source. pub(crate) fn new(client: GrpcClient) -> Self { @@ -294,152 +317,6 @@ impl GrpcSource { &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 get_transaction(&self, transaction_digest: TransactionDigest) -> Result { - let digest = Digest::new(transaction_digest.into_inner()); - let transactions = self - .client - .get_transactions(&[digest], Some(ReadMask::from(TRANSACTION_PROOF_FIELDS))) - .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchTransaction { - source: Box::new(source), - }, - ) - })?; - - transactions - .body() - .first() - .cloned() - .ok_or_else(|| SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound)) - } - - /// Fetches the 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_id, expected_ref.map(|object_ref| object_ref.version))], - Some(ReadMask::from(OBJECT_PROOF_FIELDS)), - ) - .await - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::FetchObject { - source: Box::new(source), - }, - ) - })?; - let object: Object = objects - .body() - .first() - .ok_or_else(|| SourceError::object(object_id, SourceErrorKind::ObjectNotFound))? - .object() - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::Object { - source: Box::new(source), - }, - ) - })? - .into(); - let object_ref = object.as_inner().object_ref(); - - 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_ref, object)) - } - - /// Fetches the certified checkpoint summary and contents for an executed transaction. - async fn get_checkpoint( - &self, - transaction_digest: TransactionDigest, - sequence_number: u64, - ) -> Result { - self.client - .get_checkpoint_by_sequence_number( - sequence_number, - Some(ReadMask::from(CHECKPOINT_PROOF_FIELDS)), - None, - None, - ) - .await - .map(|response| response.into_inner()) - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchCheckpoint { - sequence_number, - source: Box::new(source), - }, - ) - }) - } - - /// Selects the transaction shared by all targets or rejects a conflicting target. - fn ensure_same_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(()) - } - /// Reads the certified summary and contents from a checkpoint response. fn parse_checkpoint( transaction_digest: TransactionDigest, @@ -523,13 +400,12 @@ impl GrpcSource { }) } - /// Builds the transaction evidence committed to by the checkpoint contents. - fn build_transaction_proof( + /// Decodes the transaction evidence needed by the transport-independent builder. + fn parse_transaction( transaction_digest: TransactionDigest, executed_transaction: &ExecutedTransaction, - checkpoint_contents: CheckpointContents, effects: TransactionEffects, - ) -> Result { + ) -> Result { let transaction = executed_transaction .transaction() .map_err(|source| { @@ -610,114 +486,149 @@ impl GrpcSource { None }; - Ok(TransactionProof::new(checkpoint_contents, transaction, effects, events)) + let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingCheckpointSequence { + source: Box::new(source), + }, + ) + })?; + + Ok(SourceTransaction { + transaction, + effects, + events, + checkpoint_sequence_number, + }) } } +#[cfg(feature = "native-grpc")] #[async_trait] impl Source for GrpcSource { - async fn proof(&self, targets: &[SourceTarget]) -> Result { - let mut selected_transaction = None; - let mut object_ids = Vec::new(); - let mut events = Vec::new(); - - for target in targets.iter().copied() { - match target { - SourceTarget::Transaction(transaction_digest) => { - Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; - } - 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)?; - events.push(event_id); - } - } - } - - 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| { + async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { + let service_info = self + .client + .get_service_info(Some(ReadMask::from(ServiceInfoField::CHAIN_ID))) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchChainIdentifier { + source: Box::new(source), + }, + ) + })?; + let chain_identifier = service_info.body().chain_identifier().map_err(|source| { SourceError::transaction( transaction_digest, - SourceErrorKind::MissingCheckpointSequence { + SourceErrorKind::ChainIdentifier { 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, effects)?; - 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); - } - 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); - } + Ok(ChainIdentifier::from(CheckpointDigest::new( + chain_identifier.into_inner(), + ))) + } + + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + let digest = Digest::new(transaction_digest.into_inner()); + let transactions = self + .client + .get_transactions( + &[digest], + Some(ReadMask::from(&[ + TransactionField::TRANSACTION_BCS, + TransactionField::SIGNATURES, + TransactionField::EFFECTS_BCS, + TransactionField::EVENTS_DIGEST, + TransactionField::EVENTS_EVENTS_BCS, + TransactionField::CHECKPOINT, + ])), + ) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchTransaction { + source: Box::new(source), + }, + ) + })?; + let Some(executed_transaction) = transactions.body().first() else { + return Ok(None); + }; + let effects = Self::parse_effects(transaction_digest, executed_transaction)?; + + Self::parse_transaction(transaction_digest, executed_transaction, effects).map(Some) + } + + async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { + let objects = self + .client + .get_objects(&[(object_id, version)], Some(ReadMask::from(ObjectField::BCS))) + .await + .map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::FetchObject { + source: Box::new(source), + }, + ) + })?; + let Some(response) = objects.body().first() else { + return Ok(None); + }; + let object: Object = response + .object() + .map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::Object { + source: Box::new(source), + }, + ) + })? + .into(); + Ok(Some(object)) + } + + async fn checkpoint( + &self, + transaction_digest: TransactionDigest, + sequence_number: u64, + ) -> Result { + let checkpoint = self + .client + .get_checkpoint_by_sequence_number( + sequence_number, + Some(ReadMask::from(&[ + CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, + CheckpointResponseField::CHECKPOINT_SIGNATURE, + CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, + ])), + None, + None, + ) + .await + .map(|response| response.into_inner()) + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) + })?; + let (summary, contents) = Self::parse_checkpoint(transaction_digest, &checkpoint)?; - Ok(proof) + Ok(SourceCheckpoint { summary, contents }) } } diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index 9d8b92c..0ee335e 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -9,51 +9,87 @@ use std::sync::{ }; use async_trait::async_trait; +use iota_sdk_types::{ObjectId, Version}; 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 iota_types::{ + digests::{ChainIdentifier, TransactionDigest}, + event::EventID, + object::Object, +}; +use poi_rs::{ + ProofBuilder, ProofBuilderError, Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, + SourceTransaction, +}; use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; struct RejectingSource; #[async_trait] impl Source for RejectingSource { - 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_id) => SourceError::object(object_id, SourceErrorKind::ObjectNotFound), - SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), - _ => panic!("unsupported source target"), - }) + async fn chain_identifier(&self, _transaction_digest: TransactionDigest) -> Result { + unreachable!("rejected transactions do not resolve a chain identifier") + } + + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + Err(SourceError::transaction( + transaction_digest, + SourceErrorKind::TransactionNotFound, + )) + } + + async fn object(&self, object_id: ObjectId, _version: Option) -> Result, SourceError> { + Err(SourceError::object(object_id, SourceErrorKind::ObjectNotFound)) + } + + async fn checkpoint( + &self, + _transaction_digest: TransactionDigest, + _sequence_number: u64, + ) -> Result { + unreachable!("rejected transactions do not resolve a checkpoint") } } struct RecordingSource { requests: Arc, - targets: Arc>>, + transactions: Arc>>, } #[async_trait] impl Source for RecordingSource { - async fn proof(&self, targets: &[SourceTarget]) -> Result { + async fn chain_identifier(&self, _transaction_digest: TransactionDigest) -> Result { + unreachable!("rejected transactions do not resolve a chain identifier") + } + + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError> { self.requests.fetch_add(1, Ordering::Relaxed); - self.targets + self.transactions .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_id) => SourceError::object(object_id, SourceErrorKind::ObjectNotFound), - SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), - _ => panic!("unsupported source target"), - }) + .expect("recorded transactions lock must not be poisoned") + .push(transaction_digest); + + Err(SourceError::transaction( + transaction_digest, + SourceErrorKind::TransactionNotFound, + )) + } + + async fn object(&self, object_id: ObjectId, _version: Option) -> Result, SourceError> { + Err(SourceError::object(object_id, SourceErrorKind::ObjectNotFound)) + } + + async fn checkpoint( + &self, + _transaction_digest: TransactionDigest, + _sequence_number: u64, + ) -> Result { + unreachable!("rejected transactions do not resolve a checkpoint") } } @@ -82,7 +118,7 @@ async fn builder_without_a_target_is_rejected() { } #[tokio::test] -async fn stacked_targets_are_deduplicated_in_one_source_request() { +async fn stacked_targets_reuse_one_transaction_request() { let transaction_digest = TransactionDigest::random(); let object_a = dbg_object_id(1); let object_b = dbg_object_id(2); @@ -95,11 +131,11 @@ async fn stacked_targets_are_deduplicated_in_one_source_request() { event_seq: 1, }; let requests = Arc::new(AtomicUsize::new(0)); - let targets = Arc::new(Mutex::new(Vec::new())); + let transactions = Arc::new(Mutex::new(Vec::new())); let _ = ProofBuilder::new(RecordingSource { requests: requests.clone(), - targets: targets.clone(), + transactions: transactions.clone(), }) .transaction(transaction_digest) .objects([object_a, object_b, object_a]) @@ -112,14 +148,10 @@ async fn stacked_targets_are_deduplicated_in_one_source_request() { 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), - ] + *transactions + .lock() + .expect("recorded transactions lock must not be poisoned"), + vec![transaction_digest] ); } From 300b9fb10d81ca9f63c80110df15454a73935f0b Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 13:52:11 +0300 Subject: [PATCH 23/41] feat: implement LedgerSource and PoiClient for IOTA Proof of Inclusion - Added LedgerSource class to handle ledger reads required by Proof of Inclusion. - Introduced PoiClient class for creating Proof of Inclusion builders backed by IOTA ledger endpoints. - Created source-types.ts to define interfaces for transaction and checkpoint evidence. - Updated TypeScript configuration for the new structure. - Implemented tests for LedgerSource and PoiClient to ensure functionality. - Refactored existing code to integrate new classes and improve modularity. --- bindings/wasm/poi_wasm/README.md | 37 ++++++----- .../wasm/poi_wasm/examples/service-info.ts | 13 +++- bindings/wasm/poi_wasm/grpc/buf.gen.yaml | 3 +- bindings/wasm/poi_wasm/{src => lib}/client.ts | 1 - .../grpc/generated/google/rpc/status_pb.ts | 0 .../grpc/generated/iota/grpc/options_pb.ts | 0 .../grpc/generated/iota/grpc/v1/bcs_pb.ts | 0 .../generated/iota/grpc/v1/checkpoint_pb.ts | 0 .../grpc/generated/iota/grpc/v1/epoch_pb.ts | 0 .../grpc/generated/iota/grpc/v1/event_pb.ts | 0 .../grpc/generated/iota/grpc/v1/filter_pb.ts | 0 .../iota/grpc/v1/ledger_service_pb.ts | 0 .../grpc/generated/iota/grpc/v1/object_pb.ts | 0 .../generated/iota/grpc/v1/signatures_pb.ts | 0 .../generated/iota/grpc/v1/transaction_pb.ts | 0 .../grpc/generated/iota/grpc/v1/types_pb.ts | 0 bindings/wasm/poi_wasm/lib/index.ts | 5 ++ .../ledger-source.ts} | 32 ++------- bindings/wasm/poi_wasm/lib/poi-client.ts | 65 +++++++++++++++++++ bindings/wasm/poi_wasm/lib/source-types.ts | 33 ++++++++++ .../tsconfig.json} | 9 ++- bindings/wasm/poi_wasm/package.json | 11 +++- bindings/wasm/poi_wasm/src/index.ts | 15 ----- bindings/wasm/poi_wasm/src/lib.rs | 6 +- bindings/wasm/poi_wasm/src/proof.rs | 12 ++-- .../src/{node_source.rs => source.rs} | 38 +++++------ bindings/wasm/poi_wasm/tests/client.test.ts | 5 +- ...i-source.test.ts => ledger-source.test.ts} | 10 +-- .../wasm/poi_wasm/tests/poi-client.test.ts | 29 +++++++++ .../wasm/poi_wasm/tests/wasm-source.test.ts | 11 ++-- bindings/wasm/poi_wasm/tsconfig.json | 3 +- 31 files changed, 226 insertions(+), 112 deletions(-) rename bindings/wasm/poi_wasm/{src => lib}/client.ts (99%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/google/rpc/status_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/options_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/bcs_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/checkpoint_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/epoch_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/event_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/filter_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/ledger_service_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/object_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/signatures_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/transaction_pb.ts (100%) rename bindings/wasm/poi_wasm/{src => lib}/grpc/generated/iota/grpc/v1/types_pb.ts (100%) create mode 100644 bindings/wasm/poi_wasm/lib/index.ts rename bindings/wasm/poi_wasm/{src/node-poi-source.ts => lib/ledger-source.ts} (90%) create mode 100644 bindings/wasm/poi_wasm/lib/poi-client.ts create mode 100644 bindings/wasm/poi_wasm/lib/source-types.ts rename bindings/wasm/poi_wasm/{tsconfig.build.json => lib/tsconfig.json} (52%) delete mode 100644 bindings/wasm/poi_wasm/src/index.ts rename bindings/wasm/poi_wasm/src/{node_source.rs => source.rs} (92%) rename bindings/wasm/poi_wasm/tests/{node-poi-source.test.ts => ledger-source.test.ts} (93%) create mode 100644 bindings/wasm/poi_wasm/tests/poi-client.test.ts diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index e8419e3..cd1e517 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -30,29 +30,30 @@ npm run grpc:generate Review the lock file, Buf image and generated TypeScript changes together. -## Source usage +## Client creation ```ts -import { NodePoiSource } from "./src/index.js"; +import { PoiClient } from "@iota/poi-wasm"; -const source = new NodePoiSource("https://grpc.testnet.iota.cafe:443"); -const chainIdentifier = await source.chainIdentifier(); -const transaction = await source.transaction(transactionDigest); -const object = await source.object(objectId, version); -const checkpoint = await source.checkpoint(42n); +const mainnet = PoiClient.mainnet(); +const testnet = PoiClient.testnet(); +const devnet = PoiClient.devnet(); +const custom = PoiClient.custom("https://my-node.example:443"); ``` -`NodePoiSource` returns only opaque BCS bytes and checkpoint sequence numbers. -The WASM `Source` adapter decodes those values into existing IOTA Rust types, -then delegates target resolution and proof construction to `poi-rs`. +No network is selected implicitly. The named constructors use the public IOTA +gRPC endpoints, while `custom` supports private nodes, archives, local networks, +and alternative endpoints. Selecting an endpoint determines where evidence is +fetched; it does not establish the trust anchor used to verify that evidence. ## Proof construction ```ts -import { NodePoiSource, ProofBuilder } from "./src/index.js"; +import { PoiClient } from "@iota/poi-wasm"; -const source = new NodePoiSource("https://grpc.testnet.iota.cafe:443"); -const proof = await new ProofBuilder(source) +const client = PoiClient.testnet(); +const proof = await client + .proof() .transaction(transactionDigest) .build(); @@ -63,9 +64,11 @@ The same builder also exposes `object(objectId)` and `event(transactionDigest, eventSequence)`. All 64-bit values use JavaScript `bigint`. -The lower-level generated client remains available through -`createIotaGrpcClient` when direct access to another `LedgerService` method is -needed. +`PoiClient` hides the generated protobuf client, gRPC transport, and +JavaScript/WASM source adapter. The adapter passes only opaque BCS bytes and +checkpoint sequence numbers into WASM. Rust decodes those values into existing +IOTA domain types and delegates target resolution and proof construction to +`poi-rs`. ## Verification @@ -78,7 +81,7 @@ Verification regenerates the Node.js protobuf client from the committed schema image, builds `poi-rs` for `wasm32-unknown-unknown`, type-checks the TypeScript boundary, and runs the tests. The tests use an in-memory generated service implementation and do not require a running IOTA node. To query a live -endpoint: +endpoint with the development diagnostic: ```sh npm run example:service-info -- https://grpc.testnet.iota.cafe:443 diff --git a/bindings/wasm/poi_wasm/examples/service-info.ts b/bindings/wasm/poi_wasm/examples/service-info.ts index 036e23d..a7f9e5a 100644 --- a/bindings/wasm/poi_wasm/examples/service-info.ts +++ b/bindings/wasm/poi_wasm/examples/service-info.ts @@ -1,11 +1,18 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { NodePoiSource } from "../src/index.js"; +import { createIotaGrpcClient } from "../lib/client.js"; const endpoint = process.argv[2] ?? "https://grpc.testnet.iota.cafe:443"; -const source = new NodePoiSource(endpoint); -const chainIdentifier = await source.chainIdentifier(); +const client = createIotaGrpcClient(endpoint); +const serviceInfo = await client.getServiceInfo({ + readMask: { paths: ["chain_id"] }, +}); +const chainIdentifier = serviceInfo.chainId?.digest; + +if (!chainIdentifier) { + throw new Error("getServiceInfo returned no chain identifier"); +} console.log({ endpoint, diff --git a/bindings/wasm/poi_wasm/grpc/buf.gen.yaml b/bindings/wasm/poi_wasm/grpc/buf.gen.yaml index f7e5db9..63d2c2c 100644 --- a/bindings/wasm/poi_wasm/grpc/buf.gen.yaml +++ b/bindings/wasm/poi_wasm/grpc/buf.gen.yaml @@ -3,7 +3,7 @@ clean: true plugins: - local: protoc-gen-es - out: src/grpc/generated + out: lib/grpc/generated opt: - target=ts - import_extension=js @@ -11,4 +11,3 @@ plugins: inputs: - binary_image: grpc/iota-ledger.binpb - diff --git a/bindings/wasm/poi_wasm/src/client.ts b/bindings/wasm/poi_wasm/lib/client.ts similarity index 99% rename from bindings/wasm/poi_wasm/src/client.ts rename to bindings/wasm/poi_wasm/lib/client.ts index e7cd605..527b3a9 100644 --- a/bindings/wasm/poi_wasm/src/client.ts +++ b/bindings/wasm/poi_wasm/lib/client.ts @@ -41,4 +41,3 @@ export function createIotaGrpcClient( return createClient(LedgerService, transport); } - diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/google/rpc/status_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/google/rpc/status_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/google/rpc/status_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/google/rpc/status_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/options_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/options_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/options_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/options_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/bcs_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/bcs_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/bcs_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/bcs_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/checkpoint_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/checkpoint_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/checkpoint_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/checkpoint_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/epoch_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/epoch_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/epoch_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/epoch_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/event_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/event_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/event_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/event_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/filter_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/filter_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/filter_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/filter_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/ledger_service_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/ledger_service_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/ledger_service_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/ledger_service_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/object_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/object_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/object_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/object_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/signatures_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/signatures_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/signatures_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/signatures_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/transaction_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/transaction_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/transaction_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/transaction_pb.ts diff --git a/bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/types_pb.ts b/bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/types_pb.ts similarity index 100% rename from bindings/wasm/poi_wasm/src/grpc/generated/iota/grpc/v1/types_pb.ts rename to bindings/wasm/poi_wasm/lib/grpc/generated/iota/grpc/v1/types_pb.ts diff --git a/bindings/wasm/poi_wasm/lib/index.ts b/bindings/wasm/poi_wasm/lib/index.ts new file mode 100644 index 0000000..c361545 --- /dev/null +++ b/bindings/wasm/poi_wasm/lib/index.ts @@ -0,0 +1,5 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export { PoiClient, type PoiClientOptions } from "./poi-client.js"; +export { Proof, type ProofBuilder } from "../node/poi_wasm.js"; diff --git a/bindings/wasm/poi_wasm/src/node-poi-source.ts b/bindings/wasm/poi_wasm/lib/ledger-source.ts similarity index 90% rename from bindings/wasm/poi_wasm/src/node-poi-source.ts rename to bindings/wasm/poi_wasm/lib/ledger-source.ts index 3d08fca..268c99d 100644 --- a/bindings/wasm/poi_wasm/src/node-poi-source.ts +++ b/bindings/wasm/poi_wasm/lib/ledger-source.ts @@ -8,6 +8,11 @@ import { type IotaGrpcClient, type IotaGrpcClientOptions, } from "./client.js"; +import type { + CheckpointEvidence, + LedgerSource as LedgerSourceContract, + TransactionEvidence, +} from "./source-types.js"; const CHAIN_IDENTIFIER_FIELDS = ["chain_id"]; const OBJECT_PROOF_FIELDS = ["bcs"]; @@ -26,35 +31,12 @@ const CHECKPOINT_PROOF_FIELDS = [ ]; /** - * Serialized transaction data needed by `poi-rs` to build a proof. - * - * These values are opaque BCS bytes. Node.js fetches them, while Rust remains - * responsible for decoding and validating them. - */ -export interface TransactionEvidence { - transactionBcs: Uint8Array; - signaturesBcs: Uint8Array[]; - effectsBcs: Uint8Array; - eventsBcs?: Uint8Array[]; - checkpointSequenceNumber: bigint; -} - -/** - * Serialized checkpoint data needed by `poi-rs` to authenticate a transaction. - */ -export interface CheckpointEvidence { - summaryBcs: Uint8Array; - signatureBcs: Uint8Array; - contentsBcs: Uint8Array; -} - -/** - * Node.js implementation of the ledger reads required by Proof of Inclusion. + * Internal implementation of the ledger reads required by Proof of Inclusion. * * The generated client owns gRPC and protobuf. This class narrows its responses * to BCS evidence that can cross the JavaScript/WASM boundary. */ -export class NodePoiSource { +export class LedgerSource implements LedgerSourceContract { readonly #client: IotaGrpcClient; public constructor(endpoint: string, options: IotaGrpcClientOptions = {}) { diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts new file mode 100644 index 0000000..fad590c --- /dev/null +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -0,0 +1,65 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import type { Transport } from "@connectrpc/connect"; + +import { ProofBuilder } from "../node/poi_wasm.js"; +import { LedgerSource } from "./ledger-source.js"; + +const MAINNET_ENDPOINT = "https://grpc.mainnet.iota.cafe:443"; +const TESTNET_ENDPOINT = "https://grpc.testnet.iota.cafe:443"; +const DEVNET_ENDPOINT = "https://grpc.devnet.iota.cafe:443"; + +/** + * Options for configuring the ledger connection used by a {@link PoiClient}. + */ +export interface PoiClientOptions { + /** Default timeout applied to ledger requests, in milliseconds. */ + defaultTimeoutMs?: number; + /** Maximum response size accepted from the ledger, in bytes. */ + readMaxBytes?: number; + /** Custom ConnectRPC transport, primarily for advanced use and testing. */ + transport?: Transport; +} + +/** + * Creates Proof of Inclusion builders backed by an IOTA ledger endpoint. + * + * Use one of the named public-network constructors, or {@link PoiClient.custom} + * for a private node, archive, local network, or alternative endpoint. + */ +export class PoiClient { + readonly #source: LedgerSource; + + private constructor(endpoint: string, options: PoiClientOptions = {}) { + this.#source = new LedgerSource(endpoint, options); + } + + /** Creates a client connected to the public IOTA mainnet gRPC endpoint. */ + public static mainnet(options: PoiClientOptions = {}): PoiClient { + return new PoiClient(MAINNET_ENDPOINT, options); + } + + /** Creates a client connected to the public IOTA testnet gRPC endpoint. */ + public static testnet(options: PoiClientOptions = {}): PoiClient { + return new PoiClient(TESTNET_ENDPOINT, options); + } + + /** Creates a client connected to the public IOTA devnet gRPC endpoint. */ + public static devnet(options: PoiClientOptions = {}): PoiClient { + return new PoiClient(DEVNET_ENDPOINT, options); + } + + /** Creates a client connected to a caller-provided IOTA gRPC endpoint. */ + public static custom( + endpoint: string, + options: PoiClientOptions = {}, + ): PoiClient { + return new PoiClient(endpoint, options); + } + + /** Creates a fresh builder for one Proof of Inclusion. */ + public proof(): ProofBuilder { + return new ProofBuilder(this.#source); + } +} diff --git a/bindings/wasm/poi_wasm/lib/source-types.ts b/bindings/wasm/poi_wasm/lib/source-types.ts new file mode 100644 index 0000000..77175c8 --- /dev/null +++ b/bindings/wasm/poi_wasm/lib/source-types.ts @@ -0,0 +1,33 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * Serialized transaction data needed by `poi-rs` to build a proof. + * + * These values are opaque BCS bytes. JavaScript fetches them, while Rust + * remains responsible for decoding and validating them. + */ +export interface TransactionEvidence { + transactionBcs: Uint8Array; + signaturesBcs: Uint8Array[]; + effectsBcs: Uint8Array; + eventsBcs?: Uint8Array[]; + checkpointSequenceNumber: bigint; +} + +/** Serialized checkpoint data needed by `poi-rs` to authenticate a transaction. */ +export interface CheckpointEvidence { + summaryBcs: Uint8Array; + signatureBcs: Uint8Array; + contentsBcs: Uint8Array; +} + +/** Internal JavaScript contract consumed by the WASM proof builder. */ +export interface LedgerSource { + chainIdentifier(): Promise; + transaction( + digest: Uint8Array, + ): Promise; + object(objectId: Uint8Array, version?: bigint): Promise; + checkpoint(sequenceNumber: bigint): Promise; +} diff --git a/bindings/wasm/poi_wasm/tsconfig.build.json b/bindings/wasm/poi_wasm/lib/tsconfig.json similarity index 52% rename from bindings/wasm/poi_wasm/tsconfig.build.json rename to bindings/wasm/poi_wasm/lib/tsconfig.json index a394da7..eabf669 100644 --- a/bindings/wasm/poi_wasm/tsconfig.build.json +++ b/bindings/wasm/poi_wasm/lib/tsconfig.json @@ -1,14 +1,13 @@ { - "extends": "./tsconfig.json", + "extends": "../tsconfig.json", "compilerOptions": { "declaration": true, "module": "CommonJS", "moduleResolution": "Node", "noEmit": false, - "outDir": "./node", - "rootDir": "./src", + "outDir": "../node", + "rootDir": ".", "verbatimModuleSyntax": false }, - "include": ["src/**/*.ts"], - "exclude": ["examples", "node", "tests"] + "include": ["**/*.ts"] } diff --git a/bindings/wasm/poi_wasm/package.json b/bindings/wasm/poi_wasm/package.json index d5f1ca4..3d1b366 100644 --- a/bindings/wasm/poi_wasm/package.json +++ b/bindings/wasm/poi_wasm/package.json @@ -5,13 +5,22 @@ "description": "Node.js WASM bindings for the IOTA Proof of Inclusion Package.", "license": "Apache-2.0", "type": "module", + "main": "./node/index.js", + "types": "./node/index.d.ts", + "exports": { + ".": { + "types": "./node/index.d.ts", + "import": "./node/index.js", + "require": "./node/index.js" + } + }, "files": [ "node/*" ], "scripts": { "build:src:nodejs": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", "prebundle:nodejs": "rimraf node", - "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/poi_wasm.wasm --typescript --target nodejs --out-dir node && node ../build/node poi_wasm --skip-fetch-polyfill && tsc --project ./tsconfig.build.json", + "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/poi_wasm.wasm --typescript --target nodejs --out-dir node && node ../build/node poi_wasm --skip-fetch-polyfill && tsc --project ./lib/tsconfig.json", "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/poi_wasm_bg.wasm -o node/poi_wasm_bg.wasm", "grpc:schema:update": "node scripts/update-iota-schema.mjs", "grpc:generate": "node scripts/generate-grpc.mjs", diff --git a/bindings/wasm/poi_wasm/src/index.ts b/bindings/wasm/poi_wasm/src/index.ts deleted file mode 100644 index 50bc989..0000000 --- a/bindings/wasm/poi_wasm/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -export { - createIotaGrpcClient, - type IotaGrpcClient, - type IotaGrpcClientOptions, -} from "./client.js"; -export { LedgerService } from "./grpc/generated/iota/grpc/v1/ledger_service_pb.js"; -export { - NodePoiSource, - type CheckpointEvidence, - type TransactionEvidence, -} from "./node-poi-source.js"; -export { Proof, ProofBuilder } from "../node/poi_wasm.js"; diff --git a/bindings/wasm/poi_wasm/src/lib.rs b/bindings/wasm/poi_wasm/src/lib.rs index e818942..a672b12 100644 --- a/bindings/wasm/poi_wasm/src/lib.rs +++ b/bindings/wasm/poi_wasm/src/lib.rs @@ -1,8 +1,8 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -mod node_source; mod proof; +mod source; mod versioned; #[wasm_bindgen::prelude::wasm_bindgen(start)] @@ -11,6 +11,6 @@ pub fn start() { } #[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)] -const NODE_SOURCE_IMPORT: &str = r#" -import type { NodePoiSource } from "../src/node-poi-source.js"; +const LEDGER_SOURCE_IMPORT: &str = r#" +import type { LedgerSource } from "./source-types.js"; "#; diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index cce6e62..9f03146 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -9,7 +9,7 @@ use js_sys::Uint8Array; use poi_rs::{Proof, ProofBuilder}; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; -use crate::node_source::{NodePoiSource, WasmSource}; +use crate::source::{LedgerSource, SourceAdapter}; /// Proof of Inclusion evidence constructed by `poi-rs`. #[wasm_bindgen(js_name = Proof)] @@ -25,16 +25,16 @@ impl WasmProof { } } -/// Builds Proof of Inclusion evidence with a JavaScript `NodePoiSource`. +/// Builds Proof of Inclusion evidence with an internal JavaScript ledger source. #[wasm_bindgen(js_name = ProofBuilder)] -pub struct WasmProofBuilder(ProofBuilder); +pub struct WasmProofBuilder(ProofBuilder); #[wasm_bindgen(js_class = ProofBuilder)] impl WasmProofBuilder { - /// Creates a builder backed by the provided Node.js gRPC source. + /// Creates a builder backed by the provided JavaScript ledger source. #[wasm_bindgen(constructor)] - pub fn new(source: NodePoiSource) -> Self { - Self(ProofBuilder::new(WasmSource::new(source))) + pub fn new(source: LedgerSource) -> Self { + Self(ProofBuilder::new(SourceAdapter::new(source))) } /// Adds a transaction target. diff --git a/bindings/wasm/poi_wasm/src/node_source.rs b/bindings/wasm/poi_wasm/src/source.rs similarity index 92% rename from bindings/wasm/poi_wasm/src/node_source.rs rename to bindings/wasm/poi_wasm/src/source.rs index ebe1196..d1697cd 100644 --- a/bindings/wasm/poi_wasm/src/node_source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -27,29 +27,29 @@ use crate::versioned::{VersionedCheckpointSummary, VersionedEvent, VersionedVali #[wasm_bindgen] extern "C" { - /// JavaScript source that owns the generated Node.js gRPC client. - #[wasm_bindgen(typescript_type = "NodePoiSource")] - pub type NodePoiSource; + /// JavaScript source that owns the generated ledger client. + #[wasm_bindgen(typescript_type = "LedgerSource")] + pub type LedgerSource; #[wasm_bindgen(method, catch, structural, js_name = chainIdentifier)] - fn chain_identifier_js(this: &NodePoiSource) -> Result; + fn chain_identifier(this: &LedgerSource) -> Result; - #[wasm_bindgen(method, catch, structural, js_name = transaction)] - fn transaction_js(this: &NodePoiSource, digest: Uint8Array) -> Result; + #[wasm_bindgen(method, catch, structural)] + fn transaction(this: &LedgerSource, digest: Uint8Array) -> Result; - #[wasm_bindgen(method, catch, structural, js_name = object)] - fn object_js(this: &NodePoiSource, object_id: Uint8Array, version: Option) -> Result; + #[wasm_bindgen(method, catch, structural)] + fn object(this: &LedgerSource, object_id: Uint8Array, version: Option) -> Result; - #[wasm_bindgen(method, catch, structural, js_name = checkpoint)] - fn checkpoint_js(this: &NodePoiSource, sequence_number: u64) -> Result; + #[wasm_bindgen(method, catch, structural)] + fn checkpoint(this: &LedgerSource, sequence_number: u64) -> Result; } -pub(crate) struct WasmSource { - source: NodePoiSource, +pub(crate) struct SourceAdapter { + source: LedgerSource, } -impl WasmSource { - pub(crate) fn new(source: NodePoiSource) -> Self { +impl SourceAdapter { + pub(crate) fn new(source: LedgerSource) -> Self { Self { source } } @@ -78,9 +78,9 @@ struct JsCheckpointEvidence { } #[async_trait(?Send)] -impl Source for WasmSource { +impl Source for SourceAdapter { async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { - let value = Self::await_method(self.source.chain_identifier_js()) + let value = Self::await_method(self.source.chain_identifier()) .await .map_err(|source| { SourceError::transaction( @@ -111,7 +111,7 @@ impl Source for WasmSource { transaction_digest: TransactionDigest, ) -> Result, SourceError> { let digest = Uint8Array::from(transaction_digest.as_ref()); - let value = Self::await_method(self.source.transaction_js(digest)) + let value = Self::await_method(self.source.transaction(digest)) .await .map_err(|source| { SourceError::transaction( @@ -142,7 +142,7 @@ impl Source for WasmSource { let object_id_bytes = Uint8Array::from(object_id.as_ref()); let value = Self::await_method( self.source - .object_js(object_id_bytes, version.map(|version| version.as_u64())), + .object(object_id_bytes, version.map(|version| version.as_u64())), ) .await .map_err(|source| { @@ -177,7 +177,7 @@ impl Source for WasmSource { transaction_digest: TransactionDigest, sequence_number: u64, ) -> Result { - let value = Self::await_method(self.source.checkpoint_js(sequence_number)) + let value = Self::await_method(self.source.checkpoint(sequence_number)) .await .map_err(|source| { SourceError::transaction( diff --git a/bindings/wasm/poi_wasm/tests/client.test.ts b/bindings/wasm/poi_wasm/tests/client.test.ts index e109633..e05f4b9 100644 --- a/bindings/wasm/poi_wasm/tests/client.test.ts +++ b/bindings/wasm/poi_wasm/tests/client.test.ts @@ -7,14 +7,14 @@ import test from "node:test"; import { create } from "@bufbuild/protobuf"; import { createRouterTransport } from "@connectrpc/connect"; -import { createIotaGrpcClient } from "../src/client.js"; +import { createIotaGrpcClient } from "../lib/client.js"; import { CheckpointDataSchema, GetObjectsResponseSchema, GetServiceInfoResponseSchema, GetTransactionsResponseSchema, LedgerService, -} from "../src/grpc/generated/iota/grpc/v1/ledger_service_pb.js"; +} from "../lib/grpc/generated/iota/grpc/v1/ledger_service_pb.js"; test("creates the generated LedgerService client", async () => { const requests = { @@ -116,4 +116,3 @@ async function collect(stream: AsyncIterable): Promise { return values; } - diff --git a/bindings/wasm/poi_wasm/tests/node-poi-source.test.ts b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts similarity index 93% rename from bindings/wasm/poi_wasm/tests/node-poi-source.test.ts rename to bindings/wasm/poi_wasm/tests/ledger-source.test.ts index 5414d5b..6044975 100644 --- a/bindings/wasm/poi_wasm/tests/node-poi-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts @@ -13,8 +13,8 @@ import { GetServiceInfoResponseSchema, GetTransactionsResponseSchema, LedgerService, -} from "../src/grpc/generated/iota/grpc/v1/ledger_service_pb.js"; -import { NodePoiSource } from "../src/node-poi-source.js"; +} from "../lib/grpc/generated/iota/grpc/v1/ledger_service_pb.js"; +import { LedgerSource } from "../lib/ledger-source.js"; test("returns the BCS evidence needed by poi-rs", async () => { const chainId = bytes(0x01); @@ -128,7 +128,7 @@ test("returns the BCS evidence needed by poi-rs", async () => { }, }); }); - const source = new NodePoiSource("http://unused.test", { transport }); + const source = new LedgerSource("http://unused.test", { transport }); assert.deepEqual(await source.chainIdentifier(), chainId); assert.deepEqual(await source.transaction(transactionDigest), { @@ -157,7 +157,7 @@ test("returns undefined when a transaction or object is not returned", async () }, }); }); - const source = new NodePoiSource("http://unused.test", { transport }); + const source = new LedgerSource("http://unused.test", { transport }); assert.equal(await source.transaction(bytes(0x01)), undefined); assert.equal(await source.object(bytes(0x02)), undefined); @@ -176,7 +176,7 @@ test("rejects incomplete checkpoint evidence", async () => { }, }); }); - const source = new NodePoiSource("http://unused.test", { transport }); + const source = new LedgerSource("http://unused.test", { transport }); await assert.rejects( source.checkpoint(42n), diff --git a/bindings/wasm/poi_wasm/tests/poi-client.test.ts b/bindings/wasm/poi_wasm/tests/poi-client.test.ts new file mode 100644 index 0000000..f38a856 --- /dev/null +++ b/bindings/wasm/poi_wasm/tests/poi-client.test.ts @@ -0,0 +1,29 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PoiClient } from "../lib/index.js"; + +test("creates clients for every supported public network", () => { + const clients = [ + PoiClient.mainnet(), + PoiClient.testnet(), + PoiClient.devnet(), + ]; + + for (const client of clients) { + assert.equal(typeof client.proof().transaction, "function"); + } +}); + +test("creates a client for a custom endpoint", () => { + const client = PoiClient.custom("https://ledger.example:443"); + + assert.equal(typeof client.proof().transaction, "function"); +}); + +test("rejects an empty custom endpoint", () => { + assert.throws(() => PoiClient.custom(" "), /endpoint must not be empty/); +}); diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts index e762616..9b9929a 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -4,9 +4,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { ProofBuilder, type NodePoiSource } from "../src/index.js"; +import { ProofBuilder } from "../node/poi_wasm.js"; +import type { LedgerSource } from "../lib/source-types.js"; -test("the WASM builder reads transaction evidence from NodePoiSource", async () => { +test("the WASM builder reads transaction evidence from the ledger source", async () => { const transactionDigest = new Uint8Array(32).fill(0x2a); let requestedDigest: Uint8Array | undefined; const source = { @@ -22,7 +23,7 @@ test("the WASM builder reads transaction evidence from NodePoiSource", async () checkpointSequenceNumber: 7n, }; }, - } as unknown as NodePoiSource; + } as unknown as LedgerSource; await assert.rejects( new ProofBuilder(source).transaction(transactionDigest).build(), @@ -32,10 +33,10 @@ test("the WASM builder reads transaction evidence from NodePoiSource", async () }); test("the WASM builder validates digest lengths before fetching", () => { - const source = {} as NodePoiSource; + const source = {} as LedgerSource; assert.throws( () => new ProofBuilder(source).transaction(new Uint8Array(31)), - /transaction digest must contain 32 bytes/, + /invalid digest byte length: expected 32, got 31/, ); }); diff --git a/bindings/wasm/poi_wasm/tsconfig.json b/bindings/wasm/poi_wasm/tsconfig.json index d47edcd..9d6f496 100644 --- a/bindings/wasm/poi_wasm/tsconfig.json +++ b/bindings/wasm/poi_wasm/tsconfig.json @@ -13,6 +13,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] + "include": ["lib/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] } - From 2743727cb74ae076505374e2b964ecd84a5e2455 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 14:34:03 +0300 Subject: [PATCH 24/41] feat: remove custom endpoint creation method from PoiClient --- bindings/wasm/poi_wasm/lib/poi-client.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index fad590c..86ca49a 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -50,14 +50,6 @@ export class PoiClient { return new PoiClient(DEVNET_ENDPOINT, options); } - /** Creates a client connected to a caller-provided IOTA gRPC endpoint. */ - public static custom( - endpoint: string, - options: PoiClientOptions = {}, - ): PoiClient { - return new PoiClient(endpoint, options); - } - /** Creates a fresh builder for one Proof of Inclusion. */ public proof(): ProofBuilder { return new ProofBuilder(this.#source); From a0b207e99f62bc7cd91ff860cca6ad78fd5e0647 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 15:05:35 +0300 Subject: [PATCH 25/41] feat: implement committee management and resolver in PoiClient and LedgerSource --- bindings/wasm/poi_wasm/Cargo.toml | 1 + bindings/wasm/poi_wasm/README.md | 24 ++++- bindings/wasm/poi_wasm/lib/index.ts | 7 +- bindings/wasm/poi_wasm/lib/ledger-source.ts | 65 +++++------- bindings/wasm/poi_wasm/lib/poi-client.ts | 11 ++- bindings/wasm/poi_wasm/lib/source-types.ts | 12 +++ bindings/wasm/poi_wasm/src/committee.rs | 98 +++++++++++++++++++ bindings/wasm/poi_wasm/src/lib.rs | 1 + bindings/wasm/poi_wasm/src/proof.rs | 18 +++- bindings/wasm/poi_wasm/src/source.rs | 6 +- .../wasm/poi_wasm/tests/ledger-source.test.ts | 20 ++++ .../wasm/poi_wasm/tests/poi-client.test.ts | 10 -- .../wasm/poi_wasm/tests/wasm-source.test.ts | 29 +++++- 13 files changed, 241 insertions(+), 61 deletions(-) create mode 100644 bindings/wasm/poi_wasm/src/committee.rs diff --git a/bindings/wasm/poi_wasm/Cargo.toml b/bindings/wasm/poi_wasm/Cargo.toml index 7002a6a..2dfd485 100644 --- a/bindings/wasm/poi_wasm/Cargo.toml +++ b/bindings/wasm/poi_wasm/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["cdylib", "rlib"] async-trait = { version = "0.1", default-features = false } bcs = "0.1.6" console_error_panic_hook = "0.1" +fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto" } iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11", default-features = false, features = ["serde"] } iota-types = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } js-sys = "=0.3.85" diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index cd1e517..ade3e30 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -38,13 +38,10 @@ import { PoiClient } from "@iota/poi-wasm"; const mainnet = PoiClient.mainnet(); const testnet = PoiClient.testnet(); const devnet = PoiClient.devnet(); -const custom = PoiClient.custom("https://my-node.example:443"); ``` No network is selected implicitly. The named constructors use the public IOTA -gRPC endpoints, while `custom` supports private nodes, archives, local networks, -and alternative endpoints. Selecting an endpoint determines where evidence is -fetched; it does not establish the trust anchor used to verify that evidence. +gRPC endpoints. ## Proof construction @@ -70,7 +67,24 @@ checkpoint sequence numbers into WASM. Rust decodes those values into existing IOTA domain types and delegates target resolution and proof construction to `poi-rs`. -## Verification +## Trusted-node verification + +```ts +const resolver = client.committeeResolver(); +const committee = await resolver.resolve(proof.checkpointEpoch); + +proof.verify(committee); +``` + +`CommitteeResolver` asks the client's node for the committee governing the +proof checkpoint epoch. Rust validates the returned committee representation +and performs proof verification locally with `poi-rs`. + +This mode places the node inside the caller's trust boundary. It does not +authenticate committee lineage from genesis. Genesis-anchored committee +resolution will be added separately. + +## Package verification ```sh npm install diff --git a/bindings/wasm/poi_wasm/lib/index.ts b/bindings/wasm/poi_wasm/lib/index.ts index c361545..36f4c1f 100644 --- a/bindings/wasm/poi_wasm/lib/index.ts +++ b/bindings/wasm/poi_wasm/lib/index.ts @@ -2,4 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 export { PoiClient, type PoiClientOptions } from "./poi-client.js"; -export { Proof, type ProofBuilder } from "../node/poi_wasm.js"; +export { + Committee, + CommitteeResolver, + Proof, + type ProofBuilder, +} from "../node/poi_wasm.js"; diff --git a/bindings/wasm/poi_wasm/lib/ledger-source.ts b/bindings/wasm/poi_wasm/lib/ledger-source.ts index 268c99d..8d9b484 100644 --- a/bindings/wasm/poi_wasm/lib/ledger-source.ts +++ b/bindings/wasm/poi_wasm/lib/ledger-source.ts @@ -10,11 +10,13 @@ import { } from "./client.js"; import type { CheckpointEvidence, + Committee, LedgerSource as LedgerSourceContract, TransactionEvidence, } from "./source-types.js"; const CHAIN_IDENTIFIER_FIELDS = ["chain_id"]; +const COMMITTEE_FIELDS = ["committee"]; const OBJECT_PROOF_FIELDS = ["bcs"]; const TRANSACTION_PROOF_FIELDS = [ "transaction.bcs", @@ -48,7 +50,7 @@ export class LedgerSource implements LedgerSourceContract { readMask: { paths: CHAIN_IDENTIFIER_FIELDS }, }); - return required(response.chainId?.digest, "service info chain_id"); + return response.chainId!.digest!; } public async transaction( @@ -87,28 +89,17 @@ export class LedgerSource implements LedgerSourceContract { return undefined; } - const signatures = required( - transaction.signatures, - "transaction signatures", - ); + const signatures = transaction.signatures!; const transactionEvents = transaction.events?.events?.events; return { - transactionBcs: required( - transaction.transaction?.bcs?.data, - "transaction BCS", - ), - signaturesBcs: signatures.signatures.map((signature, index) => - required(signature.bcs?.data, `transaction signature ${index} BCS`), - ), - effectsBcs: required(transaction.effects?.bcs?.data, "effects BCS"), - eventsBcs: transactionEvents?.map((event, index) => - required(event.bcs?.data, `transaction event ${index} BCS`), - ), - checkpointSequenceNumber: required( - transaction.checkpoint, - "transaction checkpoint sequence number", + transactionBcs: transaction.transaction!.bcs!.data!, + signaturesBcs: signatures.signatures.map( + (signature) => signature.bcs!.data!, ), + effectsBcs: transaction.effects!.bcs!.data!, + eventsBcs: transactionEvents?.map((event) => event.bcs!.data!), + checkpointSequenceNumber: transaction.checkpoint!, }; } @@ -146,7 +137,7 @@ export class LedgerSource implements LedgerSourceContract { throw new Error("getObjects returned more than one object for one ID"); } - objectBcs = required(result.result.value.bcs?.data, "object BCS"); + objectBcs = result.result.value.bcs!.data!; } } @@ -185,18 +176,9 @@ export class LedgerSource implements LedgerSourceContract { } checkpoint = { - summaryBcs: required( - value.summary?.bcs?.data, - "checkpoint summary BCS", - ), - signatureBcs: required( - value.signature?.bcs?.data, - "checkpoint signature BCS", - ), - contentsBcs: required( - value.contents?.bcs?.data, - "checkpoint contents BCS", - ), + summaryBcs: value.summary!.bcs!.data!, + signatureBcs: value.signature!.bcs!.data!, + contentsBcs: value.contents!.bcs!.data!, }; } else if (response.payload.case === "endMarker") { const returnedSequenceNumber = response.payload.value.sequenceNumber; @@ -228,14 +210,21 @@ export class LedgerSource implements LedgerSourceContract { return checkpoint; } -} -function required(value: T | null | undefined, field: string): T { - if (value === undefined || value === null) { - throw new Error(`IOTA gRPC response is missing ${field}`); - } + public async committee(epoch: bigint): Promise { + const response = await this.#client.getEpoch({ + epoch, + readMask: { paths: COMMITTEE_FIELDS }, + }); + const committee = response.epoch!.committee!; - return value; + return { + members: committee.members!.members.map((member) => ({ + publicKey: member.publicKey!, + weight: member.weight!, + })), + }; + } } function statusError(method: string, status: Status): Error { diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index 86ca49a..82c33c1 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -3,7 +3,7 @@ import type { Transport } from "@connectrpc/connect"; -import { ProofBuilder } from "../node/poi_wasm.js"; +import { CommitteeResolver, ProofBuilder } from "../node/poi_wasm.js"; import { LedgerSource } from "./ledger-source.js"; const MAINNET_ENDPOINT = "https://grpc.mainnet.iota.cafe:443"; @@ -54,4 +54,13 @@ export class PoiClient { public proof(): ProofBuilder { return new ProofBuilder(this.#source); } + + /** + * Creates a resolver that trusts this client's node for committee data. + * + * The resolver does not authenticate committee lineage from genesis. + */ + public committeeResolver(): CommitteeResolver { + return new CommitteeResolver(this.#source); + } } diff --git a/bindings/wasm/poi_wasm/lib/source-types.ts b/bindings/wasm/poi_wasm/lib/source-types.ts index 77175c8..674d929 100644 --- a/bindings/wasm/poi_wasm/lib/source-types.ts +++ b/bindings/wasm/poi_wasm/lib/source-types.ts @@ -22,6 +22,17 @@ export interface CheckpointEvidence { contentsBcs: Uint8Array; } +/** A validator entry reported by a trusted IOTA node. */ +export interface CommitteeMember { + publicKey: Uint8Array; + weight: bigint; +} + +/** Committee data reported by a trusted IOTA node. */ +export interface Committee { + members: CommitteeMember[]; +} + /** Internal JavaScript contract consumed by the WASM proof builder. */ export interface LedgerSource { chainIdentifier(): Promise; @@ -30,4 +41,5 @@ export interface LedgerSource { ): Promise; object(objectId: Uint8Array, version?: bigint): Promise; checkpoint(sequenceNumber: bigint): Promise; + committee(epoch: bigint): Promise; } diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs new file mode 100644 index 0000000..33fefa6 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -0,0 +1,98 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; + +use fastcrypto::traits::ToFromBytes; +use iota_types::{base_types::AuthorityName, committee::Committee}; +use js_sys::Promise; +use serde::Deserialize; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; + +use crate::{ + proof::error_to_js, + source::{BridgeError, LedgerSource, SourceAdapter}, +}; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(method, catch, structural)] + fn committee(this: &LedgerSource, epoch: u64) -> Result; +} + +/// A validator committee used to verify a Proof of Inclusion proof. +#[wasm_bindgen(js_name = Committee)] +pub struct WasmCommittee(Committee); + +impl WasmCommittee { + pub(crate) const fn inner(&self) -> &Committee { + &self.0 + } +} + +#[wasm_bindgen(js_class = Committee)] +impl WasmCommittee { + /// Returns the epoch governed by this committee. + #[wasm_bindgen(getter)] + pub fn epoch(&self) -> u64 { + self.0.epoch() + } +} + +/// Resolves committees reported by a node inside the caller's trust boundary. +/// +/// This resolver does not authenticate committee lineage from genesis. The +/// connected node is authoritative for the committee returned for each epoch. +#[wasm_bindgen(js_name = CommitteeResolver)] +pub struct WasmCommitteeResolver { + source: LedgerSource, +} + +#[wasm_bindgen(js_class = CommitteeResolver)] +impl WasmCommitteeResolver { + /// Creates a trusted-node resolver backed by a JavaScript ledger source. + #[wasm_bindgen(constructor)] + pub fn new(source: LedgerSource) -> Self { + Self { source } + } + + /// Returns the committee reported by the trusted node for `epoch`. + pub async fn resolve(&self, epoch: u64) -> Result { + let value = SourceAdapter::await_method(self.source.committee(epoch)) + .await + .map_err(error_to_js)?; + let evidence: JsCommittee = + serde_wasm_bindgen::from_value(value).map_err(|source| error_to_js(BridgeError(source.to_string())))?; + + decode_committee(epoch, evidence) + .map(WasmCommittee) + .map_err(error_to_js) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsCommittee { + members: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsCommitteeMember { + public_key: Vec, + weight: u64, +} + +fn decode_committee(requested_epoch: u64, evidence: JsCommittee) -> Result { + let voting_rights = evidence + .members + .into_iter() + .map(|member| { + AuthorityName::from_bytes(&member.public_key) + .map(|authority| (authority, member.weight)) + .map_err(|source| BridgeError(format!("invalid committee public key: {source}"))) + }) + .collect::, _>>()?; + + Ok(Committee::new(requested_epoch, voting_rights)) +} diff --git a/bindings/wasm/poi_wasm/src/lib.rs b/bindings/wasm/poi_wasm/src/lib.rs index a672b12..45d8df0 100644 --- a/bindings/wasm/poi_wasm/src/lib.rs +++ b/bindings/wasm/poi_wasm/src/lib.rs @@ -1,6 +1,7 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +mod committee; mod proof; mod source; mod versioned; diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 9f03146..f9e15ea 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -9,14 +9,28 @@ use js_sys::Uint8Array; use poi_rs::{Proof, ProofBuilder}; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; +use crate::committee::WasmCommittee; use crate::source::{LedgerSource, SourceAdapter}; /// Proof of Inclusion evidence constructed by `poi-rs`. #[wasm_bindgen(js_name = Proof)] -pub struct WasmProof(Proof); +pub struct WasmProof(pub(crate) Proof); #[wasm_bindgen(js_class = Proof)] impl WasmProof { + /// Returns the epoch of the committee that certified this proof. + #[wasm_bindgen(getter, js_name = checkpointEpoch)] + pub fn checkpoint_epoch(&self) -> u64 { + self.0.checkpoint_summary.epoch() + } + + /// Verifies this proof locally with the supplied committee. + pub fn verify(&self, committee: &WasmCommittee) -> Result<(), JsValue> { + poi_rs::ProofVerifier::new(committee.inner()) + .verify(&self.0) + .map_err(error_to_js) + } + /// Serializes this proof as JSON. #[wasm_bindgen(js_name = toJSON)] pub fn to_json(&self) -> Result { @@ -64,7 +78,7 @@ impl WasmProofBuilder { } } -fn error_to_js(error: impl Error) -> JsValue { +pub(crate) fn error_to_js(error: impl Error) -> JsValue { let mut message = error.to_string(); let mut source = error.source(); diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index d1697cd..f14abc4 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -53,7 +53,7 @@ impl SourceAdapter { Self { source } } - async fn await_method(result: Result) -> Result { + pub(crate) async fn await_method(result: Result) -> Result { let promise = result.map_err(BridgeError::from_js)?; JsFuture::from(promise).await.map_err(BridgeError::from_js) } @@ -345,10 +345,10 @@ where } #[derive(Debug)] -struct BridgeError(String); +pub(crate) struct BridgeError(pub(crate) String); impl BridgeError { - fn from_js(value: JsValue) -> Self { + pub(crate) fn from_js(value: JsValue) -> Self { let message = value .dyn_ref::() .map(js_sys::Error::message) diff --git a/bindings/wasm/poi_wasm/tests/ledger-source.test.ts b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts index 6044975..b55726b 100644 --- a/bindings/wasm/poi_wasm/tests/ledger-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts @@ -9,6 +9,7 @@ import { createRouterTransport } from "@connectrpc/connect"; import { CheckpointDataSchema, + GetEpochResponseSchema, GetObjectsResponseSchema, GetServiceInfoResponseSchema, GetTransactionsResponseSchema, @@ -28,6 +29,7 @@ test("returns the BCS evidence needed by poi-rs", async () => { const summaryBcs = bytes(0x09); const checkpointSignatureBcs = bytes(0x0a); const contentsBcs = bytes(0x0b); + const committeePublicKey = new Uint8Array(96).fill(0x0c); const transport = createRouterTransport((router) => { router.service(LedgerService, { @@ -126,6 +128,21 @@ test("returns the BCS evidence needed by poi-rs", async () => { }, }); }, + getEpoch(request) { + assert.equal(request.epoch, 7n); + assert.deepEqual(request.readMask?.paths, ["committee"]); + + return create(GetEpochResponseSchema, { + epoch: { + committee: { + epoch: 7n, + members: { + members: [{ publicKey: committeePublicKey, weight: 10_000n }], + }, + }, + }, + }); + }, }); }); const source = new LedgerSource("http://unused.test", { transport }); @@ -144,6 +161,9 @@ test("returns the BCS evidence needed by poi-rs", async () => { signatureBcs: checkpointSignatureBcs, contentsBcs, }); + assert.deepEqual(await source.committee(7n), { + members: [{ publicKey: committeePublicKey, weight: 10_000n }], + }); }); test("returns undefined when a transaction or object is not returned", async () => { diff --git a/bindings/wasm/poi_wasm/tests/poi-client.test.ts b/bindings/wasm/poi_wasm/tests/poi-client.test.ts index f38a856..37863ab 100644 --- a/bindings/wasm/poi_wasm/tests/poi-client.test.ts +++ b/bindings/wasm/poi_wasm/tests/poi-client.test.ts @@ -17,13 +17,3 @@ test("creates clients for every supported public network", () => { assert.equal(typeof client.proof().transaction, "function"); } }); - -test("creates a client for a custom endpoint", () => { - const client = PoiClient.custom("https://ledger.example:443"); - - assert.equal(typeof client.proof().transaction, "function"); -}); - -test("rejects an empty custom endpoint", () => { - assert.throws(() => PoiClient.custom(" "), /endpoint must not be empty/); -}); diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts index 9b9929a..1889313 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; -import { ProofBuilder } from "../node/poi_wasm.js"; +import { CommitteeResolver, ProofBuilder } from "../node/poi_wasm.js"; import type { LedgerSource } from "../lib/source-types.js"; test("the WASM builder reads transaction evidence from the ledger source", async () => { @@ -40,3 +41,29 @@ test("the WASM builder validates digest lengths before fetching", () => { /invalid digest byte length: expected 32, got 31/, ); }); + +test("the WASM resolver constructs a committee reported by a trusted node", async () => { + const fixture = JSON.parse( + await readFile( + new URL("../../../../poi-rs/tests/fixtures/v1/committee.json", import.meta.url), + "utf8", + ), + ) as { + epoch: number; + voting_rights: [string, number][]; + }; + const source = { + async committee() { + return { + members: fixture.voting_rights.map(([publicKey, weight]) => ({ + publicKey: Buffer.from(publicKey, "base64"), + weight: BigInt(weight), + })), + }; + }, + } as unknown as LedgerSource; + + const committee = await new CommitteeResolver(source).resolve(0n); + + assert.equal(committee.epoch, 0n); +}); From 45dc296fe4853683da033fb41c5d45875c0a35b2 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 15:28:30 +0300 Subject: [PATCH 26/41] feat: implement anchored committee resolver and proof deserialization in WASM bindings --- bindings/wasm/poi_wasm/README.md | 10 ++- bindings/wasm/poi_wasm/lib/poi-client.ts | 17 +++- bindings/wasm/poi_wasm/src/committee.rs | 54 +++++++++-- bindings/wasm/poi_wasm/src/proof.rs | 19 ++++ bindings/wasm/poi_wasm/src/source.rs | 89 +++++++++---------- .../wasm/poi_wasm/tests/wasm-source.test.ts | 42 ++++++++- 6 files changed, 170 insertions(+), 61 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index ade3e30..be78858 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -82,7 +82,15 @@ and performs proof verification locally with `poi-rs`. This mode places the node inside the caller's trust boundary. It does not authenticate committee lineage from genesis. Genesis-anchored committee -resolution will be added separately. +resolution is reserved by the following API but is not implemented yet: + +```ts +const resolver = client.anchoredCommitteeResolver(trustedGenesisCommittee); +await resolver.resolve(proof.checkpointEpoch); +``` + +Until epoch-close walking is connected, `resolve()` rejects with an explicit +not-implemented error in anchored mode. ## Package verification diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index 82c33c1..62ca2be 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -3,7 +3,11 @@ import type { Transport } from "@connectrpc/connect"; -import { CommitteeResolver, ProofBuilder } from "../node/poi_wasm.js"; +import { + type Committee, + CommitteeResolver, + ProofBuilder, +} from "../node/poi_wasm.js"; import { LedgerSource } from "./ledger-source.js"; const MAINNET_ENDPOINT = "https://grpc.mainnet.iota.cafe:443"; @@ -63,4 +67,15 @@ export class PoiClient { public committeeResolver(): CommitteeResolver { return new CommitteeResolver(this.#source); } + + /** + * Creates a resolver anchored at an already trusted committee. + * + * Committee walking is reserved by this API but not implemented yet. + */ + public anchoredCommitteeResolver( + committee: Committee, + ): CommitteeResolver { + return CommitteeResolver.anchor(this.#source, committee); + } } diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index 33fefa6..b958643 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -5,19 +5,18 @@ use std::collections::BTreeMap; use fastcrypto::traits::ToFromBytes; use iota_types::{base_types::AuthorityName, committee::Committee}; -use js_sys::Promise; use serde::Deserialize; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use crate::{ proof::error_to_js, - source::{BridgeError, LedgerSource, SourceAdapter}, + source::{BridgeError, LedgerSource}, }; #[wasm_bindgen] extern "C" { #[wasm_bindgen(method, catch, structural)] - fn committee(this: &LedgerSource, epoch: u64) -> Result; + async fn committee(this: &LedgerSource, epoch: u64) -> Result; } /// A validator committee used to verify a Proof of Inclusion proof. @@ -39,13 +38,19 @@ impl WasmCommittee { } } -/// Resolves committees reported by a node inside the caller's trust boundary. +/// Resolves the committee required to verify a Proof of Inclusion proof. /// -/// This resolver does not authenticate committee lineage from genesis. The -/// connected node is authoritative for the committee returned for each epoch. +/// Node mode trusts the JavaScript source for committee data. Anchored mode +/// reserves the API for future genesis-authenticated committee walking. #[wasm_bindgen(js_name = CommitteeResolver)] pub struct WasmCommitteeResolver { source: LedgerSource, + mode: CommitteeResolution, +} + +enum CommitteeResolution { + Node, + Anchor(Committee), } #[wasm_bindgen(js_class = CommitteeResolver)] @@ -53,13 +58,44 @@ impl WasmCommitteeResolver { /// Creates a trusted-node resolver backed by a JavaScript ledger source. #[wasm_bindgen(constructor)] pub fn new(source: LedgerSource) -> Self { - Self { source } + Self::node(source) + } + + /// Creates a resolver that trusts the JavaScript source for committee data. + pub fn node(source: LedgerSource) -> Self { + Self { + source, + mode: CommitteeResolution::Node, + } } - /// Returns the committee reported by the trusted node for `epoch`. + /// Creates a resolver anchored at an already trusted committee. + /// + /// Genesis-anchored committee walking is not implemented yet. The method + /// reserves the public API that will delegate to `poi-rs` once the updated + /// epoch-close resolver is integrated. + pub fn anchor(source: LedgerSource, committee: &WasmCommittee) -> Self { + Self { + source, + mode: CommitteeResolution::Anchor(committee.0.clone()), + } + } + + /// Resolves the committee governing `epoch`. pub async fn resolve(&self, epoch: u64) -> Result { - let value = SourceAdapter::await_method(self.source.committee(epoch)) + if let CommitteeResolution::Anchor(committee) = &self.mode { + return Err(js_sys::Error::new(&format!( + "genesis-anchored committee resolution from epoch {} is not implemented yet", + committee.epoch() + )) + .into()); + } + + let value = self + .source + .committee(epoch) .await + .map_err(BridgeError::from_js) .map_err(error_to_js)?; let evidence: JsCommittee = serde_wasm_bindgen::from_value(value).map_err(|source| error_to_js(BridgeError(source.to_string())))?; diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index f9e15ea..215d96d 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -18,6 +18,20 @@ pub struct WasmProof(pub(crate) Proof); #[wasm_bindgen(js_class = Proof)] impl WasmProof { + /// Deserializes a proof from JSON. + #[wasm_bindgen(js_name = fromJSON)] + pub fn from_json(json: &str) -> Result { + Proof::from_json_slice(json.as_bytes()) + .map(WasmProof) + .map_err(error_to_js) + } + + /// Returns the proof format version. + #[wasm_bindgen(getter)] + pub fn version(&self) -> u16 { + self.0.version().value() + } + /// Returns the epoch of the committee that certified this proof. #[wasm_bindgen(getter, js_name = checkpointEpoch)] pub fn checkpoint_epoch(&self) -> u64 { @@ -31,6 +45,11 @@ impl WasmProof { .map_err(error_to_js) } + /// Validates the proof format version. + pub fn validate(&self) -> Result<(), JsValue> { + self.0.validate().map_err(error_to_js) + } + /// Serializes this proof as JSON. #[wasm_bindgen(js_name = toJSON)] pub fn to_json(&self) -> Result { diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index f14abc4..c294750 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -15,12 +15,11 @@ use iota_types::{ digests::{ChainIdentifier, CheckpointDigest}, object::Object, }; -use js_sys::{Promise, Uint8Array}; +use js_sys::Uint8Array; use poi_rs::Source; use poi_rs::{SourceCheckpoint, SourceError, SourceErrorKind, SourceTransaction}; use serde::Deserialize; use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; -use wasm_bindgen_futures::JsFuture; use crate::versioned::VersionedObject; use crate::versioned::{VersionedCheckpointSummary, VersionedEvent, VersionedValidatorAggregatedSignature}; @@ -32,16 +31,16 @@ extern "C" { pub type LedgerSource; #[wasm_bindgen(method, catch, structural, js_name = chainIdentifier)] - fn chain_identifier(this: &LedgerSource) -> Result; + async fn chain_identifier(this: &LedgerSource) -> Result; #[wasm_bindgen(method, catch, structural)] - fn transaction(this: &LedgerSource, digest: Uint8Array) -> Result; + async fn transaction(this: &LedgerSource, digest: Uint8Array) -> Result; #[wasm_bindgen(method, catch, structural)] - fn object(this: &LedgerSource, object_id: Uint8Array, version: Option) -> Result; + async fn object(this: &LedgerSource, object_id: Uint8Array, version: Option) -> Result; #[wasm_bindgen(method, catch, structural)] - fn checkpoint(this: &LedgerSource, sequence_number: u64) -> Result; + async fn checkpoint(this: &LedgerSource, sequence_number: u64) -> Result; } pub(crate) struct SourceAdapter { @@ -52,11 +51,6 @@ impl SourceAdapter { pub(crate) fn new(source: LedgerSource) -> Self { Self { source } } - - pub(crate) async fn await_method(result: Result) -> Result { - let promise = result.map_err(BridgeError::from_js)?; - JsFuture::from(promise).await.map_err(BridgeError::from_js) - } } #[derive(Deserialize)] @@ -80,17 +74,19 @@ struct JsCheckpointEvidence { #[async_trait(?Send)] impl Source for SourceAdapter { async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { - let value = Self::await_method(self.source.chain_identifier()) + let bytes = self + .source + .chain_identifier() .await .map_err(|source| { SourceError::transaction( transaction_digest, SourceErrorKind::FetchChainIdentifier { - source: Box::new(source), + source: Box::new(BridgeError::from_js(source)), }, ) - })?; - let bytes = Uint8Array::new(&value).to_vec(); + })? + .to_vec(); let digest = bytes.try_into().map_err(|bytes: Vec| { SourceError::transaction( transaction_digest, @@ -111,16 +107,14 @@ impl Source for SourceAdapter { transaction_digest: TransactionDigest, ) -> Result, SourceError> { let digest = Uint8Array::from(transaction_digest.as_ref()); - let value = Self::await_method(self.source.transaction(digest)) - .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchTransaction { - source: Box::new(source), - }, - ) - })?; + let value = self.source.transaction(digest).await.map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchTransaction { + source: Box::new(BridgeError::from_js(source)), + }, + ) + })?; if value.is_undefined() || value.is_null() { return Ok(None); @@ -140,19 +134,18 @@ impl Source for SourceAdapter { async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { let object_id_bytes = Uint8Array::from(object_id.as_ref()); - let value = Self::await_method( - self.source - .object(object_id_bytes, version.map(|version| version.as_u64())), - ) - .await - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::FetchObject { - source: Box::new(source), - }, - ) - })?; + let value = self + .source + .object(object_id_bytes, version.map(|version| version.as_u64())) + .await + .map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::FetchObject { + source: Box::new(BridgeError::from_js(source)), + }, + ) + })?; if value.is_undefined() || value.is_null() { return Ok(None); @@ -177,17 +170,15 @@ impl Source for SourceAdapter { transaction_digest: TransactionDigest, sequence_number: u64, ) -> Result { - let value = Self::await_method(self.source.checkpoint(sequence_number)) - .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchCheckpoint { - sequence_number, - source: Box::new(source), - }, - ) - })?; + let value = self.source.checkpoint(sequence_number).await.map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(BridgeError::from_js(source)), + }, + ) + })?; let evidence: JsCheckpointEvidence = serde_wasm_bindgen::from_value(value).map_err(|source| { SourceError::transaction( transaction_digest, diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts index 1889313..9f96b12 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { CommitteeResolver, ProofBuilder } from "../node/poi_wasm.js"; +import { CommitteeResolver, Proof, ProofBuilder } from "../node/poi_wasm.js"; import type { LedgerSource } from "../lib/source-types.js"; test("the WASM builder reads transaction evidence from the ledger source", async () => { @@ -67,3 +67,43 @@ test("the WASM resolver constructs a committee reported by a trusted node", asyn assert.equal(committee.epoch, 0n); }); + +test("the WASM proof can be deserialized for verification", async () => { + const json = await readFile( + new URL("../../../../poi-rs/tests/fixtures/v1/transaction.json", import.meta.url), + "utf8", + ); + + const proof = Proof.fromJSON(json); + + assert.equal(proof.version, 1); + assert.equal(proof.checkpointEpoch, 0n); + assert.doesNotThrow(() => proof.validate()); +}); + +test("the anchored resolver reserves the future API without trusting the node", async () => { + const fixture = JSON.parse( + await readFile( + new URL("../../../../poi-rs/tests/fixtures/v1/committee.json", import.meta.url), + "utf8", + ), + ) as { + voting_rights: [string, number][]; + }; + const source = { + async committee() { + return { + members: fixture.voting_rights.map(([publicKey, weight]) => ({ + publicKey: Buffer.from(publicKey, "base64"), + weight: BigInt(weight), + })), + }; + }, + } as unknown as LedgerSource; + const committee = await CommitteeResolver.node(source).resolve(0n); + + await assert.rejects( + CommitteeResolver.anchor(source, committee).resolve(1n), + /genesis-anchored committee resolution from epoch 0 is not implemented yet/, + ); +}); From c4be42e47e904e0ec4fc439c89a55449cd61ac48 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 15:39:32 +0300 Subject: [PATCH 27/41] feat: introduce PoiError for error handling and refactor committee and source modules --- bindings/wasm/poi_wasm/Cargo.toml | 1 + bindings/wasm/poi_wasm/src/committee.rs | 23 +++++------ bindings/wasm/poi_wasm/src/error.rs | 55 +++++++++++++++++++++++++ bindings/wasm/poi_wasm/src/lib.rs | 1 + bindings/wasm/poi_wasm/src/proof.rs | 36 +++++----------- bindings/wasm/poi_wasm/src/source.rs | 44 +++++--------------- 6 files changed, 88 insertions(+), 72 deletions(-) create mode 100644 bindings/wasm/poi_wasm/src/error.rs diff --git a/bindings/wasm/poi_wasm/Cargo.toml b/bindings/wasm/poi_wasm/Cargo.toml index 2dfd485..fcf49fd 100644 --- a/bindings/wasm/poi_wasm/Cargo.toml +++ b/bindings/wasm/poi_wasm/Cargo.toml @@ -26,6 +26,7 @@ js-sys = "=0.3.85" poi-rs = { path = "../../../poi-rs", default-features = false } serde = { version = "1.0", features = ["derive"] } serde-wasm-bindgen = "0.6.5" +thiserror = { version = "2.0", default-features = false } wasm-bindgen = "=0.2.108" wasm-bindgen-futures = "=0.4.58" diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index b958643..a9f507b 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -9,8 +9,8 @@ use serde::Deserialize; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use crate::{ - proof::error_to_js, - source::{BridgeError, LedgerSource}, + error::{PoiError, WasmResult}, + source::LedgerSource, }; #[wasm_bindgen] @@ -95,14 +95,13 @@ impl WasmCommitteeResolver { .source .committee(epoch) .await - .map_err(BridgeError::from_js) - .map_err(error_to_js)?; - let evidence: JsCommittee = - serde_wasm_bindgen::from_value(value).map_err(|source| error_to_js(BridgeError(source.to_string())))?; - - decode_committee(epoch, evidence) - .map(WasmCommittee) - .map_err(error_to_js) + .map_err(PoiError::from_js) + .wasm_result()?; + let evidence: JsCommittee = serde_wasm_bindgen::from_value(value) + .map_err(|source| PoiError::invalid_response(source.to_string())) + .wasm_result()?; + + decode_committee(epoch, evidence).map(WasmCommittee).wasm_result() } } @@ -119,14 +118,14 @@ struct JsCommitteeMember { weight: u64, } -fn decode_committee(requested_epoch: u64, evidence: JsCommittee) -> Result { +fn decode_committee(requested_epoch: u64, evidence: JsCommittee) -> Result { let voting_rights = evidence .members .into_iter() .map(|member| { AuthorityName::from_bytes(&member.public_key) .map(|authority| (authority, member.weight)) - .map_err(|source| BridgeError(format!("invalid committee public key: {source}"))) + .map_err(|source| PoiError::invalid_response(format!("invalid committee public key: {source}"))) }) .collect::, _>>()?; diff --git a/bindings/wasm/poi_wasm/src/error.rs b/bindings/wasm/poi_wasm/src/error.rs new file mode 100644 index 0000000..364c5b4 --- /dev/null +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -0,0 +1,55 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::error::Error; + +use wasm_bindgen::{JsCast, JsValue}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PoiError { + #[error("{0}")] + JavaScript(String), + #[error("{0}")] + InvalidResponse(String), +} + +impl PoiError { + pub(crate) fn from_js(value: JsValue) -> Self { + let message = value + .dyn_ref::() + .map(js_sys::Error::message) + .and_then(|message| message.as_string()) + .or_else(|| value.as_string()) + .unwrap_or_else(|| format!("{value:?}")); + + Self::JavaScript(message) + } + + pub(crate) fn invalid_response(message: impl Into) -> Self { + Self::InvalidResponse(message.into()) + } +} + +pub(crate) trait WasmResult { + fn wasm_result(self) -> Result; +} + +impl WasmResult for Result +where + E: Error, +{ + fn wasm_result(self) -> Result { + self.map_err(|error| { + let mut message = error.to_string(); + let mut source = error.source(); + + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + + js_sys::Error::new(&message).into() + }) + } +} diff --git a/bindings/wasm/poi_wasm/src/lib.rs b/bindings/wasm/poi_wasm/src/lib.rs index 45d8df0..813291c 100644 --- a/bindings/wasm/poi_wasm/src/lib.rs +++ b/bindings/wasm/poi_wasm/src/lib.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 mod committee; +mod error; mod proof; mod source; mod versioned; diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 215d96d..55251ac 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -1,8 +1,6 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::error::Error; - use iota_sdk_types::ObjectId; use iota_types::{digests::TransactionDigest, event::EventID}; use js_sys::Uint8Array; @@ -10,6 +8,7 @@ use poi_rs::{Proof, ProofBuilder}; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use crate::committee::WasmCommittee; +use crate::error::WasmResult; use crate::source::{LedgerSource, SourceAdapter}; /// Proof of Inclusion evidence constructed by `poi-rs`. @@ -21,9 +20,7 @@ impl WasmProof { /// Deserializes a proof from JSON. #[wasm_bindgen(js_name = fromJSON)] pub fn from_json(json: &str) -> Result { - Proof::from_json_slice(json.as_bytes()) - .map(WasmProof) - .map_err(error_to_js) + Proof::from_json_slice(json.as_bytes()).map(WasmProof).wasm_result() } /// Returns the proof format version. @@ -42,19 +39,19 @@ impl WasmProof { pub fn verify(&self, committee: &WasmCommittee) -> Result<(), JsValue> { poi_rs::ProofVerifier::new(committee.inner()) .verify(&self.0) - .map_err(error_to_js) + .wasm_result() } /// Validates the proof format version. pub fn validate(&self) -> Result<(), JsValue> { - self.0.validate().map_err(error_to_js) + self.0.validate().wasm_result() } /// Serializes this proof as JSON. #[wasm_bindgen(js_name = toJSON)] pub fn to_json(&self) -> Result { - let bytes = self.0.to_json_vec().map_err(error_to_js)?; - String::from_utf8(bytes).map_err(error_to_js) + let bytes = self.0.to_json_vec().wasm_result()?; + String::from_utf8(bytes).wasm_result() } } @@ -72,19 +69,19 @@ impl WasmProofBuilder { /// Adds a transaction target. pub fn transaction(self, transaction_digest: Uint8Array) -> Result { - let digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).map_err(error_to_js)?; + let digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).wasm_result()?; Ok(Self(self.0.transaction(digest))) } /// Adds an object target. pub fn object(self, object_id: Uint8Array) -> Result { - let object_id = ObjectId::from_bytes(object_id.to_vec()).map_err(error_to_js)?; + let object_id = ObjectId::from_bytes(object_id.to_vec()).wasm_result()?; Ok(Self(self.0.object(object_id))) } /// Adds an event target. pub fn event(self, transaction_digest: Uint8Array, event_sequence: u64) -> Result { - let tx_digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).map_err(error_to_js)?; + let tx_digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).wasm_result()?; Ok(Self(self.0.event(EventID { tx_digest, event_seq: event_sequence, @@ -93,19 +90,6 @@ impl WasmProofBuilder { /// Fetches the requested evidence and constructs the proof. pub async fn build(self) -> Result { - self.0.build().await.map(WasmProof).map_err(error_to_js) - } -} - -pub(crate) fn error_to_js(error: impl Error) -> JsValue { - let mut message = error.to_string(); - let mut source = error.source(); - - while let Some(cause) = source { - message.push_str(": "); - message.push_str(&cause.to_string()); - source = cause.source(); + self.0.build().await.map(WasmProof).wasm_result() } - - js_sys::Error::new(&message).into() } diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index c294750..9b1da1f 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -1,8 +1,6 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{error::Error, fmt}; - use async_trait::async_trait; use iota_sdk_types::{ObjectId, Version}; use iota_sdk_types::{SignedCheckpointSummary, SignedTransaction, Transaction, TransactionEffects, UserSignature}; @@ -19,8 +17,9 @@ use js_sys::Uint8Array; use poi_rs::Source; use poi_rs::{SourceCheckpoint, SourceError, SourceErrorKind, SourceTransaction}; use serde::Deserialize; -use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; +use crate::error::PoiError; use crate::versioned::VersionedObject; use crate::versioned::{VersionedCheckpointSummary, VersionedEvent, VersionedValidatorAggregatedSignature}; @@ -82,7 +81,7 @@ impl Source for SourceAdapter { SourceError::transaction( transaction_digest, SourceErrorKind::FetchChainIdentifier { - source: Box::new(BridgeError::from_js(source)), + source: Box::new(PoiError::from_js(source)), }, ) })? @@ -91,7 +90,7 @@ impl Source for SourceAdapter { SourceError::transaction( transaction_digest, SourceErrorKind::ChainIdentifier { - source: Box::new(BridgeError(format!( + source: Box::new(PoiError::invalid_response(format!( "chain identifier must contain 32 bytes, received {}", bytes.len() ))), @@ -111,7 +110,7 @@ impl Source for SourceAdapter { SourceError::transaction( transaction_digest, SourceErrorKind::FetchTransaction { - source: Box::new(BridgeError::from_js(source)), + source: Box::new(PoiError::from_js(source)), }, ) })?; @@ -124,7 +123,7 @@ impl Source for SourceAdapter { SourceError::transaction( transaction_digest, SourceErrorKind::Transaction { - source: Box::new(BridgeError(source.to_string())), + source: Box::new(PoiError::invalid_response(source.to_string())), }, ) })?; @@ -142,7 +141,7 @@ impl Source for SourceAdapter { SourceError::object( object_id, SourceErrorKind::FetchObject { - source: Box::new(BridgeError::from_js(source)), + source: Box::new(PoiError::from_js(source)), }, ) })?; @@ -175,7 +174,7 @@ impl Source for SourceAdapter { transaction_digest, SourceErrorKind::FetchCheckpoint { sequence_number, - source: Box::new(BridgeError::from_js(source)), + source: Box::new(PoiError::from_js(source)), }, ) })?; @@ -183,7 +182,7 @@ impl Source for SourceAdapter { SourceError::transaction( transaction_digest, SourceErrorKind::CheckpointSummary { - source: Box::new(BridgeError(source.to_string())), + source: Box::new(PoiError::invalid_response(source.to_string())), }, ) })?; @@ -243,7 +242,7 @@ fn decode_transaction( SourceError::transaction( transaction_digest, SourceErrorKind::MissingEvents { - source: Box::new(BridgeError( + source: Box::new(PoiError::invalid_response( "transaction effects commit to events but eventsBcs is missing".to_owned(), )), }, @@ -335,29 +334,6 @@ where bcs::from_bytes(bytes) } -#[derive(Debug)] -pub(crate) struct BridgeError(pub(crate) String); - -impl BridgeError { - pub(crate) fn from_js(value: JsValue) -> Self { - let message = value - .dyn_ref::() - .map(js_sys::Error::message) - .and_then(|message| message.as_string()) - .or_else(|| value.as_string()) - .unwrap_or_else(|| format!("{value:?}")); - Self(message) - } -} - -impl fmt::Display for BridgeError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - -impl Error for BridgeError {} - #[cfg(test)] mod tests { use iota_sdk_types::{ From 7d9823e47657e59565db7a6fcac0fa9bc1623559 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 27 Jul 2026 16:16:34 +0300 Subject: [PATCH 28/41] Refactor tests to use updated fixture paths and remove deprecated JSON files - Deleted outdated JSON fixture files: committee.json, event.json, object.json, transaction.json. - Updated test cases in golden.rs to reference new fixture paths under "fixtures/current". - Adjusted proof builder tests to accommodate changes in the iota_sdk_types module. - Modified utility functions to use new types and structures from iota_sdk_types. - Ensured compatibility with the latest changes in the IOTA SDK and proof verification logic. --- Cargo.toml | 11 +- poi-rs/Cargo.toml | 6 +- poi-rs/README.md | 2 +- poi-rs/src/bin/poi.rs | 4 +- poi-rs/src/builder.rs | 4 +- poi-rs/src/committee.rs | 160 ++++---- poi-rs/src/proof.rs | 9 +- poi-rs/src/source.rs | 36 +- poi-rs/src/target.rs | 8 +- poi-rs/tests/committee.rs | 38 +- poi-rs/tests/fixtures/current/committee.json | 15 + poi-rs/tests/fixtures/current/event.json | 355 ++++++++++++++++++ poi-rs/tests/fixtures/current/object.json | 285 ++++++++++++++ .../tests/fixtures/current/transaction.json | 263 +++++++++++++ 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 | 22 +- poi-rs/tests/proof_builder.rs | 3 +- poi-rs/tests/utils/mod.rs | 13 +- poi-rs/tests/utils/proofs.rs | 18 +- poi-rs/tests/verifier.rs | 5 +- 23 files changed, 1106 insertions(+), 752 deletions(-) create mode 100644 poi-rs/tests/fixtures/current/committee.json create mode 100644 poi-rs/tests/fixtures/current/event.json create mode 100644 poi-rs/tests/fixtures/current/object.json create mode 100644 poi-rs/tests/fixtures/current/transaction.json delete mode 100644 poi-rs/tests/fixtures/v1/committee.json delete mode 100644 poi-rs/tests/fixtures/v1/event.json delete mode 100644 poi-rs/tests/fixtures/v1/object.json delete mode 100644 poi-rs/tests/fixtures/v1/transaction.json diff --git a/Cargo.toml b/Cargo.toml index aa2c6de..d43cfce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,11 @@ 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" } +iota-grpc-client = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-client", rev = "b77fcd5ac5fedb3dfbc77ba7d183140e43512339" } +iota-grpc-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", package = "iota-sdk-grpc-types", rev = "b77fcd5ac5fedb3dfbc77ba7d183140e43512339" } iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.26.1" } -iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11", default-features = false } -iota-types = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } +iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "b77fcd5ac5fedb3dfbc77ba7d183140e43512339", default-features = false } +iota-types = { git = "https://github.com/iotaledger/iota.git", rev = "420df58ea50ce916a927ebba2dcaee192832e436" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction" } iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_ts" } @@ -38,5 +38,8 @@ tempfile = "3.27.0" thiserror = { version = "2.0", default-features = false } tokio = { version = "1.52.2", default-features = false, features = ["macros", "sync", "rt", "process"] } +[patch."https://github.com/MystenLabs/fastcrypto"] +fastcrypto = "=0.1.11" + [profile.release.package.iota_interaction_ts] opt-level = 's' diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index 2322bd4..2453330 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -19,7 +19,7 @@ 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-config = { git = "https://github.com/iotaledger/iota.git", rev = "420df58ea50ce916a927ebba2dcaee192832e436", optional = true } iota-sdk-types.workspace = true iota-types.workspace = true reqwest = { workspace = true, optional = true } @@ -29,8 +29,8 @@ 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" } +iota-config = { git = "https://github.com/iotaledger/iota.git", rev = "420df58ea50ce916a927ebba2dcaee192832e436" } +test-cluster = { git = "https://github.com/iotaledger/iota.git", rev = "420df58ea50ce916a927ebba2dcaee192832e436", package = "test-cluster" } [[bin]] name = "poi" diff --git a/poi-rs/README.md b/poi-rs/README.md index 21bec66..4b51d81 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -13,7 +13,7 @@ locally without trusting the source that supplied it. so the calling application always chooses where it fetches proof material. ```rust,no_run -use iota_types::digests::TransactionDigest; +use iota_sdk_types::TransactionDigest; use poi_rs::ProofBuilder; # async fn example() -> Result<(), Box> { diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index 1afee34..7faa16a 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -13,8 +13,8 @@ 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 iota_sdk_types::{ObjectId, TransactionDigest}; +use iota_types::event::EventID; use poi_rs::{CommitteeResolver, Proof, ProofBuilder, ProofVerifier}; const GENESIS_CACHE_DIR: &str = "poi"; diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 1cfdd83..b3059b7 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use iota_grpc_client::Client as GrpcClient; -use iota_sdk_types::ObjectId; -use iota_types::{digests::TransactionDigest, event::EventID}; +use iota_sdk_types::{ObjectId, TransactionDigest}; +use iota_types::event::EventID; use crate::{Proof, Source, SourceError, SourceTarget, source::GrpcSource}; diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index d579830..77e0bd3 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -5,8 +5,10 @@ use std::sync::Arc; use iota_grpc_client::{ Client as GrpcClient, ReadMask, - read_mask_fields::{CheckpointResponseField, EpochField, ServiceInfoField}, + read_mask_fields::{EpochField, ServiceInfoField}, }; +use iota_grpc_types::proto::TryFromProtoError; +use iota_sdk_types::SignedCheckpointSummary; use iota_types::{ committee::{Committee, EpochId}, error::IotaError, @@ -87,26 +89,17 @@ pub enum CommitteeResolutionErrorKind { #[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. + /// A closed epoch response omitted its epoch-close proof. + #[error("epoch {epoch} is missing its epoch-close proof")] + MissingEpochCloseProof { + /// Closed epoch whose proof 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, + /// Reading or converting the certified checkpoint in an epoch-close proof failed. + #[error("failed to read epoch {epoch} close proof")] + EpochCloseProof { + /// Epoch whose proof was returned by the node. + epoch: EpochId, /// Underlying response or conversion error. #[source] source: BoxError, @@ -373,22 +366,25 @@ impl CommitteeResolver { current_committee: &Committee, cache: &dyn CommitteeCache, ) -> Result { - let sequence_number = self - .epoch_last_checkpoint(target_epoch, current_committee.epoch) + let summary = self + .certified_epoch_close_summary(target_epoch, current_committee.epoch) .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 } - /// Fetches the checkpoint sequence number that closes an epoch. - async fn epoch_last_checkpoint( + /// Fetches the certified closing checkpoint embedded in an epoch response. + async fn certified_epoch_close_summary( &self, target_epoch: EpochId, epoch: EpochId, - ) -> Result { - self.client - .get_epoch(Some(epoch), Some(ReadMask::from(EpochField::LAST_CHECKPOINT))) + ) -> Result { + let epoch_info = self + .client + .get_epoch( + Some(epoch), + Some(ReadMask::from(EpochField::EPOCH_CLOSE_PROOF_CHECKPOINT)), + ) .await .map_err(|source| { CommitteeResolutionError::new( @@ -399,57 +395,87 @@ impl CommitteeResolver { }, ) })? - .into_inner() - .last_checkpoint + .into_inner(); + let epoch_close_proof = epoch_info + .epoch_close_proof() + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, + source: Box::new(source), + }, + ) + })? .ok_or_else(|| { CommitteeResolutionError::new( target_epoch, - CommitteeResolutionErrorKind::MissingLastCheckpoint { epoch }, + CommitteeResolutionErrorKind::MissingEpochCloseProof { epoch }, ) - }) - } - - /// Fetches only the signed checkpoint summary required to authenticate the next committee. - async fn certified_checkpoint_summary( - &self, - target_epoch: EpochId, - sequence_number: u64, - ) -> Result { - let checkpoint = self - .client - .get_checkpoint_by_sequence_number( - sequence_number, - Some(ReadMask::from(CHECKPOINT_SUMMARY_FIELDS)), - None, - None, + })?; + let checkpoint = epoch_close_proof.checkpoint().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, + source: Box::new(source), + }, ) - .await + })?; + let summary = checkpoint + .summary + .as_ref() + .ok_or_else(|| TryFromProtoError::missing("summary")) .map_err(|source| { CommitteeResolutionError::new( target_epoch, - CommitteeResolutionErrorKind::FetchCheckpoint { - sequence_number, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, source: Box::new(source), }, ) - })? - .into_inner(); - - let summary = checkpoint.signed_summary().map_err(|source| { + })?; + let checkpoint_summary = summary.summary().map_err(|source| { CommitteeResolutionError::new( target_epoch, - CommitteeResolutionErrorKind::CheckpointSummary { - sequence_number, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, source: Box::new(source), }, ) })?; + let signature = checkpoint + .signature + .as_ref() + .ok_or_else(|| TryFromProtoError::missing("signature")) + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, + source: Box::new(source), + }, + ) + })?; + let checkpoint_signature = signature.signature().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, + source: Box::new(source), + }, + ) + })?; + let signed_summary = SignedCheckpointSummary { + checkpoint: checkpoint_summary, + signature: checkpoint_signature, + }; - summary.try_into().map_err(|source| { + signed_summary.try_into().map_err(|source| { CommitteeResolutionError::new( target_epoch, - CommitteeResolutionErrorKind::CheckpointSummary { - sequence_number, + CommitteeResolutionErrorKind::EpochCloseProof { + epoch, source: Box::new(source), }, ) @@ -495,10 +521,7 @@ impl CommitteeResolver { .expect("checked before signature verification") .next_epoch_committee; - Ok(Committee::new( - next_epoch, - next_epoch_committee.iter().cloned().collect(), - )) + Ok(Committee::from_committee_members(next_epoch, next_epoch_committee)) } /// Authenticates a committee handoff before exposing it through the cache. @@ -525,18 +548,11 @@ impl CommitteeResolver { } } -/// Checkpoint fields required to authenticate the next committee. -const CHECKPOINT_SUMMARY_FIELDS: &[&str] = &[ - CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, - CheckpointResponseField::CHECKPOINT_SIGNATURE, -]; - #[cfg(test)] mod tests { use std::sync::Mutex; - use iota_sdk_types::gas::GasCostSummary; - use iota_types::messages_checkpoint::{CheckpointSummary, EndOfEpochData}; + use iota_sdk_types::{CheckpointSummary, EndOfEpochData, gas::GasCostSummary}; use super::*; @@ -590,8 +606,8 @@ mod tests { 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(), + next_epoch_committee: next_committee.committee_members(), + next_epoch_protocol_version: 1, epoch_commitments: Vec::new(), epoch_supply_change: 0, }); diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 35929c6..5d10d98 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -1,11 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_sdk_types::{CheckpointContents, EndOfEpochData}; use iota_types::{ committee::Committee, digests::ChainIdentifier, effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}, - messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, EndOfEpochData}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContentsExt}, transaction::Transaction, }; use serde::{Deserialize, Serialize}; @@ -322,11 +323,11 @@ impl<'committee> ProofVerifier<'committee> { }); }; - let actual_committee = Committee::new( + let actual_committee = Committee::from_committee_members( summary.epoch().checked_add(1).ok_or(VerifyError { kind: VerifyErrorKind::NextEpochOverflow, })?, - next_epoch_committee.iter().cloned().collect(), + next_epoch_committee, ); if actual_committee != *expected_committee { @@ -356,7 +357,7 @@ impl<'committee> ProofVerifier<'committee> { let transaction_is_in_checkpoint = transaction_proof .checkpoint_contents .enumerate_transactions(summary) - .any(|(_, digests)| digests == &execution_digests); + .any(|(_, digests)| digests == execution_digests); if !transaction_is_in_checkpoint { return Err(VerifyError { diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index 0e4141c..655faf1 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -9,13 +9,14 @@ use iota_grpc_client::{ read_mask_fields::{CheckpointResponseField, ObjectField, ServiceInfoField, TransactionField}, }; use iota_grpc_types::v1::transaction::ExecutedTransaction; -use iota_sdk_types::{Digest, ObjectId, SignedTransaction}; +use iota_sdk_types::{ + CheckpointContents, CheckpointDigest, ObjectId, ObjectReference, SignedTransaction, TransactionDigest, +}; use iota_types::{ - base_types::ObjectRef, - digests::{ChainIdentifier, CheckpointDigest, TransactionDigest}, + digests::ChainIdentifier, effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, event::EventID, - messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents}, + messages_checkpoint::CertifiedCheckpointSummary, object::Object, transaction::Transaction, }; @@ -324,10 +325,9 @@ impl GrpcSource { /// Fetches the executed transaction envelope with the fields needed for inclusion. async fn get_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))) + .get_transactions(&[transaction_digest], Some(ReadMask::from(TRANSACTION_PROOF_FIELDS))) .await .map_err(|source| { SourceError::transaction( @@ -349,8 +349,8 @@ impl GrpcSource { async fn get_object( &self, object_id: ObjectId, - expected_ref: Option, - ) -> Result<(ObjectRef, Object), SourceError> { + expected_ref: Option, + ) -> Result<(ObjectReference, Object), SourceError> { let objects = self .client .get_objects( @@ -482,16 +482,6 @@ impl GrpcSource { source: Box::new(source), }, ) - }) - .and_then(|contents| { - contents.try_into().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - ) - }) })?; Ok((checkpoint_summary, checkpoint_contents)) @@ -576,15 +566,7 @@ impl GrpcSource { transaction, signatures, } - .try_into() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(source), - }, - ) - })?; + .into(); let events = if effects.events_digest().is_some() { executed_transaction .events() diff --git a/poi-rs/src/target.rs b/poi-rs/src/target.rs index 1792a73..dee31e1 100644 --- a/poi-rs/src/target.rs +++ b/poi-rs/src/target.rs @@ -1,9 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_sdk_types::Event; +use iota_sdk_types::{Event, ObjectReference}; use iota_types::committee::Committee; -use iota_types::{base_types::ObjectRef, event::EventID, object::Object}; +use iota_types::{event::EventID, object::Object}; use serde::{Deserialize, Serialize}; /// Target claims authenticated by a Proof of Inclusion. @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; #[derive(Default, Debug, Serialize, Deserialize, Clone)] pub struct ProofTargets { /// Objects that need to be certified. - pub objects: Vec<(ObjectRef, Object)>, + pub objects: Vec<(ObjectReference, Object)>, /// Events that need to be certified. pub events: Vec<(EventID, Event)>, @@ -35,7 +35,7 @@ impl ProofTargets { /// /// 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 { + pub fn add_object(mut self, object_ref: ObjectReference, object: Object) -> Self { self.objects.push((object_ref, object)); self } diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee.rs index 72208e0..43f353b 100644 --- a/poi-rs/tests/committee.rs +++ b/poi-rs/tests/committee.rs @@ -3,7 +3,8 @@ mod utils; -use iota_grpc_client::Client as GrpcClient; +use iota_config::genesis::Genesis; +use iota_grpc_client::{Client as GrpcClient, ReadMask, read_mask_fields::ServiceInfoField}; use iota_types::committee::Committee; use poi_rs::{CommitteeCache, CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; use utils::{advance_to_epoch, genesis_committee, grpc_client, start_test_cluster}; @@ -83,3 +84,38 @@ async fn trusted_node_resolution_does_not_write_to_an_anchor_cache() { assert_eq!(resolved, *cluster.committee()); assert!(cache.is_empty().await); } + +#[tokio::test] +#[ignore = "requires POI_TEST_GRPC_URL and POI_TEST_GENESIS"] +async fn live_endpoint_authenticates_committees_from_genesis() { + let endpoint = std::env::var("POI_TEST_GRPC_URL").expect("POI_TEST_GRPC_URL must identify the live gRPC endpoint"); + let genesis_path = + std::env::var("POI_TEST_GENESIS").expect("POI_TEST_GENESIS must identify the trusted genesis blob"); + let client = GrpcClient::new(endpoint).expect("live gRPC client must be constructed"); + let current_epoch = client + .get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) + .await + .expect("service information must be available") + .body() + .epoch + .expect("service information must contain the current epoch"); + assert!( + current_epoch > 0, + "the live network must have at least one closed epoch" + ); + let target_epoch = current_epoch.min(10); + let trusted_committee = Genesis::load(genesis_path) + .expect("trusted genesis blob must load") + .committee() + .expect("trusted genesis blob must contain a committee"); + let expected = CommitteeResolver::node(client.clone()) + .resolve(target_epoch) + .await + .expect("the node must expose the target committee"); + let authenticated = CommitteeResolver::anchor(client, trusted_committee) + .resolve(target_epoch) + .await + .expect("epoch-close proofs must authenticate the target committee"); + + assert_eq!(authenticated, expected); +} diff --git a/poi-rs/tests/fixtures/current/committee.json b/poi-rs/tests/fixtures/current/committee.json new file mode 100644 index 0000000..4358ddc --- /dev/null +++ b/poi-rs/tests/fixtures/current/committee.json @@ -0,0 +1,15 @@ +{ + "epoch": 0, + "voting_rights": [ + [ + "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ", + 10000 + ] + ], + "expanded_keys": { + "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ": "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ" + }, + "index_map": { + "mfJe9h+AMrkUY2RgmCxcxvE07x3a52ZX8sv+wev8jQlzdAgN9vzw3Li8Sw2OCvXYDrv/K0xZn1T0LWMS38MUJ2B4wcw0fru+xRmL4lhRPzhrkw0CwnSagD4jMJVevRoQ": 0 + } +} \ No newline at end of file diff --git a/poi-rs/tests/fixtures/current/event.json b/poi-rs/tests/fixtures/current/event.json new file mode 100644 index 0000000..485b36d --- /dev/null +++ b/poi-rs/tests/fixtures/current/event.json @@ -0,0 +1,355 @@ +{ + "version": 1, + "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", + "target": { + "objects": [], + "events": [ + [ + { + "txDigest": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", + "eventSeq": "0" + }, + { + "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "sender": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b", + "type": "0x3::validator::StakingRequestEvent", + "contents": "Ls9FicZo2wce3fWuF76Ozh9MHFqDi/lbNDQo5sI7qjGMRV8ikXw5a0aj9OvWJ3KGimZuazhPTwzuaNAE3kXuz8nVMKEzLacIv60tS2NMr6JltltdTeKeVS2SAOTxF/wLAAAAAAAAAAAAAENP15RqAA==" + } + ] + ], + "committee": null + }, + "checkpoint_summary": { + "data": { + "epoch": "0", + "sequence_number": "7", + "network_total_transactions": "40", + "content_digest": "C7AbY9VsMGiK43TCv6s8FnL77Wf97KQsdJ2AEVTkXdK1", + "previous_digest": "D8kX1nzCc2MGmXRyPTcP9iT6spLirMSU9BYfddkGUe8R", + "epoch_rolling_gas_cost_summary": { + "computation_cost": "2000000", + "computation_cost_burned": "2000000", + "storage_cost": "16537600", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "timestamp_ms": "1785157327014", + "checkpoint_commitments": [], + "end_of_epoch_data": null, + "version_specific_data": "AAECAAAAAAAAAA==" + }, + "auth_signature": { + "epoch": 0, + "signature": "uWe3felkoJULwW0GX+V1uIz2Zkp/DyNdilZVgBL5NYl3PBRcTx+PjNywaxF9ZSDY", + "signers_map": [ + 58, + 48, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 16, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "transaction_proof": { + "checkpoint_contents": { + "V1": [ + { + "transaction": "123ugpG5FWSQZ3yZkxVxNyw3RjDobNMGTXDom6aJsnDU", + "effects": "BBbiQ6R1A9QTCg4u3KrQw7nXtNJN9o4TFLZ3az93BEYJ", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "6Sjy1JpAgvYWp9NFZ6tYY3AMxjsB8d8Wjr7o7YDoThP6", + "effects": "JBMBLxwJWvnRaAbHVM1x1LRizxE8LqPmoLrfG1ZEikkv", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "5a2akdP8QpYw5Tr7d4iE4NbybHeJw8JgRQ3PPiPfJuk7", + "effects": "G8r2qRw4cCWx5zet4RE3qpHRGyywKHB378kQ9Ku1vdVe", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", + "effects": "fG1qHEADjgH1rbEMBy3kvLDhFcBi6akkRBf5eBsYHTt", + "signatures": [ + { + "scheme": "ed25519", + "signature": "ny3sKc8wQfcK3jOtxov8TvX3FsAQmaNE+/Kmhl5ODZyPrbiwog63/67vek1SpJqCLI8HU122VxiSuKB7G8NHDA==", + "public_key": "3tudne38ft4blQx4QMgi89Dbud82vs9JX0wqZJjR61k=" + } + ] + }, + { + "transaction": "B1yr1zybhNUwKeadAxCRvhnYdZhhk1BDLgkjRx693bbu", + "effects": "73QeVLRjs57CqEzR7Za1pSbFNJurnYZiZw8wbDwNcMsK", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "5JQhS5my3KrapKu2hKsbLRyqtHrLcB9RTggGsd5Pyx3F", + "effects": "CBqkCM2cP3xVHb9nEwNFk1QJ8veELTtBPdQYJGUxvvun", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "GbemSdr6vTaJ5q6KanqizjNQCy59fJgkHpexCRBfTHrp", + "effects": "EN4LoN2V7ffkQ2LoDnvXNL72pSbPb3335VYtVjScxULv", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + } + ] + }, + "transaction": { + "data": { + "transaction": { + "V1": { + "kind": { + "Programmable": { + "inputs": [ + { + "Shared": { + "object_id": "0x0000000000000000000000000000000000000000000000000000000000000005", + "initial_shared_version": "1", + "mutable": true + } + }, + { + "ImmutableOrOwned": { + "object_id": "0x7d2ecf75235b50ed8fa4b3286a28a8f9aa795d3ab267da775a6712e45d3787b4", + "version": "1", + "digest": "8mErr9cmex177LFsahrQAuBEpReKyLqhN1sAjnzyGunU" + } + }, + { + "Pure": "jEVfIpF8OWtGo/Tr1idyhopmbms4T08M7mjQBN5F7s8=" + } + ], + "commands": [ + { + "MoveCall": { + "package": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "function": "request_add_stake", + "type_arguments": [], + "arguments": [ + { + "Input": 0 + }, + { + "Input": 1 + }, + { + "Input": 2 + } + ] + } + } + ] + } + }, + "sender": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b", + "gas_payment": { + "objects": [ + { + "object_id": "0x97eb1bbdf3f02a31d920009da79477162af50544959db47a2dfefe9e7fbdd1b6", + "version": "1", + "digest": "HFCwd7tYY9ChQZay1eMGgVYYexdVUFacZ4TPhzX3cT1K" + } + ], + "owner": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b", + "price": "1000", + "budget": "5000000000" + }, + "expiration": "None" + } + }, + "signatures": [ + { + "scheme": "ed25519", + "signature": "ny3sKc8wQfcK3jOtxov8TvX3FsAQmaNE+/Kmhl5ODZyPrbiwog63/67vek1SpJqCLI8HU122VxiSuKB7G8NHDA==", + "public_key": "3tudne38ft4blQx4QMgi89Dbud82vs9JX0wqZJjR61k=" + } + ] + }, + "auth_signature": {} + }, + "effects": { + "V1": { + "status": { + "success": true + }, + "epoch": "0", + "gas_cost_summary": { + "computation_cost": "1000000", + "computation_cost_burned": "1000000", + "storage_cost": "14576800", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "transaction_digest": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", + "gas_object_index": 5, + "events_digest": "9Y8mNCezYf67FvKWRYKBg57TZcqjApuxDU2ZQwXnFHSN", + "dependencies": [ + "54ohH6BW2vfMLD6r63KKuZhcgrMq2ty4XU9JHD7D2HAW" + ], + "lamport_version": "2", + "changed_objects": [ + { + "object_id": "0x0000000000000000000000000000000000000000000000000000000000000005", + "input_state": { + "Data": { + "version": "1", + "digest": "4ohgCSrvVpNCSaWJJU46aBZt3NZDJ3q1BLayT8NiFcMm", + "owner": { + "Shared": "1" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "2KwThepY7sQfMY26i9JcB74CaqyQbGyZkgKdv3txJZMR", + "owner": { + "Shared": "1" + } + } + }, + "id_operation": "none" + }, + { + "object_id": "0x328ca92144d761e46332a48827e1bd457b84be93342c098a7accafbf0b400600", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "7NkZCXoPjeFjAn2BiwqoWL8BBdHKHy66uX2PWFc5dzja", + "owner": { + "Address": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b" + } + } + }, + "id_operation": "created" + }, + { + "object_id": "0x5b890eaf2abcfa2ab90b77b8e6f3d5d8609586c3e583baf3dccd5af17edf48d1", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "8nLcDJUXKTabUKDPjTisr3ytEJCZ23szL6P4r7X9UKLs", + "owner": { + "Object": "0x0000000000000000000000000000000000000000000000000000000000000005" + } + } + }, + "id_operation": "created" + }, + { + "object_id": "0x6af2a2b7ca60bf76174adfd3e9c4957f8e937759603182f9b46c7f6c5f19c6d2", + "input_state": { + "Data": { + "version": "1", + "digest": "GzB88P8Qmp4pikBvydmYGWVPb224hSi2gZgWbZUUvaU3", + "owner": { + "Object": "0x0000000000000000000000000000000000000000000000000000000000000005" + } + } + }, + "output_state": "Missing", + "id_operation": "deleted" + }, + { + "object_id": "0x7d2ecf75235b50ed8fa4b3286a28a8f9aa795d3ab267da775a6712e45d3787b4", + "input_state": { + "Data": { + "version": "1", + "digest": "8mErr9cmex177LFsahrQAuBEpReKyLqhN1sAjnzyGunU", + "owner": { + "Address": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b" + } + } + }, + "output_state": "Missing", + "id_operation": "deleted" + }, + { + "object_id": "0x97eb1bbdf3f02a31d920009da79477162af50544959db47a2dfefe9e7fbdd1b6", + "input_state": { + "Data": { + "version": "1", + "digest": "HFCwd7tYY9ChQZay1eMGgVYYexdVUFacZ4TPhzX3cT1K", + "owner": { + "Address": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "6Pu7xMgpN2URN5vccHzYF4BqCg3x39doN4HCPU1zGsgU", + "owner": { + "Address": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b" + } + } + }, + "id_operation": "none" + } + ], + "unchanged_shared_objects": [], + "auxiliary_data_digest": null + } + }, + "events": [ + { + "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", + "module": "iota_system", + "sender": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b", + "type": "0x3::validator::StakingRequestEvent", + "contents": "Ls9FicZo2wce3fWuF76Ozh9MHFqDi/lbNDQo5sI7qjGMRV8ikXw5a0aj9OvWJ3KGimZuazhPTwzuaNAE3kXuz8nVMKEzLacIv60tS2NMr6JltltdTeKeVS2SAOTxF/wLAAAAAAAAAAAAAENP15RqAA==" + } + ] + } +} \ No newline at end of file diff --git a/poi-rs/tests/fixtures/current/object.json b/poi-rs/tests/fixtures/current/object.json new file mode 100644 index 0000000..e2d555d --- /dev/null +++ b/poi-rs/tests/fixtures/current/object.json @@ -0,0 +1,285 @@ +{ + "version": 1, + "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", + "target": { + "objects": [ + [ + { + "object_id": "0x1f8ef3d2929482a43abc43c3693a695e247d55fd865636719bd428a606d3366c", + "version": "2", + "digest": "GhdZ7GvETWWMYR5G8XmmWwZ6Ta3GEsNBX4ajzazfAPC1" + }, + { + "data": { + "Struct": { + "object_type": "0x2::coin::Coin<0x2::iota::IOTA>", + "version": "2", + "contents": "H47z0pKUgqQ6vEPDaTppXiR9Vf2GVjZxm9QopgbTNmxf0hVP15RqAA==" + } + }, + "owner": { + "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" + }, + "previous_transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "storage_rebate": "980400" + } + ] + ], + "events": [], + "committee": null + }, + "checkpoint_summary": { + "data": { + "epoch": "0", + "sequence_number": "2", + "network_total_transactions": "10", + "content_digest": "6EucatZAZSiXpJBkyeozmy1kzRERXWq1ty7N1grkTuSk", + "previous_digest": "Et5CyqsQpZSbHgai9vf9qhJspF2SvF1uV75CGsnRft36", + "epoch_rolling_gas_cost_summary": { + "computation_cost": "1000000", + "computation_cost_burned": "1000000", + "storage_cost": "1960800", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "timestamp_ms": "1785157325745", + "checkpoint_commitments": [], + "end_of_epoch_data": null, + "version_specific_data": "AAA=" + }, + "auth_signature": { + "epoch": 0, + "signature": "hU5kzMpMZw1NCnTOZtpdxH8pmxpk8gHbIpza8vveKcp3oqiM4J+OpqR5f7+Fn402", + "signers_map": [ + 58, + 48, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 16, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "transaction_proof": { + "checkpoint_contents": { + "V1": [ + { + "transaction": "GuWB2VN7iieJPXADJmdrPsEPXffkC2J36ynXKzh3Jyow", + "effects": "D9ZR6gfoHMBMQS6XtrotsPBMykcVUJwYQY9obfnUcXuz", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "84s7gmgJVA8uVtKi9HpGSJtkVfv838oirdMmEpMYhgog", + "effects": "EZYAw9QNDhFiTHaJ3W28jRMGRdvvhpy1q4g1x8BwgCKo", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "DhvRjECEkgd3PmmPpUwjFtnk9cSC2M3oDayagwvRVSYw", + "effects": "8PMXpKFk8uFGBEv2UyqGgycfSPE5wfnjtyEdt8LUEHtf", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "GuHQHaXjy8ZPipiYYYcrWX8g2bfkdDni2pEPoFJUSNwq", + "effects": "BoRtHiQf9kGur9XRHagdXq4kXN7huyibCeJ65Y2o55wY", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "effects": "4xbK8Ebw8yq5VMd4ztJbk1eb5AFyKvUtSoRGHRZ9jnCD", + "signatures": [ + { + "scheme": "ed25519", + "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", + "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" + } + ] + }, + { + "transaction": "7NShvzK9emiobPtBphmqZapFmww6rDPnGSJWU2U9f41E", + "effects": "AtRzYVJW8yrhQEAi8V8PutQz7rrwhR9sHz3hRXW6JfHr", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "Dnzx53gMFPuTu9DSbBDiBbVxqgf9DoJ9ep3j8CBTPogw", + "effects": "3vRAtw8276TpaSbNk8TMS12HCb2sFe1EWw7wkn7hecZc", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + } + ] + }, + "transaction": { + "data": { + "transaction": { + "V1": { + "kind": { + "Programmable": { + "inputs": [ + { + "Pure": "mY0mkbKsOUO9bNh5FeqbV7rBk8XcB1fyWn0rQSfIe/o=" + }, + { + "Pure": "AQAAAAAAAAA=" + } + ], + "commands": [ + { + "SplitCoins": { + "coin": "Gas", + "amounts": [ + { + "Input": 1 + } + ] + } + }, + { + "TransferObjects": { + "objects": [ + { + "Result": 0 + } + ], + "address": { + "Input": 0 + } + } + } + ] + } + }, + "sender": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7", + "gas_payment": { + "objects": [ + { + "object_id": "0x1f8ef3d2929482a43abc43c3693a695e247d55fd865636719bd428a606d3366c", + "version": "1", + "digest": "4VSj48G4aoTSAYwavNA2Y79gCLQ9uHFh5yQYEbppze7h" + } + ], + "owner": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7", + "price": "1000", + "budget": "10000000" + }, + "expiration": "None" + } + }, + "signatures": [ + { + "scheme": "ed25519", + "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", + "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" + } + ] + }, + "auth_signature": {} + }, + "effects": { + "V1": { + "status": { + "success": true + }, + "epoch": "0", + "gas_cost_summary": { + "computation_cost": "1000000", + "computation_cost_burned": "1000000", + "storage_cost": "1960800", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "transaction_digest": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "gas_object_index": 0, + "events_digest": null, + "dependencies": [ + "54ohH6BW2vfMLD6r63KKuZhcgrMq2ty4XU9JHD7D2HAW" + ], + "lamport_version": "2", + "changed_objects": [ + { + "object_id": "0x1f8ef3d2929482a43abc43c3693a695e247d55fd865636719bd428a606d3366c", + "input_state": { + "Data": { + "version": "1", + "digest": "4VSj48G4aoTSAYwavNA2Y79gCLQ9uHFh5yQYEbppze7h", + "owner": { + "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "GhdZ7GvETWWMYR5G8XmmWwZ6Ta3GEsNBX4ajzazfAPC1", + "owner": { + "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" + } + } + }, + "id_operation": "none" + }, + { + "object_id": "0xbf840e88aa464d25b4fb3164eacbf487a9c7391838b5f684a4a3a2a69c6fbdbf", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "EdZxTKeh9pRZqSV7mACnTgBzmnj3hz9pwgdRfL7ofV1e", + "owner": { + "Address": "0x998d2691b2ac3943bd6cd87915ea9b57bac193c5dc0757f25a7d2b4127c87bfa" + } + } + }, + "id_operation": "created" + } + ], + "unchanged_shared_objects": [], + "auxiliary_data_digest": null + } + }, + "events": null + } +} \ No newline at end of file diff --git a/poi-rs/tests/fixtures/current/transaction.json b/poi-rs/tests/fixtures/current/transaction.json new file mode 100644 index 0000000..1f9272d --- /dev/null +++ b/poi-rs/tests/fixtures/current/transaction.json @@ -0,0 +1,263 @@ +{ + "version": 1, + "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", + "target": { + "objects": [], + "events": [], + "committee": null + }, + "checkpoint_summary": { + "data": { + "epoch": "0", + "sequence_number": "2", + "network_total_transactions": "10", + "content_digest": "6EucatZAZSiXpJBkyeozmy1kzRERXWq1ty7N1grkTuSk", + "previous_digest": "Et5CyqsQpZSbHgai9vf9qhJspF2SvF1uV75CGsnRft36", + "epoch_rolling_gas_cost_summary": { + "computation_cost": "1000000", + "computation_cost_burned": "1000000", + "storage_cost": "1960800", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "timestamp_ms": "1785157325745", + "checkpoint_commitments": [], + "end_of_epoch_data": null, + "version_specific_data": "AAA=" + }, + "auth_signature": { + "epoch": 0, + "signature": "hU5kzMpMZw1NCnTOZtpdxH8pmxpk8gHbIpza8vveKcp3oqiM4J+OpqR5f7+Fn402", + "signers_map": [ + 58, + 48, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 16, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "transaction_proof": { + "checkpoint_contents": { + "V1": [ + { + "transaction": "GuWB2VN7iieJPXADJmdrPsEPXffkC2J36ynXKzh3Jyow", + "effects": "D9ZR6gfoHMBMQS6XtrotsPBMykcVUJwYQY9obfnUcXuz", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "84s7gmgJVA8uVtKi9HpGSJtkVfv838oirdMmEpMYhgog", + "effects": "EZYAw9QNDhFiTHaJ3W28jRMGRdvvhpy1q4g1x8BwgCKo", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "DhvRjECEkgd3PmmPpUwjFtnk9cSC2M3oDayagwvRVSYw", + "effects": "8PMXpKFk8uFGBEv2UyqGgycfSPE5wfnjtyEdt8LUEHtf", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "GuHQHaXjy8ZPipiYYYcrWX8g2bfkdDni2pEPoFJUSNwq", + "effects": "BoRtHiQf9kGur9XRHagdXq4kXN7huyibCeJ65Y2o55wY", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "effects": "4xbK8Ebw8yq5VMd4ztJbk1eb5AFyKvUtSoRGHRZ9jnCD", + "signatures": [ + { + "scheme": "ed25519", + "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", + "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" + } + ] + }, + { + "transaction": "7NShvzK9emiobPtBphmqZapFmww6rDPnGSJWU2U9f41E", + "effects": "AtRzYVJW8yrhQEAi8V8PutQz7rrwhR9sHz3hRXW6JfHr", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "Dnzx53gMFPuTu9DSbBDiBbVxqgf9DoJ9ep3j8CBTPogw", + "effects": "3vRAtw8276TpaSbNk8TMS12HCb2sFe1EWw7wkn7hecZc", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + } + ] + }, + "transaction": { + "data": { + "transaction": { + "V1": { + "kind": { + "Programmable": { + "inputs": [ + { + "Pure": "mY0mkbKsOUO9bNh5FeqbV7rBk8XcB1fyWn0rQSfIe/o=" + }, + { + "Pure": "AQAAAAAAAAA=" + } + ], + "commands": [ + { + "SplitCoins": { + "coin": "Gas", + "amounts": [ + { + "Input": 1 + } + ] + } + }, + { + "TransferObjects": { + "objects": [ + { + "Result": 0 + } + ], + "address": { + "Input": 0 + } + } + } + ] + } + }, + "sender": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7", + "gas_payment": { + "objects": [ + { + "object_id": "0x1f8ef3d2929482a43abc43c3693a695e247d55fd865636719bd428a606d3366c", + "version": "1", + "digest": "4VSj48G4aoTSAYwavNA2Y79gCLQ9uHFh5yQYEbppze7h" + } + ], + "owner": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7", + "price": "1000", + "budget": "10000000" + }, + "expiration": "None" + } + }, + "signatures": [ + { + "scheme": "ed25519", + "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", + "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" + } + ] + }, + "auth_signature": {} + }, + "effects": { + "V1": { + "status": { + "success": true + }, + "epoch": "0", + "gas_cost_summary": { + "computation_cost": "1000000", + "computation_cost_burned": "1000000", + "storage_cost": "1960800", + "storage_rebate": "0", + "non_refundable_storage_fee": "0" + }, + "transaction_digest": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "gas_object_index": 0, + "events_digest": null, + "dependencies": [ + "54ohH6BW2vfMLD6r63KKuZhcgrMq2ty4XU9JHD7D2HAW" + ], + "lamport_version": "2", + "changed_objects": [ + { + "object_id": "0x1f8ef3d2929482a43abc43c3693a695e247d55fd865636719bd428a606d3366c", + "input_state": { + "Data": { + "version": "1", + "digest": "4VSj48G4aoTSAYwavNA2Y79gCLQ9uHFh5yQYEbppze7h", + "owner": { + "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" + } + } + }, + "output_state": { + "ObjectWrite": { + "digest": "GhdZ7GvETWWMYR5G8XmmWwZ6Ta3GEsNBX4ajzazfAPC1", + "owner": { + "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" + } + } + }, + "id_operation": "none" + }, + { + "object_id": "0xbf840e88aa464d25b4fb3164eacbf487a9c7391838b5f684a4a3a2a69c6fbdbf", + "input_state": "Missing", + "output_state": { + "ObjectWrite": { + "digest": "EdZxTKeh9pRZqSV7mACnTgBzmnj3hz9pwgdRfL7ofV1e", + "owner": { + "Address": "0x998d2691b2ac3943bd6cd87915ea9b57bac193c5dc0757f25a7d2b4127c87bfa" + } + } + }, + "id_operation": "created" + } + ], + "unchanged_shared_objects": [], + "auxiliary_data_digest": null + } + }, + "events": null + } +} \ No newline at end of file diff --git a/poi-rs/tests/fixtures/v1/committee.json b/poi-rs/tests/fixtures/v1/committee.json deleted file mode 100644 index 649c0a9..0000000 --- a/poi-rs/tests/fixtures/v1/committee.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 9d85d58..0000000 --- a/poi-rs/tests/fixtures/v1/event.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "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 deleted file mode 100644 index a5303a3..0000000 --- a/poi-rs/tests/fixtures/v1/object.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "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 deleted file mode 100644 index 4153165..0000000 --- a/poi-rs/tests/fixtures/v1/transaction.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "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 index ea596a7..bfe49de 100644 --- a/poi-rs/tests/golden.rs +++ b/poi-rs/tests/golden.rs @@ -4,12 +4,12 @@ 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"); +const COMMITTEE: &str = include_str!("fixtures/current/committee.json"); +const TRANSACTION: &str = include_str!("fixtures/current/transaction.json"); +const OBJECT: &str = include_str!("fixtures/current/object.json"); +const EVENT: &str = include_str!("fixtures/current/event.json"); -fn assert_version_one_compatibility(fixture: &str) -> Proof { +fn assert_current_format(fixture: &str) -> Proof { let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); let proof = Proof::from_json_slice(fixture.as_bytes()).expect("proof fixture must deserialize"); @@ -27,24 +27,24 @@ fn assert_version_one_compatibility(fixture: &str) -> Proof { } #[test] -fn version_one_transaction_fixture_remains_compatible() { - let proof = assert_version_one_compatibility(TRANSACTION); +fn current_transaction_fixture_remains_stable() { + let proof = assert_current_format(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); +fn current_object_fixture_remains_stable() { + let proof = assert_current_format(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); +fn current_event_fixture_remains_stable() { + let proof = assert_current_format(EVENT); assert!(proof.target().objects.is_empty()); assert_eq!(proof.target().events.len(), 1); diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index 9d8b92c..7a9a53b 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -9,8 +9,9 @@ use std::sync::{ }; use async_trait::async_trait; +use iota_sdk_types::TransactionDigest; use iota_types::base_types::dbg_object_id; -use iota_types::{digests::TransactionDigest, event::EventID, object::Object}; +use iota_types::{event::EventID, object::Object}; use poi_rs::{Proof, ProofBuilder, ProofBuilderError, Source, SourceError, SourceErrorKind, SourceTarget}; use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs index 1c3a81b..3c6cd49 100644 --- a/poi-rs/tests/utils/mod.rs +++ b/poi-rs/tests/utils/mod.rs @@ -7,28 +7,25 @@ use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis}; use iota_grpc_client::Client as GrpcClient; -use iota_types::{ - base_types::ObjectRef, - committee::Committee, - digests::{ChainIdentifier, TransactionDigest}, -}; +use iota_sdk_types::{ObjectReference, TransactionDigest}; +use iota_types::{committee::Committee, digests::ChainIdentifier}; use test_cluster::{TestCluster, TestClusterBuilder}; pub mod proofs; pub struct CheckpointedTransfer { pub digest: TransactionDigest, - pub gas_object: ObjectRef, + pub gas_object: ObjectReference, } pub struct CheckpointedStaking { pub digest: TransactionDigest, - pub gas_object: ObjectRef, + pub gas_object: ObjectReference, } pub struct CheckpointedObjectTransfer { pub digest: TransactionDigest, - pub objects: [ObjectRef; 2], + pub objects: [ObjectReference; 2], } pub async fn start_test_cluster() -> TestCluster { diff --git a/poi-rs/tests/utils/proofs.rs b/poi-rs/tests/utils/proofs.rs index 36953e1..e237810 100644 --- a/poi-rs/tests/utils/proofs.rs +++ b/poi-rs/tests/utils/proofs.rs @@ -1,15 +1,15 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_sdk_types::{Event, gas::GasCostSummary}; +use iota_sdk_types::{ + CheckpointContents, CheckpointSummary, EndOfEpochData, Event, TransactionDigest, gas::GasCostSummary, +}; use iota_types::{ base_types::ExecutionData, committee::Committee, - digests::{ChainIdentifier, TransactionDigest}, + digests::ChainIdentifier, effects::{TestEffectsBuilder, TransactionEvents}, - messages_checkpoint::{ - CertifiedCheckpointSummary, CheckpointContents, CheckpointSummary, EndOfEpochData, FullCheckpointContents, - }, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContentsExt, FullCheckpointContents}, sdk_types::{Address, Identifier, ObjectId, StructTag}, }; use poi_rs::{Proof, ProofTargets, TransactionProof}; @@ -28,8 +28,8 @@ fn signed_checkpoint( let summary = CheckpointSummary { epoch: 0, sequence_number: 0, - network_total_transactions: contents.size() as u64, - content_digest: *contents.digest(), + network_total_transactions: contents.len() as u64, + content_digest: contents.digest(), previous_digest: None, epoch_rolling_gas_cost_summary: GasCostSummary::default(), timestamp_ms: 0, @@ -102,8 +102,8 @@ pub fn next_epoch_committee(committee: &Committee) -> Committee { pub fn end_of_epoch_data(committee: &Committee) -> EndOfEpochData { EndOfEpochData { - next_epoch_committee: committee.voting_rights.clone(), - next_epoch_protocol_version: 1.into(), + next_epoch_committee: committee.committee_members(), + next_epoch_protocol_version: 1, epoch_commitments: Vec::new(), epoch_supply_change: 0, } diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/verifier.rs index bce4570..5ede386 100644 --- a/poi-rs/tests/verifier.rs +++ b/poi-rs/tests/verifier.rs @@ -3,9 +3,10 @@ mod utils; +use iota_sdk_types::CheckpointContents; use iota_types::{ base_types::dbg_object_id, committee::Committee, effects::TransactionEvents, event::EventID, - messages_checkpoint::CheckpointContents, object::Object, + messages_checkpoint::CheckpointContentsExt, object::Object, }; use poi_rs::{ProofTargets, ProofVerifier, VerifyErrorKind}; use utils::proofs::{ @@ -137,7 +138,7 @@ 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]), + tx_digest: iota_sdk_types::TransactionDigest::new([0xff; 32]), event_seq: 0, }; proof.target = ProofTargets::new().add_event(event_id, target); From 42d213e700da45f2145123c8bd15e288c83c131b Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 28 Jul 2026 12:46:09 +0300 Subject: [PATCH 29/41] feat: refactor source and committee handling, implement gRPC source for committee resolution --- poi-rs/README.md | 11 +- poi-rs/src/builder.rs | 22 +- poi-rs/src/committee.rs | 113 +--------- poi-rs/src/lib.rs | 10 +- poi-rs/src/source.rs | 376 +++------------------------------ poi-rs/src/source/grpc.rs | 385 ++++++++++++++++++++++++++++++++++ poi-rs/tests/proof_builder.rs | 46 +++- 7 files changed, 473 insertions(+), 490 deletions(-) create mode 100644 poi-rs/src/source/grpc.rs diff --git a/poi-rs/README.md b/poi-rs/README.md index 6abb0ac..181e069 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -33,15 +33,16 @@ A builder can stack multiple object and event targets by calling `object()` and `objects()` and `events()` batch methods. Every target must belong to the same transaction. The builder ignores exact duplicates and reuses one transaction and one checkpoint for the complete target set. -`Source` is the transport boundary: it fetches decoded transaction, object, checkpoint, and chain evidence. -`ProofBuilder` owns target resolution, consistency checks, and proof construction, so custom sources do not reimplement -that logic. +`Source` is the transport boundary: it fetches decoded transaction, object, checkpoint, chain, and committee evidence. +`ProofBuilder` owns target resolution, consistency checks, and proof construction, while `CommitteeResolver` owns +committee authentication and caching, so custom sources do not reimplement either workflow. Network selection configures only the proof source. It does not make the returned proof trusted or select an authoritative committee for verification. -The default `native-grpc` feature provides `GrpcSource`, the public-network constructors, and `CommitteeResolver`. -WASM packages can disable default features and supply a JavaScript-backed `Source` without compiling native gRPC. +The default `native-grpc` feature implements `Source` directly for the SDK `GrpcClient` and provides the public-network +constructors. WASM packages can disable default features and supply a JavaScript-backed `Source` without compiling native +gRPC. ## Proof Model diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 821f936..d6ab56a 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -6,11 +6,7 @@ use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, ObjectReference, TransactionDigest}; use iota_types::{effects::TransactionEffectsExt, event::EventID, object::Object}; -#[cfg(feature = "native-grpc")] -use crate::source::GrpcSource; -use crate::{ - Proof, ProofTargets, Source, SourceError, SourceErrorKind, SourceTarget, TransactionMismatch, TransactionProof, -}; +use crate::{Proof, ProofTargets, Source, SourceError, SourceErrorKind, SourceTarget, TransactionProof}; /// Error returned when a proof cannot be constructed by [`ProofBuilder`]. #[derive(Debug, thiserror::Error)] @@ -39,7 +35,7 @@ pub struct ProofBuilder { } #[cfg(feature = "native-grpc")] -impl ProofBuilder { +impl ProofBuilder { /// Creates a proof builder connected to the public IOTA mainnet gRPC endpoint. /// /// Selecting an endpoint does not establish verification trust. Verify the @@ -66,7 +62,7 @@ impl ProofBuilder { /// Creates a proof builder backed by an existing SDK gRPC client. pub fn from_grpc_client(client: GrpcClient) -> Self { - Self::new(GrpcSource::new(client)) + Self::new(client) } } @@ -262,10 +258,8 @@ impl ProofBuilder { return Err(SourceError { target, kind: SourceErrorKind::TargetTransactionMismatch { - mismatch: Box::new(TransactionMismatch { - expected: *expected, - actual: transaction_digest, - }), + expected: *expected, + actual: transaction_digest, }, }); } @@ -293,7 +287,7 @@ mod tests { 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()); + assert_eq!(builder.source.uri(), expected.uri()); } #[tokio::test] @@ -301,7 +295,7 @@ mod tests { 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()); + assert_eq!(builder.source.uri(), expected.uri()); } #[tokio::test] @@ -309,6 +303,6 @@ mod tests { 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()); + assert_eq!(builder.source.uri(), expected.uri()); } } diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 23302ef..f676821 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -3,23 +3,15 @@ use std::sync::Arc; -use async_trait::async_trait; #[cfg(feature = "native-grpc")] -use iota_grpc_client::{ - Client as GrpcClient, ReadMask, - read_mask_fields::{EpochField, ServiceInfoField}, -}; -#[cfg(feature = "native-grpc")] -use iota_grpc_types::proto::TryFromProtoError; -#[cfg(feature = "native-grpc")] -use iota_sdk_types::SignedCheckpointSummary; +use iota_grpc_client::Client as GrpcClient; use iota_types::{ committee::{Committee, EpochId}, error::IotaError, messages_checkpoint::CertifiedCheckpointSummary, }; -use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; +use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Source}; /// Error returned when a committee cannot be resolved for an epoch. #[derive(Debug, thiserror::Error)] @@ -124,27 +116,6 @@ pub enum CommitteeResolutionErrorKind { }, } -/// Ledger-read boundary used to resolve validator committees. -/// -/// Implementations may use native gRPC, a JavaScript client, fixtures, or -/// another source. Committee authentication and caching remain centralized in -/// [`CommitteeResolver`]. -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -pub trait CommitteeSource { - /// Error returned when the source cannot provide committee evidence. - type Error: std::error::Error + Send + Sync + 'static; - - /// Fetches the committee reported for `epoch`. - async fn committee(&self, epoch: EpochId) -> Result; - - /// Fetches the current epoch reported by the source. - async fn current_epoch(&self) -> Result, Self::Error>; - - /// Fetches the certified checkpoint summary that closed `epoch`. - async fn epoch_close_summary(&self, epoch: EpochId) -> Result, Self::Error>; -} - /// Selects how a resolver establishes trust in committee data. #[derive(Clone)] enum CommitteeResolution { @@ -170,7 +141,7 @@ pub struct CommitteeResolver { impl CommitteeResolver where - S: CommitteeSource, + S: Source, { /// Creates a resolver that trusts the connected node for committee data. /// @@ -452,84 +423,6 @@ impl CommitteeResolver { } } -/// Error returned by the native SDK gRPC committee source. -#[cfg(feature = "native-grpc")] -#[derive(Debug, thiserror::Error)] -#[error("gRPC committee source failed")] -pub struct GrpcCommitteeSourceError { - #[source] - source: BoxError, -} - -#[cfg(feature = "native-grpc")] -impl GrpcCommitteeSourceError { - fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { - Self { - source: Box::new(source), - } - } -} - -#[cfg(feature = "native-grpc")] -#[async_trait] -impl CommitteeSource for GrpcClient { - type Error = GrpcCommitteeSourceError; - - async fn committee(&self, epoch: EpochId) -> Result { - let epoch = self - .get_epoch(Some(epoch), Some(ReadMask::from(EpochField::COMMITTEE))) - .await - .map_err(GrpcCommitteeSourceError::new)? - .into_inner(); - let committee = epoch.committee().map_err(GrpcCommitteeSourceError::new)?; - - Ok(committee.into()) - } - - async fn current_epoch(&self) -> Result, Self::Error> { - self.get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) - .await - .map(|response| response.body().epoch) - .map_err(GrpcCommitteeSourceError::new) - } - - async fn epoch_close_summary(&self, epoch: EpochId) -> Result, Self::Error> { - let epoch_info = self - .get_epoch( - Some(epoch), - Some(ReadMask::from(EpochField::EPOCH_CLOSE_PROOF_CHECKPOINT)), - ) - .await - .map_err(GrpcCommitteeSourceError::new)? - .into_inner(); - let Some(epoch_close_proof) = epoch_info.epoch_close_proof().map_err(GrpcCommitteeSourceError::new)? else { - return Ok(None); - }; - let checkpoint = epoch_close_proof.checkpoint().map_err(GrpcCommitteeSourceError::new)?; - let summary = checkpoint - .summary - .as_ref() - .ok_or_else(|| TryFromProtoError::missing("summary")) - .map_err(GrpcCommitteeSourceError::new)?; - let checkpoint_summary = summary.summary().map_err(GrpcCommitteeSourceError::new)?; - let signature = checkpoint - .signature - .as_ref() - .ok_or_else(|| TryFromProtoError::missing("signature")) - .map_err(GrpcCommitteeSourceError::new)?; - let checkpoint_signature = signature.signature().map_err(GrpcCommitteeSourceError::new)?; - let signed_summary = SignedCheckpointSummary { - checkpoint: checkpoint_summary, - signature: checkpoint_signature, - }; - - signed_summary - .try_into() - .map(Some) - .map_err(GrpcCommitteeSourceError::new) - } -} - #[cfg(test)] mod tests { use std::sync::Mutex; diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index c2cc3bf..be44f4f 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -22,16 +22,12 @@ pub mod target; pub use builder::{ProofBuilder, ProofBuilderError}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; -#[cfg(feature = "native-grpc")] -pub use committee::GrpcCommitteeSourceError; -pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver, CommitteeSource}; +pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, }; #[cfg(feature = "native-grpc")] -pub use source::GrpcSource; -pub use source::{ - Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, SourceTransaction, TransactionMismatch, -}; +pub use source::GrpcSourceError; +pub use source::{Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, SourceTransaction}; pub use target::ProofTargets; diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index e06dca7..c267fc1 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -4,19 +4,9 @@ use std::fmt; use async_trait::async_trait; -#[cfg(feature = "native-grpc")] -use iota_grpc_client::{ - CheckpointResponse, Client as GrpcClient, ReadMask, - read_mask_fields::{CheckpointResponseField, ObjectField, ServiceInfoField, TransactionField}, -}; -#[cfg(feature = "native-grpc")] -use iota_grpc_types::v1::transaction::ExecutedTransaction; use iota_sdk_types::{CheckpointContents, ObjectId, TransactionDigest, Version}; -#[cfg(feature = "native-grpc")] -use iota_sdk_types::{CheckpointDigest, SignedTransaction}; -#[cfg(feature = "native-grpc")] -use iota_types::effects::TransactionEffectsAPI; use iota_types::{ + committee::{Committee, EpochId}, digests::ChainIdentifier, effects::{TransactionEffects, TransactionEvents}, event::EventID, @@ -27,6 +17,11 @@ use iota_types::{ use crate::BoxError; +#[cfg(feature = "native-grpc")] +mod grpc; +#[cfg(feature = "native-grpc")] +pub use grpc::GrpcSourceError; + /// Source target requested by the caller. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -92,25 +87,6 @@ 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] @@ -169,10 +145,12 @@ pub enum SourceErrorKind { #[error("event was not found")] EventNotFound, /// A requested target belongs to a different transaction than the other targets. - #[error("{mismatch}")] + #[error("{actual} does not match expected transaction {expected}")] TargetTransactionMismatch { - /// Conflicting transaction details. - mismatch: Box, + /// Transaction selected by the first proof target. + expected: TransactionDigest, + /// Transaction that owns the conflicting target. + actual: TransactionDigest, }, /// The transaction response did not expose a checkpoint sequence number. #[error("transaction response is missing checkpoint sequence")] @@ -267,14 +245,17 @@ pub struct SourceCheckpoint { pub contents: CheckpointContents, } -/// Ledger-read boundary used by [`crate::ProofBuilder`]. +/// Ledger-read boundary used by [`crate::ProofBuilder`] and [`crate::CommitteeResolver`]. /// /// Implementations may fetch evidence from native gRPC, a JavaScript client, -/// archive storage, fixtures, or another source. Proof assembly and target -/// validation remain centralized in [`crate::ProofBuilder`]. +/// archive storage, fixtures, or another source. Proof assembly, target +/// validation, committee authentication, and caching remain outside the source. #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait Source { + /// Error returned when the source cannot provide committee evidence. + type CommitteeError: std::error::Error + Send + Sync + 'static; + /// Fetches the genesis-checkpoint digest that identifies the source chain. async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result; @@ -293,323 +274,16 @@ pub trait Source { transaction_digest: TransactionDigest, sequence_number: u64, ) -> Result; -} - -/// Proof source backed by an SDK gRPC client. -/// -/// Applications normally construct this source through the network and client -/// convenience constructors on [`crate::ProofBuilder`]. -#[cfg(feature = "native-grpc")] -pub struct GrpcSource { - client: GrpcClient, -} - -#[cfg(feature = "native-grpc")] -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 - } - - /// 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| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, - ) - })? - .try_into() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, - ) - })?; - let checkpoint_contents: CheckpointContents = checkpoint - .contents() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - ) - })? - .contents() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - ) - })?; - 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), - }, - ) - }) - } - - /// Decodes the transaction evidence needed by the transport-independent builder. - fn parse_transaction( - transaction_digest: TransactionDigest, - executed_transaction: &ExecutedTransaction, - effects: TransactionEffects, - ) -> Result { - let transaction = executed_transaction - .transaction() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(source), - }, - ) - })? - .transaction() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(source), - }, - ) - })?; - let signatures = executed_transaction - .signatures() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Signatures { - source: Box::new(source), - }, - ) - })? - .signatures - .iter() - .map(|signature| { - signature.signature().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Signatures { - source: Box::new(source), - }, - ) - }) - }) - .collect::, SourceError>>()?; - let transaction: Transaction = SignedTransaction { - transaction, - signatures, - } - .into(); - let events = if effects.events_digest().is_some() { - executed_transaction - .events() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::MissingEvents { - source: Box::new(source), - }, - ) - })? - .events() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Events { - source: Box::new(source), - }, - ) - }) - .map(Some)? - } else { - None - }; + /// Fetches the committee reported for `epoch`. + async fn committee(&self, epoch: EpochId) -> Result; - let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::MissingCheckpointSequence { - source: Box::new(source), - }, - ) - })?; - - Ok(SourceTransaction { - transaction, - effects, - events, - checkpoint_sequence_number, - }) - } -} - -#[cfg(feature = "native-grpc")] -#[async_trait] -impl Source for GrpcSource { - async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { - let service_info = self - .client - .get_service_info(Some(ReadMask::from(ServiceInfoField::CHAIN_ID))) - .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchChainIdentifier { - source: Box::new(source), - }, - ) - })?; - let chain_identifier = service_info.body().chain_identifier().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::ChainIdentifier { - source: Box::new(source), - }, - ) - })?; - - Ok(ChainIdentifier::from(CheckpointDigest::new( - chain_identifier.into_inner(), - ))) - } + /// Fetches the current epoch reported by the source. + async fn current_epoch(&self) -> Result, Self::CommitteeError>; - async fn transaction( + /// Fetches the certified checkpoint summary that closed `epoch`. + async fn epoch_close_summary( &self, - transaction_digest: TransactionDigest, - ) -> Result, SourceError> { - let transactions = self - .client - .get_transactions( - &[transaction_digest], - Some(ReadMask::from(&[ - TransactionField::TRANSACTION_BCS, - TransactionField::SIGNATURES, - TransactionField::EFFECTS_BCS, - TransactionField::EVENTS_DIGEST, - TransactionField::EVENTS_EVENTS_BCS, - TransactionField::CHECKPOINT, - ])), - ) - .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchTransaction { - source: Box::new(source), - }, - ) - })?; - let Some(executed_transaction) = transactions.body().first() else { - return Ok(None); - }; - let effects = Self::parse_effects(transaction_digest, executed_transaction)?; - - Self::parse_transaction(transaction_digest, executed_transaction, effects).map(Some) - } - - async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { - let objects = self - .client - .get_objects(&[(object_id, version)], Some(ReadMask::from(ObjectField::BCS))) - .await - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::FetchObject { - source: Box::new(source), - }, - ) - })?; - let Some(response) = objects.body().first() else { - return Ok(None); - }; - let object: Object = response - .object() - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::Object { - source: Box::new(source), - }, - ) - })? - .into(); - Ok(Some(object)) - } - - async fn checkpoint( - &self, - transaction_digest: TransactionDigest, - sequence_number: u64, - ) -> Result { - let checkpoint = self - .client - .get_checkpoint_by_sequence_number( - sequence_number, - Some(ReadMask::from(&[ - CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, - CheckpointResponseField::CHECKPOINT_SIGNATURE, - CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, - ])), - None, - None, - ) - .await - .map(|response| response.into_inner()) - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchCheckpoint { - sequence_number, - source: Box::new(source), - }, - ) - })?; - let (summary, contents) = Self::parse_checkpoint(transaction_digest, &checkpoint)?; - - Ok(SourceCheckpoint { summary, contents }) - } + epoch: EpochId, + ) -> Result, Self::CommitteeError>; } diff --git a/poi-rs/src/source/grpc.rs b/poi-rs/src/source/grpc.rs new file mode 100644 index 0000000..9e9c6ad --- /dev/null +++ b/poi-rs/src/source/grpc.rs @@ -0,0 +1,385 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_grpc_client::{ + CheckpointResponse, Client as GrpcClient, ReadMask, + read_mask_fields::{CheckpointResponseField, EpochField, ObjectField, ServiceInfoField, TransactionField}, +}; +use iota_grpc_types::{proto::TryFromProtoError, v1::transaction::ExecutedTransaction}; +use iota_sdk_types::{ + CheckpointContents, CheckpointDigest, ObjectId, SignedCheckpointSummary, SignedTransaction, TransactionDigest, + Version, +}; +use iota_types::{ + committee::{Committee, EpochId}, + digests::ChainIdentifier, + effects::{TransactionEffects, TransactionEffectsAPI}, + messages_checkpoint::CertifiedCheckpointSummary, + object::Object, + transaction::Transaction, +}; + +use super::{Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTransaction}; +use crate::BoxError; + +/// Error returned by native gRPC committee reads. +#[derive(Debug, thiserror::Error)] +#[error("gRPC source failed")] +pub struct GrpcSourceError { + #[source] + source: BoxError, +} + +impl GrpcSourceError { + fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self { + source: Box::new(source), + } + } +} + +#[async_trait] +impl Source for GrpcClient { + type CommitteeError = GrpcSourceError; + + async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { + let service_info = self + .get_service_info(Some(ReadMask::from(ServiceInfoField::CHAIN_ID))) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchChainIdentifier { + source: Box::new(source), + }, + ) + })?; + let chain_identifier = service_info.body().chain_identifier().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::ChainIdentifier { + source: Box::new(source), + }, + ) + })?; + + Ok(ChainIdentifier::from(CheckpointDigest::new( + chain_identifier.into_inner(), + ))) + } + + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + let transactions = self + .get_transactions( + &[transaction_digest], + Some(ReadMask::from(&[ + TransactionField::TRANSACTION_BCS, + TransactionField::SIGNATURES, + TransactionField::EFFECTS_BCS, + TransactionField::EVENTS_DIGEST, + TransactionField::EVENTS_EVENTS_BCS, + TransactionField::CHECKPOINT, + ])), + ) + .await + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchTransaction { + source: Box::new(source), + }, + ) + })?; + let Some(executed_transaction) = transactions.body().first() else { + return Ok(None); + }; + let effects = parse_effects(transaction_digest, executed_transaction)?; + + parse_transaction(transaction_digest, executed_transaction, effects).map(Some) + } + + async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { + let objects = self + .get_objects(&[(object_id, version)], Some(ReadMask::from(ObjectField::BCS))) + .await + .map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::FetchObject { + source: Box::new(source), + }, + ) + })?; + let Some(response) = objects.body().first() else { + return Ok(None); + }; + let object: Object = response + .object() + .map_err(|source| { + SourceError::object( + object_id, + SourceErrorKind::Object { + source: Box::new(source), + }, + ) + })? + .into(); + + Ok(Some(object)) + } + + async fn checkpoint( + &self, + transaction_digest: TransactionDigest, + sequence_number: u64, + ) -> Result { + let checkpoint = self + .get_checkpoint_by_sequence_number( + sequence_number, + Some(ReadMask::from(&[ + CheckpointResponseField::CHECKPOINT_SUMMARY_BCS, + CheckpointResponseField::CHECKPOINT_SIGNATURE, + CheckpointResponseField::CHECKPOINT_CONTENTS_BCS, + ])), + None, + None, + ) + .await + .map(|response| response.into_inner()) + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) + })?; + let (summary, contents) = parse_checkpoint(transaction_digest, &checkpoint)?; + + Ok(SourceCheckpoint { summary, contents }) + } + + async fn committee(&self, epoch: EpochId) -> Result { + let epoch_info = self + .get_epoch(Some(epoch), Some(ReadMask::from(EpochField::COMMITTEE))) + .await + .map_err(GrpcSourceError::new)? + .into_inner(); + let committee = epoch_info.committee().map_err(GrpcSourceError::new)?; + + Ok(committee.into()) + } + + async fn current_epoch(&self) -> Result, Self::CommitteeError> { + self.get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) + .await + .map(|response| response.body().epoch) + .map_err(GrpcSourceError::new) + } + + async fn epoch_close_summary( + &self, + epoch: EpochId, + ) -> Result, Self::CommitteeError> { + let epoch_info = self + .get_epoch( + Some(epoch), + Some(ReadMask::from(EpochField::EPOCH_CLOSE_PROOF_CHECKPOINT)), + ) + .await + .map_err(GrpcSourceError::new)? + .into_inner(); + let Some(epoch_close_proof) = epoch_info.epoch_close_proof().map_err(GrpcSourceError::new)? else { + return Ok(None); + }; + let checkpoint = epoch_close_proof.checkpoint().map_err(GrpcSourceError::new)?; + let summary = checkpoint + .summary + .as_ref() + .ok_or_else(|| TryFromProtoError::missing("summary")) + .map_err(GrpcSourceError::new)?; + let summary = summary.summary().map_err(GrpcSourceError::new)?; + let signature = checkpoint + .signature + .as_ref() + .ok_or_else(|| TryFromProtoError::missing("signature")) + .map_err(GrpcSourceError::new)?; + let signature = signature.signature().map_err(GrpcSourceError::new)?; + let signed_summary = SignedCheckpointSummary { + checkpoint: summary, + signature, + }; + let certified_summary = signed_summary.try_into().map_err(GrpcSourceError::new)?; + + Ok(Some(certified_summary)) + } +} + +fn parse_checkpoint( + transaction_digest: TransactionDigest, + checkpoint: &CheckpointResponse, +) -> Result<(CertifiedCheckpointSummary, CheckpointContents), SourceError> { + let checkpoint_summary: CertifiedCheckpointSummary = checkpoint + .signed_summary() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })? + .try_into() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointSummary { + source: Box::new(source), + }, + ) + })?; + let checkpoint_contents: CheckpointContents = checkpoint + .contents() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) + })? + .contents() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::CheckpointContents { + source: Box::new(source), + }, + ) + })?; + + Ok((checkpoint_summary, checkpoint_contents)) +} + +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), + }, + ) + }) +} + +fn parse_transaction( + transaction_digest: TransactionDigest, + executed_transaction: &ExecutedTransaction, + effects: TransactionEffects, +) -> Result { + let transaction = executed_transaction + .transaction() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })? + .transaction() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let signatures = executed_transaction + .signatures() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + })? + .signatures + .iter() + .map(|signature| { + signature.signature().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + }) + }) + .collect::, SourceError>>()?; + let transaction: Transaction = SignedTransaction { + transaction, + signatures, + } + .into(); + let events = if effects.events_digest().is_some() { + executed_transaction + .events() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingEvents { + source: Box::new(source), + }, + ) + })? + .events() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Events { + source: Box::new(source), + }, + ) + }) + .map(Some)? + } else { + None + }; + let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingCheckpointSequence { + source: Box::new(source), + }, + ) + })?; + + Ok(SourceTransaction { + transaction, + effects, + events, + checkpoint_sequence_number, + }) +} diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index 3b0b191..29e461e 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -11,7 +11,13 @@ use std::sync::{ use async_trait::async_trait; use iota_sdk_types::{ObjectId, TransactionDigest, Version}; use iota_types::base_types::dbg_object_id; -use iota_types::{digests::ChainIdentifier, event::EventID, object::Object}; +use iota_types::{ + committee::{Committee, EpochId}, + digests::ChainIdentifier, + event::EventID, + messages_checkpoint::CertifiedCheckpointSummary, + object::Object, +}; use poi_rs::{ ProofBuilder, ProofBuilderError, Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, SourceTransaction, @@ -22,6 +28,8 @@ struct RejectingSource; #[async_trait] impl Source for RejectingSource { + type CommitteeError = std::convert::Infallible; + async fn chain_identifier(&self, _transaction_digest: TransactionDigest) -> Result { unreachable!("rejected transactions do not resolve a chain identifier") } @@ -47,6 +55,21 @@ impl Source for RejectingSource { ) -> Result { unreachable!("rejected transactions do not resolve a checkpoint") } + + async fn committee(&self, _epoch: EpochId) -> Result { + unreachable!("proof-only test source does not resolve committees") + } + + async fn current_epoch(&self) -> Result, Self::CommitteeError> { + unreachable!("proof-only test source does not resolve the current epoch") + } + + async fn epoch_close_summary( + &self, + _epoch: EpochId, + ) -> Result, Self::CommitteeError> { + unreachable!("proof-only test source does not resolve epoch-close summaries") + } } struct RecordingSource { @@ -56,6 +79,8 @@ struct RecordingSource { #[async_trait] impl Source for RecordingSource { + type CommitteeError = std::convert::Infallible; + async fn chain_identifier(&self, _transaction_digest: TransactionDigest) -> Result { unreachable!("rejected transactions do not resolve a chain identifier") } @@ -87,6 +112,21 @@ impl Source for RecordingSource { ) -> Result { unreachable!("rejected transactions do not resolve a checkpoint") } + + async fn committee(&self, _epoch: EpochId) -> Result { + unreachable!("proof-only test source does not resolve committees") + } + + async fn current_epoch(&self) -> Result, Self::CommitteeError> { + unreachable!("proof-only test source does not resolve the current epoch") + } + + async fn epoch_close_summary( + &self, + _epoch: EpochId, + ) -> Result, Self::CommitteeError> { + unreachable!("proof-only test source does not resolve epoch-close summaries") + } } #[tokio::test] @@ -272,7 +312,7 @@ async fn object_targets_from_different_transactions_are_rejected() { assert_eq!(source.target, SourceTarget::Object(second_object_id)); assert!(matches!( source.kind, - SourceErrorKind::TargetTransactionMismatch { mismatch } - if mismatch.expected == first.digest && mismatch.actual == second.digest + SourceErrorKind::TargetTransactionMismatch { expected, actual } + if expected == first.digest && actual == second.digest )); } From 7d5e78dcbfcfb8d260f32e9ce4b1ed4f4111e09a Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 28 Jul 2026 15:33:34 +0300 Subject: [PATCH 30/41] Refactor ProofBuilder to use ProofTarget enum and improve error handling - Introduced `ProofTarget` enum to represent different proof requests (Transaction, Object, Event). - Updated `ProofBuilder` to utilize `ProofTarget` instead of the previous `SourceTarget`. - Enhanced error handling in `ProofBuilderError` to provide more specific error types related to proof construction failures. - Modified `SourceError` to streamline error reporting and removed unnecessary complexity. - Adjusted tests to align with the new error handling and proof target structure. --- poi-rs/README.md | 5 +- poi-rs/src/builder.rs | 144 +++++++++++++------ poi-rs/src/lib.rs | 8 +- poi-rs/src/source.rs | 240 ++++++-------------------------- poi-rs/src/source/grpc.rs | 255 ++++++---------------------------- poi-rs/tests/proof_builder.rs | 126 +++++++---------- 6 files changed, 244 insertions(+), 534 deletions(-) diff --git a/poi-rs/README.md b/poi-rs/README.md index 181e069..73ff0e5 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -87,9 +87,12 @@ 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. +- `ProofTarget`: Transaction, object, or event requested from a `ProofBuilder`. - `ProofBuilder`: Network-aware or custom-source proof construction. - `Source`: Ledger-read boundary for gRPC nodes, JavaScript clients, archives, fixtures, and other evidence sources. - `SourceTransaction` and `SourceCheckpoint`: Transport-independent decoded evidence returned by a `Source`. - `CommitteeResolver`: Trusted-node or anchored committee resolution. - `ProofVerifier`: Offline verifier for `Proof` values. -- `VerifyError`, `SourceError`, `SerializationError`, and `VersionError`: Operation-specific errors. +- `SourceError`: Transport and response failures from a ledger source. +- `ProofBuilderError`, `CommitteeResolutionError`, `VerifyError`, `SerializationError`, and `VersionError`: + Operation-specific errors. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index d6ab56a..4b5ade4 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -1,12 +1,36 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::fmt; + #[cfg(feature = "native-grpc")] use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, ObjectReference, TransactionDigest}; use iota_types::{effects::TransactionEffectsExt, event::EventID, object::Object}; -use crate::{Proof, ProofTargets, Source, SourceError, SourceErrorKind, SourceTarget, TransactionProof}; +use crate::{Proof, ProofTargets, Source, SourceError, TransactionProof}; + +/// Ledger target requested from a [`ProofBuilder`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ProofTarget { + /// A transaction proof request. + Transaction(TransactionDigest), + /// An object proof request identified by object ID. + Object(ObjectId), + /// An event proof request. + Event(EventID), +} + +impl fmt::Display for ProofTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transaction(transaction_digest) => write!(f, "transaction {transaction_digest}"), + Self::Object(object_id) => write!(f, "object {object_id}"), + Self::Event(event_id) => write!(f, "event {event_id:?}"), + } + } +} /// Error returned when a proof cannot be constructed by [`ProofBuilder`]. #[derive(Debug, thiserror::Error)] @@ -15,13 +39,45 @@ pub enum ProofBuilderError { /// No proof target was selected before building. #[error("proof builder requires a target")] MissingTarget, - /// The configured source failed to construct the requested proof. - #[error("proof source failed")] + /// The configured source failed while reading evidence for a target. + #[error("source failed while reading {target}")] Source { + /// Proof target whose evidence was being read. + target: ProofTarget, /// Underlying source failure. #[source] source: SourceError, }, + /// The source did not return evidence for a requested target. + #[error("{target} was not found")] + TargetNotFound { + /// Proof target that was not returned. + target: ProofTarget, + }, + /// The returned object does not match the requested ID or transaction effects. + #[error("object {object_id} reference does not match the requested object")] + ObjectReferenceMismatch { + /// Requested object ID. + object_id: ObjectId, + }, + /// The requested object was not changed by the selected transaction. + #[error("object {object_id} was not changed by transaction {transaction_digest}")] + ObjectNotChangedByTransaction { + /// Requested object ID. + object_id: ObjectId, + /// Transaction selected by the other proof targets. + transaction_digest: TransactionDigest, + }, + /// A requested target belongs to a different transaction than the other targets. + #[error("{target} belongs to transaction {actual}, expected {expected}")] + TargetTransactionMismatch { + /// Target that conflicts with the previously selected transaction. + target: ProofTarget, + /// Transaction selected by the first proof target. + expected: TransactionDigest, + /// Transaction that owns the conflicting target. + actual: TransactionDigest, + }, } /// Constructs Proof of Inclusion evidence from a caller-provided [`Source`]. @@ -31,7 +87,7 @@ pub enum ProofBuilderError { /// through `ProofBuilder::from_grpc_client`. pub struct ProofBuilder { source: S, - targets: Vec, + targets: Vec, } #[cfg(feature = "native-grpc")] @@ -77,7 +133,7 @@ impl ProofBuilder { /// Adds a transaction proof target. pub fn transaction(mut self, transaction_digest: TransactionDigest) -> Self { - self.push_target(SourceTarget::Transaction(transaction_digest)); + self.push_target(ProofTarget::Transaction(transaction_digest)); self } @@ -85,28 +141,28 @@ impl ProofBuilder { /// /// 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.push_target(ProofTarget::Object(object_id)); self } /// 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.push_target(ProofTarget::Object(object_id)); } self } /// Adds an event proof target. pub fn event(mut self, event_id: EventID) -> Self { - self.push_target(SourceTarget::Event(event_id)); + self.push_target(ProofTarget::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.push_target(ProofTarget::Event(event_id)); } self } @@ -117,23 +173,21 @@ impl ProofBuilder { return Err(ProofBuilderError::MissingTarget); } - self.build_proof() - .await - .map_err(|source| ProofBuilderError::Source { source }) + self.build_proof().await } - async fn build_proof(&self) -> Result { + async fn build_proof(&self) -> Result { let mut selected_transaction = None; let mut object_ids = Vec::new(); let mut events = Vec::new(); for target in self.targets.iter().copied() { match target { - SourceTarget::Transaction(transaction_digest) => { + ProofTarget::Transaction(transaction_digest) => { Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; } - SourceTarget::Object(object_id) => object_ids.push(object_id), - SourceTarget::Event(event_id) => { + ProofTarget::Object(object_id) => object_ids.push(object_id), + ProofTarget::Event(event_id) => { Self::ensure_same_transaction(&mut selected_transaction, target, event_id.tx_digest)?; events.push(event_id); } @@ -149,11 +203,9 @@ impl ProofBuilder { 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 }, - ) + .ok_or(ProofBuilderError::ObjectNotChangedByTransaction { + object_id, + transaction_digest, })?; objects.push(self.fetch_object(object_id, Some(object_ref)).await?); } @@ -166,7 +218,7 @@ impl ProofBuilder { let (object_ref, object) = self.fetch_object(object_id, None).await?; Self::ensure_same_transaction( &mut selected_transaction, - SourceTarget::Object(object_id), + ProofTarget::Object(object_id), object.previous_transaction, )?; objects.push((object_ref, object)); @@ -179,11 +231,17 @@ impl ProofBuilder { (transaction_digest, transaction, objects) }; - let chain_identifier = self.source.chain_identifier(transaction_digest).await?; + let target = ProofTarget::Transaction(transaction_digest); + let chain_identifier = self + .source + .chain_identifier() + .await + .map_err(|source| ProofBuilderError::Source { target, source })?; let checkpoint = self .source - .checkpoint(transaction_digest, transaction.checkpoint_sequence_number) - .await?; + .checkpoint(transaction.checkpoint_sequence_number) + .await + .map_err(|source| ProofBuilderError::Source { target, source })?; let transaction_proof = TransactionProof::new( checkpoint.contents, transaction.transaction, @@ -212,7 +270,9 @@ impl ProofBuilder { .and_then(|index| events.get(index)) }) .cloned() - .ok_or_else(|| SourceError::event(event_id, SourceErrorKind::EventNotFound))?; + .ok_or(ProofBuilderError::TargetNotFound { + target: ProofTarget::Event(event_id), + })?; proof.target = proof.target.add_event(event_id, event); } @@ -222,27 +282,31 @@ impl ProofBuilder { async fn fetch_transaction( &self, transaction_digest: TransactionDigest, - ) -> Result { + ) -> Result { + let target = ProofTarget::Transaction(transaction_digest); self.source .transaction(transaction_digest) - .await? - .ok_or_else(|| SourceError::transaction(transaction_digest, SourceErrorKind::TransactionNotFound)) + .await + .map_err(|source| ProofBuilderError::Source { target, source })? + .ok_or(ProofBuilderError::TargetNotFound { target }) } async fn fetch_object( &self, object_id: ObjectId, expected_ref: Option, - ) -> Result<(ObjectReference, Object), SourceError> { + ) -> Result<(ObjectReference, Object), ProofBuilderError> { + let target = ProofTarget::Object(object_id); let object = self .source .object(object_id, expected_ref.map(|object_ref| object_ref.version)) - .await? - .ok_or_else(|| SourceError::object(object_id, SourceErrorKind::ObjectNotFound))?; + .await + .map_err(|source| ProofBuilderError::Source { target, source })? + .ok_or(ProofBuilderError::TargetNotFound { target })?; let object_ref = object.as_inner().object_ref(); if object_ref.object_id != object_id || expected_ref.is_some_and(|expected| expected != object_ref) { - return Err(SourceError::object(object_id, SourceErrorKind::ObjectReferenceMismatch)); + return Err(ProofBuilderError::ObjectReferenceMismatch { object_id }); } Ok((object_ref, object)) @@ -250,17 +314,15 @@ impl ProofBuilder { fn ensure_same_transaction( selected: &mut Option, - target: SourceTarget, + target: ProofTarget, transaction_digest: TransactionDigest, - ) -> Result<(), SourceError> { + ) -> Result<(), ProofBuilderError> { if let Some(expected) = selected { if *expected != transaction_digest { - return Err(SourceError { + return Err(ProofBuilderError::TargetTransactionMismatch { target, - kind: SourceErrorKind::TargetTransactionMismatch { - expected: *expected, - actual: transaction_digest, - }, + expected: *expected, + actual: transaction_digest, }); } } else { @@ -270,7 +332,7 @@ impl ProofBuilder { Ok(()) } - fn push_target(&mut self, target: SourceTarget) { + fn push_target(&mut self, target: ProofTarget) { if !self.targets.contains(&target) { self.targets.push(target); } diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index be44f4f..e851bb2 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -15,19 +15,17 @@ pub mod cache; pub mod committee; /// Proof data types and offline verification. pub mod proof; -/// Sources for constructing proofs. +/// Ledger evidence source abstraction. pub mod source; /// Target claims authenticated by a proof. pub mod target; -pub use builder::{ProofBuilder, ProofBuilderError}; +pub use builder::{ProofBuilder, ProofBuilderError, ProofTarget}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, }; -#[cfg(feature = "native-grpc")] -pub use source::GrpcSourceError; -pub use source::{Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, SourceTransaction}; +pub use source::{Source, SourceCheckpoint, SourceError, SourceTransaction}; pub use target::ProofTargets; diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index c267fc1..40361d7 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -1,15 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::fmt; - use async_trait::async_trait; use iota_sdk_types::{CheckpointContents, ObjectId, TransactionDigest, Version}; use iota_types::{ committee::{Committee, EpochId}, digests::ChainIdentifier, effects::{TransactionEffects, TransactionEvents}, - event::EventID, messages_checkpoint::CertifiedCheckpointSummary, object::Object, transaction::Transaction, @@ -19,204 +16,55 @@ use crate::BoxError; #[cfg(feature = "native-grpc")] mod grpc; -#[cfg(feature = "native-grpc")] -pub use grpc::GrpcSourceError; - -/// 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 identified by object ID. - Object(ObjectId), - /// An event proof request. - Event(EventID), -} - -impl fmt::Display for SourceTarget { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Transaction(transaction_digest) => write!(f, "transaction {transaction_digest}"), - Self::Object(object_id) => write!(f, "object {object_id}"), - Self::Event(event_id) => write!(f, "event {event_id:?}"), - } - } -} - -/// Error returned when a source cannot build a proof. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -#[error("failed to build proof for {target}")] -pub struct SourceError { - /// Target requested from the source. - pub target: SourceTarget, - /// Source failure details. - #[source] - pub kind: SourceErrorKind, -} - -impl SourceError { - /// Creates a source error for a requested transaction. - pub fn new(transaction_digest: TransactionDigest, kind: SourceErrorKind) -> Self { - Self::transaction(transaction_digest, kind) - } - - /// Creates a source error for a requested transaction. - pub fn transaction(transaction_digest: TransactionDigest, kind: SourceErrorKind) -> Self { - Self { - target: SourceTarget::Transaction(transaction_digest), - kind, - } - } - - /// Creates a source error for a requested object. - pub fn object(object_id: ObjectId, kind: SourceErrorKind) -> Self { - Self { - target: SourceTarget::Object(object_id), - 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. +/// Error returned when a ledger source cannot provide requested data. #[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 { - /// Underlying source error. - #[source] - source: BoxError, - }, - /// The source returned no transaction for the requested digest. - #[error("transaction was not found")] - TransactionNotFound, - /// Fetching the object from the source failed. - #[error("failed to fetch object")] - FetchObject { - /// Underlying source error. - #[source] - source: BoxError, - }, - /// The source returned no object for the requested ID. - #[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 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, - /// A requested target belongs to a different transaction than the other targets. - #[error("{actual} does not match expected transaction {expected}")] - TargetTransactionMismatch { - /// Transaction selected by the first proof target. - expected: TransactionDigest, - /// Transaction that owns the conflicting target. - actual: TransactionDigest, - }, - /// 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, +pub enum SourceError { + /// A request to the source failed. + #[error("source request failed")] + Request { /// 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 { + /// A response could not be decoded or converted. + #[error("source returned an invalid response")] + InvalidResponse { /// 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 { + /// Required source data was omitted. + #[error("source response is missing required data")] + MissingData { /// 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, - }, +} + +impl SourceError { + /// Creates an error for a failed source request. + pub fn request(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::Request { + source: Box::new(source), + } + } + + /// Creates an error for an invalid source response. + pub fn invalid_response(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::InvalidResponse { + source: Box::new(source), + } + } + + /// Creates an error for required data omitted from a source response. + pub fn missing_data(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::MissingData { + source: Box::new(source), + } + } } /// Decoded transaction evidence returned by a [`Source`]. @@ -253,11 +101,8 @@ pub struct SourceCheckpoint { #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait Source { - /// Error returned when the source cannot provide committee evidence. - type CommitteeError: std::error::Error + Send + Sync + 'static; - /// Fetches the genesis-checkpoint digest that identifies the source chain. - async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result; + async fn chain_identifier(&self) -> Result; /// Fetches and decodes one executed transaction. async fn transaction( @@ -269,21 +114,14 @@ pub trait Source { async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError>; /// Fetches and decodes one certified checkpoint and its contents. - async fn checkpoint( - &self, - transaction_digest: TransactionDigest, - sequence_number: u64, - ) -> Result; + async fn checkpoint(&self, sequence_number: u64) -> Result; /// Fetches the committee reported for `epoch`. - async fn committee(&self, epoch: EpochId) -> Result; + async fn committee(&self, epoch: EpochId) -> Result; /// Fetches the current epoch reported by the source. - async fn current_epoch(&self) -> Result, Self::CommitteeError>; + async fn current_epoch(&self) -> Result, SourceError>; /// Fetches the certified checkpoint summary that closed `epoch`. - async fn epoch_close_summary( - &self, - epoch: EpochId, - ) -> Result, Self::CommitteeError>; + async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError>; } diff --git a/poi-rs/src/source/grpc.rs b/poi-rs/src/source/grpc.rs index 9e9c6ad..fb5da04 100644 --- a/poi-rs/src/source/grpc.rs +++ b/poi-rs/src/source/grpc.rs @@ -20,49 +20,19 @@ use iota_types::{ transaction::Transaction, }; -use super::{Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTransaction}; -use crate::BoxError; - -/// Error returned by native gRPC committee reads. -#[derive(Debug, thiserror::Error)] -#[error("gRPC source failed")] -pub struct GrpcSourceError { - #[source] - source: BoxError, -} - -impl GrpcSourceError { - fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { - Self { - source: Box::new(source), - } - } -} +use super::{Source, SourceCheckpoint, SourceError, SourceTransaction}; #[async_trait] impl Source for GrpcClient { - type CommitteeError = GrpcSourceError; - - async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { + async fn chain_identifier(&self) -> Result { let service_info = self .get_service_info(Some(ReadMask::from(ServiceInfoField::CHAIN_ID))) .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchChainIdentifier { - source: Box::new(source), - }, - ) - })?; - let chain_identifier = service_info.body().chain_identifier().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::ChainIdentifier { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::request)?; + let chain_identifier = service_info + .body() + .chain_identifier() + .map_err(SourceError::invalid_response)?; Ok(ChainIdentifier::from(CheckpointDigest::new( chain_identifier.into_inner(), @@ -86,57 +56,29 @@ impl Source for GrpcClient { ])), ) .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchTransaction { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::request)?; let Some(executed_transaction) = transactions.body().first() else { return Ok(None); }; - let effects = parse_effects(transaction_digest, executed_transaction)?; + let effects = parse_effects(executed_transaction)?; - parse_transaction(transaction_digest, executed_transaction, effects).map(Some) + parse_transaction(executed_transaction, effects).map(Some) } async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { let objects = self .get_objects(&[(object_id, version)], Some(ReadMask::from(ObjectField::BCS))) .await - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::FetchObject { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::request)?; let Some(response) = objects.body().first() else { return Ok(None); }; - let object: Object = response - .object() - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::Object { - source: Box::new(source), - }, - ) - })? - .into(); + let object: Object = response.object().map_err(SourceError::invalid_response)?.into(); Ok(Some(object)) } - async fn checkpoint( - &self, - transaction_digest: TransactionDigest, - sequence_number: u64, - ) -> Result { + async fn checkpoint(&self, sequence_number: u64) -> Result { let checkpoint = self .get_checkpoint_by_sequence_number( sequence_number, @@ -150,193 +92,105 @@ impl Source for GrpcClient { ) .await .map(|response| response.into_inner()) - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchCheckpoint { - sequence_number, - source: Box::new(source), - }, - ) - })?; - let (summary, contents) = parse_checkpoint(transaction_digest, &checkpoint)?; + .map_err(SourceError::request)?; + let (summary, contents) = parse_checkpoint(&checkpoint)?; Ok(SourceCheckpoint { summary, contents }) } - async fn committee(&self, epoch: EpochId) -> Result { + async fn committee(&self, epoch: EpochId) -> Result { let epoch_info = self .get_epoch(Some(epoch), Some(ReadMask::from(EpochField::COMMITTEE))) .await - .map_err(GrpcSourceError::new)? + .map_err(SourceError::request)? .into_inner(); - let committee = epoch_info.committee().map_err(GrpcSourceError::new)?; + let committee = epoch_info.committee().map_err(SourceError::invalid_response)?; Ok(committee.into()) } - async fn current_epoch(&self) -> Result, Self::CommitteeError> { + async fn current_epoch(&self) -> Result, SourceError> { self.get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) .await .map(|response| response.body().epoch) - .map_err(GrpcSourceError::new) + .map_err(SourceError::request) } - async fn epoch_close_summary( - &self, - epoch: EpochId, - ) -> Result, Self::CommitteeError> { + async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { let epoch_info = self .get_epoch( Some(epoch), Some(ReadMask::from(EpochField::EPOCH_CLOSE_PROOF_CHECKPOINT)), ) .await - .map_err(GrpcSourceError::new)? + .map_err(SourceError::request)? .into_inner(); - let Some(epoch_close_proof) = epoch_info.epoch_close_proof().map_err(GrpcSourceError::new)? else { + let Some(epoch_close_proof) = epoch_info.epoch_close_proof().map_err(SourceError::invalid_response)? else { return Ok(None); }; - let checkpoint = epoch_close_proof.checkpoint().map_err(GrpcSourceError::new)?; + let checkpoint = epoch_close_proof.checkpoint().map_err(SourceError::missing_data)?; let summary = checkpoint .summary .as_ref() .ok_or_else(|| TryFromProtoError::missing("summary")) - .map_err(GrpcSourceError::new)?; - let summary = summary.summary().map_err(GrpcSourceError::new)?; + .map_err(SourceError::missing_data)?; + let summary = summary.summary().map_err(SourceError::invalid_response)?; let signature = checkpoint .signature .as_ref() .ok_or_else(|| TryFromProtoError::missing("signature")) - .map_err(GrpcSourceError::new)?; - let signature = signature.signature().map_err(GrpcSourceError::new)?; + .map_err(SourceError::missing_data)?; + let signature = signature.signature().map_err(SourceError::invalid_response)?; let signed_summary = SignedCheckpointSummary { checkpoint: summary, signature, }; - let certified_summary = signed_summary.try_into().map_err(GrpcSourceError::new)?; + let certified_summary = signed_summary.try_into().map_err(SourceError::invalid_response)?; Ok(Some(certified_summary)) } } fn parse_checkpoint( - transaction_digest: TransactionDigest, checkpoint: &CheckpointResponse, ) -> Result<(CertifiedCheckpointSummary, CheckpointContents), SourceError> { let checkpoint_summary: CertifiedCheckpointSummary = checkpoint .signed_summary() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, - ) - })? + .map_err(SourceError::invalid_response)? .try_into() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::invalid_response)?; let checkpoint_contents: CheckpointContents = checkpoint .contents() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - ) - })? + .map_err(SourceError::invalid_response)? .contents() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::invalid_response)?; Ok((checkpoint_summary, checkpoint_contents)) } -fn parse_effects( - transaction_digest: TransactionDigest, - executed_transaction: &ExecutedTransaction, -) -> Result { +fn parse_effects(executed_transaction: &ExecutedTransaction) -> Result { executed_transaction .effects() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Effects { - source: Box::new(source), - }, - ) - })? + .map_err(SourceError::invalid_response)? .effects() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Effects { - source: Box::new(source), - }, - ) - }) + .map_err(SourceError::invalid_response) } fn parse_transaction( - transaction_digest: TransactionDigest, executed_transaction: &ExecutedTransaction, effects: TransactionEffects, ) -> Result { let transaction = executed_transaction .transaction() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(source), - }, - ) - })? + .map_err(SourceError::invalid_response)? .transaction() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::invalid_response)?; let signatures = executed_transaction .signatures() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Signatures { - source: Box::new(source), - }, - ) - })? + .map_err(SourceError::invalid_response)? .signatures .iter() - .map(|signature| { - signature.signature().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Signatures { - source: Box::new(source), - }, - ) - }) - }) + .map(|signature| signature.signature().map_err(SourceError::invalid_response)) .collect::, SourceError>>()?; let transaction: Transaction = SignedTransaction { transaction, @@ -346,35 +200,16 @@ fn parse_transaction( let events = if effects.events_digest().is_some() { executed_transaction .events() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::MissingEvents { - source: Box::new(source), - }, - ) - })? + .map_err(SourceError::missing_data)? .events() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Events { - source: Box::new(source), - }, - ) - }) + .map_err(SourceError::invalid_response) .map(Some)? } else { None }; - let checkpoint_sequence_number = executed_transaction.checkpoint_sequence_number().map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::MissingCheckpointSequence { - source: Box::new(source), - }, - ) - })?; + let checkpoint_sequence_number = executed_transaction + .checkpoint_sequence_number() + .map_err(SourceError::missing_data)?; Ok(SourceTransaction { transaction, diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index 29e461e..b0f6fef 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -18,56 +18,41 @@ use iota_types::{ messages_checkpoint::CertifiedCheckpointSummary, object::Object, }; -use poi_rs::{ - ProofBuilder, ProofBuilderError, Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTarget, - SourceTransaction, -}; +use poi_rs::{ProofBuilder, ProofBuilderError, ProofTarget, Source, SourceCheckpoint, SourceError, SourceTransaction}; use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; struct RejectingSource; #[async_trait] impl Source for RejectingSource { - type CommitteeError = std::convert::Infallible; - - async fn chain_identifier(&self, _transaction_digest: TransactionDigest) -> Result { + async fn chain_identifier(&self) -> Result { unreachable!("rejected transactions do not resolve a chain identifier") } async fn transaction( &self, - transaction_digest: TransactionDigest, + _transaction_digest: TransactionDigest, ) -> Result, SourceError> { - Err(SourceError::transaction( - transaction_digest, - SourceErrorKind::TransactionNotFound, - )) + Err(SourceError::request(std::io::Error::other("transaction rejected"))) } - async fn object(&self, object_id: ObjectId, _version: Option) -> Result, SourceError> { - Err(SourceError::object(object_id, SourceErrorKind::ObjectNotFound)) + async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { + Ok(None) } - async fn checkpoint( - &self, - _transaction_digest: TransactionDigest, - _sequence_number: u64, - ) -> Result { + async fn checkpoint(&self, _sequence_number: u64) -> Result { unreachable!("rejected transactions do not resolve a checkpoint") } - async fn committee(&self, _epoch: EpochId) -> Result { + async fn committee(&self, _epoch: EpochId) -> Result { unreachable!("proof-only test source does not resolve committees") } - async fn current_epoch(&self) -> Result, Self::CommitteeError> { + async fn current_epoch(&self) -> Result, SourceError> { unreachable!("proof-only test source does not resolve the current epoch") } - async fn epoch_close_summary( - &self, - _epoch: EpochId, - ) -> Result, Self::CommitteeError> { + async fn epoch_close_summary(&self, _epoch: EpochId) -> Result, SourceError> { unreachable!("proof-only test source does not resolve epoch-close summaries") } } @@ -79,9 +64,7 @@ struct RecordingSource { #[async_trait] impl Source for RecordingSource { - type CommitteeError = std::convert::Infallible; - - async fn chain_identifier(&self, _transaction_digest: TransactionDigest) -> Result { + async fn chain_identifier(&self) -> Result { unreachable!("rejected transactions do not resolve a chain identifier") } @@ -95,36 +78,26 @@ impl Source for RecordingSource { .expect("recorded transactions lock must not be poisoned") .push(transaction_digest); - Err(SourceError::transaction( - transaction_digest, - SourceErrorKind::TransactionNotFound, - )) + Ok(None) } - async fn object(&self, object_id: ObjectId, _version: Option) -> Result, SourceError> { - Err(SourceError::object(object_id, SourceErrorKind::ObjectNotFound)) + async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { + Ok(None) } - async fn checkpoint( - &self, - _transaction_digest: TransactionDigest, - _sequence_number: u64, - ) -> Result { + async fn checkpoint(&self, _sequence_number: u64) -> Result { unreachable!("rejected transactions do not resolve a checkpoint") } - async fn committee(&self, _epoch: EpochId) -> Result { + async fn committee(&self, _epoch: EpochId) -> Result { unreachable!("proof-only test source does not resolve committees") } - async fn current_epoch(&self) -> Result, Self::CommitteeError> { + async fn current_epoch(&self) -> Result, SourceError> { unreachable!("proof-only test source does not resolve the current epoch") } - async fn epoch_close_summary( - &self, - _epoch: EpochId, - ) -> Result, Self::CommitteeError> { + async fn epoch_close_summary(&self, _epoch: EpochId) -> Result, SourceError> { unreachable!("proof-only test source does not resolve epoch-close summaries") } } @@ -139,11 +112,11 @@ async fn builder_accepts_a_custom_source() { .await .unwrap_err(); - let ProofBuilderError::Source { source } = error else { + let ProofBuilderError::Source { target, source } = error else { panic!("custom source error must be preserved"); }; - assert_eq!(source.target, SourceTarget::Transaction(transaction_digest)); - assert!(matches!(source.kind, SourceErrorKind::TransactionNotFound)); + assert_eq!(target, ProofTarget::Transaction(transaction_digest)); + assert!(matches!(source, SourceError::Request { .. })); } #[tokio::test] @@ -192,7 +165,7 @@ async fn stacked_targets_reuse_one_transaction_request() { } #[tokio::test] -async fn unknown_transaction_returns_a_fetch_error() { +async fn unknown_transaction_returns_a_request_error() { let cluster = start_test_cluster().await; let transaction_digest = TransactionDigest::random(); @@ -202,11 +175,11 @@ async fn unknown_transaction_returns_a_fetch_error() { .await .unwrap_err(); - let ProofBuilderError::Source { source } = error else { + let ProofBuilderError::Source { target, 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 { .. })); + assert_eq!(target, ProofTarget::Transaction(transaction_digest)); + assert!(matches!(source, SourceError::Request { .. })); } #[tokio::test] @@ -224,7 +197,7 @@ async fn proof_uses_the_genesis_checkpoint_as_its_chain_identifier() { } #[tokio::test] -async fn unknown_object_returns_a_fetch_error() { +async fn unknown_object_returns_a_request_error() { let cluster = start_test_cluster().await; let object_id = Object::immutable_for_testing().id(); @@ -234,11 +207,11 @@ async fn unknown_object_returns_a_fetch_error() { .await .unwrap_err(); - let ProofBuilderError::Source { source } = error else { + let ProofBuilderError::Source { target, source } = error else { panic!("missing object must return a source error"); }; - assert_eq!(source.target, SourceTarget::Object(object_id)); - assert!(matches!(source.kind, SourceErrorKind::FetchObject { .. })); + assert_eq!(target, ProofTarget::Object(object_id)); + assert!(matches!(source, SourceError::Request { .. })); } #[tokio::test] @@ -256,11 +229,10 @@ async fn event_sequence_outside_the_transaction_is_rejected() { .await .unwrap_err(); - let ProofBuilderError::Source { source } = error else { - panic!("missing event must return a source error"); + let ProofBuilderError::TargetNotFound { target } = error else { + panic!("missing event must return a target-not-found error"); }; - assert_eq!(source.target, SourceTarget::Event(event_id)); - assert!(matches!(source.kind, SourceErrorKind::EventNotFound)); + assert_eq!(target, ProofTarget::Event(event_id)); } #[tokio::test] @@ -281,15 +253,15 @@ async fn object_outside_the_event_transaction_is_rejected() { .await .unwrap_err(); - let ProofBuilderError::Source { source } = error else { - panic!("mixed transactions must return a source error"); + let ProofBuilderError::ObjectNotChangedByTransaction { + object_id: returned_object_id, + transaction_digest, + } = error + else { + panic!("unrelated object must return a proof-builder error"); }; - assert_eq!(source.target, SourceTarget::Object(object_id)); - assert!(matches!( - source.kind, - SourceErrorKind::ObjectNotChangedByTransaction { transaction_digest } - if transaction_digest == staking.digest - )); + assert_eq!(returned_object_id, object_id); + assert_eq!(transaction_digest, staking.digest); } #[tokio::test] @@ -306,13 +278,15 @@ async fn object_targets_from_different_transactions_are_rejected() { .await .unwrap_err(); - let ProofBuilderError::Source { source } = error else { - panic!("mixed transactions must return a source error"); + let ProofBuilderError::TargetTransactionMismatch { + target, + expected, + actual, + } = error + else { + panic!("mixed transactions must return a proof-builder error"); }; - assert_eq!(source.target, SourceTarget::Object(second_object_id)); - assert!(matches!( - source.kind, - SourceErrorKind::TargetTransactionMismatch { expected, actual } - if expected == first.digest && actual == second.digest - )); + assert_eq!(target, ProofTarget::Object(second_object_id)); + assert_eq!(expected, first.digest); + assert_eq!(actual, second.digest); } From 4307ecad8ae8e773e68c1d271f6ed0ba3bfc55be Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 29 Jul 2026 11:16:09 +0300 Subject: [PATCH 31/41] feat: update currentEpoch method to return undefined and improve error handling in LedgerSource --- bindings/wasm/poi_wasm/lib/ledger-source.ts | 4 +- bindings/wasm/poi_wasm/lib/source-types.ts | 2 +- bindings/wasm/poi_wasm/src/source.rs | 306 ++++++------------ .../wasm/poi_wasm/tests/wasm-source.test.ts | 35 +- poi-rs/src/source/grpc.rs | 129 ++++---- 5 files changed, 196 insertions(+), 280 deletions(-) diff --git a/bindings/wasm/poi_wasm/lib/ledger-source.ts b/bindings/wasm/poi_wasm/lib/ledger-source.ts index a1a9d48..9bcffde 100644 --- a/bindings/wasm/poi_wasm/lib/ledger-source.ts +++ b/bindings/wasm/poi_wasm/lib/ledger-source.ts @@ -229,12 +229,12 @@ export class LedgerSource implements LedgerSourceContract { }; } - public async currentEpoch(): Promise { + public async currentEpoch(): Promise { const response = await this.#client.getServiceInfo({ readMask: { paths: CURRENT_EPOCH_FIELDS }, }); - return response.epoch!; + return response.epoch; } public async epochCloseSummary( diff --git a/bindings/wasm/poi_wasm/lib/source-types.ts b/bindings/wasm/poi_wasm/lib/source-types.ts index 584e05a..877a4c2 100644 --- a/bindings/wasm/poi_wasm/lib/source-types.ts +++ b/bindings/wasm/poi_wasm/lib/source-types.ts @@ -48,7 +48,7 @@ export interface LedgerSource { object(objectId: Uint8Array, version?: bigint): Promise; checkpoint(sequenceNumber: bigint): Promise; committee(epoch: bigint): Promise; - currentEpoch(): Promise; + currentEpoch(): Promise; epochCloseSummary( epoch: bigint, ): Promise; diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index 2908012..ac8c7a8 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -18,7 +18,7 @@ use iota_types::{ object::Object, }; use js_sys::Uint8Array; -use poi_rs::{CommitteeSource, Source, SourceCheckpoint, SourceError, SourceErrorKind, SourceTransaction}; +use poi_rs::{Source, SourceCheckpoint, SourceError, SourceTransaction}; use serde::Deserialize; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; @@ -102,70 +102,20 @@ struct JsCommitteeMember { weight: u64, } -#[async_trait(?Send)] -impl CommitteeSource for SourceAdapter { - type Error = PoiError; - - async fn committee(&self, epoch: EpochId) -> Result { - let value = self.source.committee(epoch).await.map_err(PoiError::from_js)?; - let evidence: JsCommittee = - serde_wasm_bindgen::from_value(value).map_err(|source| PoiError::invalid_response(source.to_string()))?; - - decode_committee(epoch, evidence) - } - - async fn current_epoch(&self) -> Result, Self::Error> { - let value = self.source.current_epoch().await.map_err(PoiError::from_js)?; - let epoch = - serde_wasm_bindgen::from_value(value).map_err(|source| PoiError::invalid_response(source.to_string()))?; - - Ok(Some(epoch)) - } - - async fn epoch_close_summary(&self, epoch: EpochId) -> Result, Self::Error> { - let value = self - .source - .epoch_close_summary(epoch) - .await - .map_err(PoiError::from_js)?; - - if value.is_undefined() || value.is_null() { - return Ok(None); - } - - let evidence: JsCheckpointSummaryEvidence = - serde_wasm_bindgen::from_value(value).map_err(|source| PoiError::invalid_response(source.to_string()))?; - - decode_certified_summary(&evidence.summary_bcs, &evidence.signature_bcs).map(Some) - } -} - #[async_trait(?Send)] impl Source for SourceAdapter { - async fn chain_identifier(&self, transaction_digest: TransactionDigest) -> Result { + async fn chain_identifier(&self) -> Result { let bytes = self .source .chain_identifier() .await - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchChainIdentifier { - source: Box::new(PoiError::from_js(source)), - }, - ) - })? + .map_err(|source| SourceError::request(PoiError::from_js(source)))? .to_vec(); let digest = bytes.try_into().map_err(|bytes: Vec| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::ChainIdentifier { - source: Box::new(PoiError::invalid_response(format!( - "chain identifier must contain 32 bytes, received {}", - bytes.len() - ))), - }, - ) + SourceError::invalid_response(PoiError::invalid_response(format!( + "chain identifier must contain 32 bytes, received {}", + bytes.len() + ))) })?; Ok(ChainIdentifier::from(CheckpointDigest::new(digest))) @@ -176,29 +126,20 @@ impl Source for SourceAdapter { transaction_digest: TransactionDigest, ) -> Result, SourceError> { let digest = Uint8Array::from(transaction_digest.as_ref()); - let value = self.source.transaction(digest).await.map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchTransaction { - source: Box::new(PoiError::from_js(source)), - }, - ) - })?; + let value = self + .source + .transaction(digest) + .await + .map_err(|source| SourceError::request(PoiError::from_js(source)))?; if value.is_undefined() || value.is_null() { return Ok(None); } - let evidence: JsTransactionEvidence = serde_wasm_bindgen::from_value(value).map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(PoiError::invalid_response(source.to_string())), - }, - ) - })?; + let evidence: JsTransactionEvidence = serde_wasm_bindgen::from_value(value) + .map_err(|source| SourceError::invalid_response(PoiError::invalid_response(source.to_string())))?; - decode_transaction(transaction_digest, evidence).map(Some) + decode_transaction(evidence).map(Some) } async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { @@ -207,108 +148,99 @@ impl Source for SourceAdapter { .source .object(object_id_bytes, version.map(|version| version.as_u64())) .await - .map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::FetchObject { - source: Box::new(PoiError::from_js(source)), - }, - ) - })?; + .map_err(|source| SourceError::request(PoiError::from_js(source)))?; if value.is_undefined() || value.is_null() { return Ok(None); } let bytes = Uint8Array::new(&value).to_vec(); - let versioned: VersionedObject = decode_bcs(&bytes).map_err(|source| { - SourceError::object( - object_id, - SourceErrorKind::Object { - source: Box::new(source), - }, - ) - })?; + let versioned: VersionedObject = decode_bcs(&bytes).map_err(SourceError::invalid_response)?; let VersionedObject::V1(object) = versioned; Ok(Some(object.into())) } - async fn checkpoint( - &self, - transaction_digest: TransactionDigest, - sequence_number: u64, - ) -> Result { - let value = self.source.checkpoint(sequence_number).await.map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::FetchCheckpoint { - sequence_number, - source: Box::new(PoiError::from_js(source)), - }, - ) - })?; - let evidence: JsCheckpointEvidence = serde_wasm_bindgen::from_value(value).map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointSummary { - source: Box::new(PoiError::invalid_response(source.to_string())), - }, - ) - })?; + async fn checkpoint(&self, sequence_number: u64) -> Result { + let value = self + .source + .checkpoint(sequence_number) + .await + .map_err(|source| SourceError::request(PoiError::from_js(source)))?; + let evidence: JsCheckpointEvidence = serde_wasm_bindgen::from_value(value) + .map_err(|source| SourceError::invalid_response(PoiError::invalid_response(source.to_string())))?; - decode_checkpoint(transaction_digest, evidence) + decode_checkpoint(evidence) + } + + async fn committee(&self, epoch: EpochId) -> Result { + let value = self + .source + .committee(epoch) + .await + .map_err(|source| SourceError::request(PoiError::from_js(source)))?; + let evidence: JsCommittee = serde_wasm_bindgen::from_value(value) + .map_err(|source| SourceError::invalid_response(PoiError::invalid_response(source.to_string())))?; + + decode_committee(epoch, evidence).map_err(SourceError::invalid_response) + } + + async fn current_epoch(&self) -> Result, SourceError> { + let value = self + .source + .current_epoch() + .await + .map_err(|source| SourceError::request(PoiError::from_js(source)))?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + + let epoch = serde_wasm_bindgen::from_value(value) + .map_err(|source| SourceError::invalid_response(PoiError::invalid_response(source.to_string())))?; + + Ok(Some(epoch)) + } + + async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { + let value = self + .source + .epoch_close_summary(epoch) + .await + .map_err(|source| SourceError::request(PoiError::from_js(source)))?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + + let evidence: JsCheckpointSummaryEvidence = serde_wasm_bindgen::from_value(value) + .map_err(|source| SourceError::invalid_response(PoiError::invalid_response(source.to_string())))?; + + decode_certified_summary(&evidence.summary_bcs, &evidence.signature_bcs) + .map(Some) + .map_err(SourceError::invalid_response) } } -fn decode_transaction( - transaction_digest: TransactionDigest, - evidence: JsTransactionEvidence, -) -> Result { - let transaction: Transaction = decode_bcs(&evidence.transaction_bcs).map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Transaction { - source: Box::new(source), - }, - ) - })?; +fn decode_transaction(evidence: JsTransactionEvidence) -> Result { + let transaction: Transaction = decode_bcs(&evidence.transaction_bcs).map_err(SourceError::invalid_response)?; let signatures = evidence .signatures_bcs .iter() .map(|bytes| decode_bcs::(bytes)) .collect::, _>>() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Signatures { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::invalid_response)?; let transaction: iota_types::transaction::Transaction = SignedTransaction { transaction, signatures, } .into(); - let effects: TransactionEffects = decode_bcs(&evidence.effects_bcs).map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Effects { - source: Box::new(source), - }, - ) - })?; + let effects: TransactionEffects = decode_bcs(&evidence.effects_bcs).map_err(SourceError::invalid_response)?; let events = if effects.events_digest().is_some() { let events_bcs = evidence.events_bcs.ok_or_else(|| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::MissingEvents { - source: Box::new(PoiError::invalid_response( - "transaction effects commit to events but eventsBcs is missing".to_owned(), - )), - }, - ) + SourceError::missing_data(PoiError::invalid_response( + "transaction effects commit to events but eventsBcs is missing", + )) })?; let events = events_bcs .iter() @@ -317,14 +249,7 @@ fn decode_transaction( Ok(event) }) .collect::, bcs::Error>>() - .map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::Events { - source: Box::new(source), - }, - ) - })?; + .map_err(SourceError::invalid_response)?; Some(TransactionEvents(events)) } else { @@ -339,26 +264,10 @@ fn decode_transaction( }) } -fn decode_checkpoint( - transaction_digest: TransactionDigest, - evidence: JsCheckpointEvidence, -) -> Result { - let summary = decode_certified_summary(&evidence.summary_bcs, &evidence.signature_bcs).map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointSummary { - source: Box::new(source), - }, - ) - })?; - let contents: CheckpointContents = decode_bcs(&evidence.contents_bcs).map_err(|source| { - SourceError::transaction( - transaction_digest, - SourceErrorKind::CheckpointContents { - source: Box::new(source), - }, - ) - })?; +fn decode_checkpoint(evidence: JsCheckpointEvidence) -> Result { + let summary = decode_certified_summary(&evidence.summary_bcs, &evidence.signature_bcs) + .map_err(SourceError::invalid_response)?; + let contents: CheckpointContents = decode_bcs(&evidence.contents_bcs).map_err(SourceError::invalid_response)?; Ok(SourceCheckpoint { summary, contents }) } @@ -416,7 +325,6 @@ mod tests { fn decodes_the_grpc_bcs_evidence_into_existing_iota_types() { let proof = Proof::from_json_slice(include_bytes!("../../../../poi-rs/tests/fixtures/current/event.json")) .expect("fixture must deserialize"); - let transaction_digest = *proof.transaction_proof.transaction.digest(); let signed_transaction: SdkSignedTransaction = proof .transaction_proof .transaction @@ -431,20 +339,17 @@ mod tests { .map(|event| bcs::to_bytes(&VersionedEvent::V1(event)).expect("event must serialize")) .collect() }); - let transaction = decode_transaction( - transaction_digest, - JsTransactionEvidence { - transaction_bcs: bcs::to_bytes(&signed_transaction.transaction).expect("transaction must serialize"), - signatures_bcs: signed_transaction - .signatures - .iter() - .map(|signature| bcs::to_bytes(signature).expect("signature must serialize")) - .collect(), - effects_bcs: bcs::to_bytes(&proof.transaction_proof.effects).expect("effects must serialize"), - events_bcs, - checkpoint_sequence_number: proof.checkpoint_summary.sequence_number, - }, - ) + let transaction = decode_transaction(JsTransactionEvidence { + transaction_bcs: bcs::to_bytes(&signed_transaction.transaction).expect("transaction must serialize"), + signatures_bcs: signed_transaction + .signatures + .iter() + .map(|signature| bcs::to_bytes(signature).expect("signature must serialize")) + .collect(), + effects_bcs: bcs::to_bytes(&proof.transaction_proof.effects).expect("effects must serialize"), + events_bcs, + checkpoint_sequence_number: proof.checkpoint_summary.sequence_number, + }) .expect("transaction evidence must decode"); assert_eq!(transaction.transaction, proof.transaction_proof.transaction); @@ -458,16 +363,13 @@ mod tests { .expect("checkpoint summary must convert to SDK types"); let contents = SdkCheckpointContents::try_from(proof.transaction_proof.checkpoint_contents.clone()) .expect("checkpoint contents must convert to SDK types"); - let checkpoint = decode_checkpoint( - transaction_digest, - JsCheckpointEvidence { - summary_bcs: bcs::to_bytes(&VersionedCheckpointSummary::V1(signed_summary.checkpoint)) - .expect("checkpoint summary must serialize"), - signature_bcs: bcs::to_bytes(&VersionedValidatorAggregatedSignature::V1(signed_summary.signature)) - .expect("checkpoint signature must serialize"), - contents_bcs: bcs::to_bytes(&contents).expect("checkpoint contents must serialize"), - }, - ) + let checkpoint = decode_checkpoint(JsCheckpointEvidence { + summary_bcs: bcs::to_bytes(&VersionedCheckpointSummary::V1(signed_summary.checkpoint)) + .expect("checkpoint summary must serialize"), + signature_bcs: bcs::to_bytes(&VersionedValidatorAggregatedSignature::V1(signed_summary.signature)) + .expect("checkpoint signature must serialize"), + contents_bcs: bcs::to_bytes(&contents).expect("checkpoint contents must serialize"), + }) .expect("checkpoint evidence must decode"); assert_eq!( diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts index 5cc938e..91a17b1 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -28,7 +28,7 @@ test("the WASM builder reads transaction evidence from the ledger source", async await assert.rejects( new ProofBuilder(source).transaction(transactionDigest).build(), - /failed to read signed transaction/, + /source failed while reading transaction .*: source returned an invalid response/, ); assert.deepEqual(requestedDigest, transactionDigest); }); @@ -106,6 +106,39 @@ test("the anchored resolver returns its trusted committee without fetching it ag assert.equal(anchored.epoch, 0n); }); +test("the anchored resolver reports a missing current epoch", async () => { + const fixture = JSON.parse( + await readFile( + new URL( + "../../../../poi-rs/tests/fixtures/current/committee.json", + import.meta.url, + ), + "utf8", + ), + ) as { + voting_rights: [string, number][]; + }; + const source = { + async committee() { + return { + members: fixture.voting_rights.map(([publicKey, weight]) => ({ + publicKey: Buffer.from(publicKey, "base64"), + weight: BigInt(weight), + })), + }; + }, + async currentEpoch() { + return undefined; + }, + } as unknown as LedgerSource; + const committee = await CommitteeResolver.node(source).resolve(0n); + + await assert.rejects( + CommitteeResolver.anchor(source, committee).resolve(1n), + /service information is missing the current epoch/, + ); +}); + test("the anchored resolver requests epoch-close evidence through the JavaScript source", async () => { const fixture = JSON.parse( await readFile( diff --git a/poi-rs/src/source/grpc.rs b/poi-rs/src/source/grpc.rs index fb5da04..9c5e3d4 100644 --- a/poi-rs/src/source/grpc.rs +++ b/poi-rs/src/source/grpc.rs @@ -3,10 +3,10 @@ use async_trait::async_trait; use iota_grpc_client::{ - CheckpointResponse, Client as GrpcClient, ReadMask, + Client as GrpcClient, ReadMask, read_mask_fields::{CheckpointResponseField, EpochField, ObjectField, ServiceInfoField, TransactionField}, }; -use iota_grpc_types::{proto::TryFromProtoError, v1::transaction::ExecutedTransaction}; +use iota_grpc_types::proto::TryFromProtoError; use iota_sdk_types::{ CheckpointContents, CheckpointDigest, ObjectId, SignedCheckpointSummary, SignedTransaction, TransactionDigest, Version, @@ -14,7 +14,7 @@ use iota_sdk_types::{ use iota_types::{ committee::{Committee, EpochId}, digests::ChainIdentifier, - effects::{TransactionEffects, TransactionEffectsAPI}, + effects::TransactionEffectsAPI, messages_checkpoint::CertifiedCheckpointSummary, object::Object, transaction::Transaction, @@ -60,9 +60,49 @@ impl Source for GrpcClient { let Some(executed_transaction) = transactions.body().first() else { return Ok(None); }; - let effects = parse_effects(executed_transaction)?; + let effects = executed_transaction + .effects() + .map_err(SourceError::invalid_response)? + .effects() + .map_err(SourceError::invalid_response)?; - parse_transaction(executed_transaction, effects).map(Some) + let transaction = executed_transaction + .transaction() + .map_err(SourceError::invalid_response)? + .transaction() + .map_err(SourceError::invalid_response)?; + let signatures = executed_transaction + .signatures() + .map_err(SourceError::invalid_response)? + .signatures + .iter() + .map(|signature| signature.signature().map_err(SourceError::invalid_response)) + .collect::, SourceError>>()?; + let transaction: Transaction = SignedTransaction { + transaction, + signatures, + } + .into(); + let events = if effects.events_digest().is_some() { + executed_transaction + .events() + .map_err(SourceError::missing_data)? + .events() + .map_err(SourceError::invalid_response) + .map(Some)? + } else { + None + }; + let checkpoint_sequence_number = executed_transaction + .checkpoint_sequence_number() + .map_err(SourceError::missing_data)?; + + Ok(Some(SourceTransaction { + transaction, + effects, + events, + checkpoint_sequence_number, + })) } async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { @@ -93,7 +133,16 @@ impl Source for GrpcClient { .await .map(|response| response.into_inner()) .map_err(SourceError::request)?; - let (summary, contents) = parse_checkpoint(&checkpoint)?; + let summary: CertifiedCheckpointSummary = checkpoint + .signed_summary() + .map_err(SourceError::invalid_response)? + .try_into() + .map_err(SourceError::invalid_response)?; + let contents: CheckpointContents = checkpoint + .contents() + .map_err(SourceError::invalid_response)? + .contents() + .map_err(SourceError::invalid_response)?; Ok(SourceCheckpoint { summary, contents }) } @@ -150,71 +199,3 @@ impl Source for GrpcClient { Ok(Some(certified_summary)) } } - -fn parse_checkpoint( - checkpoint: &CheckpointResponse, -) -> Result<(CertifiedCheckpointSummary, CheckpointContents), SourceError> { - let checkpoint_summary: CertifiedCheckpointSummary = checkpoint - .signed_summary() - .map_err(SourceError::invalid_response)? - .try_into() - .map_err(SourceError::invalid_response)?; - let checkpoint_contents: CheckpointContents = checkpoint - .contents() - .map_err(SourceError::invalid_response)? - .contents() - .map_err(SourceError::invalid_response)?; - - Ok((checkpoint_summary, checkpoint_contents)) -} - -fn parse_effects(executed_transaction: &ExecutedTransaction) -> Result { - executed_transaction - .effects() - .map_err(SourceError::invalid_response)? - .effects() - .map_err(SourceError::invalid_response) -} - -fn parse_transaction( - executed_transaction: &ExecutedTransaction, - effects: TransactionEffects, -) -> Result { - let transaction = executed_transaction - .transaction() - .map_err(SourceError::invalid_response)? - .transaction() - .map_err(SourceError::invalid_response)?; - let signatures = executed_transaction - .signatures() - .map_err(SourceError::invalid_response)? - .signatures - .iter() - .map(|signature| signature.signature().map_err(SourceError::invalid_response)) - .collect::, SourceError>>()?; - let transaction: Transaction = SignedTransaction { - transaction, - signatures, - } - .into(); - let events = if effects.events_digest().is_some() { - executed_transaction - .events() - .map_err(SourceError::missing_data)? - .events() - .map_err(SourceError::invalid_response) - .map(Some)? - } else { - None - }; - let checkpoint_sequence_number = executed_transaction - .checkpoint_sequence_number() - .map_err(SourceError::missing_data)?; - - Ok(SourceTransaction { - transaction, - effects, - events, - checkpoint_sequence_number, - }) -} From 0c44a3e2de6aa9f75ffa47518d33c2a5a5b4cd75 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 29 Jul 2026 12:10:41 +0300 Subject: [PATCH 32/41] feat: enhance PoiClient to support explicit endpoints and improve committee deserialization --- bindings/wasm/poi_wasm/Cargo.toml | 1 + bindings/wasm/poi_wasm/README.md | 18 +++++- bindings/wasm/poi_wasm/lib/poi-client.ts | 8 ++- bindings/wasm/poi_wasm/src/committee.rs | 56 ++++++++++++++++--- bindings/wasm/poi_wasm/src/error.rs | 6 ++ bindings/wasm/poi_wasm/src/proof.rs | 6 +- bindings/wasm/poi_wasm/src/source.rs | 19 +------ .../wasm/poi_wasm/tests/poi-client.test.ts | 6 ++ .../wasm/poi_wasm/tests/wasm-source.test.ts | 36 +++++++++++- 9 files changed, 120 insertions(+), 36 deletions(-) diff --git a/bindings/wasm/poi_wasm/Cargo.toml b/bindings/wasm/poi_wasm/Cargo.toml index 0d8d4fe..d13d0c8 100644 --- a/bindings/wasm/poi_wasm/Cargo.toml +++ b/bindings/wasm/poi_wasm/Cargo.toml @@ -26,6 +26,7 @@ js-sys = "=0.3.85" poi-rs = { path = "../../../poi-rs", default-features = false } serde = { version = "1.0", features = ["derive"] } serde-wasm-bindgen = "0.6.5" +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } thiserror = { version = "2.0", default-features = false } wasm-bindgen = "=0.2.108" wasm-bindgen-futures = "=0.4.58" diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 7be5792..956edab 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -38,10 +38,12 @@ import { PoiClient } from "@iota/poi-wasm"; const mainnet = PoiClient.mainnet(); const testnet = PoiClient.testnet(); const devnet = PoiClient.devnet(); +const custom = new PoiClient("http://localhost:9000"); ``` No network is selected implicitly. The named constructors use the public IOTA -gRPC endpoints. +gRPC endpoints. Construct `PoiClient` with an explicit endpoint for private +nodes, archives, local networks, or alternative endpoints. ## Proof construction @@ -85,10 +87,22 @@ authenticate committee lineage from genesis. To authenticate committee lineage from an already trusted committee: ```ts +import { readFile } from "node:fs/promises"; +import { Committee } from "@iota/poi-wasm"; + +const committeeJson = await readFile("trusted-committee.json", "utf8"); +const trustedGenesisCommittee = Committee.fromJSON(committeeJson); const resolver = client.anchoredCommitteeResolver(trustedGenesisCommittee); -await resolver.resolve(proof.checkpointEpoch); +const committee = await resolver.resolve(proof.checkpointEpoch); + +proof.verify(committee); ``` +The committee JSON uses the Rust `Committee` fields `epoch` and +`voting_rights`. Rust/WASM validates public keys, rejects duplicate authorities, +requires total voting power to equal 10,000, and reconstructs the committee's +derived lookup state. + The resolver fetches the certified checkpoint in each epoch-close proof, verifies it with the current committee, and only then accepts and caches the next committee. The node supplies evidence but is not trusted to choose the diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index 6f75601..6a65a5b 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -29,13 +29,15 @@ export interface PoiClientOptions { /** * Creates Proof of Inclusion builders backed by an IOTA ledger endpoint. * - * Use one of the named public-network constructors, or {@link PoiClient.custom} - * for a private node, archive, local network, or alternative endpoint. + * Use one of the named public-network constructors, or construct a client with + * an explicit endpoint for a private node, archive, local network, or + * alternative endpoint. */ export class PoiClient { readonly #source: LedgerSource; - private constructor(endpoint: string, options: PoiClientOptions = {}) { + /** Creates a client connected to an explicit IOTA gRPC endpoint. */ + public constructor(endpoint: string, options: PoiClientOptions = {}) { this.#source = new LedgerSource(endpoint, options); } diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index fde9451..41b4e8d 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -1,15 +1,27 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_types::committee::Committee; +use std::collections::BTreeMap; + +use iota_types::{ + base_types::AuthorityName, + committee::{Committee, EpochId, StakeUnit, TOTAL_VOTING_POWER}, +}; use poi_rs::CommitteeResolver; +use serde::Deserialize; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use crate::{ - error::WasmResult, - source::{LedgerSource, SourceAdapter}, + error::{PoiError, WasmResult}, + source::LedgerSource, }; +#[derive(Deserialize)] +struct CommitteeJson { + epoch: EpochId, + voting_rights: Vec<(AuthorityName, StakeUnit)>, +} + /// A validator committee used to verify a Proof of Inclusion proof. #[wasm_bindgen(js_name = Committee)] pub struct WasmCommittee(Committee); @@ -22,6 +34,35 @@ impl WasmCommittee { #[wasm_bindgen(js_class = Committee)] impl WasmCommittee { + /// Deserializes and validates a committee from its Rust JSON representation. + #[wasm_bindgen(js_name = fromJSON)] + pub fn from_json(json: &str) -> Result { + let committee: CommitteeJson = serde_json::from_str(json) + .map_err(|error| PoiError::invalid_input(format!("invalid committee JSON: {error}"))) + .wasm_result()?; + let mut voting_rights = BTreeMap::new(); + let mut total_voting_power = 0_u64; + + for (authority, voting_power) in committee.voting_rights { + if voting_rights.insert(authority, voting_power).is_some() { + return Err(PoiError::invalid_input("committee contains a duplicate authority")).wasm_result(); + } + total_voting_power = total_voting_power + .checked_add(voting_power) + .ok_or_else(|| PoiError::invalid_input("committee voting power exceeds the supported range")) + .wasm_result()?; + } + + if total_voting_power != TOTAL_VOTING_POWER { + return Err(PoiError::invalid_input(format!( + "committee voting power must total {TOTAL_VOTING_POWER}, received {total_voting_power}" + ))) + .wasm_result(); + } + + Ok(WasmCommittee(Committee::new(committee.epoch, voting_rights))) + } + /// Returns the epoch governed by this committee. #[wasm_bindgen(getter)] pub fn epoch(&self) -> u64 { @@ -35,7 +76,7 @@ impl WasmCommittee { /// authenticates committee lineage from a trusted committee and caches verified /// committees in memory. #[wasm_bindgen(js_name = CommitteeResolver)] -pub struct WasmCommitteeResolver(CommitteeResolver); +pub struct WasmCommitteeResolver(CommitteeResolver); #[wasm_bindgen(js_class = CommitteeResolver)] impl WasmCommitteeResolver { @@ -47,7 +88,7 @@ impl WasmCommitteeResolver { /// Creates a resolver that trusts the JavaScript source for committee data. pub fn node(source: LedgerSource) -> Self { - Self(CommitteeResolver::node(SourceAdapter::new(source))) + Self(CommitteeResolver::node(source)) } /// Creates a resolver anchored at an already trusted committee. @@ -55,10 +96,7 @@ impl WasmCommitteeResolver { /// The resolver authenticates every epoch-close checkpoint from the trusted /// committee up to the requested epoch. pub fn anchor(source: LedgerSource, committee: &WasmCommittee) -> Self { - Self(CommitteeResolver::anchor( - SourceAdapter::new(source), - committee.0.clone(), - )) + Self(CommitteeResolver::anchor(source, committee.0.clone())) } /// Resolves the committee governing `epoch`. diff --git a/bindings/wasm/poi_wasm/src/error.rs b/bindings/wasm/poi_wasm/src/error.rs index 364c5b4..96ac80e 100644 --- a/bindings/wasm/poi_wasm/src/error.rs +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -10,6 +10,8 @@ pub(crate) enum PoiError { #[error("{0}")] JavaScript(String), #[error("{0}")] + InvalidInput(String), + #[error("{0}")] InvalidResponse(String), } @@ -28,6 +30,10 @@ impl PoiError { pub(crate) fn invalid_response(message: impl Into) -> Self { Self::InvalidResponse(message.into()) } + + pub(crate) fn invalid_input(message: impl Into) -> Self { + Self::InvalidInput(message.into()) + } } pub(crate) trait WasmResult { diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 9ec7aa4..5ecf8ed 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -9,7 +9,7 @@ use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use crate::committee::WasmCommittee; use crate::error::WasmResult; -use crate::source::{LedgerSource, SourceAdapter}; +use crate::source::LedgerSource; /// Proof of Inclusion evidence constructed by `poi-rs`. #[wasm_bindgen(js_name = Proof)] @@ -57,14 +57,14 @@ impl WasmProof { /// Builds Proof of Inclusion evidence with an internal JavaScript ledger source. #[wasm_bindgen(js_name = ProofBuilder)] -pub struct WasmProofBuilder(ProofBuilder); +pub struct WasmProofBuilder(ProofBuilder); #[wasm_bindgen(js_class = ProofBuilder)] impl WasmProofBuilder { /// Creates a builder backed by the provided JavaScript ledger source. #[wasm_bindgen(constructor)] pub fn new(source: LedgerSource) -> Self { - Self(ProofBuilder::new(SourceAdapter::new(source))) + Self(ProofBuilder::new(source)) } /// Adds a transaction target. diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index ac8c7a8..9d6d2db 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -54,16 +54,6 @@ extern "C" { async fn epoch_close_summary(this: &LedgerSource, epoch: u64) -> Result; } -pub(crate) struct SourceAdapter { - source: LedgerSource, -} - -impl SourceAdapter { - pub(crate) fn new(source: LedgerSource) -> Self { - Self { source } - } -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct JsTransactionEvidence { @@ -103,10 +93,9 @@ struct JsCommitteeMember { } #[async_trait(?Send)] -impl Source for SourceAdapter { +impl Source for LedgerSource { async fn chain_identifier(&self) -> Result { let bytes = self - .source .chain_identifier() .await .map_err(|source| SourceError::request(PoiError::from_js(source)))? @@ -127,7 +116,6 @@ impl Source for SourceAdapter { ) -> Result, SourceError> { let digest = Uint8Array::from(transaction_digest.as_ref()); let value = self - .source .transaction(digest) .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; @@ -145,7 +133,6 @@ impl Source for SourceAdapter { async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { let object_id_bytes = Uint8Array::from(object_id.as_ref()); let value = self - .source .object(object_id_bytes, version.map(|version| version.as_u64())) .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; @@ -163,7 +150,6 @@ impl Source for SourceAdapter { async fn checkpoint(&self, sequence_number: u64) -> Result { let value = self - .source .checkpoint(sequence_number) .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; @@ -175,7 +161,6 @@ impl Source for SourceAdapter { async fn committee(&self, epoch: EpochId) -> Result { let value = self - .source .committee(epoch) .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; @@ -187,7 +172,6 @@ impl Source for SourceAdapter { async fn current_epoch(&self) -> Result, SourceError> { let value = self - .source .current_epoch() .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; @@ -204,7 +188,6 @@ impl Source for SourceAdapter { async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { let value = self - .source .epoch_close_summary(epoch) .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; diff --git a/bindings/wasm/poi_wasm/tests/poi-client.test.ts b/bindings/wasm/poi_wasm/tests/poi-client.test.ts index 37863ab..5da7587 100644 --- a/bindings/wasm/poi_wasm/tests/poi-client.test.ts +++ b/bindings/wasm/poi_wasm/tests/poi-client.test.ts @@ -17,3 +17,9 @@ test("creates clients for every supported public network", () => { assert.equal(typeof client.proof().transaction, "function"); } }); + +test("creates a client for an explicit endpoint", () => { + const client = new PoiClient("http://localhost:9000"); + + assert.equal(typeof client.proof().transaction, "function"); +}); diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts index 91a17b1..d1fc216 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -5,7 +5,12 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { CommitteeResolver, Proof, ProofBuilder } from "../node/poi_wasm.js"; +import { + Committee, + CommitteeResolver, + Proof, + ProofBuilder, +} from "../node/poi_wasm.js"; import type { LedgerSource } from "../lib/source-types.js"; test("the WASM builder reads transaction evidence from the ledger source", async () => { @@ -68,6 +73,35 @@ test("the WASM resolver constructs a committee reported by a trusted node", asyn assert.equal(committee.epoch, 0n); }); +test("the WASM committee can be deserialized from Rust JSON", async () => { + const json = await readFile( + new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), + "utf8", + ); + + const committee = Committee.fromJSON(json); + + assert.equal(committee.epoch, 0n); +}); + +test("the WASM committee rejects invalid total voting power", async () => { + const fixture = JSON.parse( + await readFile( + new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), + "utf8", + ), + ) as { + epoch: number; + voting_rights: [string, number][]; + }; + fixture.voting_rights[0]![1] = 9_999; + + assert.throws( + () => Committee.fromJSON(JSON.stringify(fixture)), + /committee voting power must total 10000, received 9999/, + ); +}); + test("the WASM proof can be deserialized for verification", async () => { const json = await readFile( new URL("../../../../poi-rs/tests/fixtures/current/transaction.json", import.meta.url), From ca7bdab8d835789a95b4f344de841487e3b3e79f Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 29 Jul 2026 14:03:07 +0300 Subject: [PATCH 33/41] feat: introduce PoiClient for proof construction and verification, replacing CommitteeResolver with trustedNodeVerifier and anchoredVerifier methods --- bindings/wasm/poi_wasm/README.md | 24 ++--- bindings/wasm/poi_wasm/lib/poi-client.ts | 14 ++- bindings/wasm/poi_wasm/src/committee.rs | 6 ++ .../wasm/poi_wasm/tests/poi-client.test.ts | 1 + .../wasm/poi_wasm/tests/wasm-source.test.ts | 20 ++++ poi-rs/README.md | 50 +++++++--- poi-rs/src/bin/poi.rs | 15 ++- poi-rs/src/client.rs | 75 +++++++++++++++ poi-rs/src/committee.rs | 40 +++++++- poi-rs/src/lib.rs | 7 +- poi-rs/tests/proof_of_inclusion.rs | 92 +++++++++++-------- 11 files changed, 264 insertions(+), 80 deletions(-) create mode 100644 poi-rs/src/client.rs diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 956edab..0dca270 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -72,15 +72,13 @@ IOTA domain types and delegates target resolution and proof construction to ## Trusted-node verification ```ts -const resolver = client.committeeResolver(); -const committee = await resolver.resolve(proof.checkpointEpoch); - -proof.verify(committee); +const verifier = client.trustedNodeVerifier(); +await verifier.verify(proof); ``` -`CommitteeResolver` asks the client's node for the committee governing the -proof checkpoint epoch. Rust validates the returned committee representation -and performs proof verification locally with `poi-rs`. +The verifier asks the client's node for the committee governing the proof +checkpoint epoch. Rust validates the returned committee representation and +performs proof verification locally with `poi-rs`. This mode places the node inside the caller's trust boundary. It does not authenticate committee lineage from genesis. To authenticate committee @@ -92,10 +90,9 @@ import { Committee } from "@iota/poi-wasm"; const committeeJson = await readFile("trusted-committee.json", "utf8"); const trustedGenesisCommittee = Committee.fromJSON(committeeJson); -const resolver = client.anchoredCommitteeResolver(trustedGenesisCommittee); -const committee = await resolver.resolve(proof.checkpointEpoch); +const verifier = client.anchoredVerifier(trustedGenesisCommittee); -proof.verify(committee); +await verifier.verify(proof); ``` The committee JSON uses the Rust `Committee` fields `epoch` and @@ -103,11 +100,16 @@ The committee JSON uses the Rust `Committee` fields `epoch` and requires total voting power to equal 10,000, and reconstructs the committee's derived lookup state. -The resolver fetches the certified checkpoint in each epoch-close proof, +The verifier fetches the certified checkpoint in each epoch-close proof, verifies it with the current committee, and only then accepts and caches the next committee. The node supplies evidence but is not trusted to choose the committee. +Retain the verifier when checking multiple proofs so its authenticated +committee cache is reused. `CommitteeResolver.resolve(epoch)` and +`Proof.verify(committee)` remain available for callers that need the +lower-level committee or offline-verification APIs. + ## Package verification ```sh diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index 6a65a5b..d768440 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -62,23 +62,21 @@ export class PoiClient { } /** - * Creates a resolver that trusts this client's node for committee data. + * Creates a verifier that trusts this client's node for committee data. * - * The resolver does not authenticate committee lineage from genesis. + * The verifier does not authenticate committee lineage from genesis. */ - public committeeResolver(): CommitteeResolver { + public trustedNodeVerifier(): CommitteeResolver { return new CommitteeResolver(this.#source); } /** - * Creates a resolver anchored at an already trusted committee. + * Creates a verifier anchored at an already trusted committee. * - * The resolver authenticates each epoch-close checkpoint before accepting + * The verifier authenticates each epoch-close checkpoint before accepting * and caching the next committee. */ - public anchoredCommitteeResolver( - committee: Committee, - ): CommitteeResolver { + public anchoredVerifier(committee: Committee): CommitteeResolver { return CommitteeResolver.anchor(this.#source, committee); } } diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index 41b4e8d..e91a199 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -13,6 +13,7 @@ use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; use crate::{ error::{PoiError, WasmResult}, + proof::WasmProof, source::LedgerSource, }; @@ -103,4 +104,9 @@ impl WasmCommitteeResolver { pub async fn resolve(&self, epoch: u64) -> Result { self.0.resolve(epoch).await.map(WasmCommittee).wasm_result() } + + /// Resolves the committee required by `proof` and verifies the proof with it. + pub async fn verify(&self, proof: &WasmProof) -> Result<(), JsValue> { + self.0.verify(&proof.0).await.wasm_result() + } } diff --git a/bindings/wasm/poi_wasm/tests/poi-client.test.ts b/bindings/wasm/poi_wasm/tests/poi-client.test.ts index 5da7587..16a97a2 100644 --- a/bindings/wasm/poi_wasm/tests/poi-client.test.ts +++ b/bindings/wasm/poi_wasm/tests/poi-client.test.ts @@ -22,4 +22,5 @@ test("creates a client for an explicit endpoint", () => { const client = new PoiClient("http://localhost:9000"); assert.equal(typeof client.proof().transaction, "function"); + assert.equal(typeof client.trustedNodeVerifier().verify, "function"); }); diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts index d1fc216..694ce7b 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/wasm-source.test.ts @@ -115,6 +115,26 @@ test("the WASM proof can be deserialized for verification", async () => { assert.doesNotThrow(() => proof.validate()); }); +test("the anchored verifier resolves the committee and verifies the proof", async () => { + const [committeeJson, proofJson] = await Promise.all([ + readFile( + new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), + "utf8", + ), + readFile( + new URL("../../../../poi-rs/tests/fixtures/current/transaction.json", import.meta.url), + "utf8", + ), + ]); + const committee = Committee.fromJSON(committeeJson); + const proof = Proof.fromJSON(proofJson); + const source = {} as LedgerSource; + + await assert.doesNotReject( + CommitteeResolver.anchor(source, committee).verify(proof), + ); +}); + test("the anchored resolver returns its trusted committee without fetching it again", async () => { const fixture = JSON.parse( await readFile( diff --git a/poi-rs/README.md b/poi-rs/README.md index 73ff0e5..5fe1d4f 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -9,16 +9,18 @@ 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. +`PoiClient` provides explicit constructors for the public IOTA networks. The client does not select a default network, so +the calling application always chooses where it fetches proof material. ```rust,no_run use iota_sdk_types::TransactionDigest; -use poi_rs::ProofBuilder; +use poi_rs::PoiClient; # async fn example() -> Result<(), Box> { let transaction_digest: TransactionDigest = todo!(); -let proof = ProofBuilder::mainnet()? +let client = PoiClient::mainnet()?; +let proof = client + .proof() .transaction(transaction_digest) .build() .await?; @@ -26,8 +28,9 @@ 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. +Use `PoiClient::testnet()` or `PoiClient::devnet()` for the other public networks. Applications can pass a custom `Source` +to `PoiClient::new(source)` when they use a private node, archive, fixture, or local test cluster. `ProofBuilder` remains +available directly for lower-level use. 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 @@ -57,8 +60,28 @@ must carry the transaction evidence that links the target claim to the checkpoin ## 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. +For the common source-backed workflow, create a verifier from the same `PoiClient`. The verifier resolves the committee +required by the proof and then performs offline proof verification: + +```rust,no_run +use iota_types::committee::Committee; +use poi_rs::{PoiClient, Proof}; + +# async fn example(proof: &Proof, trusted_genesis_committee: Committee) -> Result<(), Box> { +let client = PoiClient::testnet()?; +let verifier = client.anchored_verifier(trusted_genesis_committee); + +verifier.verify(proof).await?; +# Ok(()) +# } +``` + +`PoiClient::trusted_node_verifier()` is available when the connected node is explicitly inside the caller's trust +boundary. `PoiClient::anchored_verifier()` instead authenticates committee lineage from the supplied trusted committee. +Retain the verifier when checking multiple proofs so its authenticated committee cache is reused. + +`ProofVerifier` remains the offline verification entry point for callers that already possess the authoritative +committee. It verifies only the proof material passed by the caller. Verification checks: @@ -75,8 +98,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. `CommitteeResolver` can resolve committee history -before the caller invokes the verifier. +`CommitteeResolver::verify()` composes committee resolution with offline verification for source-backed workflows. +`CommitteeResolver::resolve()` remains available when callers need the authenticated committee itself. 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. @@ -88,11 +111,12 @@ 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. - `ProofTarget`: Transaction, object, or event requested from a `ProofBuilder`. +- `PoiClient`: Source-backed entry point for proof construction and trusted-node or anchored verification. - `ProofBuilder`: Network-aware or custom-source proof construction. - `Source`: Ledger-read boundary for gRPC nodes, JavaScript clients, archives, fixtures, and other evidence sources. - `SourceTransaction` and `SourceCheckpoint`: Transport-independent decoded evidence returned by a `Source`. -- `CommitteeResolver`: Trusted-node or anchored committee resolution. +- `CommitteeResolver`: Trusted-node or anchored committee resolution and source-backed proof verification. - `ProofVerifier`: Offline verifier for `Proof` values. - `SourceError`: Transport and response failures from a ledger source. -- `ProofBuilderError`, `CommitteeResolutionError`, `VerifyError`, `SerializationError`, and `VersionError`: - Operation-specific errors. +- `ProofBuilderError`, `CommitteeResolutionError`, `ProofVerificationError`, `VerifyError`, `SerializationError`, and + `VersionError`: Operation-specific errors. diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index 7faa16a..f85d7f4 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -15,7 +15,7 @@ use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; use iota_types::event::EventID; -use poi_rs::{CommitteeResolver, Proof, ProofBuilder, ProofVerifier}; +use poi_rs::{PoiClient, Proof}; const GENESIS_CACHE_DIR: &str = "poi"; const MAINNET_GENESIS_URL: &str = "https://dbfiles.mainnet.iota.cafe/genesis.blob"; @@ -103,7 +103,8 @@ impl CreateArgs { event, output, } = self; - let mut builder = ProofBuilder::from_grpc_client(endpoint.client()?); + let client = PoiClient::from_grpc_client(endpoint.client()?); + let mut builder = client.proof(); if let Some(transaction) = transaction { builder = builder.transaction(transaction); @@ -172,14 +173,10 @@ impl VerifyArgs { 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) + PoiClient::from_grpc_client(self.endpoint.client()?) + .anchored_verifier(trusted_committee) .verify(&proof) + .await .context("proof verification failed")?; writeln!(io::stdout().lock(), "valid").context("failed to write verification result to stdout") } diff --git a/poi-rs/src/client.rs b/poi-rs/src/client.rs new file mode 100644 index 0000000..c923312 --- /dev/null +++ b/poi-rs/src/client.rs @@ -0,0 +1,75 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(feature = "native-grpc")] +use iota_grpc_client::Client as GrpcClient; +use iota_types::committee::Committee; + +use crate::{CommitteeResolver, ProofBuilder, Source}; + +/// Convenient entry point for proof construction and verification backed by one ledger source. +#[derive(Clone)] +pub struct PoiClient { + source: S, +} + +impl PoiClient { + /// Creates a client backed by `source`. + pub const fn new(source: S) -> Self { + Self { source } + } +} + +impl PoiClient +where + S: Source + Clone, +{ + /// Creates a fresh builder for one Proof of Inclusion. + pub fn proof(&self) -> ProofBuilder { + ProofBuilder::new(self.source.clone()) + } + + /// Creates a verifier that trusts this client's source for committee data. + /// + /// This mode does not authenticate committee lineage. Use it only when the + /// source is inside the caller's trust boundary. + pub fn trusted_node_verifier(&self) -> CommitteeResolver { + CommitteeResolver::node(self.source.clone()) + } + + /// Creates a verifier anchored at an already trusted committee. + /// + /// The verifier authenticates every epoch-close checkpoint from the trusted + /// committee up to the epoch required by each proof. + pub fn anchored_verifier(&self, trusted_committee: Committee) -> CommitteeResolver { + CommitteeResolver::anchor(self.source.clone(), trusted_committee) + } +} + +#[cfg(feature = "native-grpc")] +impl PoiClient { + /// Creates a client connected to the public IOTA mainnet gRPC endpoint. + pub fn mainnet() -> iota_grpc_client::Result { + GrpcClient::new_mainnet().map(Self::new) + } + + /// Creates a client connected to the public IOTA testnet gRPC endpoint. + pub fn testnet() -> iota_grpc_client::Result { + GrpcClient::new_testnet().map(Self::new) + } + + /// Creates a client connected to the public IOTA devnet gRPC endpoint. + pub fn devnet() -> iota_grpc_client::Result { + GrpcClient::new_devnet().map(Self::new) + } + + /// Creates a client backed by an existing SDK gRPC client. + pub const fn from_grpc_client(client: GrpcClient) -> Self { + Self::new(client) + } + + /// Returns the underlying SDK gRPC client. + pub const fn grpc_client(&self) -> &GrpcClient { + &self.source + } +} diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index f676821..6136081 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -11,7 +11,9 @@ use iota_types::{ messages_checkpoint::CertifiedCheckpointSummary, }; -use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Source}; +use crate::{ + BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Proof, ProofVerifier, Source, VerifyError, +}; /// Error returned when a committee cannot be resolved for an epoch. #[derive(Debug, thiserror::Error)] @@ -116,6 +118,26 @@ pub enum CommitteeResolutionErrorKind { }, } +/// Error returned when committee resolution or proof verification fails. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ProofVerificationError { + /// The committee required by the proof could not be resolved. + #[error("failed to resolve the committee required by the proof")] + CommitteeResolution { + /// Committee-resolution failure. + #[source] + source: CommitteeResolutionError, + }, + /// Offline proof verification failed. + #[error("proof verification failed")] + Proof { + /// Offline verification failure. + #[source] + source: VerifyError, + }, +} + /// Selects how a resolver establishes trust in committee data. #[derive(Clone)] enum CommitteeResolution { @@ -194,6 +216,22 @@ where } } + /// Resolves the committee required by `proof` and verifies the proof with it. + /// + /// Committee resolution may fetch committee or epoch-close evidence from + /// the source. The final proof verification is performed locally by + /// [`ProofVerifier`]. + pub async fn verify(&self, proof: &Proof) -> Result<(), ProofVerificationError> { + let committee = self + .resolve(proof.checkpoint_summary.epoch()) + .await + .map_err(|source| ProofVerificationError::CommitteeResolution { source })?; + + ProofVerifier::new(&committee) + .verify(proof) + .map_err(|source| ProofVerificationError::Proof { source }) + } + /// Fetches a committee directly from a node inside the caller's trust boundary. async fn resolve_from_node(&self, target_epoch: EpochId) -> Result { self.source.committee(target_epoch).await.map_err(|source| { diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index e851bb2..3af92ad 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -11,6 +11,8 @@ pub(crate) type BoxError = Box; pub mod builder; /// Verified committee lineage caches for anchored resolution. pub mod cache; +/// Convenient source-backed client for proof construction and verification. +pub mod client; /// Committee resolution for checkpoint verification. pub mod committee; /// Proof data types and offline verification. @@ -22,7 +24,10 @@ pub mod target; pub use builder::{ProofBuilder, ProofBuilderError, ProofTarget}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; -pub use committee::{CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver}; +pub use client::PoiClient; +pub use committee::{ + CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver, ProofVerificationError, +}; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, VerifyErrorKind, VersionError, diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_of_inclusion.rs index 8473ad5..c0ab8d0 100644 --- a/poi-rs/tests/proof_of_inclusion.rs +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -4,27 +4,49 @@ mod utils; use iota_types::event::EventID; -use poi_rs::{CommitteeResolver, ProofBuilder, ProofVerifier}; -use utils::{grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; +use poi_rs::PoiClient; +use utils::{ + advance_to_epoch, genesis_committee, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx, +}; + +#[tokio::test] +async fn anchored_verifier_walks_from_genesis_and_verifies_the_proof() { + let cluster = start_test_cluster().await; + let trusted_committee = genesis_committee(&cluster); + advance_to_epoch(&cluster, 1).await; + let transfer = transfer_tx(&cluster).await; + let client = PoiClient::from_grpc_client(grpc_client(&cluster)); + let proof = client + .proof() + .transaction(transfer.digest) + .build() + .await + .expect("transaction proof must be constructed"); + + client + .anchored_verifier(trusted_committee) + .verify(&proof) + .await + .expect("anchored verifier must authenticate the committee and verify the proof"); +} #[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 client = PoiClient::from_grpc_client(grpc_client(&cluster)); - let proof = ProofBuilder::from_grpc_client(client.clone()) + let proof = client + .proof() .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) + client + .trusted_node_verifier() .verify(&proof) + .await .expect("transaction proof must verify"); } @@ -32,21 +54,20 @@ async fn transaction_proof_verifies_with_the_resolved_committee() { 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 client = PoiClient::from_grpc_client(grpc_client(&cluster)); - let proof = ProofBuilder::from_grpc_client(client.clone()) + let proof = client + .proof() .object(transfer.gas_object.object_id) .build() .await .expect("object proof must be constructed"); - let committee = CommitteeResolver::node(client) - .resolve(proof.checkpoint_summary.epoch()) - .await - .expect("checkpoint committee must resolve"); assert_eq!(proof.target.objects[0].0, transfer.gas_object); - ProofVerifier::new(&committee) + client + .trusted_node_verifier() .verify(&proof) + .await .expect("object proof must verify"); } @@ -54,24 +75,23 @@ async fn object_proof_verifies_with_the_resolved_committee() { async fn event_proof_verifies_with_the_resolved_committee() { let cluster = start_test_cluster().await; let staking = staking_tx(&cluster).await; - let client = grpc_client(&cluster); + let client = PoiClient::from_grpc_client(grpc_client(&cluster)); let event_id = EventID { tx_digest: staking.digest, event_seq: 0, }; - let proof = ProofBuilder::from_grpc_client(client.clone()) + let proof = client + .proof() .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) + client + .trusted_node_verifier() .verify(&proof) + .await .expect("event proof must verify"); } @@ -79,22 +99,21 @@ async fn event_proof_verifies_with_the_resolved_committee() { 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 client = PoiClient::from_grpc_client(grpc_client(&cluster)); - let proof = ProofBuilder::from_grpc_client(client.clone()) + let proof = client + .proof() .objects(transfer.objects.map(|object_ref| object_ref.object_id)) .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) + client + .trusted_node_verifier() .verify(&proof) + .await .expect("stacked object proof must verify"); } @@ -102,28 +121,27 @@ async fn multiple_object_targets_share_one_verified_transaction_proof() { 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 client = PoiClient::from_grpc_client(grpc_client(&cluster)); let event_id = EventID { tx_digest: staking.digest, event_seq: 0, }; - let proof = ProofBuilder::from_grpc_client(client.clone()) + let proof = client + .proof() .object(staking.gas_object.object_id) .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[0].0, staking.gas_object); assert_eq!(proof.target.objects.len(), 1); assert_eq!(proof.target.events.len(), 1); - ProofVerifier::new(&committee) + client + .trusted_node_verifier() .verify(&proof) + .await .expect("mixed target proof must verify"); } From 65506e702ba9377589c1c9fb569d6b3da8009430 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 30 Jul 2026 09:35:46 +0300 Subject: [PATCH 34/41] feat: update PoiClient methods for committee verification and add support for genesis blob anchoring --- bindings/wasm/poi_wasm/README.md | 18 +- bindings/wasm/poi_wasm/lib/poi-client.ts | 22 +- bindings/wasm/poi_wasm/src/committee.rs | 15 +- bindings/wasm/poi_wasm/src/source.rs | 1 + .../wasm/poi_wasm/tests/poi-client.test.ts | 26 --- poi-rs/Cargo.toml | 1 + poi-rs/README.md | 12 +- poi-rs/src/bin/poi.rs | 14 +- poi-rs/src/client.rs | 55 ++++- poi-rs/src/committee.rs | 207 ++++++++++-------- poi-rs/tests/committee.rs | 11 +- poi-rs/tests/proof_of_inclusion.rs | 27 ++- poi-rs/tests/utils/mod.rs | 17 +- 13 files changed, 245 insertions(+), 181 deletions(-) delete mode 100644 bindings/wasm/poi_wasm/tests/poi-client.test.ts diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 0dca270..7d5c345 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -72,7 +72,7 @@ IOTA domain types and delegates target resolution and proof construction to ## Trusted-node verification ```ts -const verifier = client.trustedNodeVerifier(); +const verifier = client.trustedNode(); await verifier.verify(proof); ``` @@ -86,19 +86,19 @@ lineage from an already trusted committee: ```ts import { readFile } from "node:fs/promises"; -import { Committee } from "@iota/poi-wasm"; -const committeeJson = await readFile("trusted-committee.json", "utf8"); -const trustedGenesisCommittee = Committee.fromJSON(committeeJson); -const verifier = client.anchoredVerifier(trustedGenesisCommittee); +const trustedGenesisBlob = await readFile("genesis.blob"); +const verifier = client.anchoredAtGenesis(trustedGenesisBlob); await verifier.verify(proof); ``` -The committee JSON uses the Rust `Committee` fields `epoch` and -`voting_rights`. Rust/WASM validates public keys, rejects duplicate authorities, -requires total voting power to equal 10,000, and reconstructs the committee's -derived lookup state. +`anchoredAtGenesis()` decodes the BCS-encoded IOTA genesis blob and extracts its +committee in Rust. Callers that already possess an extracted trusted committee +can use `anchoredAt(committee)` instead. `Committee.fromJSON()` accepts the Rust +`Committee` fields `epoch` and `voting_rights`, validates public keys, rejects +duplicate authorities, requires total voting power to equal 10,000, and +reconstructs the committee's derived lookup state. The verifier fetches the certified checkpoint in each epoch-close proof, verifies it with the current committee, and only then accepts and caches the diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index d768440..e9a6f47 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -62,21 +62,29 @@ export class PoiClient { } /** - * Creates a verifier that trusts this client's node for committee data. + * Configures verification to trust this client's node for committee data. * - * The verifier does not authenticate committee lineage from genesis. + * This does not authenticate committee lineage from genesis. */ - public trustedNodeVerifier(): CommitteeResolver { + public trustedNode(): CommitteeResolver { return new CommitteeResolver(this.#source); } /** - * Creates a verifier anchored at an already trusted committee. + * Configures verification to anchor at an already trusted committee. * - * The verifier authenticates each epoch-close checkpoint before accepting - * and caching the next committee. + * Each epoch-close checkpoint is authenticated before the next committee is + * accepted and cached. */ - public anchoredVerifier(committee: Committee): CommitteeResolver { + public anchoredAt(committee: Committee): CommitteeResolver { return CommitteeResolver.anchor(this.#source, committee); } + + /** + * Configures verification to anchor at the committee contained in a trusted + * IOTA genesis blob. + */ + public anchoredAtGenesis(genesisBlob: Uint8Array): CommitteeResolver { + return CommitteeResolver.anchorAtGenesis(this.#source, genesisBlob); + } } diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index e91a199..6f591e2 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -7,7 +7,8 @@ use iota_types::{ base_types::AuthorityName, committee::{Committee, EpochId, StakeUnit, TOTAL_VOTING_POWER}, }; -use poi_rs::CommitteeResolver; +use js_sys::Uint8Array; +use poi_rs::{CommitteeResolver, PoiClient}; use serde::Deserialize; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; @@ -100,6 +101,18 @@ impl WasmCommitteeResolver { Self(CommitteeResolver::anchor(source, committee.0.clone())) } + /// Creates a resolver anchored at the committee contained in a trusted genesis blob. + #[wasm_bindgen(js_name = anchorAtGenesis)] + pub fn anchor_at_genesis(source: LedgerSource, genesis_blob: Uint8Array) -> Result { + let bytes = genesis_blob.to_vec(); + let resolver = PoiClient::new(source) + .anchored_at_genesis(bytes.as_slice()) + .map_err(|error| PoiError::invalid_input(format!("failed to load trusted genesis blob: {error}"))) + .wasm_result()?; + + Ok(Self(resolver)) + } + /// Resolves the committee governing `epoch`. pub async fn resolve(&self, epoch: u64) -> Result { self.0.resolve(epoch).await.map(WasmCommittee).wasm_result() diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index 9d6d2db..4eaeae0 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -29,6 +29,7 @@ use crate::versioned::{VersionedCheckpointSummary, VersionedEvent, VersionedVali #[wasm_bindgen] extern "C" { /// JavaScript source that owns the generated ledger client. + #[derive(Clone)] #[wasm_bindgen(typescript_type = "LedgerSource")] pub type LedgerSource; diff --git a/bindings/wasm/poi_wasm/tests/poi-client.test.ts b/bindings/wasm/poi_wasm/tests/poi-client.test.ts deleted file mode 100644 index 16a97a2..0000000 --- a/bindings/wasm/poi_wasm/tests/poi-client.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import test from "node:test"; - -import { PoiClient } from "../lib/index.js"; - -test("creates clients for every supported public network", () => { - const clients = [ - PoiClient.mainnet(), - PoiClient.testnet(), - PoiClient.devnet(), - ]; - - for (const client of clients) { - assert.equal(typeof client.proof().transaction, "function"); - } -}); - -test("creates a client for an explicit endpoint", () => { - const client = new PoiClient("http://localhost:9000"); - - assert.equal(typeof client.proof().transaction, "function"); - assert.equal(typeof client.trustedNodeVerifier().verify, "function"); -}); diff --git a/poi-rs/Cargo.toml b/poi-rs/Cargo.toml index c336b9b..c912725 100644 --- a/poi-rs/Cargo.toml +++ b/poi-rs/Cargo.toml @@ -28,6 +28,7 @@ cli = [ [dependencies] anyhow = { workspace = true, optional = true } async-trait.workspace = true +bcs.workspace = true clap = { workspace = true, optional = true } iota-grpc-client = { workspace = true, optional = true } iota-grpc-types = { workspace = true, optional = true } diff --git a/poi-rs/README.md b/poi-rs/README.md index 5fe1d4f..327a0e7 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -64,20 +64,22 @@ For the common source-backed workflow, create a verifier from the same `PoiClien required by the proof and then performs offline proof verification: ```rust,no_run -use iota_types::committee::Committee; +use std::fs::File; + use poi_rs::{PoiClient, Proof}; -# async fn example(proof: &Proof, trusted_genesis_committee: Committee) -> Result<(), Box> { +# async fn example(proof: &Proof) -> Result<(), Box> { let client = PoiClient::testnet()?; -let verifier = client.anchored_verifier(trusted_genesis_committee); +let verifier = client.anchored_at_genesis(File::open("genesis.blob")?)?; verifier.verify(proof).await?; # Ok(()) # } ``` -`PoiClient::trusted_node_verifier()` is available when the connected node is explicitly inside the caller's trust -boundary. `PoiClient::anchored_verifier()` instead authenticates committee lineage from the supplied trusted committee. +`PoiClient::trusted_node()` is available when the connected node is explicitly inside the caller's trust boundary. +`PoiClient::anchored_at_genesis()` loads the anchor committee from a trusted BCS-encoded genesis blob. +`PoiClient::anchored_at()` accepts an already extracted trusted committee instead. Retain the verifier when checking multiple proofs so its authenticated committee cache is reused. `ProofVerifier` remains the offline verification entry point for callers that already possess the authoritative diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index f85d7f4..de0adb5 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -11,7 +11,7 @@ use std::{ 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_config::{IOTA_GENESIS_FILENAME, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; use iota_types::event::EventID; @@ -159,7 +159,7 @@ impl VerifyArgs { let genesis = match self.genesis.as_deref() { Some(path) => { - Genesis::load(path).with_context(|| format!("failed to load genesis blob '{}'", path.display()))? + fs::File::open(path).with_context(|| format!("failed to open genesis blob '{}'", path.display()))? } None => { load_genesis( @@ -170,11 +170,9 @@ impl VerifyArgs { .await? } }; - let trusted_committee = genesis - .committee() - .context("failed to read committee from genesis blob")?; PoiClient::from_grpc_client(self.endpoint.client()?) - .anchored_verifier(trusted_committee) + .anchored_at_genesis(genesis) + .map_err(|error| anyhow::anyhow!("failed to load trusted genesis blob: {error}"))? .verify(&proof) .await .context("proof verification failed")?; @@ -244,7 +242,7 @@ impl Network { } } -async fn 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) @@ -268,7 +266,7 @@ async fn load_genesis(network: Network) -> Result { fs::write(&path, bytes).with_context(|| format!("failed to cache genesis blob at '{}'", path.display()))?; } - Genesis::load(&path).with_context(|| format!("failed to load genesis blob '{}'", path.display())) + fs::File::open(&path).with_context(|| format!("failed to open genesis blob '{}'", path.display())) } #[tokio::main(flavor = "current_thread")] diff --git a/poi-rs/src/client.rs b/poi-rs/src/client.rs index c923312..2af5a55 100644 --- a/poi-rs/src/client.rs +++ b/poi-rs/src/client.rs @@ -1,9 +1,20 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::io::Read; + #[cfg(feature = "native-grpc")] use iota_grpc_client::Client as GrpcClient; -use iota_types::committee::Committee; +use iota_sdk_types::CheckpointContents; +use iota_types::{ + committee::Committee, + effects::{TransactionEffects, TransactionEvents}, + iota_system_state::{IotaSystemStateTrait, get_iota_system_state}, + messages_checkpoint::CertifiedCheckpointSummary, + object::Object, + transaction::Transaction, +}; +use serde::Deserialize; use crate::{CommitteeResolver, ProofBuilder, Source}; @@ -29,21 +40,49 @@ where ProofBuilder::new(self.source.clone()) } - /// Creates a verifier that trusts this client's source for committee data. + /// Configures verification to trust this client's source for committee data. /// - /// This mode does not authenticate committee lineage. Use it only when the + /// This does not authenticate committee lineage. Use it only when the /// source is inside the caller's trust boundary. - pub fn trusted_node_verifier(&self) -> CommitteeResolver { + pub fn trusted_node(&self) -> CommitteeResolver { CommitteeResolver::node(self.source.clone()) } - /// Creates a verifier anchored at an already trusted committee. + /// Configures verification to anchor at an already trusted committee. /// - /// The verifier authenticates every epoch-close checkpoint from the trusted - /// committee up to the epoch required by each proof. - pub fn anchored_verifier(&self, trusted_committee: Committee) -> CommitteeResolver { + /// Every epoch-close checkpoint from the trusted committee up to the epoch + /// required by each proof is authenticated before that proof is verified. + pub fn anchored_at(&self, trusted_committee: Committee) -> CommitteeResolver { CommitteeResolver::anchor(self.source.clone(), trusted_committee) } + + /// Configures verification to anchor at the committee contained in a trusted genesis blob. + /// + /// The reader must contain the BCS-encoded `genesis.blob` for the proof's + /// network. The blob establishes the caller's trust anchor. + pub fn anchored_at_genesis( + &self, + reader: impl Read, + ) -> Result, Box> { + #[allow(dead_code)] + #[derive(Deserialize)] + struct GenesisBlob { + checkpoint: CertifiedCheckpointSummary, + checkpoint_contents: CheckpointContents, + transaction: Transaction, + effects: TransactionEffects, + events: TransactionEvents, + objects: Vec, + } + + let genesis: GenesisBlob = bcs::from_reader(reader)?; + let objects = genesis.objects.as_slice(); + + let system_state = get_iota_system_state(&objects)?; + let committee = system_state.get_current_epoch_committee().committee().clone(); + + Ok(CommitteeResolver::anchor(self.source.clone(), committee)) + } } #[cfg(feature = "native-grpc")] diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 6136081..0aedc70 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -8,7 +8,6 @@ use iota_grpc_client::Client as GrpcClient; use iota_types::{ committee::{Committee, EpochId}, error::IotaError, - messages_checkpoint::CertifiedCheckpointSummary, }; use crate::{ @@ -384,60 +383,52 @@ where ) })?; - Self::authenticate_and_store_next_committee(target_epoch, current_committee, summary, cache).await - } - - /// Verifies an end-of-epoch summary before accepting its next committee. - fn authenticate_next_committee( - current_committee: &Committee, - summary: CertifiedCheckpointSummary, - ) -> Result { let sequence_number = summary.sequence_number; 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, - }), - }); + return Err(CommitteeResolutionError::new( + target_epoch, + 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 }); + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number }, + )); } - let next_epoch = summary_epoch - .checked_add(1) - .ok_or(CommitteeResolutionErrorKind::NextEpochOverflow { epoch: summary_epoch })?; + let next_epoch = summary_epoch.checked_add(1).ok_or_else(|| { + CommitteeResolutionError::new( + target_epoch, + 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), - } + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::InvalidEndOfEpochCheckpoint { + epoch: current_committee.epoch, + sequence_number, + source: Box::new(source), + }, + ) })?; let next_epoch_committee = &verified .end_of_epoch_data .as_ref() .expect("checked before signature verification") .next_epoch_committee; - - Ok(Committee::from_committee_members(next_epoch, next_epoch_committee)) - } - - /// 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))?; + let next_committee = Committee::from_committee_members(next_epoch, next_epoch_committee); cache.store(&next_committee).await.map_err(|source| { CommitteeResolutionError::new( @@ -465,14 +456,60 @@ impl CommitteeResolver { mod tests { use std::sync::Mutex; - use iota_sdk_types::{CheckpointSummary, EndOfEpochData, gas::GasCostSummary}; + use iota_sdk_types::{ + CheckpointSummary, EndOfEpochData, ObjectId, TransactionDigest, Version, gas::GasCostSummary, + }; + use iota_types::{digests::ChainIdentifier, messages_checkpoint::CertifiedCheckpointSummary, object::Object}; use super::*; + use crate::{SourceCheckpoint, SourceError, SourceTransaction}; struct StaticCache { committee: Committee, } + #[derive(Clone)] + struct EpochCloseSource { + summary: CertifiedCheckpointSummary, + } + + #[async_trait::async_trait] + impl Source for EpochCloseSource { + async fn chain_identifier(&self) -> Result { + unreachable!("committee transition does not resolve a chain identifier") + } + + async fn transaction( + &self, + _transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + unreachable!("committee transition does not resolve transactions") + } + + async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { + unreachable!("committee transition does not resolve objects") + } + + async fn checkpoint(&self, _sequence_number: u64) -> Result { + unreachable!("committee transition does not resolve checkpoints") + } + + async fn committee(&self, _epoch: EpochId) -> Result { + unreachable!("anchored committee transition does not trust node committees") + } + + async fn current_epoch(&self) -> Result, SourceError> { + unreachable!("direct committee transition test does not resolve the current epoch") + } + + async fn epoch_close_summary( + &self, + _epoch: EpochId, + ) -> Result, SourceError> { + Ok(Some(self.summary.clone())) + } + } + #[derive(Clone, Default)] struct RecordingCache { stored: Arc>>, @@ -546,15 +583,12 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, current_committee.clone()); - let committee = CommitteeResolver::::authenticate_and_store_next_committee( - 4, - ¤t_committee, - summary, - &cache, - ) - .await - .unwrap(); + let committee = resolver + .fetch_next_committee(4, ¤t_committee, &cache) + .await + .unwrap(); assert_eq!(committee, expected_committee); assert_eq!(cache.stored(), vec![expected_committee]); @@ -566,15 +600,12 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, wrong_committee.clone()); - let error = CommitteeResolver::::authenticate_and_store_next_committee( - 4, - &wrong_committee, - summary, - &cache, - ) - .await - .unwrap_err(); + let error = resolver + .fetch_next_committee(4, &wrong_committee, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -591,15 +622,12 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, current_committee.clone()); - let error = CommitteeResolver::::authenticate_and_store_next_committee( - 4, - ¤t_committee, - summary, - &cache, - ) - .await - .unwrap_err(); + let error = resolver + .fetch_next_committee(4, ¤t_committee, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -614,15 +642,12 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, wrong_committee.clone()); - let error = CommitteeResolver::::authenticate_and_store_next_committee( - 4, - &wrong_committee, - summary, - &cache, - ) - .await - .unwrap_err(); + let error = resolver + .fetch_next_committee(4, &wrong_committee, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -636,15 +661,12 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, expected_committee.clone()); - let error = CommitteeResolver::::authenticate_and_store_next_committee( - 4, - &expected_committee, - summary, - &cache, - ) - .await - .unwrap_err(); + let error = resolver + .fetch_next_committee(4, &expected_committee, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -661,15 +683,12 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, current_committee.clone()); - let error = CommitteeResolver::::authenticate_and_store_next_committee( - EpochId::MAX, - ¤t_committee, - summary, - &cache, - ) - .await - .unwrap_err(); + let error = resolver + .fetch_next_committee(EpochId::MAX, ¤t_committee, &cache) + .await + .unwrap_err(); assert!(matches!( error.kind, @@ -687,11 +706,9 @@ mod tests { #[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 (current_committee, next_committee, _) = signed_end_of_epoch_summary(3, true); let cache = crate::MemoryCommitteeCache::new(); - cache.store(&authenticated_committee).await.unwrap(); + cache.store(&next_committee).await.unwrap(); let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); let resolver = CommitteeResolver::anchor_with_cache(client, current_committee, cache); @@ -702,15 +719,13 @@ mod tests { #[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 (current_committee, next_committee, _) = signed_end_of_epoch_summary(3, true); let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); let resolver = CommitteeResolver::anchor(client, current_committee); let CommitteeResolution::Anchor { cache, .. } = &resolver.mode else { panic!("anchor resolver must have a committee cache"); }; - cache.store(&authenticated_committee).await.unwrap(); + cache.store(&next_committee).await.unwrap(); let resolved = resolver.resolve(4).await.unwrap(); diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee.rs index 43f353b..40f7337 100644 --- a/poi-rs/tests/committee.rs +++ b/poi-rs/tests/committee.rs @@ -3,12 +3,15 @@ mod utils; -use iota_config::genesis::Genesis; +use std::fs::File; + use iota_grpc_client::{Client as GrpcClient, ReadMask, read_mask_fields::ServiceInfoField}; use iota_types::committee::Committee; use poi_rs::{CommitteeCache, CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; use utils::{advance_to_epoch, genesis_committee, grpc_client, start_test_cluster}; +use crate::utils::committee_from_genesis; + fn committee_at(epoch: u64) -> Committee { let (committee, _) = Committee::new_simple_test_committee(); Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) @@ -104,10 +107,8 @@ async fn live_endpoint_authenticates_committees_from_genesis() { "the live network must have at least one closed epoch" ); let target_epoch = current_epoch.min(10); - let trusted_committee = Genesis::load(genesis_path) - .expect("trusted genesis blob must load") - .committee() - .expect("trusted genesis blob must contain a committee"); + let genesis = File::open(genesis_path).expect("trusted genesis blob must be available"); + let trusted_committee = committee_from_genesis(genesis).expect("trusted genesis committee must load"); let expected = CommitteeResolver::node(client.clone()) .resolve(target_epoch) .await diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_of_inclusion.rs index c0ab8d0..3892ca2 100644 --- a/poi-rs/tests/proof_of_inclusion.rs +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -3,16 +3,18 @@ mod utils; +use std::fs::File; + +use iota_config::IOTA_GENESIS_FILENAME; use iota_types::event::EventID; use poi_rs::PoiClient; -use utils::{ - advance_to_epoch, genesis_committee, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx, -}; +use utils::{advance_to_epoch, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; #[tokio::test] -async fn anchored_verifier_walks_from_genesis_and_verifies_the_proof() { +async fn anchored_verification_walks_from_genesis_and_verifies_the_proof() { let cluster = start_test_cluster().await; - let trusted_committee = genesis_committee(&cluster); + let genesis = File::open(cluster.swarm.dir().join(IOTA_GENESIS_FILENAME)) + .expect("test cluster genesis blob must be available"); advance_to_epoch(&cluster, 1).await; let transfer = transfer_tx(&cluster).await; let client = PoiClient::from_grpc_client(grpc_client(&cluster)); @@ -24,10 +26,11 @@ async fn anchored_verifier_walks_from_genesis_and_verifies_the_proof() { .expect("transaction proof must be constructed"); client - .anchored_verifier(trusted_committee) + .anchored_at_genesis(genesis) + .expect("test cluster genesis blob must load") .verify(&proof) .await - .expect("anchored verifier must authenticate the committee and verify the proof"); + .expect("anchored verification must authenticate the committee and verify the proof"); } #[tokio::test] @@ -44,7 +47,7 @@ async fn transaction_proof_verifies_with_the_resolved_committee() { .expect("transaction proof must be constructed"); client - .trusted_node_verifier() + .trusted_node() .verify(&proof) .await .expect("transaction proof must verify"); @@ -65,7 +68,7 @@ async fn object_proof_verifies_with_the_resolved_committee() { assert_eq!(proof.target.objects[0].0, transfer.gas_object); client - .trusted_node_verifier() + .trusted_node() .verify(&proof) .await .expect("object proof must verify"); @@ -89,7 +92,7 @@ async fn event_proof_verifies_with_the_resolved_committee() { .expect("event proof must be constructed"); client - .trusted_node_verifier() + .trusted_node() .verify(&proof) .await .expect("event proof must verify"); @@ -111,7 +114,7 @@ async fn multiple_object_targets_share_one_verified_transaction_proof() { assert_eq!(proof.transaction_proof.transaction.digest(), &transfer.digest); assert_eq!(proof.target.objects.len(), 2); client - .trusted_node_verifier() + .trusted_node() .verify(&proof) .await .expect("stacked object proof must verify"); @@ -140,7 +143,7 @@ async fn object_and_event_targets_share_one_verified_transaction_proof() { assert_eq!(proof.target.objects.len(), 1); assert_eq!(proof.target.events.len(), 1); client - .trusted_node_verifier() + .trusted_node() .verify(&proof) .await .expect("mixed target proof must verify"); diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs index 3c6cd49..6e1cfc7 100644 --- a/poi-rs/tests/utils/mod.rs +++ b/poi-rs/tests/utils/mod.rs @@ -5,9 +5,12 @@ // by sibling test crates otherwise appear unused. #![allow(dead_code)] +use std::fs::File; + use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectReference, TransactionDigest}; +use iota_types::iota_system_state::{IotaSystemStateTrait, get_iota_system_state}; use iota_types::{committee::Committee, digests::ChainIdentifier}; use test_cluster::{TestCluster, TestClusterBuilder}; @@ -125,10 +128,16 @@ pub async fn staking_tx(cluster: &TestCluster) -> CheckpointedStaking { 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") + let genesis = File::open(genesis_path).expect("test cluster genesis blob must be available"); + + committee_from_genesis(genesis).expect("test cluster genesis committee must be extractable") +} + +pub fn committee_from_genesis(genesis: impl std::io::Read) -> Result { + let genesis: iota_config::genesis::Genesis = bcs::from_reader(genesis).map_err(|_| ())?; + let system_state = get_iota_system_state(&genesis.objects()).map_err(|_| ())?; + + Ok(system_state.get_current_epoch_committee().committee().clone()) } pub fn genesis_chain_identifier(cluster: &TestCluster) -> ChainIdentifier { From 886a359ccc40f377fb3b8bba4f9f1cadb3c4c55a Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 3 Aug 2026 13:11:58 +0300 Subject: [PATCH 35/41] feat: refactor tests to utilize PoiClient and improve proof verification methods --- poi-rs/tests/committee.rs | 112 ++++++-------- poi-rs/tests/golden.rs | 18 ++- poi-rs/tests/proof_builder.rs | 237 +++++++++++++++++++++-------- poi-rs/tests/proof_of_inclusion.rs | 30 +--- poi-rs/tests/utils/mod.rs | 17 ++- poi-rs/tests/verifier.rs | 50 ++++-- 6 files changed, 285 insertions(+), 179 deletions(-) diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee.rs index 40f7337..ea05004 100644 --- a/poi-rs/tests/committee.rs +++ b/poi-rs/tests/committee.rs @@ -5,29 +5,27 @@ mod utils; use std::fs::File; -use iota_grpc_client::{Client as GrpcClient, ReadMask, read_mask_fields::ServiceInfoField}; -use iota_types::committee::Committee; -use poi_rs::{CommitteeCache, CommitteeResolutionErrorKind, CommitteeResolver, MemoryCommitteeCache}; -use utils::{advance_to_epoch, genesis_committee, grpc_client, start_test_cluster}; +use iota_config::IOTA_GENESIS_FILENAME; +use iota_grpc_client::Client as GrpcClient; +use poi_rs::{CommitteeResolutionErrorKind, PoiClient}; +use utils::{advance_to_epoch, grpc_client, start_test_cluster}; -use crate::utils::committee_from_genesis; - -fn committee_at(epoch: u64) -> Committee { - let (committee, _) = Committee::new_simple_test_committee(); - Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) -} +use crate::utils::committee_at; 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() { +async fn genesis_anchored_client_authenticates_committee_across_epochs() { 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 genesis_path = cluster.swarm.dir().join(IOTA_GENESIS_FILENAME); + let genesis = File::open(genesis_path).expect("test cluster genesis blob must be available"); + + let resolver = PoiClient::new(grpc_client(&cluster)) + .anchored_at_genesis(genesis) + .expect("test cluster genesis committee must be extractable"); let resolved = resolver .resolve(10) @@ -35,20 +33,29 @@ async fn genesis_anchor_authenticates_committees_through_epoch_ten() { .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)); +async fn committee_anchored_client_returns_its_trust_anchor_without_fetching() { + let trusted_committee = committee_at(7); + let resolver = PoiClient::new(disconnected_client()).anchored_at(trusted_committee.clone()); + + let resolved = resolver + .resolve(7) + .await + .expect("the trusted committee must resolve without fetching"); + + assert_eq!(resolved, trusted_committee); +} + +#[tokio::test] +async fn committee_anchored_client_rejects_epochs_before_its_anchor_without_fetching() { + let resolver = PoiClient::new(disconnected_client()).anchored_at(committee_at(7)); - let error = resolver.resolve(6).await.unwrap_err(); + let error = resolver + .resolve(6) + .await + .expect_err("an anchored resolver cannot walk backwards"); assert_eq!(error.target_epoch, 6); assert!(matches!( @@ -58,26 +65,29 @@ async fn epoch_before_the_trust_anchor_is_rejected() { } #[tokio::test] -async fn epoch_ahead_of_the_node_is_rejected_without_caching() { +async fn genesis_anchored_client_rejects_epochs_ahead_of_the_node() { 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(); + let genesis_path = cluster.swarm.dir().join(IOTA_GENESIS_FILENAME); + let genesis = File::open(genesis_path).expect("test cluster genesis blob must be available"); + let resolver = PoiClient::new(grpc_client(&cluster)) + .anchored_at_genesis(genesis) + .expect("test cluster genesis committee must be extractable"); + + let error = resolver + .resolve(1) + .await + .expect_err("an epoch beyond the node's current epoch must be rejected"); 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() { +async fn trusted_node_client_returns_the_committee_reported_by_its_source() { let cluster = start_test_cluster().await; - let cache = MemoryCommitteeCache::new(); - let resolver = CommitteeResolver::node(grpc_client(&cluster)); + let resolver = PoiClient::new(grpc_client(&cluster)).trusted_node(); let resolved = resolver .resolve(0) @@ -85,38 +95,4 @@ async fn trusted_node_resolution_does_not_write_to_an_anchor_cache() { .expect("trusted node must return its genesis committee"); assert_eq!(resolved, *cluster.committee()); - assert!(cache.is_empty().await); -} - -#[tokio::test] -#[ignore = "requires POI_TEST_GRPC_URL and POI_TEST_GENESIS"] -async fn live_endpoint_authenticates_committees_from_genesis() { - let endpoint = std::env::var("POI_TEST_GRPC_URL").expect("POI_TEST_GRPC_URL must identify the live gRPC endpoint"); - let genesis_path = - std::env::var("POI_TEST_GENESIS").expect("POI_TEST_GENESIS must identify the trusted genesis blob"); - let client = GrpcClient::new(endpoint).expect("live gRPC client must be constructed"); - let current_epoch = client - .get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) - .await - .expect("service information must be available") - .body() - .epoch - .expect("service information must contain the current epoch"); - assert!( - current_epoch > 0, - "the live network must have at least one closed epoch" - ); - let target_epoch = current_epoch.min(10); - let genesis = File::open(genesis_path).expect("trusted genesis blob must be available"); - let trusted_committee = committee_from_genesis(genesis).expect("trusted genesis committee must load"); - let expected = CommitteeResolver::node(client.clone()) - .resolve(target_epoch) - .await - .expect("the node must expose the target committee"); - let authenticated = CommitteeResolver::anchor(client, trusted_committee) - .resolve(target_epoch) - .await - .expect("epoch-close proofs must authenticate the target committee"); - - assert_eq!(authenticated, expected); } diff --git a/poi-rs/tests/golden.rs b/poi-rs/tests/golden.rs index bfe49de..5428903 100644 --- a/poi-rs/tests/golden.rs +++ b/poi-rs/tests/golden.rs @@ -9,7 +9,7 @@ const TRANSACTION: &str = include_str!("fixtures/current/transaction.json"); const OBJECT: &str = include_str!("fixtures/current/object.json"); const EVENT: &str = include_str!("fixtures/current/event.json"); -fn assert_current_format(fixture: &str) -> Proof { +fn assert_fixture_round_trips_and_verifies(fixture: &str) -> Proof { let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); let proof = Proof::from_json_slice(fixture.as_bytes()).expect("proof fixture must deserialize"); @@ -27,24 +27,24 @@ fn assert_current_format(fixture: &str) -> Proof { } #[test] -fn current_transaction_fixture_remains_stable() { - let proof = assert_current_format(TRANSACTION); +fn transaction_fixture_round_trips_and_verifies() { + let proof = assert_fixture_round_trips_and_verifies(TRANSACTION); assert!(proof.target().objects.is_empty()); assert!(proof.target().events.is_empty()); } #[test] -fn current_object_fixture_remains_stable() { - let proof = assert_current_format(OBJECT); +fn object_fixture_round_trips_and_verifies() { + let proof = assert_fixture_round_trips_and_verifies(OBJECT); assert_eq!(proof.target().objects.len(), 1); assert!(proof.target().events.is_empty()); } #[test] -fn current_event_fixture_remains_stable() { - let proof = assert_current_format(EVENT); +fn event_fixture_round_trips_and_verifies() { + let proof = assert_fixture_round_trips_and_verifies(EVENT); assert!(proof.target().objects.is_empty()); assert_eq!(proof.target().events.len(), 1); @@ -57,7 +57,9 @@ fn unsupported_fixture_version_returns_the_version_number() { 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 error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an unsupported proof version must be rejected"); let VerifyErrorKind::Version { source } = error.kind else { panic!("unsupported proof version must return a version error"); }; diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_builder.rs index b0f6fef..10b2ba5 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_builder.rs @@ -3,14 +3,11 @@ mod utils; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, -}; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; +use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest, Version}; -use iota_types::base_types::dbg_object_id; use iota_types::{ committee::{Committee, EpochId}, digests::ChainIdentifier, @@ -18,9 +15,12 @@ use iota_types::{ messages_checkpoint::CertifiedCheckpointSummary, object::Object, }; -use poi_rs::{ProofBuilder, ProofBuilderError, ProofTarget, Source, SourceCheckpoint, SourceError, SourceTransaction}; +use poi_rs::{ + PoiClient, ProofBuilderError, ProofTarget, ProofVerifier, Source, SourceCheckpoint, SourceError, SourceTransaction, +}; use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; +#[derive(Clone)] struct RejectingSource; #[async_trait] @@ -57,27 +57,84 @@ impl Source for RejectingSource { } } +#[derive(Clone)] struct RecordingSource { - requests: Arc, + source: GrpcClient, transactions: Arc>>, + object_override: Option, +} + +impl RecordingSource { + fn new(source: GrpcClient, transactions: Arc>>) -> Self { + Self { + source, + transactions, + object_override: None, + } + } + + fn with_object_override(mut self, object: Object) -> Self { + self.object_override = Some(object); + self + } } #[async_trait] impl Source for RecordingSource { async fn chain_identifier(&self) -> Result { - unreachable!("rejected transactions do not resolve a chain identifier") + self.source.chain_identifier().await } async fn transaction( &self, transaction_digest: TransactionDigest, ) -> Result, SourceError> { - self.requests.fetch_add(1, Ordering::Relaxed); self.transactions .lock() .expect("recorded transactions lock must not be poisoned") .push(transaction_digest); + self.source.transaction(transaction_digest).await + } + + async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { + if let Some(object) = &self.object_override { + return Ok(Some(object.clone())); + } + + self.source.object(object_id, version).await + } + + async fn checkpoint(&self, sequence_number: u64) -> Result { + self.source.checkpoint(sequence_number).await + } + + async fn committee(&self, epoch: EpochId) -> Result { + self.source.committee(epoch).await + } + + async fn current_epoch(&self) -> Result, SourceError> { + self.source.current_epoch().await + } + + async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { + self.source.epoch_close_summary(epoch).await + } +} + +#[derive(Clone)] +struct MissingSource; + +#[async_trait] +impl Source for MissingSource { + async fn chain_identifier(&self) -> Result { + unreachable!("missing targets do not resolve a chain identifier") + } + + async fn transaction( + &self, + _transaction_digest: TransactionDigest, + ) -> Result, SourceError> { Ok(None) } @@ -86,7 +143,7 @@ impl Source for RecordingSource { } async fn checkpoint(&self, _sequence_number: u64) -> Result { - unreachable!("rejected transactions do not resolve a checkpoint") + unreachable!("missing targets do not resolve a checkpoint") } async fn committee(&self, _epoch: EpochId) -> Result { @@ -103,14 +160,15 @@ impl Source for RecordingSource { } #[tokio::test] -async fn builder_accepts_a_custom_source() { +async fn client_uses_a_custom_source_for_proof_building() { let transaction_digest = TransactionDigest::random(); - let error = ProofBuilder::new(RejectingSource) + let error = PoiClient::new(RejectingSource) + .proof() .transaction(transaction_digest) .build() .await - .unwrap_err(); + .expect_err("the custom source error must be returned"); let ProofBuilderError::Source { target, source } = error else { panic!("custom source error must be preserved"); @@ -120,66 +178,67 @@ async fn builder_accepts_a_custom_source() { } #[tokio::test] -async fn builder_without_a_target_is_rejected() { - let error = ProofBuilder::new(RejectingSource).build().await.unwrap_err(); +async fn proof_requires_at_least_one_target() { + let error = PoiClient::new(RejectingSource) + .proof() + .build() + .await + .expect_err("a proof without a target must be rejected"); assert!(matches!(error, ProofBuilderError::MissingTarget)); } #[tokio::test] -async fn stacked_targets_reuse_one_transaction_request() { - let transaction_digest = TransactionDigest::random(); - let object_a = dbg_object_id(1); - let object_b = dbg_object_id(2); - let event_a = EventID { - tx_digest: transaction_digest, +async fn stacked_targets_are_deduplicated_and_reuse_transaction_evidence() { + let cluster = start_test_cluster().await; + let staking = staking_tx(&cluster).await; + let object_id = staking.gas_object.object_id; + let event_id = EventID { + tx_digest: staking.digest, event_seq: 0, }; - let event_b = EventID { - tx_digest: transaction_digest, - event_seq: 1, - }; - let requests = Arc::new(AtomicUsize::new(0)); let transactions = Arc::new(Mutex::new(Vec::new())); + let source = RecordingSource::new(grpc_client(&cluster), transactions.clone()); + + let proof = PoiClient::new(source) + .proof() + .transaction(staking.digest) + .objects([object_id, object_id]) + .object(object_id) + .events([event_id, event_id]) + .event(event_id) + .build() + .await + .expect("stacked targets from one transaction must produce a proof"); - let _ = ProofBuilder::new(RecordingSource { - requests: requests.clone(), - transactions: transactions.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!( *transactions .lock() .expect("recorded transactions lock must not be poisoned"), - vec![transaction_digest] + vec![staking.digest] ); + assert_eq!(proof.target.objects.len(), 1); + assert_eq!(proof.target.events.len(), 1); + ProofVerifier::new(&cluster.committee()) + .verify(&proof) + .expect("the stacked-target proof must verify offline"); } #[tokio::test] -async fn unknown_transaction_returns_a_request_error() { - let cluster = start_test_cluster().await; +async fn transaction_not_returned_by_the_source_is_reported_as_missing() { let transaction_digest = TransactionDigest::random(); - let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + let error = PoiClient::new(MissingSource) + .proof() .transaction(transaction_digest) .build() .await - .unwrap_err(); + .expect_err("a transaction omitted by the source must be rejected"); - let ProofBuilderError::Source { target, source } = error else { - panic!("missing transaction must return a source error"); + let ProofBuilderError::TargetNotFound { target } = error else { + panic!("an omitted transaction must return a target-not-found error"); }; assert_eq!(target, ProofTarget::Transaction(transaction_digest)); - assert!(matches!(source, SourceError::Request { .. })); } #[tokio::test] @@ -187,7 +246,8 @@ 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)) + let proof = PoiClient::from_grpc_client(grpc_client(&cluster)) + .proof() .transaction(transfer.digest) .build() .await @@ -197,21 +257,71 @@ async fn proof_uses_the_genesis_checkpoint_as_its_chain_identifier() { } #[tokio::test] -async fn unknown_object_returns_a_request_error() { - let cluster = start_test_cluster().await; +async fn object_not_returned_by_the_source_is_reported_as_missing() { let object_id = Object::immutable_for_testing().id(); - let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + let error = PoiClient::new(MissingSource) + .proof() .object(object_id) .build() .await - .unwrap_err(); + .expect_err("an object omitted by the source must be rejected"); - let ProofBuilderError::Source { target, source } = error else { - panic!("missing object must return a source error"); + let ProofBuilderError::TargetNotFound { target } = error else { + panic!("an omitted object must return a target-not-found error"); }; assert_eq!(target, ProofTarget::Object(object_id)); - assert!(matches!(source, SourceError::Request { .. })); +} + +#[tokio::test] +async fn object_that_does_not_match_the_requested_reference_is_rejected() { + let cluster = start_test_cluster().await; + let transfer = transfer_tx(&cluster).await; + let object_id = transfer.gas_object.object_id; + let transactions = Arc::new(Mutex::new(Vec::new())); + let source = + RecordingSource::new(grpc_client(&cluster), transactions).with_object_override(Object::immutable_for_testing()); + + let error = PoiClient::new(source) + .proof() + .transaction(transfer.digest) + .object(object_id) + .build() + .await + .expect_err("an object that does not match the effects reference must be rejected"); + + assert!(matches!( + error, + ProofBuilderError::ObjectReferenceMismatch { + object_id: returned_object_id + } if returned_object_id == object_id + )); +} + +#[tokio::test] +async fn explicit_transaction_and_event_from_different_transactions_are_rejected_without_fetching() { + let transaction_digest = TransactionDigest::new([1; 32]); + let event_id = EventID { + tx_digest: TransactionDigest::new([2; 32]), + event_seq: 0, + }; + + let error = PoiClient::new(MissingSource) + .proof() + .transaction(transaction_digest) + .event(event_id) + .build() + .await + .expect_err("targets from different transactions must be rejected"); + + assert!(matches!( + error, + ProofBuilderError::TargetTransactionMismatch { + target: ProofTarget::Event(target), + expected, + actual, + } if target == event_id && expected == transaction_digest && actual == event_id.tx_digest + )); } #[tokio::test] @@ -223,11 +333,12 @@ async fn event_sequence_outside_the_transaction_is_rejected() { event_seq: u64::MAX, }; - let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + let error = PoiClient::from_grpc_client(grpc_client(&cluster)) + .proof() .event(event_id) .build() .await - .unwrap_err(); + .expect_err("an event sequence outside the transaction must be rejected"); let ProofBuilderError::TargetNotFound { target } = error else { panic!("missing event must return a target-not-found error"); @@ -246,12 +357,13 @@ async fn object_outside_the_event_transaction_is_rejected() { event_seq: 0, }; - let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + let error = PoiClient::from_grpc_client(grpc_client(&cluster)) + .proof() .object(object_id) .event(event_id) .build() .await - .unwrap_err(); + .expect_err("an object outside the event transaction must be rejected"); let ProofBuilderError::ObjectNotChangedByTransaction { object_id: returned_object_id, @@ -272,11 +384,12 @@ async fn object_targets_from_different_transactions_are_rejected() { 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)) + let error = PoiClient::from_grpc_client(grpc_client(&cluster)) + .proof() .objects([first_object_id, second_object_id]) .build() .await - .unwrap_err(); + .expect_err("objects from different transactions must be rejected"); let ProofBuilderError::TargetTransactionMismatch { target, diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_of_inclusion.rs index 3892ca2..515ee18 100644 --- a/poi-rs/tests/proof_of_inclusion.rs +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -11,7 +11,7 @@ use poi_rs::PoiClient; use utils::{advance_to_epoch, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; #[tokio::test] -async fn anchored_verification_walks_from_genesis_and_verifies_the_proof() { +async fn client_builds_and_verifies_a_transaction_proof_from_genesis() { let cluster = start_test_cluster().await; let genesis = File::open(cluster.swarm.dir().join(IOTA_GENESIS_FILENAME)) .expect("test cluster genesis blob must be available"); @@ -34,27 +34,7 @@ async fn anchored_verification_walks_from_genesis_and_verifies_the_proof() { } #[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 = PoiClient::from_grpc_client(grpc_client(&cluster)); - - let proof = client - .proof() - .transaction(transfer.digest) - .build() - .await - .expect("transaction proof must be constructed"); - - client - .trusted_node() - .verify(&proof) - .await - .expect("transaction proof must verify"); -} - -#[tokio::test] -async fn object_proof_verifies_with_the_resolved_committee() { +async fn client_builds_and_verifies_an_object_proof_with_a_trusted_node() { let cluster = start_test_cluster().await; let transfer = transfer_tx(&cluster).await; let client = PoiClient::from_grpc_client(grpc_client(&cluster)); @@ -75,7 +55,7 @@ async fn object_proof_verifies_with_the_resolved_committee() { } #[tokio::test] -async fn event_proof_verifies_with_the_resolved_committee() { +async fn client_builds_and_verifies_an_event_proof_with_a_trusted_node() { let cluster = start_test_cluster().await; let staking = staking_tx(&cluster).await; let client = PoiClient::from_grpc_client(grpc_client(&cluster)); @@ -99,7 +79,7 @@ async fn event_proof_verifies_with_the_resolved_committee() { } #[tokio::test] -async fn multiple_object_targets_share_one_verified_transaction_proof() { +async fn client_builds_one_verified_proof_for_multiple_objects() { let cluster = start_test_cluster().await; let transfer = object_transfer_tx(&cluster).await; let client = PoiClient::from_grpc_client(grpc_client(&cluster)); @@ -121,7 +101,7 @@ async fn multiple_object_targets_share_one_verified_transaction_proof() { } #[tokio::test] -async fn object_and_event_targets_share_one_verified_transaction_proof() { +async fn client_builds_one_verified_proof_for_object_and_event_targets() { let cluster = start_test_cluster().await; let staking = staking_tx(&cluster).await; let client = PoiClient::from_grpc_client(grpc_client(&cluster)); diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs index 6e1cfc7..b65e295 100644 --- a/poi-rs/tests/utils/mod.rs +++ b/poi-rs/tests/utils/mod.rs @@ -16,6 +16,11 @@ use test_cluster::{TestCluster, TestClusterBuilder}; pub mod proofs; +pub fn committee_at(epoch: u64) -> Committee { + let (committee, _) = Committee::new_simple_test_committee(); + Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) +} + pub struct CheckpointedTransfer { pub digest: TransactionDigest, pub gas_object: ObjectReference, @@ -64,7 +69,11 @@ pub async fn transfer_tx(cluster: &TestCluster) -> CheckpointedTransfer { } pub async fn object_transfer_tx(cluster: &TestCluster) -> CheckpointedObjectTransfer { - let (sender, mut coins) = cluster.wallet.get_one_account().await.unwrap(); + let (sender, mut coins) = cluster + .wallet + .get_one_account() + .await + .expect("test cluster must contain a funded account"); 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; @@ -95,7 +104,11 @@ pub async fn object_transfer_tx(cluster: &TestCluster) -> CheckpointedObjectTran } pub async fn staking_tx(cluster: &TestCluster) -> CheckpointedStaking { - let (sender, mut coins) = cluster.wallet.get_one_account().await.unwrap(); + let (sender, mut coins) = cluster + .wallet + .get_one_account() + .await + .expect("test cluster must contain a funded account"); 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; diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/verifier.rs index 5ede386..dd6e788 100644 --- a/poi-rs/tests/verifier.rs +++ b/poi-rs/tests/verifier.rs @@ -18,9 +18,9 @@ use utils::proofs::{ fn valid_transaction_proof_is_accepted() { let (committee, proof) = valid_transaction_proof(); - let result = ProofVerifier::new(&committee).verify(&proof); - - assert!(result.is_ok()); + ProofVerifier::new(&committee) + .verify(&proof) + .expect("a valid transaction proof must verify"); } #[test] @@ -28,7 +28,9 @@ 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(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("mismatched transaction effects must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::TransactionDigestMismatch)); } @@ -38,7 +40,9 @@ 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(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("mismatched transaction events must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::EventsDigestMismatch)); } @@ -50,7 +54,9 @@ fn checkpoint_contents_must_match_the_signed_summary() { proof.transaction_proof.checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([alternate.digests()]); - let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("checkpoint contents outside the signed summary must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::CheckpointSummary { .. })); } @@ -62,7 +68,9 @@ fn transaction_must_be_present_in_the_checkpoint() { proof.transaction_proof.transaction = alternate.transaction; proof.transaction_proof.effects = alternate.effects; - let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("a transaction outside the checkpoint must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint)); } @@ -73,7 +81,9 @@ fn committee_target_requires_end_of_epoch_data() { 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(); + let error = ProofVerifier::new(&verifying_committee) + .verify(&proof) + .expect_err("a committee target without end-of-epoch data must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::MissingEndOfEpochCommittee)); } @@ -87,7 +97,9 @@ fn committee_target_must_match_end_of_epoch_data() { 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(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("a committee target not committed by end-of-epoch data must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::CommitteeMismatch)); } @@ -100,7 +112,9 @@ fn object_target_must_match_its_reference() { 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(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an object that does not match its reference must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::ObjectReferenceMismatch)); } @@ -112,7 +126,9 @@ fn object_target_must_appear_in_the_transaction_effects() { 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(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an object absent from the transaction effects must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::ObjectNotFound)); } @@ -128,7 +144,9 @@ fn event_target_must_match_the_packaged_event() { }; proof.target = ProofTargets::new().add_event(event_id, target); - let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an event that does not match the packaged event must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::EventContentsMismatch)); } @@ -143,7 +161,9 @@ fn event_target_must_belong_to_the_proven_transaction() { }; proof.target = ProofTargets::new().add_event(event_id, target); - let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an event from another transaction must be rejected"); assert!(matches!(error.kind, VerifyErrorKind::EventTransactionMismatch)); } @@ -158,7 +178,9 @@ fn event_sequence_must_exist_in_the_transaction() { }; proof.target = ProofTargets::new().add_event(event_id, target); - let error = ProofVerifier::new(&committee).verify(&proof).unwrap_err(); + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an event sequence outside the transaction must be rejected"); assert!(matches!( error.kind, From d9463ccba25e81b67a7e47cc1cbb885b8087ae48 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 3 Aug 2026 14:58:45 +0300 Subject: [PATCH 36/41] feat: add comprehensive tests for committee resolution and proof construction in WASM and Rust implementations --- .../poi_wasm/tests/committee-bindings.test.ts | 43 +++++ ...e.test.ts => committee-resolution.test.ts} | 103 ++---------- .../poi_wasm/tests/proof-bindings.test.ts | 59 +++++++ .../{committee.rs => committee_resolution.rs} | 0 ...proof_builder.rs => proof_construction.rs} | 159 +----------------- .../{golden.rs => proof_serialization.rs} | 0 .../{verifier.rs => proof_verification.rs} | 0 ...oof_of_inclusion.rs => proof_workflows.rs} | 0 poi-rs/tests/utils/mod.rs | 1 + poi-rs/tests/utils/sources.rs | 154 +++++++++++++++++ 10 files changed, 281 insertions(+), 238 deletions(-) create mode 100644 bindings/wasm/poi_wasm/tests/committee-bindings.test.ts rename bindings/wasm/poi_wasm/tests/{wasm-source.test.ts => committee-resolution.test.ts} (60%) create mode 100644 bindings/wasm/poi_wasm/tests/proof-bindings.test.ts rename poi-rs/tests/{committee.rs => committee_resolution.rs} (100%) rename poi-rs/tests/{proof_builder.rs => proof_construction.rs} (61%) rename poi-rs/tests/{golden.rs => proof_serialization.rs} (100%) rename poi-rs/tests/{verifier.rs => proof_verification.rs} (100%) rename poi-rs/tests/{proof_of_inclusion.rs => proof_workflows.rs} (100%) create mode 100644 poi-rs/tests/utils/sources.rs diff --git a/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts new file mode 100644 index 0000000..e239aa4 --- /dev/null +++ b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts @@ -0,0 +1,43 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { Committee } from "../node/poi_wasm.js"; + +test("the WASM committee can be deserialized from Rust JSON", async () => { + const json = await readFile( + new URL( + "../../../../poi-rs/tests/fixtures/current/committee.json", + import.meta.url, + ), + "utf8", + ); + + const committee = Committee.fromJSON(json); + + assert.equal(committee.epoch, 0n); +}); + +test("the WASM committee rejects invalid total voting power", async () => { + const fixture = JSON.parse( + await readFile( + new URL( + "../../../../poi-rs/tests/fixtures/current/committee.json", + import.meta.url, + ), + "utf8", + ), + ) as { + epoch: number; + voting_rights: [string, number][]; + }; + fixture.voting_rights[0]![1] = 9_999; + + assert.throws( + () => Committee.fromJSON(JSON.stringify(fixture)), + /committee voting power must total 10000, received 9999/, + ); +}); diff --git a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts similarity index 60% rename from bindings/wasm/poi_wasm/tests/wasm-source.test.ts rename to bindings/wasm/poi_wasm/tests/committee-resolution.test.ts index 694ce7b..f2f83a0 100644 --- a/bindings/wasm/poi_wasm/tests/wasm-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts @@ -5,52 +5,16 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { - Committee, - CommitteeResolver, - Proof, - ProofBuilder, -} from "../node/poi_wasm.js"; +import { Committee, CommitteeResolver, Proof } from "../node/poi_wasm.js"; import type { LedgerSource } from "../lib/source-types.js"; -test("the WASM builder reads transaction evidence from the ledger source", async () => { - const transactionDigest = new Uint8Array(32).fill(0x2a); - let requestedDigest: Uint8Array | undefined; - const source = { - async transaction(digest: Uint8Array) { - requestedDigest = digest; - - return { - // Deliberately invalid BCS: the test is proving that the WASM adapter - // reached this source and attempted Rust-side decoding. - transactionBcs: new Uint8Array([0xff]), - signaturesBcs: [], - effectsBcs: new Uint8Array([0xff]), - checkpointSequenceNumber: 7n, - }; - }, - } as unknown as LedgerSource; - - await assert.rejects( - new ProofBuilder(source).transaction(transactionDigest).build(), - /source failed while reading transaction .*: source returned an invalid response/, - ); - assert.deepEqual(requestedDigest, transactionDigest); -}); - -test("the WASM builder validates digest lengths before fetching", () => { - const source = {} as LedgerSource; - - assert.throws( - () => new ProofBuilder(source).transaction(new Uint8Array(31)), - /invalid digest byte length: expected 32, got 31/, - ); -}); - test("the WASM resolver constructs a committee reported by a trusted node", async () => { const fixture = JSON.parse( await readFile( - new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), + new URL( + "../../../../poi-rs/tests/fixtures/current/committee.json", + import.meta.url, + ), "utf8", ), ) as { @@ -73,56 +37,20 @@ test("the WASM resolver constructs a committee reported by a trusted node", asyn assert.equal(committee.epoch, 0n); }); -test("the WASM committee can be deserialized from Rust JSON", async () => { - const json = await readFile( - new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), - "utf8", - ); - - const committee = Committee.fromJSON(json); - - assert.equal(committee.epoch, 0n); -}); - -test("the WASM committee rejects invalid total voting power", async () => { - const fixture = JSON.parse( - await readFile( - new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), - "utf8", - ), - ) as { - epoch: number; - voting_rights: [string, number][]; - }; - fixture.voting_rights[0]![1] = 9_999; - - assert.throws( - () => Committee.fromJSON(JSON.stringify(fixture)), - /committee voting power must total 10000, received 9999/, - ); -}); - -test("the WASM proof can be deserialized for verification", async () => { - const json = await readFile( - new URL("../../../../poi-rs/tests/fixtures/current/transaction.json", import.meta.url), - "utf8", - ); - - const proof = Proof.fromJSON(json); - - assert.equal(proof.version, 1); - assert.equal(proof.checkpointEpoch, 0n); - assert.doesNotThrow(() => proof.validate()); -}); - test("the anchored verifier resolves the committee and verifies the proof", async () => { const [committeeJson, proofJson] = await Promise.all([ readFile( - new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), + new URL( + "../../../../poi-rs/tests/fixtures/current/committee.json", + import.meta.url, + ), "utf8", ), readFile( - new URL("../../../../poi-rs/tests/fixtures/current/transaction.json", import.meta.url), + new URL( + "../../../../poi-rs/tests/fixtures/current/transaction.json", + import.meta.url, + ), "utf8", ), ]); @@ -138,7 +66,10 @@ test("the anchored verifier resolves the committee and verifies the proof", asyn test("the anchored resolver returns its trusted committee without fetching it again", async () => { const fixture = JSON.parse( await readFile( - new URL("../../../../poi-rs/tests/fixtures/current/committee.json", import.meta.url), + new URL( + "../../../../poi-rs/tests/fixtures/current/committee.json", + import.meta.url, + ), "utf8", ), ) as { diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts new file mode 100644 index 0000000..32096ad --- /dev/null +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -0,0 +1,59 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { Proof, ProofBuilder } from "../node/poi_wasm.js"; +import type { LedgerSource } from "../lib/source-types.js"; + +test("the WASM builder reads transaction evidence from the ledger source", async () => { + const transactionDigest = new Uint8Array(32).fill(0x2a); + let requestedDigest: Uint8Array | undefined; + const source = { + async transaction(digest: Uint8Array) { + requestedDigest = digest; + + return { + // Deliberately invalid BCS: the test is proving that the WASM adapter + // reached this source and attempted Rust-side decoding. + transactionBcs: new Uint8Array([0xff]), + signaturesBcs: [], + effectsBcs: new Uint8Array([0xff]), + checkpointSequenceNumber: 7n, + }; + }, + } as unknown as LedgerSource; + + await assert.rejects( + new ProofBuilder(source).transaction(transactionDigest).build(), + /source failed while reading transaction .*: source returned an invalid response/, + ); + assert.deepEqual(requestedDigest, transactionDigest); +}); + +test("the WASM builder validates digest lengths before fetching", () => { + const source = {} as LedgerSource; + + assert.throws( + () => new ProofBuilder(source).transaction(new Uint8Array(31)), + /invalid digest byte length: expected 32, got 31/, + ); +}); + +test("the WASM proof can be deserialized for verification", async () => { + const json = await readFile( + new URL( + "../../../../poi-rs/tests/fixtures/current/transaction.json", + import.meta.url, + ), + "utf8", + ); + + const proof = Proof.fromJSON(json); + + assert.equal(proof.version, 1); + assert.equal(proof.checkpointEpoch, 0n); + assert.doesNotThrow(() => proof.validate()); +}); diff --git a/poi-rs/tests/committee.rs b/poi-rs/tests/committee_resolution.rs similarity index 100% rename from poi-rs/tests/committee.rs rename to poi-rs/tests/committee_resolution.rs diff --git a/poi-rs/tests/proof_builder.rs b/poi-rs/tests/proof_construction.rs similarity index 61% rename from poi-rs/tests/proof_builder.rs rename to poi-rs/tests/proof_construction.rs index 10b2ba5..06dd2aa 100644 --- a/poi-rs/tests/proof_builder.rs +++ b/poi-rs/tests/proof_construction.rs @@ -5,159 +5,14 @@ mod utils; use std::sync::{Arc, Mutex}; -use async_trait::async_trait; -use iota_grpc_client::Client as GrpcClient; -use iota_sdk_types::{ObjectId, TransactionDigest, Version}; -use iota_types::{ - committee::{Committee, EpochId}, - digests::ChainIdentifier, - event::EventID, - messages_checkpoint::CertifiedCheckpointSummary, - object::Object, +use iota_sdk_types::TransactionDigest; +use iota_types::{event::EventID, object::Object}; +use poi_rs::{PoiClient, ProofBuilderError, ProofTarget, ProofVerifier, SourceError}; +use utils::{ + genesis_chain_identifier, grpc_client, object_transfer_tx, + sources::{MissingSource, RecordingSource, RejectingSource}, + staking_tx, start_test_cluster, transfer_tx, }; -use poi_rs::{ - PoiClient, ProofBuilderError, ProofTarget, ProofVerifier, Source, SourceCheckpoint, SourceError, SourceTransaction, -}; -use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; - -#[derive(Clone)] -struct RejectingSource; - -#[async_trait] -impl Source for RejectingSource { - async fn chain_identifier(&self) -> Result { - unreachable!("rejected transactions do not resolve a chain identifier") - } - - async fn transaction( - &self, - _transaction_digest: TransactionDigest, - ) -> Result, SourceError> { - Err(SourceError::request(std::io::Error::other("transaction rejected"))) - } - - async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { - Ok(None) - } - - async fn checkpoint(&self, _sequence_number: u64) -> Result { - unreachable!("rejected transactions do not resolve a checkpoint") - } - - async fn committee(&self, _epoch: EpochId) -> Result { - unreachable!("proof-only test source does not resolve committees") - } - - async fn current_epoch(&self) -> Result, SourceError> { - unreachable!("proof-only test source does not resolve the current epoch") - } - - async fn epoch_close_summary(&self, _epoch: EpochId) -> Result, SourceError> { - unreachable!("proof-only test source does not resolve epoch-close summaries") - } -} - -#[derive(Clone)] -struct RecordingSource { - source: GrpcClient, - transactions: Arc>>, - object_override: Option, -} - -impl RecordingSource { - fn new(source: GrpcClient, transactions: Arc>>) -> Self { - Self { - source, - transactions, - object_override: None, - } - } - - fn with_object_override(mut self, object: Object) -> Self { - self.object_override = Some(object); - self - } -} - -#[async_trait] -impl Source for RecordingSource { - async fn chain_identifier(&self) -> Result { - self.source.chain_identifier().await - } - - async fn transaction( - &self, - transaction_digest: TransactionDigest, - ) -> Result, SourceError> { - self.transactions - .lock() - .expect("recorded transactions lock must not be poisoned") - .push(transaction_digest); - - self.source.transaction(transaction_digest).await - } - - async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { - if let Some(object) = &self.object_override { - return Ok(Some(object.clone())); - } - - self.source.object(object_id, version).await - } - - async fn checkpoint(&self, sequence_number: u64) -> Result { - self.source.checkpoint(sequence_number).await - } - - async fn committee(&self, epoch: EpochId) -> Result { - self.source.committee(epoch).await - } - - async fn current_epoch(&self) -> Result, SourceError> { - self.source.current_epoch().await - } - - async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { - self.source.epoch_close_summary(epoch).await - } -} - -#[derive(Clone)] -struct MissingSource; - -#[async_trait] -impl Source for MissingSource { - async fn chain_identifier(&self) -> Result { - unreachable!("missing targets do not resolve a chain identifier") - } - - async fn transaction( - &self, - _transaction_digest: TransactionDigest, - ) -> Result, SourceError> { - Ok(None) - } - - async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { - Ok(None) - } - - async fn checkpoint(&self, _sequence_number: u64) -> Result { - unreachable!("missing targets do not resolve a checkpoint") - } - - async fn committee(&self, _epoch: EpochId) -> Result { - unreachable!("proof-only test source does not resolve committees") - } - - async fn current_epoch(&self) -> Result, SourceError> { - unreachable!("proof-only test source does not resolve the current epoch") - } - - async fn epoch_close_summary(&self, _epoch: EpochId) -> Result, SourceError> { - unreachable!("proof-only test source does not resolve epoch-close summaries") - } -} #[tokio::test] async fn client_uses_a_custom_source_for_proof_building() { diff --git a/poi-rs/tests/golden.rs b/poi-rs/tests/proof_serialization.rs similarity index 100% rename from poi-rs/tests/golden.rs rename to poi-rs/tests/proof_serialization.rs diff --git a/poi-rs/tests/verifier.rs b/poi-rs/tests/proof_verification.rs similarity index 100% rename from poi-rs/tests/verifier.rs rename to poi-rs/tests/proof_verification.rs diff --git a/poi-rs/tests/proof_of_inclusion.rs b/poi-rs/tests/proof_workflows.rs similarity index 100% rename from poi-rs/tests/proof_of_inclusion.rs rename to poi-rs/tests/proof_workflows.rs diff --git a/poi-rs/tests/utils/mod.rs b/poi-rs/tests/utils/mod.rs index b65e295..e7f53d0 100644 --- a/poi-rs/tests/utils/mod.rs +++ b/poi-rs/tests/utils/mod.rs @@ -15,6 +15,7 @@ use iota_types::{committee::Committee, digests::ChainIdentifier}; use test_cluster::{TestCluster, TestClusterBuilder}; pub mod proofs; +pub mod sources; pub fn committee_at(epoch: u64) -> Committee { let (committee, _) = Committee::new_simple_test_committee(); diff --git a/poi-rs/tests/utils/sources.rs b/poi-rs/tests/utils/sources.rs new file mode 100644 index 0000000..d3e6b63 --- /dev/null +++ b/poi-rs/tests/utils/sources.rs @@ -0,0 +1,154 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use iota_grpc_client::Client as GrpcClient; +use iota_sdk_types::{ObjectId, TransactionDigest, Version}; +use iota_types::{ + committee::{Committee, EpochId}, + digests::ChainIdentifier, + messages_checkpoint::CertifiedCheckpointSummary, + object::Object, +}; +use poi_rs::{Source, SourceCheckpoint, SourceError, SourceTransaction}; + +#[derive(Clone)] +pub struct RejectingSource; + +#[async_trait] +impl Source for RejectingSource { + async fn chain_identifier(&self) -> Result { + unreachable!("rejected transactions do not resolve a chain identifier") + } + + async fn transaction( + &self, + _transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + Err(SourceError::request(std::io::Error::other("transaction rejected"))) + } + + async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { + Ok(None) + } + + async fn checkpoint(&self, _sequence_number: u64) -> Result { + unreachable!("rejected transactions do not resolve a checkpoint") + } + + async fn committee(&self, _epoch: EpochId) -> Result { + unreachable!("proof-only test source does not resolve committees") + } + + async fn current_epoch(&self) -> Result, SourceError> { + unreachable!("proof-only test source does not resolve the current epoch") + } + + async fn epoch_close_summary(&self, _epoch: EpochId) -> Result, SourceError> { + unreachable!("proof-only test source does not resolve epoch-close summaries") + } +} + +#[derive(Clone)] +pub struct RecordingSource { + source: GrpcClient, + transactions: Arc>>, + object_override: Option, +} + +impl RecordingSource { + pub fn new(source: GrpcClient, transactions: Arc>>) -> Self { + Self { + source, + transactions, + object_override: None, + } + } + + pub fn with_object_override(mut self, object: Object) -> Self { + self.object_override = Some(object); + self + } +} + +#[async_trait] +impl Source for RecordingSource { + async fn chain_identifier(&self) -> Result { + self.source.chain_identifier().await + } + + async fn transaction( + &self, + transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + self.transactions + .lock() + .expect("recorded transactions lock must not be poisoned") + .push(transaction_digest); + + self.source.transaction(transaction_digest).await + } + + async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError> { + if let Some(object) = &self.object_override { + return Ok(Some(object.clone())); + } + + self.source.object(object_id, version).await + } + + async fn checkpoint(&self, sequence_number: u64) -> Result { + self.source.checkpoint(sequence_number).await + } + + async fn committee(&self, epoch: EpochId) -> Result { + self.source.committee(epoch).await + } + + async fn current_epoch(&self) -> Result, SourceError> { + self.source.current_epoch().await + } + + async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { + self.source.epoch_close_summary(epoch).await + } +} + +#[derive(Clone)] +pub struct MissingSource; + +#[async_trait] +impl Source for MissingSource { + async fn chain_identifier(&self) -> Result { + unreachable!("missing targets do not resolve a chain identifier") + } + + async fn transaction( + &self, + _transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + Ok(None) + } + + async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { + Ok(None) + } + + async fn checkpoint(&self, _sequence_number: u64) -> Result { + unreachable!("missing targets do not resolve a checkpoint") + } + + async fn committee(&self, _epoch: EpochId) -> Result { + unreachable!("proof-only test source does not resolve committees") + } + + async fn current_epoch(&self) -> Result, SourceError> { + unreachable!("proof-only test source does not resolve the current epoch") + } + + async fn epoch_close_summary(&self, _epoch: EpochId) -> Result, SourceError> { + unreachable!("proof-only test source does not resolve epoch-close summaries") + } +} From 560b757f1fed475719b15dc1085db50cab561c73 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 3 Aug 2026 16:13:53 +0300 Subject: [PATCH 37/41] feat: refactor committee resolution handling in PoiClient and related components --- bindings/wasm/poi_wasm/README.md | 16 +- bindings/wasm/poi_wasm/lib/index.ts | 1 + bindings/wasm/poi_wasm/lib/poi-client.ts | 30 +-- bindings/wasm/poi_wasm/src/committee.rs | 64 ++++--- .../tests/committee-resolution.test.ts | 47 ++++- poi-rs/README.md | 18 +- poi-rs/src/bin/poi.rs | 7 +- poi-rs/src/client.rs | 60 +----- poi-rs/src/committee.rs | 177 +++++++++++++----- poi-rs/src/lib.rs | 13 +- poi-rs/tests/committee_resolution.rs | 27 ++- poi-rs/tests/proof_workflows.rs | 14 +- 12 files changed, 273 insertions(+), 201 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 7d5c345..0c790fe 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -69,10 +69,12 @@ checkpoint sequence numbers into WASM. Rust decodes those values into existing IOTA domain types and delegates target resolution and proof construction to `poi-rs`. -## Trusted-node verification +## Verification ```ts -const verifier = client.trustedNode(); +import { CommitteeResolution } from "@iota/poi-wasm"; + +const verifier = client.verifier(CommitteeResolution.trustedNode()); await verifier.verify(proof); ``` @@ -88,14 +90,16 @@ lineage from an already trusted committee: import { readFile } from "node:fs/promises"; const trustedGenesisBlob = await readFile("genesis.blob"); -const verifier = client.anchoredAtGenesis(trustedGenesisBlob); +const resolution = CommitteeResolution.fromGenesis(trustedGenesisBlob); +const verifier = client.verifier(resolution); await verifier.verify(proof); ``` -`anchoredAtGenesis()` decodes the BCS-encoded IOTA genesis blob and extracts its -committee in Rust. Callers that already possess an extracted trusted committee -can use `anchoredAt(committee)` instead. `Committee.fromJSON()` accepts the Rust +`CommitteeResolution.fromGenesis()` decodes the BCS-encoded IOTA genesis blob +and extracts its committee in Rust. Callers that already possess an extracted +trusted committee can use `CommitteeResolution.anchored(committee)` instead. +`Committee.fromJSON()` accepts the Rust `Committee` fields `epoch` and `voting_rights`, validates public keys, rejects duplicate authorities, requires total voting power to equal 10,000, and reconstructs the committee's derived lookup state. diff --git a/bindings/wasm/poi_wasm/lib/index.ts b/bindings/wasm/poi_wasm/lib/index.ts index 36f4c1f..e8b0813 100644 --- a/bindings/wasm/poi_wasm/lib/index.ts +++ b/bindings/wasm/poi_wasm/lib/index.ts @@ -4,6 +4,7 @@ export { PoiClient, type PoiClientOptions } from "./poi-client.js"; export { Committee, + CommitteeResolution, CommitteeResolver, Proof, type ProofBuilder, diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index e9a6f47..6d12238 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -4,7 +4,7 @@ import type { Transport } from "@connectrpc/connect"; import { - type Committee, + type CommitteeResolution, CommitteeResolver, ProofBuilder, } from "../node/poi_wasm.js"; @@ -61,30 +61,8 @@ export class PoiClient { return new ProofBuilder(this.#source); } - /** - * Configures verification to trust this client's node for committee data. - * - * This does not authenticate committee lineage from genesis. - */ - public trustedNode(): CommitteeResolver { - return new CommitteeResolver(this.#source); - } - - /** - * Configures verification to anchor at an already trusted committee. - * - * Each epoch-close checkpoint is authenticated before the next committee is - * accepted and cached. - */ - public anchoredAt(committee: Committee): CommitteeResolver { - return CommitteeResolver.anchor(this.#source, committee); - } - - /** - * Configures verification to anchor at the committee contained in a trusted - * IOTA genesis blob. - */ - public anchoredAtGenesis(genesisBlob: Uint8Array): CommitteeResolver { - return CommitteeResolver.anchorAtGenesis(this.#source, genesisBlob); + /** Creates a verifier using the selected committee-resolution strategy. */ + public verifier(resolution: CommitteeResolution): CommitteeResolver { + return new CommitteeResolver(this.#source, resolution); } } diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index 6f591e2..19da2df 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -8,7 +8,7 @@ use iota_types::{ committee::{Committee, EpochId, StakeUnit, TOTAL_VOTING_POWER}, }; use js_sys::Uint8Array; -use poi_rs::{CommitteeResolver, PoiClient}; +use poi_rs::{CommitteeResolution, CommitteeResolver}; use serde::Deserialize; use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; @@ -72,6 +72,37 @@ impl WasmCommittee { } } +/// Selects how a resolver establishes trust in committee data. +#[wasm_bindgen(js_name = CommitteeResolution)] +pub struct WasmCommitteeResolution(CommitteeResolution); + +#[wasm_bindgen(js_class = CommitteeResolution)] +impl WasmCommitteeResolution { + /// Accepts committee data returned directly by the JavaScript source. + /// + /// This does not authenticate committee lineage. Use it only when the + /// source is inside the caller's trust boundary. + #[wasm_bindgen(js_name = trustedNode)] + pub fn trusted_node() -> Self { + Self(CommitteeResolution::TrustedNode) + } + + /// Authenticates committee lineage from an already trusted committee. + pub fn anchored(committee: &WasmCommittee) -> Self { + Self(CommitteeResolution::anchored(committee.0.clone())) + } + + /// Authenticates committee lineage from the committee in a trusted genesis blob. + #[wasm_bindgen(js_name = fromGenesis)] + pub fn from_genesis(genesis_blob: Uint8Array) -> Result { + let bytes = genesis_blob.to_vec(); + CommitteeResolution::from_genesis(bytes.as_slice()) + .map(Self) + .map_err(|error| PoiError::invalid_input(format!("failed to load trusted genesis blob: {error}"))) + .wasm_result() + } +} + /// Resolves the committee required to verify a Proof of Inclusion proof. /// /// Node mode trusts the JavaScript source for committee data. Anchored mode @@ -82,35 +113,10 @@ pub struct WasmCommitteeResolver(CommitteeResolver); #[wasm_bindgen(js_class = CommitteeResolver)] impl WasmCommitteeResolver { - /// Creates a trusted-node resolver backed by a JavaScript ledger source. + /// Creates a resolver backed by a JavaScript ledger source. #[wasm_bindgen(constructor)] - pub fn new(source: LedgerSource) -> Self { - Self::node(source) - } - - /// Creates a resolver that trusts the JavaScript source for committee data. - pub fn node(source: LedgerSource) -> Self { - Self(CommitteeResolver::node(source)) - } - - /// Creates a resolver anchored at an already trusted committee. - /// - /// The resolver authenticates every epoch-close checkpoint from the trusted - /// committee up to the requested epoch. - pub fn anchor(source: LedgerSource, committee: &WasmCommittee) -> Self { - Self(CommitteeResolver::anchor(source, committee.0.clone())) - } - - /// Creates a resolver anchored at the committee contained in a trusted genesis blob. - #[wasm_bindgen(js_name = anchorAtGenesis)] - pub fn anchor_at_genesis(source: LedgerSource, genesis_blob: Uint8Array) -> Result { - let bytes = genesis_blob.to_vec(); - let resolver = PoiClient::new(source) - .anchored_at_genesis(bytes.as_slice()) - .map_err(|error| PoiError::invalid_input(format!("failed to load trusted genesis blob: {error}"))) - .wasm_result()?; - - Ok(Self(resolver)) + pub fn new(source: LedgerSource, resolution: &WasmCommitteeResolution) -> Self { + Self(CommitteeResolver::new(source, resolution.0.clone())) } /// Resolves the committee governing `epoch`. diff --git a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts index f2f83a0..dbc77ac 100644 --- a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts @@ -5,7 +5,12 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { Committee, CommitteeResolver, Proof } from "../node/poi_wasm.js"; +import { + Committee, + CommitteeResolution, + CommitteeResolver, + Proof, +} from "../node/poi_wasm.js"; import type { LedgerSource } from "../lib/source-types.js"; test("the WASM resolver constructs a committee reported by a trusted node", async () => { @@ -32,7 +37,10 @@ test("the WASM resolver constructs a committee reported by a trusted node", asyn }, } as unknown as LedgerSource; - const committee = await new CommitteeResolver(source).resolve(0n); + const committee = await new CommitteeResolver( + source, + CommitteeResolution.trustedNode(), + ).resolve(0n); assert.equal(committee.epoch, 0n); }); @@ -59,7 +67,10 @@ test("the anchored verifier resolves the committee and verifies the proof", asyn const source = {} as LedgerSource; await assert.doesNotReject( - CommitteeResolver.anchor(source, committee).verify(proof), + new CommitteeResolver( + source, + CommitteeResolution.anchored(committee), + ).verify(proof), ); }); @@ -85,8 +96,14 @@ test("the anchored resolver returns its trusted committee without fetching it ag }; }, } as unknown as LedgerSource; - const committee = await CommitteeResolver.node(source).resolve(0n); - const anchored = await CommitteeResolver.anchor(source, committee).resolve(0n); + const committee = await new CommitteeResolver( + source, + CommitteeResolution.trustedNode(), + ).resolve(0n); + const anchored = await new CommitteeResolver( + source, + CommitteeResolution.anchored(committee), + ).resolve(0n); assert.equal(anchored.epoch, 0n); }); @@ -116,10 +133,16 @@ test("the anchored resolver reports a missing current epoch", async () => { return undefined; }, } as unknown as LedgerSource; - const committee = await CommitteeResolver.node(source).resolve(0n); + const committee = await new CommitteeResolver( + source, + CommitteeResolution.trustedNode(), + ).resolve(0n); await assert.rejects( - CommitteeResolver.anchor(source, committee).resolve(1n), + new CommitteeResolver( + source, + CommitteeResolution.anchored(committee), + ).resolve(1n), /service information is missing the current epoch/, ); }); @@ -160,10 +183,16 @@ test("the anchored resolver requests epoch-close evidence through the JavaScript }; }, } as unknown as LedgerSource; - const committee = await CommitteeResolver.node(source).resolve(0n); + const committee = await new CommitteeResolver( + source, + CommitteeResolution.trustedNode(), + ).resolve(0n); await assert.rejects( - CommitteeResolver.anchor(source, committee).resolve(1n), + new CommitteeResolver( + source, + CommitteeResolution.anchored(committee), + ).resolve(1n), /failed to fetch end-of-epoch checkpoint information for epoch 0/, ); assert.equal(requestedEpoch, 0n); diff --git a/poi-rs/README.md b/poi-rs/README.md index 327a0e7..2a31b3d 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -66,20 +66,23 @@ required by the proof and then performs offline proof verification: ```rust,no_run use std::fs::File; -use poi_rs::{PoiClient, Proof}; +use poi_rs::{CommitteeResolution, PoiClient, Proof}; # async fn example(proof: &Proof) -> Result<(), Box> { let client = PoiClient::testnet()?; -let verifier = client.anchored_at_genesis(File::open("genesis.blob")?)?; +let resolution = CommitteeResolution::from_genesis(File::open("genesis.blob")?)?; +let verifier = client.verifier(resolution); verifier.verify(proof).await?; # Ok(()) # } ``` -`PoiClient::trusted_node()` is available when the connected node is explicitly inside the caller's trust boundary. -`PoiClient::anchored_at_genesis()` loads the anchor committee from a trusted BCS-encoded genesis blob. -`PoiClient::anchored_at()` accepts an already extracted trusted committee instead. +`CommitteeResolution::TrustedNode` is available when the connected node is explicitly inside the caller's trust +boundary. `CommitteeResolution::from_genesis()` loads an anchor committee from a trusted BCS-encoded genesis blob, +while `CommitteeResolution::anchored()` accepts an already extracted trusted committee. Use +`CommitteeResolution::anchored_with_cache()` or `CommitteeResolution::from_genesis_with_cache()` to supply a cache +that contains committees authenticated for the same network. Retain the verifier when checking multiple proofs so its authenticated committee cache is reused. `ProofVerifier` remains the offline verification entry point for callers that already possess the authoritative @@ -113,11 +116,12 @@ 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. - `ProofTarget`: Transaction, object, or event requested from a `ProofBuilder`. -- `PoiClient`: Source-backed entry point for proof construction and trusted-node or anchored verification. +- `PoiClient`: Source-backed entry point for proof construction and committee-aware verification. +- `CommitteeResolution`: Trusted-node or anchored committee-resolution configuration, including the committee cache. - `ProofBuilder`: Network-aware or custom-source proof construction. - `Source`: Ledger-read boundary for gRPC nodes, JavaScript clients, archives, fixtures, and other evidence sources. - `SourceTransaction` and `SourceCheckpoint`: Transport-independent decoded evidence returned by a `Source`. -- `CommitteeResolver`: Trusted-node or anchored committee resolution and source-backed proof verification. +- `CommitteeResolver`: Committee resolution and source-backed proof verification configured by `CommitteeResolution`. - `ProofVerifier`: Offline verifier for `Proof` values. - `SourceError`: Transport and response failures from a ledger source. - `ProofBuilderError`, `CommitteeResolutionError`, `ProofVerificationError`, `VerifyError`, `SerializationError`, and diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index de0adb5..7329953 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -15,7 +15,7 @@ use iota_config::{IOTA_GENESIS_FILENAME, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; use iota_types::event::EventID; -use poi_rs::{PoiClient, Proof}; +use poi_rs::{CommitteeResolution, PoiClient, Proof}; const GENESIS_CACHE_DIR: &str = "poi"; const MAINNET_GENESIS_URL: &str = "https://dbfiles.mainnet.iota.cafe/genesis.blob"; @@ -170,9 +170,10 @@ impl VerifyArgs { .await? } }; + let resolution = CommitteeResolution::from_genesis(genesis) + .map_err(|error| anyhow::anyhow!("failed to load trusted genesis blob: {error}"))?; PoiClient::from_grpc_client(self.endpoint.client()?) - .anchored_at_genesis(genesis) - .map_err(|error| anyhow::anyhow!("failed to load trusted genesis blob: {error}"))? + .verifier(resolution) .verify(&proof) .await .context("proof verification failed")?; diff --git a/poi-rs/src/client.rs b/poi-rs/src/client.rs index 2af5a55..358588b 100644 --- a/poi-rs/src/client.rs +++ b/poi-rs/src/client.rs @@ -1,22 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::io::Read; - #[cfg(feature = "native-grpc")] use iota_grpc_client::Client as GrpcClient; -use iota_sdk_types::CheckpointContents; -use iota_types::{ - committee::Committee, - effects::{TransactionEffects, TransactionEvents}, - iota_system_state::{IotaSystemStateTrait, get_iota_system_state}, - messages_checkpoint::CertifiedCheckpointSummary, - object::Object, - transaction::Transaction, -}; -use serde::Deserialize; -use crate::{CommitteeResolver, ProofBuilder, Source}; +use crate::{CommitteeResolution, CommitteeResolver, ProofBuilder, Source}; /// Convenient entry point for proof construction and verification backed by one ledger source. #[derive(Clone)] @@ -40,48 +28,12 @@ where ProofBuilder::new(self.source.clone()) } - /// Configures verification to trust this client's source for committee data. - /// - /// This does not authenticate committee lineage. Use it only when the - /// source is inside the caller's trust boundary. - pub fn trusted_node(&self) -> CommitteeResolver { - CommitteeResolver::node(self.source.clone()) - } - - /// Configures verification to anchor at an already trusted committee. - /// - /// Every epoch-close checkpoint from the trusted committee up to the epoch - /// required by each proof is authenticated before that proof is verified. - pub fn anchored_at(&self, trusted_committee: Committee) -> CommitteeResolver { - CommitteeResolver::anchor(self.source.clone(), trusted_committee) - } - - /// Configures verification to anchor at the committee contained in a trusted genesis blob. + /// Creates a verifier using the selected committee-resolution strategy. /// - /// The reader must contain the BCS-encoded `genesis.blob` for the proof's - /// network. The blob establishes the caller's trust anchor. - pub fn anchored_at_genesis( - &self, - reader: impl Read, - ) -> Result, Box> { - #[allow(dead_code)] - #[derive(Deserialize)] - struct GenesisBlob { - checkpoint: CertifiedCheckpointSummary, - checkpoint_contents: CheckpointContents, - transaction: Transaction, - effects: TransactionEffects, - events: TransactionEvents, - objects: Vec, - } - - let genesis: GenesisBlob = bcs::from_reader(reader)?; - let objects = genesis.objects.as_slice(); - - let system_state = get_iota_system_state(&objects)?; - let committee = system_state.get_current_epoch_committee().committee().clone(); - - Ok(CommitteeResolver::anchor(self.source.clone(), committee)) + /// Retain the returned resolver when verifying multiple proofs so anchored + /// resolutions can reuse their authenticated committee cache. + pub fn verifier(&self, resolution: CommitteeResolution) -> CommitteeResolver { + CommitteeResolver::new(self.source.clone(), resolution) } } diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 0aedc70..a0d4851 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -1,14 +1,21 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::sync::Arc; +use std::{io::Read, sync::Arc}; #[cfg(feature = "native-grpc")] use iota_grpc_client::Client as GrpcClient; +use iota_sdk_types::CheckpointContents; use iota_types::{ committee::{Committee, EpochId}, + effects::{TransactionEffects, TransactionEvents}, error::IotaError, + iota_system_state::{IotaSystemStateTrait, get_iota_system_state}, + messages_checkpoint::CertifiedCheckpointSummary, + object::Object, + transaction::Transaction, }; +use serde::Deserialize; use crate::{ BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Proof, ProofVerifier, Source, VerifyError, @@ -37,6 +44,13 @@ impl CommitteeResolutionError { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum CommitteeResolutionErrorKind { + /// Loading the initial committee from the trusted genesis blob failed. + #[error("failed to load the committee from the trusted genesis blob")] + LoadGenesisCommittee { + /// Genesis decoding or system-state extraction failure. + #[source] + source: BoxError, + }, /// Fetching a committee directly from the trusted node failed. #[error("failed to fetch committee for epoch {epoch} from the trusted node")] FetchCommittee { @@ -139,16 +153,83 @@ pub enum ProofVerificationError { /// Selects how a resolver establishes trust in committee data. #[derive(Clone)] -enum CommitteeResolution { +#[non_exhaustive] +pub enum CommitteeResolution { /// Accept committee data returned directly by the connected node. - Node, + /// + /// This does not authenticate committee lineage. Use it only when the node + /// is inside the caller's trust boundary. + TrustedNode, /// Authenticate committee lineage from an existing trust anchor. - Anchor { + Anchored { + /// First committee trusted by the caller. committee: Committee, + /// Cache containing only committees authenticated for the same network. cache: Arc, }, } +impl CommitteeResolution { + /// Anchors committee resolution at an already trusted committee. + /// + /// Authenticated committees are retained in a fresh in-memory cache. + pub fn anchored(committee: Committee) -> Self { + Self::anchored_with_cache(committee, MemoryCommitteeCache::new()) + } + + /// Anchors committee resolution using 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. + pub fn anchored_with_cache(committee: Committee, cache: impl CommitteeCache + 'static) -> Self { + Self::Anchored { + committee, + cache: Arc::new(cache), + } + } + + /// Anchors committee resolution at the committee contained in a trusted genesis blob. + /// + /// The reader must contain the BCS-encoded `genesis.blob` for the proof's + /// network. Authenticated committees are retained in a fresh in-memory cache. + pub fn from_genesis(reader: impl Read) -> Result { + Self::from_genesis_with_cache(reader, MemoryCommitteeCache::new()) + } + + /// Anchors committee resolution from a trusted genesis blob using a caller-provided cache. + /// + /// The reader must contain the BCS-encoded `genesis.blob` for the proof's + /// network. The cache is part of the caller's trust boundary. + pub fn from_genesis_with_cache( + reader: impl Read, + cache: impl CommitteeCache + 'static, + ) -> Result { + #[allow(dead_code)] + #[derive(Deserialize)] + struct GenesisBlob { + checkpoint: CertifiedCheckpointSummary, + checkpoint_contents: CheckpointContents, + transaction: Transaction, + effects: TransactionEffects, + events: TransactionEvents, + objects: Vec, + } + + let genesis: GenesisBlob = + bcs::from_reader(reader).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { + source: Box::new(source), + })?; + let objects = genesis.objects.as_slice(); + let system_state = + get_iota_system_state(&objects).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { + source: Box::new(source), + })?; + let committee = system_state.get_current_epoch_committee().committee().clone(); + + Ok(Self::anchored_with_cache(committee, cache)) + } +} + /// Resolves the committee required to verify a checkpoint from a ledger source. /// /// A resolver either accepts committee data directly from a trusted node or @@ -164,40 +245,11 @@ impl CommitteeResolver where S: Source, { - /// 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(source: S) -> Self { - Self { - source, - mode: CommitteeResolution::Node, - } - } - - /// Creates a resolver anchored at an already trusted committee. - /// - /// The trusted committee should be obtained from the network genesis blob - /// or from a previously authenticated checkpoint. The connected node is - /// treated only as a source of epoch and checkpoint data. Authenticated - /// committees are retained in memory for subsequent resolutions. - pub fn anchor(source: S, committee: Committee) -> Self { - Self::anchor_with_cache(source, 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(source: S, committee: Committee, cache: impl CommitteeCache + 'static) -> Self { + /// Creates a resolver backed by `source` using `resolution` to establish committee trust. + pub const fn new(source: S, resolution: CommitteeResolution) -> Self { Self { source, - mode: CommitteeResolution::Anchor { - committee, - cache: Arc::new(cache), - }, + mode: resolution, } } @@ -208,8 +260,8 @@ where /// before accepting its successor. pub async fn resolve(&self, target_epoch: EpochId) -> Result { match &self.mode { - CommitteeResolution::Node => self.resolve_from_node(target_epoch).await, - CommitteeResolution::Anchor { committee, cache } => { + CommitteeResolution::TrustedNode => self.resolve_from_node(target_epoch).await, + CommitteeResolution::Anchored { committee, cache } => { self.resolve_from_anchor(committee, cache.as_ref(), target_epoch).await } } @@ -583,7 +635,10 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, current_committee.clone()); + let resolver = CommitteeResolver::new( + EpochCloseSource { summary }, + CommitteeResolution::anchored(current_committee.clone()), + ); let committee = resolver .fetch_next_committee(4, ¤t_committee, &cache) @@ -600,7 +655,10 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, wrong_committee.clone()); + let resolver = CommitteeResolver::new( + EpochCloseSource { summary }, + CommitteeResolution::anchored(wrong_committee.clone()), + ); let error = resolver .fetch_next_committee(4, &wrong_committee, &cache) @@ -622,7 +680,10 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, current_committee.clone()); + let resolver = CommitteeResolver::new( + EpochCloseSource { summary }, + CommitteeResolution::anchored(current_committee.clone()), + ); let error = resolver .fetch_next_committee(4, ¤t_committee, &cache) @@ -642,7 +703,10 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, wrong_committee.clone()); + let resolver = CommitteeResolver::new( + EpochCloseSource { summary }, + CommitteeResolution::anchored(wrong_committee.clone()), + ); let error = resolver .fetch_next_committee(4, &wrong_committee, &cache) @@ -661,7 +725,10 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, expected_committee.clone()); + let resolver = CommitteeResolver::new( + EpochCloseSource { summary }, + CommitteeResolution::anchored(expected_committee.clone()), + ); let error = resolver .fetch_next_committee(4, &expected_committee, &cache) @@ -683,7 +750,10 @@ mod tests { 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 resolver = CommitteeResolver::anchor(EpochCloseSource { summary }, current_committee.clone()); + let resolver = CommitteeResolver::new( + EpochCloseSource { summary }, + CommitteeResolution::anchored(current_committee.clone()), + ); let error = resolver .fetch_next_committee(EpochId::MAX, ¤t_committee, &cache) @@ -699,9 +769,12 @@ mod tests { #[tokio::test] async fn node_resolution_mode_carries_no_anchored_cache() { - let resolver = CommitteeResolver::node(GrpcClient::new("http://127.0.0.1:1").unwrap()); + let resolver = CommitteeResolver::new( + GrpcClient::new("http://127.0.0.1:1").unwrap(), + CommitteeResolution::TrustedNode, + ); - assert!(matches!(resolver.mode, CommitteeResolution::Node)); + assert!(matches!(resolver.mode, CommitteeResolution::TrustedNode)); } #[tokio::test] @@ -710,7 +783,8 @@ mod tests { let cache = crate::MemoryCommitteeCache::new(); cache.store(&next_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 resolver = + crate::PoiClient::new(client).verifier(CommitteeResolution::anchored_with_cache(current_committee, cache)); let resolved = resolver.resolve(4).await.unwrap(); @@ -721,8 +795,8 @@ mod tests { async fn anchor_mode_uses_a_committee_cache_by_default() { let (current_committee, next_committee, _) = signed_end_of_epoch_summary(3, true); 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 { + let resolver = CommitteeResolver::new(client, CommitteeResolution::anchored(current_committee)); + let CommitteeResolution::Anchored { cache, .. } = &resolver.mode else { panic!("anchor resolver must have a committee cache"); }; cache.store(&next_committee).await.unwrap(); @@ -752,7 +826,10 @@ mod tests { 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 resolver = CommitteeResolver::new( + client, + CommitteeResolution::anchored_with_cache(current_committee, cache), + ); let resolved = resolver.resolve(4).await.unwrap(); diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index 3af92ad..d05e9c5 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -26,7 +26,8 @@ pub use builder::{ProofBuilder, ProofBuilderError, ProofTarget}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; pub use client::PoiClient; pub use committee::{ - CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver, ProofVerificationError, + CommitteeResolution, CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver, + ProofVerificationError, }; pub use proof::{ Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, @@ -34,3 +35,13 @@ pub use proof::{ }; pub use source::{Source, SourceCheckpoint, SourceError, SourceTransaction}; pub use target::ProofTargets; + +#[cfg(test)] +mod tests { + use crate::{PoiClient, Proof}; + + pub fn client_building_test() { + // Proof + let client = PoiClient::devnet().unwrap(); + } +} diff --git a/poi-rs/tests/committee_resolution.rs b/poi-rs/tests/committee_resolution.rs index ea05004..3919d74 100644 --- a/poi-rs/tests/committee_resolution.rs +++ b/poi-rs/tests/committee_resolution.rs @@ -7,7 +7,7 @@ use std::fs::File; use iota_config::IOTA_GENESIS_FILENAME; use iota_grpc_client::Client as GrpcClient; -use poi_rs::{CommitteeResolutionErrorKind, PoiClient}; +use poi_rs::{CommitteeCache, CommitteeResolution, CommitteeResolutionErrorKind, MemoryCommitteeCache, PoiClient}; use utils::{advance_to_epoch, grpc_client, start_test_cluster}; use crate::utils::committee_at; @@ -22,10 +22,11 @@ async fn genesis_anchored_client_authenticates_committee_across_epochs() { let expected = advance_to_epoch(&cluster, 10).await; let genesis_path = cluster.swarm.dir().join(IOTA_GENESIS_FILENAME); let genesis = File::open(genesis_path).expect("test cluster genesis blob must be available"); + let cache = MemoryCommitteeCache::new(); - let resolver = PoiClient::new(grpc_client(&cluster)) - .anchored_at_genesis(genesis) + let resolution = CommitteeResolution::from_genesis_with_cache(genesis, cache.clone()) .expect("test cluster genesis committee must be extractable"); + let resolver = PoiClient::new(grpc_client(&cluster)).verifier(resolution); let resolved = resolver .resolve(10) @@ -33,12 +34,20 @@ async fn genesis_anchored_client_authenticates_committee_across_epochs() { .expect("epoch 10 committee must resolve from genesis"); assert_eq!(resolved, expected[10]); + assert_eq!( + cache + .committee(10) + .await + .expect("caller-provided cache must remain readable"), + Some(expected[10].clone()) + ); } #[tokio::test] async fn committee_anchored_client_returns_its_trust_anchor_without_fetching() { let trusted_committee = committee_at(7); - let resolver = PoiClient::new(disconnected_client()).anchored_at(trusted_committee.clone()); + let resolver = + PoiClient::new(disconnected_client()).verifier(CommitteeResolution::anchored(trusted_committee.clone())); let resolved = resolver .resolve(7) @@ -50,7 +59,7 @@ async fn committee_anchored_client_returns_its_trust_anchor_without_fetching() { #[tokio::test] async fn committee_anchored_client_rejects_epochs_before_its_anchor_without_fetching() { - let resolver = PoiClient::new(disconnected_client()).anchored_at(committee_at(7)); + let resolver = PoiClient::new(disconnected_client()).verifier(CommitteeResolution::anchored(committee_at(7))); let error = resolver .resolve(6) @@ -69,9 +78,9 @@ async fn genesis_anchored_client_rejects_epochs_ahead_of_the_node() { let cluster = start_test_cluster().await; let genesis_path = cluster.swarm.dir().join(IOTA_GENESIS_FILENAME); let genesis = File::open(genesis_path).expect("test cluster genesis blob must be available"); - let resolver = PoiClient::new(grpc_client(&cluster)) - .anchored_at_genesis(genesis) - .expect("test cluster genesis committee must be extractable"); + let resolution = + CommitteeResolution::from_genesis(genesis).expect("test cluster genesis committee must be extractable"); + let resolver = PoiClient::new(grpc_client(&cluster)).verifier(resolution); let error = resolver .resolve(1) @@ -87,7 +96,7 @@ async fn genesis_anchored_client_rejects_epochs_ahead_of_the_node() { #[tokio::test] async fn trusted_node_client_returns_the_committee_reported_by_its_source() { let cluster = start_test_cluster().await; - let resolver = PoiClient::new(grpc_client(&cluster)).trusted_node(); + let resolver = PoiClient::new(grpc_client(&cluster)).verifier(CommitteeResolution::TrustedNode); let resolved = resolver .resolve(0) diff --git a/poi-rs/tests/proof_workflows.rs b/poi-rs/tests/proof_workflows.rs index 515ee18..3adb819 100644 --- a/poi-rs/tests/proof_workflows.rs +++ b/poi-rs/tests/proof_workflows.rs @@ -7,7 +7,7 @@ use std::fs::File; use iota_config::IOTA_GENESIS_FILENAME; use iota_types::event::EventID; -use poi_rs::PoiClient; +use poi_rs::{CommitteeResolution, PoiClient}; use utils::{advance_to_epoch, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; #[tokio::test] @@ -25,9 +25,9 @@ async fn client_builds_and_verifies_a_transaction_proof_from_genesis() { .await .expect("transaction proof must be constructed"); + let resolution = CommitteeResolution::from_genesis(genesis).expect("test cluster genesis blob must load"); client - .anchored_at_genesis(genesis) - .expect("test cluster genesis blob must load") + .verifier(resolution) .verify(&proof) .await .expect("anchored verification must authenticate the committee and verify the proof"); @@ -48,7 +48,7 @@ async fn client_builds_and_verifies_an_object_proof_with_a_trusted_node() { assert_eq!(proof.target.objects[0].0, transfer.gas_object); client - .trusted_node() + .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await .expect("object proof must verify"); @@ -72,7 +72,7 @@ async fn client_builds_and_verifies_an_event_proof_with_a_trusted_node() { .expect("event proof must be constructed"); client - .trusted_node() + .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await .expect("event proof must verify"); @@ -94,7 +94,7 @@ async fn client_builds_one_verified_proof_for_multiple_objects() { assert_eq!(proof.transaction_proof.transaction.digest(), &transfer.digest); assert_eq!(proof.target.objects.len(), 2); client - .trusted_node() + .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await .expect("stacked object proof must verify"); @@ -123,7 +123,7 @@ async fn client_builds_one_verified_proof_for_object_and_event_targets() { assert_eq!(proof.target.objects.len(), 1); assert_eq!(proof.target.events.len(), 1); client - .trusted_node() + .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await .expect("mixed target proof must verify"); From 9cc98d3cb642583d9ad3cf6d82b26c930f887cdf Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 4 Aug 2026 19:17:21 +0300 Subject: [PATCH 38/41] feat: remove target module and integrate ProofTargets into proof module --- poi-rs/src/lib.rs | 7 ++---- poi-rs/src/proof.rs | 59 +++++++++++++++++++++++++++++++++++++++++-- poi-rs/src/target.rs | 60 -------------------------------------------- 3 files changed, 59 insertions(+), 67 deletions(-) delete mode 100644 poi-rs/src/target.rs diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index d05e9c5..ec935e4 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -19,8 +19,6 @@ pub mod committee; pub mod proof; /// Ledger evidence source abstraction. pub mod source; -/// Target claims authenticated by a proof. -pub mod target; pub use builder::{ProofBuilder, ProofBuilderError, ProofTarget}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; @@ -30,11 +28,10 @@ pub use committee::{ ProofVerificationError, }; pub use proof::{ - Proof, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, VerifyError, - VerifyErrorKind, VersionError, + Proof, ProofTargets, ProofVerifier, ProofVersion, SerializationError, SerializationErrorKind, TransactionProof, + VerifyError, VerifyErrorKind, VersionError, }; pub use source::{Source, SourceCheckpoint, SourceError, SourceTransaction}; -pub use target::ProofTargets; #[cfg(test)] mod tests { diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 5d10d98..3328041 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -1,17 +1,19 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_sdk_types::{CheckpointContents, EndOfEpochData}; +use iota_sdk_types::{CheckpointContents, EndOfEpochData, Event, ObjectReference}; use iota_types::{ committee::Committee, digests::ChainIdentifier, effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}, + event::EventID, messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContentsExt}, + object::Object, transaction::Transaction, }; use serde::{Deserialize, Serialize}; -use crate::{BoxError, target::ProofTargets}; +use crate::BoxError; /// Error returned when a proof-format version is not supported. #[derive(Debug, thiserror::Error)] @@ -153,6 +155,59 @@ impl TryFrom for ProofVersion { } } +/// Target claims authenticated by a Proof of Inclusion. +/// +/// Object and event targets are authenticated through the transaction evidence in +/// the proof. Committee targets authenticate the next epoch committee recorded in +/// an end-of-epoch checkpoint summary. +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +pub struct ProofTargets { + /// Objects that need to be certified. + pub objects: Vec<(ObjectReference, Object)>, + + /// Events that need to be certified. + pub events: Vec<(EventID, Event)>, + + /// The next committee being certified. + pub committee: Option, +} + +impl ProofTargets { + /// Creates an empty target set. + /// + /// Empty targets are mainly useful while constructing proofs incrementally. + pub fn new() -> Self { + Self::default() + } + + /// Adds an object target by object reference and object contents. + /// + /// Verification checks that the object computes to the supplied reference and + /// that the transaction effects include the reference. + pub fn add_object(mut self, object_ref: ObjectReference, object: Object) -> Self { + self.objects.push((object_ref, object)); + self + } + + /// Adds an event target by event ID and event contents. + /// + /// Verification checks that the event belongs to the transaction and matches + /// the event stored at the requested event sequence. + pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { + self.events.push((event_id, event)); + self + } + + /// Adds a next-epoch committee target. + /// + /// Verification checks that the checkpoint is an end-of-epoch checkpoint and + /// that its next committee matches the supplied committee. + pub fn set_committee(mut self, committee: Committee) -> Self { + self.committee = Some(committee); + self + } +} + /// 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/src/target.rs b/poi-rs/src/target.rs deleted file mode 100644 index dee31e1..0000000 --- a/poi-rs/src/target.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_sdk_types::{Event, ObjectReference}; -use iota_types::committee::Committee; -use iota_types::{event::EventID, object::Object}; -use serde::{Deserialize, Serialize}; - -/// Target claims authenticated by a Proof of Inclusion. -/// -/// Object and event targets are authenticated through the transaction evidence in -/// the proof. Committee targets authenticate the next epoch committee recorded in -/// an end-of-epoch checkpoint summary. -#[derive(Default, Debug, Serialize, Deserialize, Clone)] -pub struct ProofTargets { - /// Objects that need to be certified. - pub objects: Vec<(ObjectReference, Object)>, - - /// Events that need to be certified. - pub events: Vec<(EventID, Event)>, - - /// The next committee being certified. - pub committee: Option, -} - -impl ProofTargets { - /// Creates an empty target set. - /// - /// Empty targets are mainly useful while constructing proofs incrementally. - pub fn new() -> Self { - Self::default() - } - - /// Adds an object target by object reference and object contents. - /// - /// Verification checks that the object computes to the supplied reference and - /// that the transaction effects include the reference. - pub fn add_object(mut self, object_ref: ObjectReference, object: Object) -> Self { - self.objects.push((object_ref, object)); - self - } - - /// Adds an event target by event ID and event contents. - /// - /// Verification checks that the event belongs to the transaction and matches - /// the event stored at the requested event sequence. - pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { - self.events.push((event_id, event)); - self - } - - /// Adds a next-epoch committee target. - /// - /// Verification checks that the checkpoint is an end-of-epoch checkpoint and - /// that its next committee matches the supplied committee. - pub fn set_committee(mut self, committee: Committee) -> Self { - self.committee = Some(committee); - self - } -} From 7b188fc5ab04ff4ee9169cfcf4319f9edf4c0837 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 5 Aug 2026 07:16:21 +0300 Subject: [PATCH 39/41] feat: update ProofTargets to remove committee claims and adjust related documentation --- poi-rs/README.md | 5 +- poi-rs/src/proof.rs | 268 ++++++++---------- poi-rs/tests/fixtures/current/event.json | 5 +- poi-rs/tests/fixtures/current/object.json | 5 +- .../tests/fixtures/current/transaction.json | 5 +- poi-rs/tests/proof_verification.rs | 42 +-- poi-rs/tests/utils/proofs.rs | 32 +-- 7 files changed, 145 insertions(+), 217 deletions(-) diff --git a/poi-rs/README.md b/poi-rs/README.md index 2a31b3d..e625ec9 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -53,7 +53,7 @@ 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. +- `ProofTargets` describing the object or event 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. @@ -98,7 +98,6 @@ Verification checks: - 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 @@ -114,7 +113,7 @@ trust the authenticated target claims relative to the supplied committee. - `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. +- `ProofTargets`: Object and event claims to authenticate. - `ProofTarget`: Transaction, object, or event requested from a `ProofBuilder`. - `PoiClient`: Source-backed entry point for proof construction and committee-aware verification. - `CommitteeResolution`: Trusted-node or anchored committee-resolution configuration, including the committee cache. diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 3328041..0e7e176 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -1,7 +1,17 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_sdk_types::{CheckpointContents, EndOfEpochData, Event, ObjectReference}; +//! Proof types and verification. +//! +//! A [`Proof`] contains a certified checkpoint and the data needed to prove that +//! a transaction, and optionally its objects or events, belong to that +//! checkpoint. [`ProofVerifier`] verifies this data against a caller-provided +//! [`Committee`] without making network requests. +//! +//! [`CertifiedCheckpointSummary`]: iota_types::messages_checkpoint::CertifiedCheckpointSummary +//! [`Committee`]: iota_types::committee::Committee + +use iota_sdk_types::{CheckpointContents, Event, ObjectReference}; use iota_types::{ committee::Committee, digests::ChainIdentifier, @@ -15,129 +25,131 @@ use serde::{Deserialize, Serialize}; use crate::BoxError; -/// Error returned when a proof-format version is not supported. +/// An unsupported proof format version. #[derive(Debug, thiserror::Error)] #[non_exhaustive] #[error("unsupported Proof of Inclusion proof format version: {version}")] pub struct VersionError { - /// Unsupported proof-format version. + /// The unsupported version. pub version: u16, } -/// Error returned when a proof cannot be serialized or deserialized. +/// An error serializing or deserializing a proof. #[derive(Debug, thiserror::Error)] #[non_exhaustive] #[error("failed to serialize or deserialize Proof of Inclusion proof")] pub struct SerializationError { - /// Serialization failure details. + /// The underlying error. #[source] pub kind: SerializationErrorKind, } -/// Kind of proof serialization or deserialization failure. +/// The cause of a [`SerializationError`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SerializationErrorKind { - /// JSON serialization or deserialization failed. + /// JSON encoding or decoding failed. #[error("json serialization or deserialization failed")] Json { - /// Underlying JSON serialization or deserialization error. + /// Error reported by `serde_json`. #[source] source: serde_json::Error, }, } -/// Error returned when offline proof verification fails. +/// An error verifying a proof. #[derive(Debug, thiserror::Error)] #[non_exhaustive] #[error("failed to verify Proof of Inclusion proof")] pub struct VerifyError { - /// Verification failure details. + /// The reason verification failed. #[source] pub kind: VerifyErrorKind, } -/// Kind of offline proof-verification failure. +/// The cause of a [`VerifyError`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum VerifyErrorKind { - /// The proof-format version is not supported. + /// The proof uses an unsupported wire-format version. #[error("proof format version is not supported")] Version { - /// Unsupported version error. + /// The version error. #[source] source: VersionError, }, - /// The checkpoint summary or its contents failed verification. + /// The committee signature or checkpoint-contents commitment is invalid. #[error("checkpoint summary verification failed")] CheckpointSummary { - /// Underlying checkpoint-verification error. + /// The 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. + /// The packaged transaction does not match the transaction digest in its effects. #[error("transaction digest does not match the execution digest")] TransactionDigestMismatch, - /// The transaction effects are not included in the checkpoint contents. + /// The packaged transaction effects are absent from the authenticated checkpoint contents. #[error("transaction digest not found in the checkpoint contents")] TransactionNotInCheckpoint, - /// Packaged events do not match the digest recorded in the effects. + /// The packaged events do not match the events digest in the transaction effects. #[error("events digest does not match the execution digest")] EventsDigestMismatch, - /// Event targets require packaged transaction events. + /// Event claims are present but the proof does not contain transaction events. #[error("transaction effects refer to events but event data is missing")] MissingEvents, - /// The event target belongs to a different transaction. + /// An event claim identifies a transaction other than the one proven by the envelope. #[error("event target does not belong to the transaction")] EventTransactionMismatch, - /// The event target sequence number is outside the packaged event list. + /// An event claim refers to an index outside the packaged transaction events. #[error("event sequence number {sequence} is out of bounds")] EventSequenceOutOfBounds { - /// Requested event sequence. + /// Transaction-local event index requested by the claim. sequence: u64, }, - /// The packaged event does not match the event target. + /// The claimed event differs from the event at the requested transaction-local index. #[error("event target contents do not match")] EventContentsMismatch, - /// The object content does not compute to the requested object reference. + /// A claimed object does not compute to its packaged object reference. #[error("object target reference does not match the object")] ObjectReferenceMismatch, - /// The transaction effects do not include the requested object reference. + /// A claimed object reference is absent from the packaged transaction effects. #[error("object target was not found in the transaction effects")] ObjectNotFound, } -/// Proof-format version used for compatibility checks and verifier dispatch. +/// The format version of a serialized [`Proof`]. +/// +/// Versions are encoded as unsigned integers. This crate currently supports +/// only [`ProofVersion::CURRENT`]. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(transparent)] pub struct ProofVersion(u16); impl ProofVersion { - /// Current Proof of Inclusion proof-format version. + /// The version produced and accepted by this crate. pub const CURRENT: Self = Self(1); - /// Creates a supported proof-format version. + /// Creates a supported proof version. + /// + /// # Errors + /// + /// Returns [`VersionError`] when `version` is not [`Self::CURRENT`]. pub fn new(version: u16) -> Result { let version = Self(version); version.validate()?; Ok(version) } - /// Returns the numeric proof-format version. + /// Returns the numeric version. pub const fn value(self) -> u16 { self.0 } - /// Returns an error when this version is not supported. + /// Checks that this version is supported. + /// + /// # Errors + /// + /// Returns [`VersionError`] when the value is not [`Self::CURRENT`]. pub fn validate(self) -> Result<(), VersionError> { if self == Self::CURRENT { Ok(()) @@ -155,78 +167,62 @@ impl TryFrom for ProofVersion { } } -/// Target claims authenticated by a Proof of Inclusion. +/// Values whose inclusion is claimed by a [`Proof`]. /// -/// 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. +/// Objects and events must belong to the proven transaction. #[derive(Default, Debug, Serialize, Deserialize, Clone)] pub struct ProofTargets { - /// Objects that need to be certified. + /// Objects claimed to have been changed by the transaction. pub objects: Vec<(ObjectReference, Object)>, - /// Events that need to be certified. + /// Events claimed to have been emitted by the transaction. pub events: Vec<(EventID, Event)>, - - /// The next committee being certified. - pub committee: Option, } impl ProofTargets { - /// Creates an empty target set. - /// - /// Empty targets are mainly useful while constructing proofs incrementally. + /// Creates an empty set of claims. pub fn new() -> Self { Self::default() } - /// Adds an object target by object reference and object contents. + /// Adds an object claim. /// - /// Verification checks that the object computes to the supplied reference and - /// that the transaction effects include the reference. + /// During verification, `object` must compute to `object_ref`, and + /// `object_ref` must appear among the objects changed by the proven + /// transaction. pub fn add_object(mut self, object_ref: ObjectReference, object: Object) -> Self { self.objects.push((object_ref, object)); self } - /// Adds an event target by event ID and event contents. + /// Adds an event claim. /// - /// Verification checks that the event belongs to the transaction and matches - /// the event stored at the requested event sequence. + /// During verification, `event_id` must identify the proven transaction, and + /// `event` must equal the event at its transaction-local sequence number. pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { self.events.push((event_id, event)); self } - - /// Adds a next-epoch committee target. - /// - /// Verification checks that the checkpoint is an end-of-epoch checkpoint and - /// that its next committee matches the supplied committee. - pub fn set_committee(mut self, committee: Committee) -> Self { - self.committee = Some(committee); - self - } } -/// Transaction evidence packaged in a Proof of Inclusion envelope. +/// The data required to prove that a transaction belongs to a checkpoint. /// -/// 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. +/// The transaction effects link the transaction to `checkpoint_contents` and, +/// when present, commit to `events`. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TransactionProof { - /// Checkpoint contents including the transaction. + /// The contents of the checkpoint containing the transaction. pub checkpoint_contents: CheckpointContents, - /// Transaction being authenticated. + /// The transaction being proven. pub transaction: Transaction, - /// Effects of the transaction being authenticated. + /// The transaction's execution effects. pub effects: TransactionEffects, - /// Events of the transaction being authenticated, when present. + /// Events emitted by the transaction, if any. pub events: Option, } impl TransactionProof { - /// Creates transaction proof evidence. + /// Creates transaction proof data. pub fn new( checkpoint_contents: CheckpointContents, transaction: Transaction, @@ -242,29 +238,29 @@ impl TransactionProof { } } -/// Proof of Inclusion evidence for targets included in a certified checkpoint. +/// Evidence that a transaction is 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. +/// Every proof contains [`TransactionProof`] and may additionally claim objects +/// or events. Call [`ProofVerifier::verify`] to verify these claims. +/// +/// [`Proof::chain`] identifies the network reported by the proof source. It is +/// informational and is not checked during verification. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Proof { - /// Proof-format version. + /// The proof format version. pub version: ProofVersion, - /// Chain or network identity. + /// The network reported by the proof source. pub chain: ChainIdentifier, - /// Target claim authenticated by this proof. + /// The values claimed by the proof. pub target: ProofTargets, - /// Certified checkpoint summary. + /// The certified summary of the checkpoint containing the transaction. pub checkpoint_summary: CertifiedCheckpointSummary, - /// Transaction evidence for the inclusion target. + /// The transaction and its checkpoint inclusion data. pub transaction_proof: TransactionProof, } impl Proof { - /// Creates a proof envelope from an explicit target and transaction proof. - /// - /// The constructor sets [`ProofVersion::CURRENT`] automatically. + /// Creates a proof using [`ProofVersion::CURRENT`]. pub fn new( chain: ChainIdentifier, target: ProofTargets, @@ -280,60 +276,86 @@ impl Proof { } } - /// Returns the proof-format version. + /// Returns the proof format version. pub const fn version(&self) -> ProofVersion { self.version } - /// Returns the proof target. + /// Returns the values claimed by the proof. pub const fn target(&self) -> &ProofTargets { &self.target } - /// Serializes this proof envelope as JSON. + /// Serializes the proof as JSON. + /// + /// # Errors + /// + /// Returns an error if the proof cannot be serialized. pub fn to_json_vec(&self) -> Result, SerializationError> { serde_json::to_vec(self).map_err(|source| SerializationError { kind: SerializationErrorKind::Json { source }, }) } - /// Deserializes a proof envelope from JSON bytes. + /// Deserializes a proof from JSON. + /// + /// # Errors + /// + /// Returns an error if `bytes` do not contain a valid JSON representation of + /// a [`Proof`]. 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. + /// Checks that the proof format version is supported. + /// + /// # Errors + /// + /// Returns [`VersionError`] when [`Self::version`] is unsupported. pub fn validate(&self) -> Result<(), VersionError> { self.version.validate() } } -/// Offline Proof of Inclusion verifier. +/// Verifies proofs against a trusted committee. /// -/// `ProofVerifier` verifies only the proof material supplied by the caller. It -/// does not fetch data, resolve committees, or trust a node. +/// Verification is offline. The verifier does not resolve committee history or +/// fetch missing proof data. The caller is responsible for supplying the +/// committee that certified the proof's checkpoint. +/// +/// The value of [`Proof::chain`] is not used to select or validate the committee. #[derive(Clone, Copy, Debug)] pub struct ProofVerifier<'committee> { committee: &'committee Committee, } impl<'committee> ProofVerifier<'committee> { - /// Creates a verifier for proofs certified by `committee`. + /// Creates a verifier using `committee` as its trust root. pub const fn new(committee: &'committee Committee) -> Self { Self { committee } } - /// Returns the committee used by this verifier. + /// Returns the committee used to verify checkpoint signatures. pub const fn committee(&self) -> &'committee Committee { self.committee } - /// Verifies a Proof of Inclusion. + /// Verifies a proof and all of its claims. + /// + /// Verification checks that: /// - /// The verifier checks the checkpoint summary and all transaction evidence - /// before authenticating object, event, or committee targets. + /// - the proof uses a supported format version; + /// - the committee certifies the checkpoint summary; + /// - the checkpoint contents match the digest in that summary; + /// - the transaction, effects, and optional events are internally consistent; + /// - the transaction effects occur in the authenticated checkpoint contents; + /// - every object and event claim matches the proof data. + /// + /// # Errors + /// + /// Returns an error if any check fails. pub fn verify(&self, proof: &Proof) -> Result<(), VerifyError> { proof.validate().map_err(|source| VerifyError { kind: VerifyErrorKind::Version { source }, @@ -350,7 +372,6 @@ impl<'committee> ProofVerifier<'committee> { }, })?; - 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)?; @@ -358,44 +379,7 @@ 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, - targets: &ProofTargets, - ) -> Result<(), VerifyError> { - let Some(expected_committee) = &targets.committee else { - return Ok(()); - }; - - let Some(EndOfEpochData { - next_epoch_committee, .. - }) = &summary.end_of_epoch_data - else { - return Err(VerifyError { - kind: VerifyErrorKind::MissingEndOfEpochCommittee, - }); - }; - - let actual_committee = Committee::from_committee_members( - summary.epoch().checked_add(1).ok_or(VerifyError { - kind: VerifyErrorKind::NextEpochOverflow, - })?, - next_epoch_committee, - ); - - if actual_committee != *expected_committee { - return Err(VerifyError { - kind: VerifyErrorKind::CommitteeMismatch, - }); - } - - Ok(()) - } - - /// Verifies that the transaction matches its effects, appears in the - /// authenticated checkpoint contents, and carries the committed events. + /// Checks the transaction-to-effects, effects-to-checkpoint, and effects-to-events links. fn verify_transaction_proof( &self, summary: &CertifiedCheckpointSummary, @@ -431,8 +415,7 @@ 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. + /// Checks each event claim against the proven transaction and its packaged events. fn verify_event_targets( &self, targets: &ProofTargets, @@ -475,8 +458,7 @@ 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. + /// Checks each object claim against its reference and the transaction effects. fn verify_object_targets( &self, targets: &ProofTargets, diff --git a/poi-rs/tests/fixtures/current/event.json b/poi-rs/tests/fixtures/current/event.json index 485b36d..3fda4c0 100644 --- a/poi-rs/tests/fixtures/current/event.json +++ b/poi-rs/tests/fixtures/current/event.json @@ -17,8 +17,7 @@ "contents": "Ls9FicZo2wce3fWuF76Ozh9MHFqDi/lbNDQo5sI7qjGMRV8ikXw5a0aj9OvWJ3KGimZuazhPTwzuaNAE3kXuz8nVMKEzLacIv60tS2NMr6JltltdTeKeVS2SAOTxF/wLAAAAAAAAAAAAAENP15RqAA==" } ] - ], - "committee": null + ] }, "checkpoint_summary": { "data": { @@ -352,4 +351,4 @@ } ] } -} \ No newline at end of file +} diff --git a/poi-rs/tests/fixtures/current/object.json b/poi-rs/tests/fixtures/current/object.json index e2d555d..8c24b9c 100644 --- a/poi-rs/tests/fixtures/current/object.json +++ b/poi-rs/tests/fixtures/current/object.json @@ -25,8 +25,7 @@ } ] ], - "events": [], - "committee": null + "events": [] }, "checkpoint_summary": { "data": { @@ -282,4 +281,4 @@ }, "events": null } -} \ No newline at end of file +} diff --git a/poi-rs/tests/fixtures/current/transaction.json b/poi-rs/tests/fixtures/current/transaction.json index 1f9272d..a677bee 100644 --- a/poi-rs/tests/fixtures/current/transaction.json +++ b/poi-rs/tests/fixtures/current/transaction.json @@ -3,8 +3,7 @@ "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", "target": { "objects": [], - "events": [], - "committee": null + "events": [] }, "checkpoint_summary": { "data": { @@ -260,4 +259,4 @@ }, "events": null } -} \ No newline at end of file +} diff --git a/poi-rs/tests/proof_verification.rs b/poi-rs/tests/proof_verification.rs index dd6e788..dab22aa 100644 --- a/poi-rs/tests/proof_verification.rs +++ b/poi-rs/tests/proof_verification.rs @@ -5,14 +5,11 @@ mod utils; use iota_sdk_types::CheckpointContents; use iota_types::{ - base_types::dbg_object_id, committee::Committee, effects::TransactionEvents, event::EventID, - messages_checkpoint::CheckpointContentsExt, object::Object, + base_types::dbg_object_id, effects::TransactionEvents, event::EventID, messages_checkpoint::CheckpointContentsExt, + 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, -}; +use utils::proofs::{event, execution_data, proof_with_events, proof_with_targets, valid_transaction_proof}; #[test] fn valid_transaction_proof_is_accepted() { @@ -75,42 +72,13 @@ fn transaction_must_be_present_in_the_checkpoint() { 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) - .expect_err("a committee target without end-of-epoch data must be rejected"); - - 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) - .expect_err("a committee target not committed by end-of-epoch data must be rejected"); - - 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 (committee, proof) = proof_with_targets(targets); let error = ProofVerifier::new(&committee) .verify(&proof) @@ -124,7 +92,7 @@ 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 (committee, proof) = proof_with_targets(targets); let error = ProofVerifier::new(&committee) .verify(&proof) diff --git a/poi-rs/tests/utils/proofs.rs b/poi-rs/tests/utils/proofs.rs index e237810..73bf0bb 100644 --- a/poi-rs/tests/utils/proofs.rs +++ b/poi-rs/tests/utils/proofs.rs @@ -1,9 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_sdk_types::{ - CheckpointContents, CheckpointSummary, EndOfEpochData, Event, TransactionDigest, gas::GasCostSummary, -}; +use iota_sdk_types::{CheckpointContents, CheckpointSummary, Event, TransactionDigest, gas::GasCostSummary}; use iota_types::{ base_types::ExecutionData, committee::Committee, @@ -21,10 +19,7 @@ pub fn execution_data() -> ExecutionData { .expect("test checkpoint contents must include a transaction") } -fn signed_checkpoint( - contents: &CheckpointContents, - end_of_epoch_data: Option, -) -> (Committee, CertifiedCheckpointSummary) { +fn signed_checkpoint(contents: &CheckpointContents) -> (Committee, CertifiedCheckpointSummary) { let summary = CheckpointSummary { epoch: 0, sequence_number: 0, @@ -34,7 +29,7 @@ fn signed_checkpoint( epoch_rolling_gas_cost_summary: GasCostSummary::default(), timestamp_ms: 0, checkpoint_commitments: Vec::new(), - end_of_epoch_data, + end_of_epoch_data: None, version_specific_data: Vec::new(), }; let (committee, keypairs) = Committee::new_simple_test_committee(); @@ -44,13 +39,13 @@ fn signed_checkpoint( } pub fn valid_transaction_proof() -> (Committee, Proof) { - proof_with_targets(ProofTargets::new(), None) + proof_with_targets(ProofTargets::new()) } -pub fn proof_with_targets(targets: ProofTargets, end_of_epoch_data: Option) -> (Committee, Proof) { +pub fn proof_with_targets(targets: ProofTargets) -> (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 (committee, summary) = signed_checkpoint(&contents); let chain = ChainIdentifier::from(*summary.digest()); let proof = Proof::new( chain, @@ -69,7 +64,7 @@ pub fn proof_with_events(events: TransactionEvents) -> (Committee, TransactionDi .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 (committee, summary) = signed_checkpoint(&contents); let chain = ChainIdentifier::from(*summary.digest()); let proof = Proof::new( chain, @@ -95,16 +90,3 @@ pub fn event(contents: Vec) -> Event { 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.committee_members(), - next_epoch_protocol_version: 1, - epoch_commitments: Vec::new(), - epoch_supply_change: 0, - } -} From f887eb964b0b39c4261565d2f19c3c60abc294d9 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 5 Aug 2026 10:31:55 +0300 Subject: [PATCH 40/41] Refactor proof structure to use 'targets' instead of 'target' --- bindings/wasm/poi_wasm/README.md | 7 + bindings/wasm/poi_wasm/src/source.rs | 4 +- .../poi_wasm/tests/proof-bindings.test.ts | 69 ++++++ poi-rs/README.md | 24 ++- poi-rs/src/builder.rs | 80 +++---- poi-rs/src/proof.rs | 171 ++++++++------- poi-rs/tests/fixtures/current/event.json | 182 ++++++++-------- poi-rs/tests/fixtures/current/object.json | 198 +++++++++--------- .../tests/fixtures/current/transaction.json | 165 +++++++-------- poi-rs/tests/proof_construction.rs | 5 +- poi-rs/tests/proof_serialization.rs | 15 +- poi-rs/tests/proof_verification.rs | 50 +---- poi-rs/tests/proof_workflows.rs | 23 +- poi-rs/tests/utils/proofs.rs | 18 +- 14 files changed, 544 insertions(+), 467 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 0c790fe..3576686 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -63,6 +63,13 @@ The same builder also exposes `object(objectId)` and `event(transactionDigest, eventSequence)`. All 64-bit values use JavaScript `bigint`. +The serialized proof records the targets explicitly selected by the caller. +Its checkpoint summary and checkpoint contents are sibling fields, while the +required transaction proof contains the transaction, effects, and optional +event evidence. Object targets contain the selected object values; event +targets contain event IDs whose contents are selected from the authenticated +transaction event list. + `PoiClient` hides the generated protobuf client, gRPC transport, and JavaScript/WASM source adapter. The adapter passes only opaque BCS bytes and checkpoint sequence numbers into WASM. Rust decodes those values into existing diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index 4eaeae0..e0b1a72 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -345,7 +345,7 @@ mod tests { .clone() .try_into() .expect("checkpoint summary must convert to SDK types"); - let contents = SdkCheckpointContents::try_from(proof.transaction_proof.checkpoint_contents.clone()) + let contents = SdkCheckpointContents::try_from(proof.checkpoint_contents.clone()) .expect("checkpoint contents must convert to SDK types"); let checkpoint = decode_checkpoint(JsCheckpointEvidence { summary_bcs: bcs::to_bytes(&VersionedCheckpointSummary::V1(signed_summary.checkpoint)) @@ -360,6 +360,6 @@ mod tests { bcs::to_bytes(&checkpoint.summary).expect("decoded checkpoint summary must serialize"), bcs::to_bytes(&proof.checkpoint_summary).expect("fixture checkpoint summary must serialize") ); - assert_eq!(checkpoint.contents, proof.transaction_proof.checkpoint_contents); + assert_eq!(checkpoint.contents, proof.checkpoint_contents); } } diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index 32096ad..4b59480 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -52,8 +52,77 @@ test("the WASM proof can be deserialized for verification", async () => { ); const proof = Proof.fromJSON(json); + const serialized = JSON.parse(proof.toJSON()) as { + targets: { + transaction: string | null; + objects: unknown[]; + events: unknown[]; + }; + checkpoint_contents: unknown; + transaction_proof: Record; + }; assert.equal(proof.version, 1); assert.equal(proof.checkpointEpoch, 0n); assert.doesNotThrow(() => proof.validate()); + assert.equal(typeof serialized.targets.transaction, "string"); + assert.deepEqual(serialized.targets.objects, []); + assert.deepEqual(serialized.targets.events, []); + assert.ok(serialized.checkpoint_contents); + assert.deepEqual(Object.keys(serialized.transaction_proof), [ + "transaction", + "effects", + "events", + ]); +}); + +test("the WASM proof keeps selected events separate from event evidence", async () => { + const json = await readFile( + new URL( + "../../../../poi-rs/tests/fixtures/current/event.json", + import.meta.url, + ), + "utf8", + ); + + const proof = Proof.fromJSON(json); + const serialized = JSON.parse(proof.toJSON()) as { + targets: { + transaction: string | null; + objects: unknown[]; + events: Array<{ txDigest: string; eventSeq: string }>; + }; + transaction_proof: { events: unknown[] | null }; + }; + + assert.equal(serialized.targets.transaction, null); + assert.deepEqual(serialized.targets.objects, []); + assert.equal(serialized.targets.events.length, 1); + assert.equal(serialized.targets.events[0]?.eventSeq, "0"); + assert.equal(serialized.transaction_proof.events?.length, 1); +}); + +test("the WASM proof stores selected objects only in its targets", async () => { + const json = await readFile( + new URL( + "../../../../poi-rs/tests/fixtures/current/object.json", + import.meta.url, + ), + "utf8", + ); + + const proof = Proof.fromJSON(json); + const serialized = JSON.parse(proof.toJSON()) as { + targets: { + transaction: string | null; + objects: unknown[]; + events: unknown[]; + }; + transaction_proof: { events: unknown[] | null }; + }; + + assert.equal(serialized.targets.transaction, null); + assert.equal(serialized.targets.objects.length, 1); + assert.deepEqual(serialized.targets.events, []); + assert.equal(serialized.transaction_proof.events, null); }); diff --git a/poi-rs/README.md b/poi-rs/README.md index e625ec9..c10343b 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -51,12 +51,15 @@ gRPC. 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 or event claims the caller wants to authenticate. +- `ProofTargets` recording the transaction, objects, and events explicitly selected by the caller. +- A `CertifiedCheckpointSummary` and its `CheckpointContents` linking the transaction to a committee-certified + checkpoint. +- A required `TransactionProof` containing the transaction, its effects, and event data when event targets are present. -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. +Object targets contain their exact object values; verification derives each object reference and finds it in the +transaction effects. Event targets contain `EventID` values, while the transaction proof carries the complete event list +needed to verify the effects' event digest. A transaction target is present only when the caller explicitly requested the +transaction itself, although transaction evidence supports every proof. ## Verification @@ -95,9 +98,10 @@ Verification checks: - 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 +- an explicitly requested transaction matches the packaged transaction +- requested object targets derive references present in the transaction effects +- event data, when required, matches the event digest recorded in the effects +- requested event targets belong to the transaction and select events in the authenticated event list ## Trust Boundaries @@ -112,8 +116,8 @@ trust the authenticated target claims relative to the supplied committee. - `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 and event claims to authenticate. +- `TransactionProof`: Transaction, effects, and optional event evidence used to prove inclusion. +- `ProofTargets`: Transaction, object, and event claims explicitly selected by the caller. - `ProofTarget`: Transaction, object, or event requested from a `ProofBuilder`. - `PoiClient`: Source-backed entry point for proof construction and committee-aware verification. - `CommitteeResolution`: Trusted-node or anchored committee-resolution configuration, including the committee cache. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 4b5ade4..c1a1481 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -178,18 +178,20 @@ impl ProofBuilder { async fn build_proof(&self) -> Result { let mut selected_transaction = None; + let mut transaction_target = None; let mut object_ids = Vec::new(); - let mut events = Vec::new(); + let mut event_targets = Vec::new(); for target in self.targets.iter().copied() { match target { ProofTarget::Transaction(transaction_digest) => { Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; + transaction_target = Some(transaction_digest); } ProofTarget::Object(object_id) => object_ids.push(object_id), ProofTarget::Event(event_id) => { Self::ensure_same_transaction(&mut selected_transaction, target, event_id.tx_digest)?; - events.push(event_id); + event_targets.push(event_id); } } } @@ -215,13 +217,13 @@ impl ProofBuilder { let mut objects = Vec::with_capacity(object_ids.len()); for object_id in object_ids { - let (object_ref, object) = self.fetch_object(object_id, None).await?; + let object = self.fetch_object(object_id, None).await?; Self::ensure_same_transaction( &mut selected_transaction, ProofTarget::Object(object_id), object.previous_transaction, )?; - objects.push((object_ref, object)); + objects.push(object); } let transaction_digest = @@ -242,41 +244,45 @@ impl ProofBuilder { .checkpoint(transaction.checkpoint_sequence_number) .await .map_err(|source| ProofBuilderError::Source { target, source })?; - let transaction_proof = TransactionProof::new( - checkpoint.contents, - transaction.transaction, - transaction.effects, - transaction.events, - ); - let mut proof = Proof::new( - chain_identifier, - ProofTargets::new(), - checkpoint.summary, - transaction_proof, - ); + let transaction_events = if event_targets.is_empty() { + None + } else { + let events = transaction.events.ok_or_else(|| ProofBuilderError::TargetNotFound { + target: ProofTarget::Event(event_targets[0]), + })?; + + for event_id in &event_targets { + let event_exists = usize::try_from(event_id.event_seq) + .ok() + .is_some_and(|index| events.get(index).is_some()); + if !event_exists { + return Err(ProofBuilderError::TargetNotFound { + target: ProofTarget::Event(*event_id), + }); + } + } - for (object_ref, object) in objects { - proof.target = proof.target.add_object(object_ref, object); + Some(events) + }; + let transaction_proof = TransactionProof::new(transaction.transaction, transaction.effects, transaction_events); + let mut targets = ProofTargets::new(); + if let Some(transaction_digest) = transaction_target { + targets = targets.set_transaction(transaction_digest); } - - 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(ProofBuilderError::TargetNotFound { - target: ProofTarget::Event(event_id), - })?; - proof.target = proof.target.add_event(event_id, event); + for object in objects { + targets = targets.add_object(object); + } + for event_id in event_targets { + targets = targets.add_event(event_id); } - Ok(proof) + Ok(Proof::new( + chain_identifier, + targets, + checkpoint.summary, + checkpoint.contents, + transaction_proof, + )) } async fn fetch_transaction( @@ -295,7 +301,7 @@ impl ProofBuilder { &self, object_id: ObjectId, expected_ref: Option, - ) -> Result<(ObjectReference, Object), ProofBuilderError> { + ) -> Result { let target = ProofTarget::Object(object_id); let object = self .source @@ -309,7 +315,7 @@ impl ProofBuilder { return Err(ProofBuilderError::ObjectReferenceMismatch { object_id }); } - Ok((object_ref, object)) + Ok(object) } fn ensure_same_transaction( diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 0e7e176..7df106a 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -11,7 +11,7 @@ //! [`CertifiedCheckpointSummary`]: iota_types::messages_checkpoint::CertifiedCheckpointSummary //! [`Committee`]: iota_types::committee::Committee -use iota_sdk_types::{CheckpointContents, Event, ObjectReference}; +use iota_sdk_types::{CheckpointContents, TransactionDigest}; use iota_types::{ committee::Committee, digests::ChainIdentifier, @@ -85,6 +85,12 @@ pub enum VerifyErrorKind { #[source] source: BoxError, }, + /// The proof does not declare a transaction, object, or event target. + #[error("proof does not contain a target")] + MissingTarget, + /// The selected transaction differs from the transaction packaged in the proof. + #[error("transaction target does not match the packaged transaction")] + TransactionTargetMismatch, /// The packaged transaction does not match the transaction digest in its effects. #[error("transaction digest does not match the execution digest")] TransactionDigestMismatch, @@ -94,8 +100,8 @@ pub enum VerifyErrorKind { /// The packaged events do not match the events digest in the transaction effects. #[error("events digest does not match the execution digest")] EventsDigestMismatch, - /// Event claims are present but the proof does not contain transaction events. - #[error("transaction effects refer to events but event data is missing")] + /// Event targets are present but the proof does not contain transaction events. + #[error("event targets require transaction event data")] MissingEvents, /// An event claim identifies a transaction other than the one proven by the envelope. #[error("event target does not belong to the transaction")] @@ -106,12 +112,6 @@ pub enum VerifyErrorKind { /// Transaction-local event index requested by the claim. sequence: u64, }, - /// The claimed event differs from the event at the requested transaction-local index. - #[error("event target contents do not match")] - EventContentsMismatch, - /// A claimed object does not compute to its packaged object reference. - #[error("object target reference does not match the object")] - ObjectReferenceMismatch, /// A claimed object reference is absent from the packaged transaction effects. #[error("object target was not found in the transaction effects")] ObjectNotFound, @@ -167,16 +167,19 @@ impl TryFrom for ProofVersion { } } -/// Values whose inclusion is claimed by a [`Proof`]. +/// Values the caller selected for a [`Proof`]. /// /// Objects and events must belong to the proven transaction. #[derive(Default, Debug, Serialize, Deserialize, Clone)] pub struct ProofTargets { - /// Objects claimed to have been changed by the transaction. - pub objects: Vec<(ObjectReference, Object)>, + /// Transaction explicitly selected by the caller. + pub transaction: Option, - /// Events claimed to have been emitted by the transaction. - pub events: Vec<(EventID, Event)>, + /// Objects explicitly selected by the caller. + pub objects: Vec, + + /// Events explicitly selected by the caller. + pub events: Vec, } impl ProofTargets { @@ -185,52 +188,48 @@ impl ProofTargets { Self::default() } - /// Adds an object claim. - /// - /// During verification, `object` must compute to `object_ref`, and - /// `object_ref` must appear among the objects changed by the proven - /// transaction. - pub fn add_object(mut self, object_ref: ObjectReference, object: Object) -> Self { - self.objects.push((object_ref, object)); + /// Sets the selected transaction. + pub fn set_transaction(mut self, transaction: TransactionDigest) -> Self { + self.transaction = Some(transaction); self } - /// Adds an event claim. - /// - /// During verification, `event_id` must identify the proven transaction, and - /// `event` must equal the event at its transaction-local sequence number. - pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { - self.events.push((event_id, event)); + /// Adds a selected object. + pub fn add_object(mut self, object: Object) -> Self { + self.objects.push(object); + self + } + + /// Adds a selected event. + pub fn add_event(mut self, event_id: EventID) -> Self { + self.events.push(event_id); self } + + /// Returns whether no target has been selected. + pub fn is_empty(&self) -> bool { + self.transaction.is_none() && self.objects.is_empty() && self.events.is_empty() + } } -/// The data required to prove that a transaction belongs to a checkpoint. +/// Transaction-specific evidence carried by a [`Proof`]. /// -/// The transaction effects link the transaction to `checkpoint_contents` and, -/// when present, commit to `events`. +/// The effects identify the transaction in its checkpoint. Event data is +/// included when the proof declares event targets. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TransactionProof { - /// The contents of the checkpoint containing the transaction. - pub checkpoint_contents: CheckpointContents, /// The transaction being proven. pub transaction: Transaction, /// The transaction's execution effects. pub effects: TransactionEffects, - /// Events emitted by the transaction, if any. + /// Complete event list included when the proof declares event targets. pub events: Option, } impl TransactionProof { /// Creates transaction proof data. - pub fn new( - checkpoint_contents: CheckpointContents, - transaction: Transaction, - effects: TransactionEffects, - events: Option, - ) -> Self { + pub fn new(transaction: Transaction, effects: TransactionEffects, events: Option) -> Self { Self { - checkpoint_contents, transaction, effects, events, @@ -240,8 +239,8 @@ impl TransactionProof { /// Evidence that a transaction is included in a certified checkpoint. /// -/// Every proof contains [`TransactionProof`] and may additionally claim objects -/// or events. Call [`ProofVerifier::verify`] to verify these claims. +/// [`ProofTargets`] records the values selected by the caller. The checkpoint +/// and transaction proof fields contain the evidence for those targets. /// /// [`Proof::chain`] identifies the network reported by the proof source. It is /// informational and is not checked during verification. @@ -251,11 +250,13 @@ pub struct Proof { pub version: ProofVersion, /// The network reported by the proof source. pub chain: ChainIdentifier, - /// The values claimed by the proof. - pub target: ProofTargets, + /// The values selected for this proof. + pub targets: ProofTargets, /// The certified summary of the checkpoint containing the transaction. pub checkpoint_summary: CertifiedCheckpointSummary, - /// The transaction and its checkpoint inclusion data. + /// Contents committed to by the checkpoint summary. + pub checkpoint_contents: CheckpointContents, + /// The transaction and its execution data pub transaction_proof: TransactionProof, } @@ -263,15 +264,17 @@ impl Proof { /// Creates a proof using [`ProofVersion::CURRENT`]. pub fn new( chain: ChainIdentifier, - target: ProofTargets, + targets: ProofTargets, checkpoint_summary: CertifiedCheckpointSummary, + checkpoint_contents: CheckpointContents, transaction_proof: TransactionProof, ) -> Self { Self { version: ProofVersion::CURRENT, chain, - target, + targets, checkpoint_summary, + checkpoint_contents, transaction_proof, } } @@ -281,9 +284,9 @@ impl Proof { self.version } - /// Returns the values claimed by the proof. - pub const fn target(&self) -> &ProofTargets { - &self.target + /// Returns the values selected for this proof. + pub const fn targets(&self) -> &ProofTargets { + &self.targets } /// Serializes the proof as JSON. @@ -351,7 +354,7 @@ impl<'committee> ProofVerifier<'committee> { /// - the checkpoint contents match the digest in that summary; /// - the transaction, effects, and optional events are internally consistent; /// - the transaction effects occur in the authenticated checkpoint contents; - /// - every object and event claim matches the proof data. + /// - every selected target matches the authenticated proof data. /// /// # Errors /// @@ -361,8 +364,14 @@ impl<'committee> ProofVerifier<'committee> { kind: VerifyErrorKind::Version { source }, })?; + if proof.targets.is_empty() { + return Err(VerifyError { + kind: VerifyErrorKind::MissingTarget, + }); + } + let summary = &proof.checkpoint_summary; - let contents = Some(&proof.transaction_proof.checkpoint_contents); + let contents = Some(&proof.checkpoint_contents); summary .verify_with_contents(self.committee, contents) @@ -372,9 +381,8 @@ impl<'committee> ProofVerifier<'committee> { }, })?; - 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)?; + self.verify_transaction_proof(summary, &proof.checkpoint_contents, &proof.transaction_proof)?; + self.verify_targets(&proof.targets, &proof.transaction_proof)?; Ok(()) } @@ -383,6 +391,7 @@ impl<'committee> ProofVerifier<'committee> { fn verify_transaction_proof( &self, summary: &CertifiedCheckpointSummary, + checkpoint_contents: &CheckpointContents, transaction_proof: &TransactionProof, ) -> Result<(), VerifyError> { let execution_digests = transaction_proof.effects.execution_digests(); @@ -393,8 +402,7 @@ impl<'committee> ProofVerifier<'committee> { }); } - let transaction_is_in_checkpoint = transaction_proof - .checkpoint_contents + let transaction_is_in_checkpoint = checkpoint_contents .enumerate_transactions(summary) .any(|(_, digests)| digests == execution_digests); @@ -404,18 +412,32 @@ impl<'committee> ProofVerifier<'committee> { }); } - if transaction_proof.effects.events_digest() - != transaction_proof.events.as_ref().map(|events| events.digest()).as_ref() - { + if let Some(events) = &transaction_proof.events { + if transaction_proof.effects.events_digest() != Some(&events.digest()) { + return Err(VerifyError { + kind: VerifyErrorKind::EventsDigestMismatch, + }); + } + } + + Ok(()) + } + + /// Checks every declared target against the transaction proof. + fn verify_targets(&self, targets: &ProofTargets, transaction_proof: &TransactionProof) -> Result<(), VerifyError> { + let transaction_digest = transaction_proof.effects.execution_digests().transaction; + + if targets.transaction.is_some_and(|target| target != transaction_digest) { return Err(VerifyError { - kind: VerifyErrorKind::EventsDigestMismatch, + kind: VerifyErrorKind::TransactionTargetMismatch, }); } - Ok(()) + self.verify_event_targets(targets, transaction_proof)?; + self.verify_object_targets(targets, transaction_proof) } - /// Checks each event claim against the proven transaction and its packaged events. + /// Checks each event target against the proven transaction and its packaged events. fn verify_event_targets( &self, targets: &ProofTargets, @@ -432,7 +454,7 @@ impl<'committee> ProofVerifier<'committee> { }; let execution_digests = transaction_proof.effects.execution_digests(); - for (event_id, event) in &targets.events { + for event_id in &targets.events { if event_id.tx_digest != execution_digests.transaction { return Err(VerifyError { kind: VerifyErrorKind::EventTransactionMismatch, @@ -440,25 +462,19 @@ impl<'committee> ProofVerifier<'committee> { } let event_index = event_id.event_seq as usize; - let Some(actual_event) = events.get(event_index) else { + let Some(_) = events.get(event_index) else { return Err(VerifyError { kind: VerifyErrorKind::EventSequenceOutOfBounds { sequence: event_id.event_seq, }, }); }; - - if actual_event != event { - return Err(VerifyError { - kind: VerifyErrorKind::EventContentsMismatch, - }); - } } Ok(()) } - /// Checks each object claim against its reference and the transaction effects. + /// Checks each object target against the transaction effects. fn verify_object_targets( &self, targets: &ProofTargets, @@ -469,16 +485,11 @@ impl<'committee> ProofVerifier<'committee> { } let changed_objects = transaction_proof.effects.all_changed_objects(); - for (object_ref, object) in &targets.objects { - if object_ref != &object.as_inner().object_ref() { - return Err(VerifyError { - kind: VerifyErrorKind::ObjectReferenceMismatch, - }); - } - + for object in &targets.objects { + let object_ref = object.as_inner().object_ref(); changed_objects .iter() - .find(|changed_object_ref| &changed_object_ref.0 == object_ref) + .find(|changed_object_ref| changed_object_ref.0 == object_ref) .ok_or(VerifyError { kind: VerifyErrorKind::ObjectNotFound, })?; diff --git a/poi-rs/tests/fixtures/current/event.json b/poi-rs/tests/fixtures/current/event.json index 3fda4c0..164b141 100644 --- a/poi-rs/tests/fixtures/current/event.json +++ b/poi-rs/tests/fixtures/current/event.json @@ -1,22 +1,14 @@ { "version": 1, "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", - "target": { + "targets": { + "transaction": null, "objects": [], "events": [ - [ - { - "txDigest": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", - "eventSeq": "0" - }, - { - "package_id": "0x0000000000000000000000000000000000000000000000000000000000000003", - "module": "iota_system", - "sender": "0xc9d530a1332da708bfad2d4b634cafa265b65b5d4de29e552d9200e4f117fc0b", - "type": "0x3::validator::StakingRequestEvent", - "contents": "Ls9FicZo2wce3fWuF76Ozh9MHFqDi/lbNDQo5sI7qjGMRV8ikXw5a0aj9OvWJ3KGimZuazhPTwzuaNAE3kXuz8nVMKEzLacIv60tS2NMr6JltltdTeKeVS2SAOTxF/wLAAAAAAAAAAAAAENP15RqAA==" - } - ] + { + "txDigest": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", + "eventSeq": "0" + } ] }, "checkpoint_summary": { @@ -63,88 +55,88 @@ ] } }, + "checkpoint_contents": { + "V1": [ + { + "transaction": "123ugpG5FWSQZ3yZkxVxNyw3RjDobNMGTXDom6aJsnDU", + "effects": "BBbiQ6R1A9QTCg4u3KrQw7nXtNJN9o4TFLZ3az93BEYJ", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "6Sjy1JpAgvYWp9NFZ6tYY3AMxjsB8d8Wjr7o7YDoThP6", + "effects": "JBMBLxwJWvnRaAbHVM1x1LRizxE8LqPmoLrfG1ZEikkv", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "5a2akdP8QpYw5Tr7d4iE4NbybHeJw8JgRQ3PPiPfJuk7", + "effects": "G8r2qRw4cCWx5zet4RE3qpHRGyywKHB378kQ9Ku1vdVe", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", + "effects": "fG1qHEADjgH1rbEMBy3kvLDhFcBi6akkRBf5eBsYHTt", + "signatures": [ + { + "scheme": "ed25519", + "signature": "ny3sKc8wQfcK3jOtxov8TvX3FsAQmaNE+/Kmhl5ODZyPrbiwog63/67vek1SpJqCLI8HU122VxiSuKB7G8NHDA==", + "public_key": "3tudne38ft4blQx4QMgi89Dbud82vs9JX0wqZJjR61k=" + } + ] + }, + { + "transaction": "B1yr1zybhNUwKeadAxCRvhnYdZhhk1BDLgkjRx693bbu", + "effects": "73QeVLRjs57CqEzR7Za1pSbFNJurnYZiZw8wbDwNcMsK", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "5JQhS5my3KrapKu2hKsbLRyqtHrLcB9RTggGsd5Pyx3F", + "effects": "CBqkCM2cP3xVHb9nEwNFk1QJ8veELTtBPdQYJGUxvvun", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "GbemSdr6vTaJ5q6KanqizjNQCy59fJgkHpexCRBfTHrp", + "effects": "EN4LoN2V7ffkQ2LoDnvXNL72pSbPb3335VYtVjScxULv", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + } + ] + }, "transaction_proof": { - "checkpoint_contents": { - "V1": [ - { - "transaction": "123ugpG5FWSQZ3yZkxVxNyw3RjDobNMGTXDom6aJsnDU", - "effects": "BBbiQ6R1A9QTCg4u3KrQw7nXtNJN9o4TFLZ3az93BEYJ", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "6Sjy1JpAgvYWp9NFZ6tYY3AMxjsB8d8Wjr7o7YDoThP6", - "effects": "JBMBLxwJWvnRaAbHVM1x1LRizxE8LqPmoLrfG1ZEikkv", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "5a2akdP8QpYw5Tr7d4iE4NbybHeJw8JgRQ3PPiPfJuk7", - "effects": "G8r2qRw4cCWx5zet4RE3qpHRGyywKHB378kQ9Ku1vdVe", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", - "effects": "fG1qHEADjgH1rbEMBy3kvLDhFcBi6akkRBf5eBsYHTt", - "signatures": [ - { - "scheme": "ed25519", - "signature": "ny3sKc8wQfcK3jOtxov8TvX3FsAQmaNE+/Kmhl5ODZyPrbiwog63/67vek1SpJqCLI8HU122VxiSuKB7G8NHDA==", - "public_key": "3tudne38ft4blQx4QMgi89Dbud82vs9JX0wqZJjR61k=" - } - ] - }, - { - "transaction": "B1yr1zybhNUwKeadAxCRvhnYdZhhk1BDLgkjRx693bbu", - "effects": "73QeVLRjs57CqEzR7Za1pSbFNJurnYZiZw8wbDwNcMsK", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "5JQhS5my3KrapKu2hKsbLRyqtHrLcB9RTggGsd5Pyx3F", - "effects": "CBqkCM2cP3xVHb9nEwNFk1QJ8veELTtBPdQYJGUxvvun", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "GbemSdr6vTaJ5q6KanqizjNQCy59fJgkHpexCRBfTHrp", - "effects": "EN4LoN2V7ffkQ2LoDnvXNL72pSbPb3335VYtVjScxULv", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - } - ] - }, "transaction": { "data": { "transaction": { diff --git a/poi-rs/tests/fixtures/current/object.json b/poi-rs/tests/fixtures/current/object.json index 8c24b9c..c69064f 100644 --- a/poi-rs/tests/fixtures/current/object.json +++ b/poi-rs/tests/fixtures/current/object.json @@ -1,29 +1,23 @@ { "version": 1, "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", - "target": { + "targets": { + "transaction": null, "objects": [ - [ - { - "object_id": "0x1f8ef3d2929482a43abc43c3693a695e247d55fd865636719bd428a606d3366c", - "version": "2", - "digest": "GhdZ7GvETWWMYR5G8XmmWwZ6Ta3GEsNBX4ajzazfAPC1" + { + "data": { + "Struct": { + "object_type": "0x2::coin::Coin<0x2::iota::IOTA>", + "version": "2", + "contents": "H47z0pKUgqQ6vEPDaTppXiR9Vf2GVjZxm9QopgbTNmxf0hVP15RqAA==" + } }, - { - "data": { - "Struct": { - "object_type": "0x2::coin::Coin<0x2::iota::IOTA>", - "version": "2", - "contents": "H47z0pKUgqQ6vEPDaTppXiR9Vf2GVjZxm9QopgbTNmxf0hVP15RqAA==" - } - }, - "owner": { - "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" - }, - "previous_transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", - "storage_rebate": "980400" - } - ] + "owner": { + "Address": "0x4341880802a84b5cab9a3a16754c52cc1ed8656b343a0a8dda0ce775da27bbe7" + }, + "previous_transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "storage_rebate": "980400" + } ], "events": [] }, @@ -71,88 +65,88 @@ ] } }, + "checkpoint_contents": { + "V1": [ + { + "transaction": "GuWB2VN7iieJPXADJmdrPsEPXffkC2J36ynXKzh3Jyow", + "effects": "D9ZR6gfoHMBMQS6XtrotsPBMykcVUJwYQY9obfnUcXuz", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "84s7gmgJVA8uVtKi9HpGSJtkVfv838oirdMmEpMYhgog", + "effects": "EZYAw9QNDhFiTHaJ3W28jRMGRdvvhpy1q4g1x8BwgCKo", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "DhvRjECEkgd3PmmPpUwjFtnk9cSC2M3oDayagwvRVSYw", + "effects": "8PMXpKFk8uFGBEv2UyqGgycfSPE5wfnjtyEdt8LUEHtf", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "GuHQHaXjy8ZPipiYYYcrWX8g2bfkdDni2pEPoFJUSNwq", + "effects": "BoRtHiQf9kGur9XRHagdXq4kXN7huyibCeJ65Y2o55wY", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "effects": "4xbK8Ebw8yq5VMd4ztJbk1eb5AFyKvUtSoRGHRZ9jnCD", + "signatures": [ + { + "scheme": "ed25519", + "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", + "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" + } + ] + }, + { + "transaction": "7NShvzK9emiobPtBphmqZapFmww6rDPnGSJWU2U9f41E", + "effects": "AtRzYVJW8yrhQEAi8V8PutQz7rrwhR9sHz3hRXW6JfHr", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "Dnzx53gMFPuTu9DSbBDiBbVxqgf9DoJ9ep3j8CBTPogw", + "effects": "3vRAtw8276TpaSbNk8TMS12HCb2sFe1EWw7wkn7hecZc", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + } + ] + }, "transaction_proof": { - "checkpoint_contents": { - "V1": [ - { - "transaction": "GuWB2VN7iieJPXADJmdrPsEPXffkC2J36ynXKzh3Jyow", - "effects": "D9ZR6gfoHMBMQS6XtrotsPBMykcVUJwYQY9obfnUcXuz", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "84s7gmgJVA8uVtKi9HpGSJtkVfv838oirdMmEpMYhgog", - "effects": "EZYAw9QNDhFiTHaJ3W28jRMGRdvvhpy1q4g1x8BwgCKo", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "DhvRjECEkgd3PmmPpUwjFtnk9cSC2M3oDayagwvRVSYw", - "effects": "8PMXpKFk8uFGBEv2UyqGgycfSPE5wfnjtyEdt8LUEHtf", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "GuHQHaXjy8ZPipiYYYcrWX8g2bfkdDni2pEPoFJUSNwq", - "effects": "BoRtHiQf9kGur9XRHagdXq4kXN7huyibCeJ65Y2o55wY", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", - "effects": "4xbK8Ebw8yq5VMd4ztJbk1eb5AFyKvUtSoRGHRZ9jnCD", - "signatures": [ - { - "scheme": "ed25519", - "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", - "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" - } - ] - }, - { - "transaction": "7NShvzK9emiobPtBphmqZapFmww6rDPnGSJWU2U9f41E", - "effects": "AtRzYVJW8yrhQEAi8V8PutQz7rrwhR9sHz3hRXW6JfHr", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "Dnzx53gMFPuTu9DSbBDiBbVxqgf9DoJ9ep3j8CBTPogw", - "effects": "3vRAtw8276TpaSbNk8TMS12HCb2sFe1EWw7wkn7hecZc", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - } - ] - }, "transaction": { "data": { "transaction": { diff --git a/poi-rs/tests/fixtures/current/transaction.json b/poi-rs/tests/fixtures/current/transaction.json index a677bee..3c9caf2 100644 --- a/poi-rs/tests/fixtures/current/transaction.json +++ b/poi-rs/tests/fixtures/current/transaction.json @@ -1,7 +1,8 @@ { "version": 1, "chain": "J3x3hNka6L8T5y8VwEBHgJYLm5XgLm4FCQwmDcUpNAf5", - "target": { + "targets": { + "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", "objects": [], "events": [] }, @@ -49,88 +50,88 @@ ] } }, + "checkpoint_contents": { + "V1": [ + { + "transaction": "GuWB2VN7iieJPXADJmdrPsEPXffkC2J36ynXKzh3Jyow", + "effects": "D9ZR6gfoHMBMQS6XtrotsPBMykcVUJwYQY9obfnUcXuz", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "84s7gmgJVA8uVtKi9HpGSJtkVfv838oirdMmEpMYhgog", + "effects": "EZYAw9QNDhFiTHaJ3W28jRMGRdvvhpy1q4g1x8BwgCKo", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "DhvRjECEkgd3PmmPpUwjFtnk9cSC2M3oDayagwvRVSYw", + "effects": "8PMXpKFk8uFGBEv2UyqGgycfSPE5wfnjtyEdt8LUEHtf", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "GuHQHaXjy8ZPipiYYYcrWX8g2bfkdDni2pEPoFJUSNwq", + "effects": "BoRtHiQf9kGur9XRHagdXq4kXN7huyibCeJ65Y2o55wY", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", + "effects": "4xbK8Ebw8yq5VMd4ztJbk1eb5AFyKvUtSoRGHRZ9jnCD", + "signatures": [ + { + "scheme": "ed25519", + "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", + "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" + } + ] + }, + { + "transaction": "7NShvzK9emiobPtBphmqZapFmww6rDPnGSJWU2U9f41E", + "effects": "AtRzYVJW8yrhQEAi8V8PutQz7rrwhR9sHz3hRXW6JfHr", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + }, + { + "transaction": "Dnzx53gMFPuTu9DSbBDiBbVxqgf9DoJ9ep3j8CBTPogw", + "effects": "3vRAtw8276TpaSbNk8TMS12HCb2sFe1EWw7wkn7hecZc", + "signatures": [ + { + "scheme": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } + ] + } + ] + }, "transaction_proof": { - "checkpoint_contents": { - "V1": [ - { - "transaction": "GuWB2VN7iieJPXADJmdrPsEPXffkC2J36ynXKzh3Jyow", - "effects": "D9ZR6gfoHMBMQS6XtrotsPBMykcVUJwYQY9obfnUcXuz", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "84s7gmgJVA8uVtKi9HpGSJtkVfv838oirdMmEpMYhgog", - "effects": "EZYAw9QNDhFiTHaJ3W28jRMGRdvvhpy1q4g1x8BwgCKo", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "DhvRjECEkgd3PmmPpUwjFtnk9cSC2M3oDayagwvRVSYw", - "effects": "8PMXpKFk8uFGBEv2UyqGgycfSPE5wfnjtyEdt8LUEHtf", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "GuHQHaXjy8ZPipiYYYcrWX8g2bfkdDni2pEPoFJUSNwq", - "effects": "BoRtHiQf9kGur9XRHagdXq4kXN7huyibCeJ65Y2o55wY", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "W5a5vsCEVHTj5woXy1MymQYpe4UFzwEoor8k8PASDeq", - "effects": "4xbK8Ebw8yq5VMd4ztJbk1eb5AFyKvUtSoRGHRZ9jnCD", - "signatures": [ - { - "scheme": "ed25519", - "signature": "o6PoW/P7BpDmgVkkOKnmVEsS5OqU8+kUxaENWwRxGcAtYvt+BbybZ3bW57oE5v5/USn7Ag7S892tPw5PL/jdBQ==", - "public_key": "i2HWsF9A7vdmrnxpB6mNXFujxewIT2B/+IltXIpxkCg=" - } - ] - }, - { - "transaction": "7NShvzK9emiobPtBphmqZapFmww6rDPnGSJWU2U9f41E", - "effects": "AtRzYVJW8yrhQEAi8V8PutQz7rrwhR9sHz3hRXW6JfHr", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - }, - { - "transaction": "Dnzx53gMFPuTu9DSbBDiBbVxqgf9DoJ9ep3j8CBTPogw", - "effects": "3vRAtw8276TpaSbNk8TMS12HCb2sFe1EWw7wkn7hecZc", - "signatures": [ - { - "scheme": "ed25519", - "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - } - ] - } - ] - }, "transaction": { "data": { "transaction": { diff --git a/poi-rs/tests/proof_construction.rs b/poi-rs/tests/proof_construction.rs index 06dd2aa..d7a3a33 100644 --- a/poi-rs/tests/proof_construction.rs +++ b/poi-rs/tests/proof_construction.rs @@ -72,8 +72,9 @@ async fn stacked_targets_are_deduplicated_and_reuse_transaction_evidence() { .expect("recorded transactions lock must not be poisoned"), vec![staking.digest] ); - assert_eq!(proof.target.objects.len(), 1); - assert_eq!(proof.target.events.len(), 1); + assert_eq!(proof.targets.transaction, Some(staking.digest)); + assert_eq!(proof.targets.objects.len(), 1); + assert_eq!(proof.targets.events.len(), 1); ProofVerifier::new(&cluster.committee()) .verify(&proof) .expect("the stacked-target proof must verify offline"); diff --git a/poi-rs/tests/proof_serialization.rs b/poi-rs/tests/proof_serialization.rs index 5428903..b7ea9e3 100644 --- a/poi-rs/tests/proof_serialization.rs +++ b/poi-rs/tests/proof_serialization.rs @@ -30,24 +30,27 @@ fn assert_fixture_round_trips_and_verifies(fixture: &str) -> Proof { fn transaction_fixture_round_trips_and_verifies() { let proof = assert_fixture_round_trips_and_verifies(TRANSACTION); - assert!(proof.target().objects.is_empty()); - assert!(proof.target().events.is_empty()); + assert!(proof.targets().transaction.is_some()); + assert!(proof.targets().objects.is_empty()); + assert!(proof.targets().events.is_empty()); } #[test] fn object_fixture_round_trips_and_verifies() { let proof = assert_fixture_round_trips_and_verifies(OBJECT); - assert_eq!(proof.target().objects.len(), 1); - assert!(proof.target().events.is_empty()); + assert!(proof.targets().transaction.is_none()); + assert_eq!(proof.targets().objects.len(), 1); + assert!(proof.targets().events.is_empty()); } #[test] fn event_fixture_round_trips_and_verifies() { let proof = assert_fixture_round_trips_and_verifies(EVENT); - assert!(proof.target().objects.is_empty()); - assert_eq!(proof.target().events.len(), 1); + assert!(proof.targets().transaction.is_none()); + assert!(proof.targets().objects.is_empty()); + assert_eq!(proof.targets().events.len(), 1); } #[test] diff --git a/poi-rs/tests/proof_verification.rs b/poi-rs/tests/proof_verification.rs index dab22aa..73f3219 100644 --- a/poi-rs/tests/proof_verification.rs +++ b/poi-rs/tests/proof_verification.rs @@ -5,8 +5,7 @@ mod utils; use iota_sdk_types::CheckpointContents; use iota_types::{ - base_types::dbg_object_id, effects::TransactionEvents, event::EventID, messages_checkpoint::CheckpointContentsExt, - object::Object, + effects::TransactionEvents, event::EventID, messages_checkpoint::CheckpointContentsExt, object::Object, }; use poi_rs::{ProofTargets, ProofVerifier, VerifyErrorKind}; use utils::proofs::{event, execution_data, proof_with_events, proof_with_targets, valid_transaction_proof}; @@ -48,8 +47,7 @@ fn events_digest_must_match_the_effects() { 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()]); + proof.checkpoint_contents = CheckpointContents::new_with_digests_only_for_tests([alternate.digests()]); let error = ProofVerifier::new(&committee) .verify(&proof) @@ -72,26 +70,10 @@ fn transaction_must_be_present_in_the_checkpoint() { assert!(matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint)); } -#[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); - - let error = ProofVerifier::new(&committee) - .verify(&proof) - .expect_err("an object that does not match its reference must be rejected"); - - 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 targets = ProofTargets::new().add_object(object); let (committee, proof) = proof_with_targets(targets); let error = ProofVerifier::new(&committee) @@ -101,33 +83,15 @@ fn object_target_must_appear_in_the_transaction_effects() { 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) - .expect_err("an event that does not match the packaged event must be rejected"); - - 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 (committee, _, mut proof) = proof_with_events(TransactionEvents(vec![target])); let event_id = EventID { tx_digest: iota_sdk_types::TransactionDigest::new([0xff; 32]), event_seq: 0, }; - proof.target = ProofTargets::new().add_event(event_id, target); + proof.targets = ProofTargets::new().add_event(event_id); let error = ProofVerifier::new(&committee) .verify(&proof) @@ -139,12 +103,12 @@ fn event_target_must_belong_to_the_proven_transaction() { #[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 (committee, transaction_digest, mut proof) = proof_with_events(TransactionEvents(vec![target])); let event_id = EventID { tx_digest: transaction_digest, event_seq: 1, }; - proof.target = ProofTargets::new().add_event(event_id, target); + proof.targets = ProofTargets::new().add_event(event_id); let error = ProofVerifier::new(&committee) .verify(&proof) diff --git a/poi-rs/tests/proof_workflows.rs b/poi-rs/tests/proof_workflows.rs index 3adb819..3c95e9f 100644 --- a/poi-rs/tests/proof_workflows.rs +++ b/poi-rs/tests/proof_workflows.rs @@ -25,6 +25,11 @@ async fn client_builds_and_verifies_a_transaction_proof_from_genesis() { .await .expect("transaction proof must be constructed"); + assert_eq!(proof.targets.transaction, Some(transfer.digest)); + assert!(proof.targets.objects.is_empty()); + assert!(proof.targets.events.is_empty()); + assert!(proof.transaction_proof.events.is_none()); + let resolution = CommitteeResolution::from_genesis(genesis).expect("test cluster genesis blob must load"); client .verifier(resolution) @@ -46,7 +51,10 @@ async fn client_builds_and_verifies_an_object_proof_with_a_trusted_node() { .await .expect("object proof must be constructed"); - assert_eq!(proof.target.objects[0].0, transfer.gas_object); + assert!(proof.targets.transaction.is_none()); + assert_eq!(proof.targets.objects[0].as_inner().object_ref(), transfer.gas_object); + assert!(proof.targets.events.is_empty()); + assert!(proof.transaction_proof.events.is_none()); client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) @@ -71,6 +79,11 @@ async fn client_builds_and_verifies_an_event_proof_with_a_trusted_node() { .await .expect("event proof must be constructed"); + assert!(proof.targets.transaction.is_none()); + assert!(proof.targets.objects.is_empty()); + assert_eq!(proof.targets.events, vec![event_id]); + assert!(proof.transaction_proof.events.is_some()); + client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) @@ -92,7 +105,7 @@ async fn client_builds_one_verified_proof_for_multiple_objects() { .expect("stacked object proof must be constructed"); assert_eq!(proof.transaction_proof.transaction.digest(), &transfer.digest); - assert_eq!(proof.target.objects.len(), 2); + assert_eq!(proof.targets.objects.len(), 2); client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) @@ -119,9 +132,9 @@ async fn client_builds_one_verified_proof_for_object_and_event_targets() { .expect("mixed target proof must be constructed"); 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); + assert_eq!(proof.targets.objects[0].as_inner().object_ref(), staking.gas_object); + assert_eq!(proof.targets.objects.len(), 1); + assert_eq!(proof.targets.events.len(), 1); client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) diff --git a/poi-rs/tests/utils/proofs.rs b/poi-rs/tests/utils/proofs.rs index 73bf0bb..0306471 100644 --- a/poi-rs/tests/utils/proofs.rs +++ b/poi-rs/tests/utils/proofs.rs @@ -39,11 +39,21 @@ fn signed_checkpoint(contents: &CheckpointContents) -> (Committee, CertifiedChec } pub fn valid_transaction_proof() -> (Committee, Proof) { - proof_with_targets(ProofTargets::new()) + let execution = execution_data(); + let transaction_digest = *execution.transaction.digest(); + proof_from_execution(ProofTargets::new().set_transaction(transaction_digest), execution, None) } pub fn proof_with_targets(targets: ProofTargets) -> (Committee, Proof) { let execution = execution_data(); + proof_from_execution(targets, execution, None) +} + +fn proof_from_execution( + targets: ProofTargets, + execution: ExecutionData, + events: Option, +) -> (Committee, Proof) { let contents = CheckpointContents::new_with_digests_only_for_tests([execution.digests()]); let (committee, summary) = signed_checkpoint(&contents); let chain = ChainIdentifier::from(*summary.digest()); @@ -51,7 +61,8 @@ pub fn proof_with_targets(targets: ProofTargets) -> (Committee, Proof) { chain, targets, summary, - TransactionProof::new(contents, execution.transaction, execution.effects, None), + contents, + TransactionProof::new(execution.transaction, execution.effects, events), ); (committee, proof) @@ -70,7 +81,8 @@ pub fn proof_with_events(events: TransactionEvents) -> (Committee, TransactionDi chain, ProofTargets::new(), summary, - TransactionProof::new(contents, execution.transaction, execution.effects, Some(events)), + contents, + TransactionProof::new(execution.transaction, execution.effects, Some(events)), ); (committee, transaction_digest, proof) From 6f81c49743c2d88db4ce8413aff538317facb7bc Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 5 Aug 2026 15:45:02 +0300 Subject: [PATCH 41/41] feat: update proof request terminology and refactor related code --- bindings/wasm/poi_wasm/src/proof.rs | 6 +- .../poi_wasm/tests/proof-bindings.test.ts | 2 +- poi-rs/README.md | 1 - poi-rs/src/builder.rs | 189 ++++++++---------- poi-rs/src/lib.rs | 2 +- poi-rs/tests/proof_construction.rs | 61 +++--- 6 files changed, 113 insertions(+), 148 deletions(-) diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 5ecf8ed..7a1bc63 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -67,19 +67,19 @@ impl WasmProofBuilder { Self(ProofBuilder::new(source)) } - /// Adds a transaction target. + /// Adds a transaction proof request. pub fn transaction(self, transaction_digest: Uint8Array) -> Result { let digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).wasm_result()?; Ok(Self(self.0.transaction(digest))) } - /// Adds an object target. + /// Adds an object proof request. pub fn object(self, object_id: Uint8Array) -> Result { let object_id = ObjectId::from_bytes(object_id.to_vec()).wasm_result()?; Ok(Self(self.0.object(object_id))) } - /// Adds an event target. + /// Adds an event proof request. pub fn event(self, transaction_digest: Uint8Array, event_sequence: u64) -> Result { let tx_digest = TransactionDigest::from_bytes(transaction_digest.to_vec()).wasm_result()?; Ok(Self(self.0.event(EventID { diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index 4b59480..c54fe18 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -28,7 +28,7 @@ test("the WASM builder reads transaction evidence from the ledger source", async await assert.rejects( new ProofBuilder(source).transaction(transactionDigest).build(), - /source failed while reading transaction .*: source returned an invalid response/, + /source failed while reading proof evidence: source returned an invalid response/, ); assert.deepEqual(requestedDigest, transactionDigest); }); diff --git a/poi-rs/README.md b/poi-rs/README.md index c10343b..cd8c472 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -118,7 +118,6 @@ trust the authenticated target claims relative to the supplied committee. - `ProofVersion`: Proof format version used for compatibility checks. - `TransactionProof`: Transaction, effects, and optional event evidence used to prove inclusion. - `ProofTargets`: Transaction, object, and event claims explicitly selected by the caller. -- `ProofTarget`: Transaction, object, or event requested from a `ProofBuilder`. - `PoiClient`: Source-backed entry point for proof construction and committee-aware verification. - `CommitteeResolution`: Trusted-node or anchored committee-resolution configuration, including the committee cache. - `ProofBuilder`: Network-aware or custom-source proof construction. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index c1a1481..801134b 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -1,8 +1,6 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::fmt; - #[cfg(feature = "native-grpc")] use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, ObjectReference, TransactionDigest}; @@ -10,49 +8,37 @@ use iota_types::{effects::TransactionEffectsExt, event::EventID, object::Object} use crate::{Proof, ProofTargets, Source, SourceError, TransactionProof}; -/// Ledger target requested from a [`ProofBuilder`]. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum ProofTarget { - /// A transaction proof request. - Transaction(TransactionDigest), - /// An object proof request identified by object ID. - Object(ObjectId), - /// An event proof request. - Event(EventID), -} - -impl fmt::Display for ProofTarget { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Transaction(transaction_digest) => write!(f, "transaction {transaction_digest}"), - Self::Object(object_id) => write!(f, "object {object_id}"), - Self::Event(event_id) => write!(f, "event {event_id:?}"), - } - } -} - /// 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, - /// The configured source failed while reading evidence for a target. - #[error("source failed while reading {target}")] + /// No proof request was selected before building. + #[error("proof builder requires a request")] + MissingRequest, + /// The configured source failed while reading proof evidence. + #[error("source failed while reading proof evidence")] Source { - /// Proof target whose evidence was being read. - target: ProofTarget, /// Underlying source failure. #[source] source: SourceError, }, - /// The source did not return evidence for a requested target. - #[error("{target} was not found")] - TargetNotFound { - /// Proof target that was not returned. - target: ProofTarget, + /// The source did not return a requested transaction. + #[error("transaction {transaction_digest} was not found")] + TransactionNotFound { + /// Transaction digest that was not returned. + transaction_digest: TransactionDigest, + }, + /// The source did not return a requested object. + #[error("object {object_id} was not found")] + ObjectNotFound { + /// Object ID that was not returned. + object_id: ObjectId, + }, + /// The requested event was not present in its transaction. + #[error("event {event_id:?} was not found")] + EventNotFound { + /// Event ID that was not present. + event_id: EventID, }, /// The returned object does not match the requested ID or transaction effects. #[error("object {object_id} reference does not match the requested object")] @@ -65,17 +51,15 @@ pub enum ProofBuilderError { ObjectNotChangedByTransaction { /// Requested object ID. object_id: ObjectId, - /// Transaction selected by the other proof targets. + /// Transaction selected by the other proof requests. transaction_digest: TransactionDigest, }, - /// A requested target belongs to a different transaction than the other targets. - #[error("{target} belongs to transaction {actual}, expected {expected}")] - TargetTransactionMismatch { - /// Target that conflicts with the previously selected transaction. - target: ProofTarget, - /// Transaction selected by the first proof target. + /// The requests belong to different transactions. + #[error("proof requests belong to different transactions: {actual}, expected {expected}")] + TransactionMismatch { + /// Transaction selected by the first request. expected: TransactionDigest, - /// Transaction that owns the conflicting target. + /// Transaction selected by a conflicting request. actual: TransactionDigest, }, } @@ -87,7 +71,9 @@ pub enum ProofBuilderError { /// through `ProofBuilder::from_grpc_client`. pub struct ProofBuilder { source: S, - targets: Vec, + transaction_digests: Vec, + object_ids: Vec, + event_ids: Vec, } #[cfg(feature = "native-grpc")] @@ -127,50 +113,52 @@ impl ProofBuilder { pub fn new(source: S) -> Self { Self { source, - targets: Vec::new(), + transaction_digests: Vec::new(), + object_ids: Vec::new(), + event_ids: Vec::new(), } } - /// Adds a transaction proof target. + /// Adds a transaction proof request. pub fn transaction(mut self, transaction_digest: TransactionDigest) -> Self { - self.push_target(ProofTarget::Transaction(transaction_digest)); + Self::push_unique(&mut self.transaction_digests, transaction_digest); self } - /// Adds an object proof target by object ID. + /// Adds an object proof request 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(ProofTarget::Object(object_id)); + Self::push_unique(&mut self.object_ids, object_id); self } - /// Adds multiple object proof targets by object ID. + /// Adds multiple object proof requests by object ID. pub fn objects(mut self, object_ids: impl IntoIterator) -> Self { for object_id in object_ids { - self.push_target(ProofTarget::Object(object_id)); + Self::push_unique(&mut self.object_ids, object_id); } self } - /// Adds an event proof target. + /// Adds an event proof request. pub fn event(mut self, event_id: EventID) -> Self { - self.push_target(ProofTarget::Event(event_id)); + Self::push_unique(&mut self.event_ids, event_id); self } - /// Adds multiple event proof targets. + /// Adds multiple event proof requests. pub fn events(mut self, event_ids: impl IntoIterator) -> Self { for event_id in event_ids { - self.push_target(ProofTarget::Event(event_id)); + Self::push_unique(&mut self.event_ids, event_id); } self } /// Builds the requested proof from the configured source. pub async fn build(self) -> Result { - if self.targets.is_empty() { - return Err(ProofBuilderError::MissingTarget); + if self.transaction_digests.is_empty() && self.object_ids.is_empty() && self.event_ids.is_empty() { + return Err(ProofBuilderError::MissingRequest); } self.build_proof().await @@ -178,30 +166,20 @@ impl ProofBuilder { async fn build_proof(&self) -> Result { let mut selected_transaction = None; - let mut transaction_target = None; - let mut object_ids = Vec::new(); - let mut event_targets = Vec::new(); - - for target in self.targets.iter().copied() { - match target { - ProofTarget::Transaction(transaction_digest) => { - Self::ensure_same_transaction(&mut selected_transaction, target, transaction_digest)?; - transaction_target = Some(transaction_digest); - } - ProofTarget::Object(object_id) => object_ids.push(object_id), - ProofTarget::Event(event_id) => { - Self::ensure_same_transaction(&mut selected_transaction, target, event_id.tx_digest)?; - event_targets.push(event_id); - } - } + + for transaction_digest in self.transaction_digests.iter().copied() { + Self::ensure_same_transaction(&mut selected_transaction, transaction_digest)?; + } + for event_id in &self.event_ids { + Self::ensure_same_transaction(&mut selected_transaction, event_id.tx_digest)?; } - let (transaction_digest, transaction, objects) = if let Some(transaction_digest) = selected_transaction { + let (transaction, objects) = if let Some(transaction_digest) = selected_transaction { let transaction = self.fetch_transaction(transaction_digest).await?; let changed_objects = transaction.effects.all_changed_objects(); - let mut objects = Vec::with_capacity(object_ids.len()); + let mut objects = Vec::with_capacity(self.object_ids.len()); - for object_id in object_ids { + for object_id in self.object_ids.iter().copied() { let object_ref = changed_objects .iter() .find_map(|(object_ref, _, _)| (object_ref.object_id == object_id).then_some(*object_ref)) @@ -212,53 +190,46 @@ impl ProofBuilder { objects.push(self.fetch_object(object_id, Some(object_ref)).await?); } - (transaction_digest, transaction, objects) + (transaction, objects) } else { - let mut objects = Vec::with_capacity(object_ids.len()); + let mut objects = Vec::with_capacity(self.object_ids.len()); - for object_id in object_ids { + for object_id in self.object_ids.iter().copied() { let object = self.fetch_object(object_id, None).await?; - Self::ensure_same_transaction( - &mut selected_transaction, - ProofTarget::Object(object_id), - object.previous_transaction, - )?; + Self::ensure_same_transaction(&mut selected_transaction, object.previous_transaction)?; objects.push(object); } let transaction_digest = - selected_transaction.expect("ProofBuilder only builds a proof for non-empty targets"); + selected_transaction.expect("ProofBuilder only builds a proof for non-empty requests"); let transaction = self.fetch_transaction(transaction_digest).await?; - (transaction_digest, transaction, objects) + (transaction, objects) }; - let target = ProofTarget::Transaction(transaction_digest); let chain_identifier = self .source .chain_identifier() .await - .map_err(|source| ProofBuilderError::Source { target, source })?; + .map_err(|source| ProofBuilderError::Source { source })?; let checkpoint = self .source .checkpoint(transaction.checkpoint_sequence_number) .await - .map_err(|source| ProofBuilderError::Source { target, source })?; - let transaction_events = if event_targets.is_empty() { + .map_err(|source| ProofBuilderError::Source { source })?; + let transaction_events = if self.event_ids.is_empty() { None } else { - let events = transaction.events.ok_or_else(|| ProofBuilderError::TargetNotFound { - target: ProofTarget::Event(event_targets[0]), + let events = transaction.events.ok_or_else(|| ProofBuilderError::EventNotFound { + event_id: self.event_ids[0], })?; - for event_id in &event_targets { + for event_id in &self.event_ids { let event_exists = usize::try_from(event_id.event_seq) .ok() .is_some_and(|index| events.get(index).is_some()); if !event_exists { - return Err(ProofBuilderError::TargetNotFound { - target: ProofTarget::Event(*event_id), - }); + return Err(ProofBuilderError::EventNotFound { event_id: *event_id }); } } @@ -266,13 +237,13 @@ impl ProofBuilder { }; let transaction_proof = TransactionProof::new(transaction.transaction, transaction.effects, transaction_events); let mut targets = ProofTargets::new(); - if let Some(transaction_digest) = transaction_target { + if let Some(transaction_digest) = self.transaction_digests.first().copied() { targets = targets.set_transaction(transaction_digest); } for object in objects { targets = targets.add_object(object); } - for event_id in event_targets { + for event_id in self.event_ids.iter().copied() { targets = targets.add_event(event_id); } @@ -289,12 +260,11 @@ impl ProofBuilder { &self, transaction_digest: TransactionDigest, ) -> Result { - let target = ProofTarget::Transaction(transaction_digest); self.source .transaction(transaction_digest) .await - .map_err(|source| ProofBuilderError::Source { target, source })? - .ok_or(ProofBuilderError::TargetNotFound { target }) + .map_err(|source| ProofBuilderError::Source { source })? + .ok_or(ProofBuilderError::TransactionNotFound { transaction_digest }) } async fn fetch_object( @@ -302,13 +272,12 @@ impl ProofBuilder { object_id: ObjectId, expected_ref: Option, ) -> Result { - let target = ProofTarget::Object(object_id); let object = self .source .object(object_id, expected_ref.map(|object_ref| object_ref.version)) .await - .map_err(|source| ProofBuilderError::Source { target, source })? - .ok_or(ProofBuilderError::TargetNotFound { target })?; + .map_err(|source| ProofBuilderError::Source { source })? + .ok_or(ProofBuilderError::ObjectNotFound { object_id })?; let object_ref = object.as_inner().object_ref(); if object_ref.object_id != object_id || expected_ref.is_some_and(|expected| expected != object_ref) { @@ -320,13 +289,11 @@ impl ProofBuilder { fn ensure_same_transaction( selected: &mut Option, - target: ProofTarget, transaction_digest: TransactionDigest, ) -> Result<(), ProofBuilderError> { if let Some(expected) = selected { if *expected != transaction_digest { - return Err(ProofBuilderError::TargetTransactionMismatch { - target, + return Err(ProofBuilderError::TransactionMismatch { expected: *expected, actual: transaction_digest, }); @@ -338,9 +305,9 @@ impl ProofBuilder { Ok(()) } - fn push_target(&mut self, target: ProofTarget) { - if !self.targets.contains(&target) { - self.targets.push(target); + fn push_unique(values: &mut Vec, value: T) { + if !values.contains(&value) { + values.push(value); } } } diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index ec935e4..b09eb9e 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -20,7 +20,7 @@ pub mod proof; /// Ledger evidence source abstraction. pub mod source; -pub use builder::{ProofBuilder, ProofBuilderError, ProofTarget}; +pub use builder::{ProofBuilder, ProofBuilderError}; pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; pub use client::PoiClient; pub use committee::{ diff --git a/poi-rs/tests/proof_construction.rs b/poi-rs/tests/proof_construction.rs index d7a3a33..a795de4 100644 --- a/poi-rs/tests/proof_construction.rs +++ b/poi-rs/tests/proof_construction.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use iota_sdk_types::TransactionDigest; use iota_types::{event::EventID, object::Object}; -use poi_rs::{PoiClient, ProofBuilderError, ProofTarget, ProofVerifier, SourceError}; +use poi_rs::{PoiClient, ProofBuilderError, ProofVerifier, SourceError}; use utils::{ genesis_chain_identifier, grpc_client, object_transfer_tx, sources::{MissingSource, RecordingSource, RejectingSource}, @@ -25,26 +25,25 @@ async fn client_uses_a_custom_source_for_proof_building() { .await .expect_err("the custom source error must be returned"); - let ProofBuilderError::Source { target, source } = error else { + let ProofBuilderError::Source { source } = error else { panic!("custom source error must be preserved"); }; - assert_eq!(target, ProofTarget::Transaction(transaction_digest)); assert!(matches!(source, SourceError::Request { .. })); } #[tokio::test] -async fn proof_requires_at_least_one_target() { +async fn proof_requires_at_least_one_request() { let error = PoiClient::new(RejectingSource) .proof() .build() .await - .expect_err("a proof without a target must be rejected"); + .expect_err("a proof without a request must be rejected"); - assert!(matches!(error, ProofBuilderError::MissingTarget)); + assert!(matches!(error, ProofBuilderError::MissingRequest)); } #[tokio::test] -async fn stacked_targets_are_deduplicated_and_reuse_transaction_evidence() { +async fn stacked_requests_are_deduplicated_and_reuse_transaction_evidence() { let cluster = start_test_cluster().await; let staking = staking_tx(&cluster).await; let object_id = staking.gas_object.object_id; @@ -64,7 +63,7 @@ async fn stacked_targets_are_deduplicated_and_reuse_transaction_evidence() { .event(event_id) .build() .await - .expect("stacked targets from one transaction must produce a proof"); + .expect("stacked requests from one transaction must produce a proof"); assert_eq!( *transactions @@ -91,10 +90,13 @@ async fn transaction_not_returned_by_the_source_is_reported_as_missing() { .await .expect_err("a transaction omitted by the source must be rejected"); - let ProofBuilderError::TargetNotFound { target } = error else { - panic!("an omitted transaction must return a target-not-found error"); + let ProofBuilderError::TransactionNotFound { + transaction_digest: missing_transaction, + } = error + else { + panic!("an omitted transaction must return a transaction-not-found error"); }; - assert_eq!(target, ProofTarget::Transaction(transaction_digest)); + assert_eq!(missing_transaction, transaction_digest); } #[tokio::test] @@ -123,10 +125,13 @@ async fn object_not_returned_by_the_source_is_reported_as_missing() { .await .expect_err("an object omitted by the source must be rejected"); - let ProofBuilderError::TargetNotFound { target } = error else { - panic!("an omitted object must return a target-not-found error"); + let ProofBuilderError::ObjectNotFound { + object_id: missing_object, + } = error + else { + panic!("an omitted object must return an object-not-found error"); }; - assert_eq!(target, ProofTarget::Object(object_id)); + assert_eq!(missing_object, object_id); } #[tokio::test] @@ -168,15 +173,12 @@ async fn explicit_transaction_and_event_from_different_transactions_are_rejected .event(event_id) .build() .await - .expect_err("targets from different transactions must be rejected"); + .expect_err("requests from different transactions must be rejected"); assert!(matches!( error, - ProofBuilderError::TargetTransactionMismatch { - target: ProofTarget::Event(target), - expected, - actual, - } if target == event_id && expected == transaction_digest && actual == event_id.tx_digest + ProofBuilderError::TransactionMismatch { expected, actual } + if expected == transaction_digest && actual == event_id.tx_digest )); } @@ -196,10 +198,13 @@ async fn event_sequence_outside_the_transaction_is_rejected() { .await .expect_err("an event sequence outside the transaction must be rejected"); - let ProofBuilderError::TargetNotFound { target } = error else { - panic!("missing event must return a target-not-found error"); + let ProofBuilderError::EventNotFound { + event_id: missing_event, + } = error + else { + panic!("missing event must return an event-not-found error"); }; - assert_eq!(target, ProofTarget::Event(event_id)); + assert_eq!(missing_event, event_id); } #[tokio::test] @@ -233,7 +238,7 @@ async fn object_outside_the_event_transaction_is_rejected() { } #[tokio::test] -async fn object_targets_from_different_transactions_are_rejected() { +async fn object_requests_from_different_transactions_are_rejected() { let cluster = start_test_cluster().await; let first = object_transfer_tx(&cluster).await; let second = object_transfer_tx(&cluster).await; @@ -247,15 +252,9 @@ async fn object_targets_from_different_transactions_are_rejected() { .await .expect_err("objects from different transactions must be rejected"); - let ProofBuilderError::TargetTransactionMismatch { - target, - expected, - actual, - } = error - else { + let ProofBuilderError::TransactionMismatch { expected, actual } = error else { panic!("mixed transactions must return a proof-builder error"); }; - assert_eq!(target, ProofTarget::Object(second_object_id)); assert_eq!(expected, first.digest); assert_eq!(actual, second.digest); }