From 7b60b9f78e9da513b0365838e81e5540bbf1513f Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 16 Jul 2026 15:37:00 +0300 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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)); - } }