diff --git a/Cargo.toml b/Cargo.toml index 36638d0..aa2c6de 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] @@ -16,19 +16,25 @@ 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" } iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.26.1" } iota-sdk-types = { git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "36bffd625e1b1d38307eb9e49bd2cb7dd988bb11", default-features = false } +iota-types = { git = "https://github.com/iotaledger/iota.git", tag = "v1.26.1" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction" } iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.22", default-features = false, package = "iota_interaction_ts" } 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 new file mode 100644 index 0000000..2322bd4 --- /dev/null +++ b/poi-rs/Cargo.toml @@ -0,0 +1,38 @@ +[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." + +[features] +cli = ["dep:anyhow", "dep:clap", "dep:iota-config", "dep:reqwest", "serde_json/std"] + +[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"] } +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 = "poi" +path = "src/bin/poi.rs" +required-features = ["cli"] diff --git a/poi-rs/README.md b/poi-rs/README.md new file mode 100644 index 0000000..21bec66 --- /dev/null +++ b/poi-rs/README.md @@ -0,0 +1,86 @@ +# 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. `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. + +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. + +## 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. `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. + +## 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. +- `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/bin/poi.rs b/poi-rs/src/bin/poi.rs new file mode 100644 index 0000000..1afee34 --- /dev/null +++ b/poi-rs/src/bin/poi.rs @@ -0,0 +1,428 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![forbid(unsafe_code)] + +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +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}; + +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: + 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: + 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 = "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")?; + + 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"), + } + } +} + +#[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: 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() { + Some(path) => { + Genesis::load(path).with_context(|| format!("failed to load genesis blob '{}'", path.display()))? + } + None => { + 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")?; + writeln!(io::stdout().lock(), "valid").context("failed to write verification result to stdout") + } +} + +#[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 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() { + 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 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()))?; + } + + Genesis::load(&path).with_context(|| format!("failed to load genesis blob '{}'", path.display())) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + Cli::parse().command.execute().await +} + +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(["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([ + "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([ + "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(["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(["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(["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(["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")); + } +} diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs new file mode 100644 index 0000000..1cfdd83 --- /dev/null +++ b/poi-rs/src/builder.rs @@ -0,0 +1,160 @@ +// Copyright 2020-2026 IOTA Stiftung +// 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 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, + /// 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.push_target(SourceTarget::Transaction(transaction_digest)); + self + } + + /// 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 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 + } + + /// Adds an event proof target. + pub fn event(mut self, event_id: EventID) -> Self { + 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 { + if self.targets.is_empty() { + return Err(ProofBuilderError::MissingTarget); + } + + 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)] +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/cache.rs b/poi-rs/src/cache.rs new file mode 100644 index 0000000..61629e2 --- /dev/null +++ b/poi-rs/src/cache.rs @@ -0,0 +1,45 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::committee::{Committee, EpochId}; + +use crate::BoxError; + +mod in_memory; + +pub use in_memory::MemoryCommitteeCache; + +/// Error returned by a committee cache. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CommitteeCacheError { + /// A cached committee conflicts with authenticated committee data. + #[error("cached committee conflicts at epoch {epoch}")] + Conflict { + /// Epoch whose cached material conflicts. + epoch: EpochId, + }, + /// A cache backend failed to read or write committee material. + #[error("committee cache backend failed at epoch {epoch}")] + Backend { + /// Epoch being accessed when the backend failed. + epoch: EpochId, + /// Underlying backend error. + #[source] + source: BoxError, + }, +} + +/// Stores authenticated committees for anchored resolution. +/// +/// A cache is part of the caller's trust boundary. Implementations must return +/// only committees previously authenticated for the same network and must +/// preserve their integrity after storage. +#[async_trait::async_trait] +pub trait CommitteeCache: Send + Sync { + /// Returns the authenticated committee for `epoch`, when available. + async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError>; + + /// Stores a committee after the resolver has authenticated it. + async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError>; +} diff --git a/poi-rs/src/cache/in_memory.rs b/poi-rs/src/cache/in_memory.rs new file mode 100644 index 0000000..b580a06 --- /dev/null +++ b/poi-rs/src/cache/in_memory.rs @@ -0,0 +1,122 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::BTreeMap, sync::Arc}; + +use iota_types::committee::{Committee, EpochId}; +use tokio::sync::RwLock; + +use super::{CommitteeCache, CommitteeCacheError}; + +/// In-memory committee cache for library usage and tests. +#[derive(Clone, Debug, Default)] +pub struct MemoryCommitteeCache { + committees: Arc>>, +} + +impl MemoryCommitteeCache { + /// Creates an empty in-memory committee cache. + pub fn new() -> Self { + Self::default() + } + + /// Returns the number of cached committees. + pub async fn len(&self) -> usize { + self.committees.read().await.len() + } + + /// Returns whether the cache contains no committees. + pub async fn is_empty(&self) -> bool { + self.committees.read().await.is_empty() + } +} + +#[async_trait::async_trait] +impl CommitteeCache for MemoryCommitteeCache { + async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError> { + Ok(self.committees.read().await.get(&epoch).cloned()) + } + + async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> { + let epoch = committee.epoch; + let mut committees = self.committees.write().await; + + if committees.get(&epoch).is_some_and(|cached| cached != committee) { + return Err(CommitteeCacheError::Conflict { epoch }); + } + + committees.entry(epoch).or_insert_with(|| committee.clone()); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn committee_at(epoch: EpochId) -> Committee { + let (committee, _) = Committee::new_simple_test_committee(); + + Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) + } + + #[tokio::test] + async fn new_cache_is_empty() { + let cache = MemoryCommitteeCache::new(); + + assert!(cache.is_empty().await); + assert_eq!(cache.len().await, 0); + assert!(cache.committee(7).await.unwrap().is_none()); + } + + #[tokio::test] + async fn store_makes_a_committee_available_by_epoch() { + let cache = MemoryCommitteeCache::new(); + let committee = committee_at(7); + + cache.store(&committee).await.unwrap(); + + assert_eq!(cache.committee(7).await.unwrap(), Some(committee)); + assert_eq!(cache.len().await, 1); + assert!(!cache.is_empty().await); + } + + #[tokio::test] + async fn storing_the_same_committee_is_idempotent() { + let cache = MemoryCommitteeCache::new(); + let committee = committee_at(7); + + cache.store(&committee).await.unwrap(); + cache.store(&committee).await.unwrap(); + + assert_eq!(cache.committee(7).await.unwrap(), Some(committee)); + assert_eq!(cache.len().await, 1); + } + + #[tokio::test] + async fn conflicting_committee_is_rejected_without_replacing_the_original() { + let cache = MemoryCommitteeCache::new(); + let original = committee_at(7); + let (conflicting, _) = Committee::new_simple_test_committee_of_size(5); + let conflicting = Committee::new(7, conflicting.voting_rights.iter().cloned().collect()); + cache.store(&original).await.unwrap(); + + let error = cache.store(&conflicting).await.unwrap_err(); + + assert!(matches!(error, CommitteeCacheError::Conflict { epoch: 7 })); + assert_eq!(cache.committee(7).await.unwrap(), Some(original)); + assert_eq!(cache.len().await, 1); + } + + #[tokio::test] + async fn clones_share_cached_committees() { + let cache = MemoryCommitteeCache::new(); + let clone = cache.clone(); + let committee = committee_at(7); + + cache.store(&committee).await.unwrap(); + + assert_eq!(clone.committee(7).await.unwrap(), Some(committee)); + } +} diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs new file mode 100644 index 0000000..d579830 --- /dev/null +++ b/poi-rs/src/committee.rs @@ -0,0 +1,789 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use iota_grpc_client::{ + Client as GrpcClient, ReadMask, + read_mask_fields::{CheckpointResponseField, EpochField, ServiceInfoField}, +}; +use iota_types::{ + committee::{Committee, EpochId}, + error::IotaError, + messages_checkpoint::CertifiedCheckpointSummary, +}; + +use crate::{BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; + +/// Error returned when a committee cannot be resolved for an epoch. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to resolve committee for epoch {target_epoch}")] +pub struct CommitteeResolutionError { + /// Epoch whose committee was requested. + pub target_epoch: EpochId, + /// Committee resolution failure details. + #[source] + pub kind: CommitteeResolutionErrorKind, +} + +impl CommitteeResolutionError { + /// Associates a resolution failure with the committee epoch requested by the caller. + fn new(target_epoch: EpochId, kind: CommitteeResolutionErrorKind) -> Self { + Self { target_epoch, kind } + } +} + +/// Kind of committee resolution failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CommitteeResolutionErrorKind { + /// Fetching a committee directly from the trusted node failed. + #[error("failed to fetch committee for epoch {epoch} from the trusted node")] + FetchCommittee { + /// Epoch requested from the node. + epoch: EpochId, + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// Reading a committee returned by the trusted node failed. + #[error("failed to read committee for epoch {epoch} from the trusted node")] + Committee { + /// Epoch requested from the node. + epoch: EpochId, + /// Underlying response error. + #[source] + source: BoxError, + }, + /// The requested epoch predates the trusted committee anchor. + #[error("target epoch is before trusted anchor epoch {anchor_epoch}")] + TargetBeforeAnchor { + /// Earliest epoch authenticated by the resolver. + anchor_epoch: EpochId, + }, + /// Fetching the node's current epoch failed. + #[error("failed to fetch the node's current epoch")] + FetchCurrentEpoch { + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// The service information response omitted the current epoch. + #[error("service information is missing the current epoch")] + MissingCurrentEpoch, + /// The requested epoch is newer than the connected node's current epoch. + #[error("target epoch is ahead of node current epoch {current_epoch}")] + TargetAheadOfNode { + /// Current epoch reported by the connected node. + current_epoch: EpochId, + }, + /// Fetching the last checkpoint of an epoch failed. + #[error("failed to fetch end-of-epoch checkpoint information for epoch {epoch}")] + FetchEpochHistory { + /// Epoch whose last checkpoint was requested. + epoch: EpochId, + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// The epoch response omitted its last checkpoint sequence number. + #[error("epoch {epoch} is missing its last checkpoint")] + MissingLastCheckpoint { + /// Epoch whose last checkpoint was requested. + epoch: EpochId, + }, + /// Fetching a certified end-of-epoch checkpoint summary failed. + #[error("failed to fetch end-of-epoch checkpoint {sequence_number}")] + FetchCheckpoint { + /// Checkpoint sequence number requested from the node. + sequence_number: u64, + /// Underlying gRPC error. + #[source] + source: BoxError, + }, + /// Reading or converting a checkpoint summary failed. + #[error("failed to read end-of-epoch checkpoint {sequence_number}")] + CheckpointSummary { + /// Checkpoint sequence number returned by the epoch response. + sequence_number: u64, + /// Underlying response or conversion error. + #[source] + source: BoxError, + }, + /// The current trusted committee did not authenticate the end-of-epoch checkpoint. + #[error("failed to verify epoch {epoch} end-of-epoch checkpoint {sequence_number}")] + InvalidEndOfEpochCheckpoint { + /// Epoch whose committee was used for verification. + epoch: EpochId, + /// Checkpoint sequence number closing the epoch. + sequence_number: u64, + /// Underlying checkpoint verification error. + #[source] + source: BoxError, + }, + /// The epoch's last checkpoint did not contain next-epoch data. + #[error("checkpoint {sequence_number} is not an end-of-epoch checkpoint")] + NotEndOfEpoch { + /// Checkpoint sequence number returned by the epoch response. + sequence_number: u64, + }, + /// Incrementing the authenticated epoch would overflow an [`EpochId`]. + #[error("next epoch after {epoch} overflows u64")] + NextEpochOverflow { + /// Authenticated checkpoint epoch. + epoch: EpochId, + }, + /// Reading or writing an authenticated committee in a cache failed. + #[error("committee cache failed at epoch {epoch}")] + Cache { + /// Epoch being resolved through the cache. + epoch: EpochId, + /// Underlying cache error. + #[source] + source: CommitteeCacheError, + }, +} + +/// Selects how a resolver establishes trust in committee data. +#[derive(Clone)] +enum CommitteeResolution { + /// Accept committee data returned directly by the connected node. + Node, + /// Authenticate committee lineage from an existing trust anchor. + Anchor { + committee: Committee, + cache: Arc, + }, +} + +/// Resolves the committee required to verify a checkpoint from a gRPC node. +/// +/// A resolver either accepts committee data directly from a trusted node or +/// starts from a trusted committee, normally obtained from the network genesis +/// blob, and authenticates every end-of-epoch handoff up to the requested epoch. +#[derive(Clone)] +pub struct CommitteeResolver { + client: GrpcClient, + mode: CommitteeResolution, +} + +impl CommitteeResolver { + /// Creates a resolver that trusts the connected node for committee data. + /// + /// This mode does not authenticate committee lineage. Use it only when the + /// node is inside the caller's trust boundary, such as local development or + /// explicitly trusted infrastructure. + pub fn node(client: GrpcClient) -> Self { + Self { + client, + mode: CommitteeResolution::Node, + } + } + + /// Creates a resolver anchored at an already trusted committee. + /// + /// The trusted committee should be obtained from the network genesis blob + /// or from a previously authenticated checkpoint. The connected node is + /// treated only as a source of epoch and checkpoint data. Authenticated + /// committees are retained in memory for subsequent resolutions. + pub fn anchor(client: GrpcClient, committee: Committee) -> Self { + Self::anchor_with_cache(client, committee, MemoryCommitteeCache::new()) + } + + /// Creates an anchored resolver backed by a caller-provided committee cache. + /// + /// The cache is part of the caller's trust boundary and must return only + /// committees authenticated for the same network. Committees fetched by + /// this resolver are cached only after successful authentication. + pub fn anchor_with_cache(client: GrpcClient, committee: Committee, cache: impl CommitteeCache + 'static) -> Self { + Self { + client, + mode: CommitteeResolution::Anchor { + committee, + cache: Arc::new(cache), + }, + } + } + + /// Returns the underlying SDK gRPC client. + pub const fn grpc_client(&self) -> &GrpcClient { + &self.client + } + + /// Resolves the authenticated committee for `target_epoch`. + /// + /// Node mode returns the committee reported by the trusted node. Anchor + /// mode verifies each end-of-epoch checkpoint with the current committee + /// before accepting its successor. + pub async fn resolve(&self, target_epoch: EpochId) -> Result { + match &self.mode { + CommitteeResolution::Node => self.resolve_from_node(target_epoch).await, + CommitteeResolution::Anchor { committee, cache } => { + self.resolve_from_anchor(committee, cache.as_ref(), target_epoch).await + } + } + } + + /// Fetches a committee directly from a node inside the caller's trust boundary. + async fn resolve_from_node(&self, target_epoch: EpochId) -> Result { + let epoch = self + .client + .get_epoch(Some(target_epoch), Some(ReadMask::from(EpochField::COMMITTEE))) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchCommittee { + epoch: target_epoch, + source: Box::new(source), + }, + ) + })? + .into_inner(); + let committee = epoch.committee().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Committee { + epoch: target_epoch, + source: Box::new(source), + }, + ) + })?; + + Ok(committee.into()) + } + + /// Resolves from trusted cached committees before walking authenticated epoch summaries. + async fn resolve_from_anchor( + &self, + trusted_committee: &Committee, + cache: &dyn CommitteeCache, + target_epoch: EpochId, + ) -> Result { + if target_epoch < trusted_committee.epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::TargetBeforeAnchor { + anchor_epoch: trusted_committee.epoch, + }, + )); + } + + if target_epoch == trusted_committee.epoch { + return Ok(trusted_committee.clone()); + } + + if let Some(committee) = cache.committee(target_epoch).await.map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: target_epoch, + source, + }, + ) + })? { + if committee.epoch != target_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: target_epoch, + source: CommitteeCacheError::Conflict { epoch: target_epoch }, + }, + )); + } + + return Ok(committee); + } + + let mut committee = trusted_committee.clone(); + + while committee.epoch < target_epoch { + let next_epoch = committee.epoch + 1; + let Some(cached) = cache.committee(next_epoch).await.map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: next_epoch, + source, + }, + ) + })? + else { + break; + }; + + if cached.epoch != next_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::Cache { + epoch: next_epoch, + source: CommitteeCacheError::Conflict { epoch: next_epoch }, + }, + )); + } + + committee = cached; + } + + if committee.epoch == target_epoch { + return Ok(committee); + } + + let current_epoch = self.current_epoch(target_epoch).await?; + if target_epoch > current_epoch { + return Err(CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::TargetAheadOfNode { current_epoch }, + )); + } + + while committee.epoch < target_epoch { + let next_committee = self.fetch_next_committee(target_epoch, &committee, cache).await?; + committee = next_committee; + } + + Ok(committee) + } + + /// Fetches the connected node's current epoch to reject unreachable targets early. + async fn current_epoch(&self, target_epoch: EpochId) -> Result { + self.client + .get_service_info(Some(ReadMask::from(ServiceInfoField::EPOCH))) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchCurrentEpoch { + source: Box::new(source), + }, + ) + })? + .body() + .epoch + .ok_or_else(|| { + CommitteeResolutionError::new(target_epoch, CommitteeResolutionErrorKind::MissingCurrentEpoch) + }) + } + + /// Fetches and authenticates the committee elected for the next epoch. + async fn fetch_next_committee( + &self, + target_epoch: EpochId, + current_committee: &Committee, + 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?; + + 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( + &self, + target_epoch: EpochId, + epoch: EpochId, + ) -> Result { + self.client + .get_epoch(Some(epoch), Some(ReadMask::from(EpochField::LAST_CHECKPOINT))) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchEpochHistory { + epoch, + source: Box::new(source), + }, + ) + })? + .into_inner() + .last_checkpoint + .ok_or_else(|| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::MissingLastCheckpoint { epoch }, + ) + }) + } + + /// Fetches only the signed checkpoint summary required to authenticate the next committee. + async fn certified_checkpoint_summary( + &self, + target_epoch: EpochId, + sequence_number: u64, + ) -> Result { + let checkpoint = self + .client + .get_checkpoint_by_sequence_number( + sequence_number, + Some(ReadMask::from(CHECKPOINT_SUMMARY_FIELDS)), + None, + None, + ) + .await + .map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::FetchCheckpoint { + sequence_number, + source: Box::new(source), + }, + ) + })? + .into_inner(); + + let summary = checkpoint.signed_summary().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::CheckpointSummary { + sequence_number, + source: Box::new(source), + }, + ) + })?; + + summary.try_into().map_err(|source| { + CommitteeResolutionError::new( + target_epoch, + CommitteeResolutionErrorKind::CheckpointSummary { + sequence_number, + source: Box::new(source), + }, + ) + }) + } + + /// Verifies an end-of-epoch summary before accepting its next committee. + fn authenticate_next_committee( + current_committee: &Committee, + summary: CertifiedCheckpointSummary, + ) -> Result { + let sequence_number = summary.sequence_number; + 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 next_epoch_committee = &verified + .end_of_epoch_data + .as_ref() + .expect("checked before signature verification") + .next_epoch_committee; + + Ok(Committee::new( + next_epoch, + 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. +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 super::*; + + struct StaticCache { + 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> { + Ok((self.committee.epoch == epoch).then(|| self.committee.clone())) + } + + async fn store(&self, _committee: &Committee) -> Result<(), CommitteeCacheError> { + Ok(()) + } + } + + fn signed_end_of_epoch_summary( + current_epoch: EpochId, + include_next_committee: bool, + ) -> (Committee, Committee, CertifiedCheckpointSummary) { + let (base_committee, keypairs) = Committee::new_simple_test_committee(); + let current_committee = Committee::new(current_epoch, base_committee.voting_rights.iter().cloned().collect()); + let (next_base_committee, _) = Committee::new_simple_test_committee_of_size(5); + let next_committee = Committee::new( + current_epoch.saturating_add(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) + } + + #[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_and_store_next_committee(4, ¤t_committee, summary, &cache) + .await + .unwrap(); + + assert_eq!(committee, expected_committee); + assert_eq!(cache.stored(), vec![expected_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_and_store_next_committee(4, &wrong_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 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_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(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::NotEndOfEpoch { sequence_number: 42 } + )); + 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(); + + 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] + async fn anchored_resolution_resumes_from_an_authenticated_cache() { + let (current_committee, next_committee, summary) = signed_end_of_epoch_summary(3, true); + let authenticated_committee = + CommitteeResolver::authenticate_next_committee(¤t_committee, summary).unwrap(); + let cache = crate::MemoryCommitteeCache::new(); + cache.store(&authenticated_committee).await.unwrap(); + let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); + let resolver = CommitteeResolver::anchor_with_cache(client, current_committee, cache); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, next_committee); + } + + #[tokio::test] + async fn anchor_mode_uses_a_committee_cache_by_default() { + let (current_committee, next_committee, summary) = signed_end_of_epoch_summary(3, true); + let authenticated_committee = + CommitteeResolver::authenticate_next_committee(¤t_committee, summary).unwrap(); + let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); + let resolver = CommitteeResolver::anchor(client, current_committee); + let CommitteeResolution::Anchor { cache, .. } = &resolver.mode else { + panic!("anchor resolver must have a committee cache"); + }; + cache.store(&authenticated_committee).await.unwrap(); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, next_committee); + } + + #[tokio::test] + async fn memory_cache_rejects_a_conflicting_committee() { + let (_, next_committee, _) = signed_end_of_epoch_summary(3, true); + let cache = crate::MemoryCommitteeCache::new(); + cache.store(&next_committee).await.unwrap(); + let (conflicting_committee, _) = Committee::new_simple_test_committee_of_size(6); + let conflicting_committee = Committee::new(4, conflicting_committee.voting_rights.iter().cloned().collect()); + + let error = cache.store(&conflicting_committee).await.unwrap_err(); + + assert!(matches!(error, CommitteeCacheError::Conflict { epoch: 4 })); + } + + #[tokio::test] + async fn anchored_resolution_accepts_a_committee_from_a_trusted_cache() { + let (current_committee, next_committee, _) = signed_end_of_epoch_summary(3, true); + let cache = StaticCache { + committee: next_committee.clone(), + }; + let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); + let resolver = CommitteeResolver::anchor_with_cache(client, current_committee, cache); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, next_committee); + } +} diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs new file mode 100644 index 0000000..d81492e --- /dev/null +++ b/poi-rs/src/lib.rs @@ -0,0 +1,31 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![doc = include_str!("../README.md")] +#![warn(missing_docs, rustdoc::all)] + +/// Shared boxed source error used by the crate's typed errors. +pub(crate) type BoxError = Box; + +/// Proof construction builders. +pub mod builder; +/// Verified committee lineage caches for anchored resolution. +pub mod cache; +/// Committee resolution for checkpoint verification. +pub mod committee; +/// Proof data types and offline verification. +pub mod proof; +/// Sources for constructing proofs. +pub mod source; +/// Target claims authenticated by a proof. +pub mod target; + +pub use 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::{Source, SourceError, SourceErrorKind, SourceTarget, TransactionMismatch}; +pub use target::ProofTargets; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs new file mode 100644 index 0000000..35929c6 --- /dev/null +++ b/poi-rs/src/proof.rs @@ -0,0 +1,451 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_types::{ + committee::Committee, + digests::ChainIdentifier, + effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}, + messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContents, EndOfEpochData}, + transaction::Transaction, +}; +use serde::{Deserialize, Serialize}; + +use crate::{BoxError, target::ProofTargets}; + +/// Error returned when a proof-format version is not supported. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("unsupported Proof of Inclusion proof format version: {version}")] +pub struct VersionError { + /// Unsupported proof-format version. + pub version: u16, +} + +/// Error returned when a proof cannot be serialized or deserialized. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to serialize or deserialize Proof of Inclusion proof")] +pub struct SerializationError { + /// Serialization failure details. + #[source] + pub kind: SerializationErrorKind, +} + +/// Kind of proof serialization or deserialization failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SerializationErrorKind { + /// JSON serialization or deserialization failed. + #[error("json serialization or deserialization failed")] + Json { + /// Underlying JSON serialization or deserialization error. + #[source] + source: serde_json::Error, + }, +} + +/// Error returned when offline proof verification fails. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[error("failed to verify Proof of Inclusion proof")] +pub struct VerifyError { + /// Verification failure details. + #[source] + pub kind: VerifyErrorKind, +} + +/// Kind of offline proof-verification failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum VerifyErrorKind { + /// The proof-format version is not supported. + #[error("proof format version is not supported")] + Version { + /// Unsupported version error. + #[source] + source: VersionError, + }, + /// The checkpoint summary or its contents failed verification. + #[error("checkpoint summary verification failed")] + CheckpointSummary { + /// Underlying checkpoint-verification error. + #[source] + source: BoxError, + }, + /// A committee target was requested but the checkpoint is not an end-of-epoch checkpoint. + #[error("checkpoint summary does not contain an end-of-epoch committee")] + MissingEndOfEpochCommittee, + /// The next epoch value overflowed while checking a committee target. + #[error("next epoch overflows u64")] + NextEpochOverflow, + /// The committee target does not match the checkpoint's next committee. + #[error("committee target does not match the checkpoint summary")] + CommitteeMismatch, + /// Transaction data does not match the transaction digest in the effects. + #[error("transaction digest does not match the execution digest")] + TransactionDigestMismatch, + /// The transaction effects are not included in the checkpoint contents. + #[error("transaction digest not found in the checkpoint contents")] + TransactionNotInCheckpoint, + /// Packaged events do not match the digest recorded in the effects. + #[error("events digest does not match the execution digest")] + EventsDigestMismatch, + /// Event targets require packaged transaction events. + #[error("transaction effects refer to events but event data is missing")] + MissingEvents, + /// The event target belongs to a different transaction. + #[error("event target does not belong to the transaction")] + EventTransactionMismatch, + /// The event target sequence number is outside the packaged event list. + #[error("event sequence number {sequence} is out of bounds")] + EventSequenceOutOfBounds { + /// Requested event sequence. + sequence: u64, + }, + /// The packaged event does not match the event target. + #[error("event target contents do not match")] + EventContentsMismatch, + /// The object content does not compute to the requested object reference. + #[error("object target reference does not match the object")] + ObjectReferenceMismatch, + /// The transaction effects do not include the requested object reference. + #[error("object target was not found in the transaction effects")] + ObjectNotFound, +} + +/// Proof-format version used for compatibility checks and verifier dispatch. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProofVersion(u16); + +impl ProofVersion { + /// Current Proof of Inclusion proof-format version. + pub const CURRENT: Self = Self(1); + + /// Creates a supported proof-format version. + pub fn new(version: u16) -> Result { + let version = Self(version); + version.validate()?; + Ok(version) + } + + /// Returns the numeric proof-format version. + pub const fn value(self) -> u16 { + self.0 + } + + /// Returns an error when this version is not supported. + pub fn validate(self) -> Result<(), VersionError> { + if self == Self::CURRENT { + Ok(()) + } else { + Err(VersionError { version: self.value() }) + } + } +} + +impl TryFrom for ProofVersion { + type Error = VersionError; + + fn try_from(version: u16) -> Result { + Self::new(version) + } +} + +/// Transaction evidence packaged in a Proof of Inclusion envelope. +/// +/// A transaction proof links one transaction to a certified checkpoint. It carries +/// the checkpoint contents, the transaction, its effects, and the transaction +/// events when the transaction emitted events. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransactionProof { + /// Checkpoint contents including the transaction. + pub checkpoint_contents: CheckpointContents, + /// Transaction being authenticated. + pub transaction: Transaction, + /// Effects of the transaction being authenticated. + pub effects: TransactionEffects, + /// Events of the transaction being authenticated, when present. + pub events: Option, +} + +impl TransactionProof { + /// Creates transaction proof evidence. + pub fn new( + checkpoint_contents: CheckpointContents, + transaction: Transaction, + effects: TransactionEffects, + events: Option, + ) -> Self { + Self { + checkpoint_contents, + transaction, + effects, + events, + } + } +} + +/// Proof of Inclusion evidence for targets included in a certified checkpoint. +/// +/// The envelope always carries transaction evidence. This keeps the public Proof +/// of Inclusion contract focused on inclusion claims rather than generic +/// checkpoint-only verification. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Proof { + /// Proof-format version. + pub version: ProofVersion, + /// Chain or network identity. + pub chain: ChainIdentifier, + /// Target claim authenticated by this proof. + pub target: ProofTargets, + /// Certified checkpoint summary. + pub checkpoint_summary: CertifiedCheckpointSummary, + /// Transaction evidence for the inclusion target. + pub transaction_proof: TransactionProof, +} + +impl Proof { + /// Creates a proof envelope from an explicit target and transaction proof. + /// + /// The constructor sets [`ProofVersion::CURRENT`] automatically. + pub fn new( + chain: ChainIdentifier, + target: ProofTargets, + checkpoint_summary: CertifiedCheckpointSummary, + transaction_proof: TransactionProof, + ) -> Self { + Self { + version: ProofVersion::CURRENT, + chain, + target, + checkpoint_summary, + transaction_proof, + } + } + + /// Returns the proof-format version. + pub const fn version(&self) -> ProofVersion { + self.version + } + + /// Returns the proof target. + pub const fn target(&self) -> &ProofTargets { + &self.target + } + + /// Serializes this proof envelope as JSON. + pub fn to_json_vec(&self) -> Result, SerializationError> { + serde_json::to_vec(self).map_err(|source| SerializationError { + kind: SerializationErrorKind::Json { source }, + }) + } + + /// 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() + } +} + +/// Offline Proof of Inclusion verifier. +/// +/// `ProofVerifier` verifies only the proof material supplied by the caller. It +/// does not fetch data, resolve committees, or trust a node. +#[derive(Clone, Copy, Debug)] +pub struct ProofVerifier<'committee> { + committee: &'committee Committee, +} + +impl<'committee> ProofVerifier<'committee> { + /// Creates a verifier for proofs certified by `committee`. + pub const fn new(committee: &'committee Committee) -> Self { + Self { committee } + } + + /// Returns the committee used by this verifier. + pub const fn committee(&self) -> &'committee Committee { + self.committee + } + + /// Verifies a Proof of Inclusion. + /// + /// The verifier checks the checkpoint summary and all transaction evidence + /// before authenticating object, event, or committee targets. + pub fn verify(&self, proof: &Proof) -> Result<(), VerifyError> { + proof.validate().map_err(|source| VerifyError { + kind: VerifyErrorKind::Version { source }, + })?; + + let summary = &proof.checkpoint_summary; + let contents = Some(&proof.transaction_proof.checkpoint_contents); + + summary + .verify_with_contents(self.committee, contents) + .map_err(|source| VerifyError { + kind: VerifyErrorKind::CheckpointSummary { + source: Box::new(source), + }, + })?; + + self.verify_committee_target(summary, &proof.target)?; + self.verify_transaction_proof(summary, &proof.transaction_proof)?; + self.verify_event_targets(&proof.target, &proof.transaction_proof)?; + self.verify_object_targets(&proof.target, &proof.transaction_proof)?; + + Ok(()) + } + + /// 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::new( + summary.epoch().checked_add(1).ok_or(VerifyError { + kind: VerifyErrorKind::NextEpochOverflow, + })?, + next_epoch_committee.iter().cloned().collect(), + ); + + if actual_committee != *expected_committee { + return Err(VerifyError { + kind: VerifyErrorKind::CommitteeMismatch, + }); + } + + Ok(()) + } + + /// 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, + }); + } + + let transaction_is_in_checkpoint = transaction_proof + .checkpoint_contents + .enumerate_transactions(summary) + .any(|(_, digests)| digests == &execution_digests); + + if !transaction_is_in_checkpoint { + return Err(VerifyError { + kind: VerifyErrorKind::TransactionNotInCheckpoint, + }); + } + + if transaction_proof.effects.events_digest() + != transaction_proof.events.as_ref().map(|events| events.digest()).as_ref() + { + return Err(VerifyError { + kind: VerifyErrorKind::EventsDigestMismatch, + }); + } + + Ok(()) + } + + /// 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, + transaction_proof: &TransactionProof, + ) -> Result<(), VerifyError> { + if targets.events.is_empty() { + return Ok(()); + } + + let Some(events) = &transaction_proof.events else { + return Err(VerifyError { + kind: VerifyErrorKind::MissingEvents, + }); + }; + + let execution_digests = transaction_proof.effects.execution_digests(); + for (event_id, event) in &targets.events { + if event_id.tx_digest != execution_digests.transaction { + return Err(VerifyError { + kind: VerifyErrorKind::EventTransactionMismatch, + }); + } + + let event_index = event_id.event_seq as usize; + let Some(actual_event) = events.get(event_index) else { + return Err(VerifyError { + kind: VerifyErrorKind::EventSequenceOutOfBounds { + sequence: event_id.event_seq, + }, + }); + }; + + if actual_event != event { + return Err(VerifyError { + kind: VerifyErrorKind::EventContentsMismatch, + }); + } + } + + Ok(()) + } + + /// 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, + transaction_proof: &TransactionProof, + ) -> Result<(), VerifyError> { + if targets.objects.is_empty() { + return Ok(()); + } + + let changed_objects = transaction_proof.effects.all_changed_objects(); + for (object_ref, object) in &targets.objects { + if object_ref != &object.as_inner().object_ref() { + return Err(VerifyError { + kind: VerifyErrorKind::ObjectReferenceMismatch, + }); + } + + changed_objects + .iter() + .find(|changed_object_ref| &changed_object_ref.0 == object_ref) + .ok_or(VerifyError { + kind: VerifyErrorKind::ObjectNotFound, + })?; + } + + Ok(()) + } +} diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs new file mode 100644 index 0000000..0e4141c --- /dev/null +++ b/poi-rs/src/source.rs @@ -0,0 +1,723 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use async_trait::async_trait; +use iota_grpc_client::{ + CheckpointResponse, Client as GrpcClient, ReadMask, + read_mask_fields::{CheckpointResponseField, ObjectField, ServiceInfoField, TransactionField}, +}; +use iota_grpc_types::v1::transaction::ExecutedTransaction; +use iota_sdk_types::{Digest, ObjectId, SignedTransaction}; +use iota_types::{ + base_types::ObjectRef, + digests::{ChainIdentifier, CheckpointDigest, TransactionDigest}, + effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt}, + 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, +]; + +/// 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, + } + } +} + +/// 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] +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("{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 { + /// 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 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; +} + +/// Proof source backed by an SDK gRPC client. +/// +/// Applications normally construct this source through the network and client +/// convenience constructors on [`crate::ProofBuilder`]. +pub struct GrpcSource { + client: GrpcClient, +} + +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 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, + 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), + }, + ) + }) + .and_then(|contents| { + contents.try_into().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), + }, + ) + }) + } + + /// 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() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })? + .transaction() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let signatures = executed_transaction + .signatures() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + })? + .signatures + .iter() + .map(|signature| { + signature.signature().map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Signatures { + source: Box::new(source), + }, + ) + }) + }) + .collect::, SourceError>>()?; + let transaction: Transaction = SignedTransaction { + transaction, + signatures, + } + .try_into() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Transaction { + source: Box::new(source), + }, + ) + })?; + let events = if effects.events_digest().is_some() { + executed_transaction + .events() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::MissingEvents { + source: Box::new(source), + }, + ) + })? + .events() + .map_err(|source| { + SourceError::transaction( + transaction_digest, + SourceErrorKind::Events { + source: Box::new(source), + }, + ) + }) + .map(Some)? + } else { + None + }; + + Ok(TransactionProof::new(checkpoint_contents, transaction, effects, events)) + } +} + +#[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| { + 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, 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(proof) + } +} diff --git a/poi-rs/src/target.rs b/poi-rs/src/target.rs new file mode 100644 index 0000000..1792a73 --- /dev/null +++ b/poi-rs/src/target.rs @@ -0,0 +1,60 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_sdk_types::Event; +use iota_types::committee::Committee; +use iota_types::{base_types::ObjectRef, event::EventID, object::Object}; +use serde::{Deserialize, Serialize}; + +/// Target claims authenticated by a Proof of Inclusion. +/// +/// Object and event targets are authenticated through the transaction evidence in +/// the proof. Committee targets authenticate the next epoch committee recorded in +/// an end-of-epoch checkpoint summary. +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +pub struct ProofTargets { + /// Objects that need to be certified. + pub objects: Vec<(ObjectRef, Object)>, + + /// Events that need to be certified. + pub events: Vec<(EventID, Event)>, + + /// The next committee being certified. + pub committee: Option, +} + +impl ProofTargets { + /// Creates an empty target set. + /// + /// Empty targets are mainly useful while constructing proofs incrementally. + pub fn new() -> Self { + Self::default() + } + + /// Adds an object target by object reference and object contents. + /// + /// Verification checks that the object computes to the supplied reference and + /// that the transaction effects include the reference. + pub fn add_object(mut self, object_ref: ObjectRef, object: Object) -> Self { + self.objects.push((object_ref, object)); + self + } + + /// Adds an event target by event ID and event contents. + /// + /// Verification checks that the event belongs to the transaction and matches + /// the event stored at the requested event sequence. + pub fn add_event(mut self, event_id: EventID, event: Event) -> Self { + self.events.push((event_id, event)); + self + } + + /// Adds a next-epoch committee target. + /// + /// Verification checks that the checkpoint is an end-of-epoch checkpoint and + /// that its next committee matches the supplied committee. + pub fn set_committee(mut self, committee: Committee) -> Self { + self.committee = Some(committee); + self + } +} diff --git a/poi-rs/tests/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/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..ea596a7 --- /dev/null +++ b/poi-rs/tests/golden.rs @@ -0,0 +1,66 @@ +// 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::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::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); + + 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..9d8b92c --- /dev/null +++ b/poi-rs/tests/proof_builder.rs @@ -0,0 +1,250 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod utils; + +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; + +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, 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"), + }) + } +} + +struct RecordingSource { + requests: Arc, + targets: Arc>>, +} + +#[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_id) => SourceError::object(object_id, SourceErrorKind::ObjectNotFound), + SourceTarget::Event(event_id) => SourceError::event(event_id, SourceErrorKind::EventNotFound), + _ => panic!("unsupported source target"), + }) + } +} + +#[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 stacked_targets_are_deduplicated_in_one_source_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, + 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] +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 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; + let object_id = Object::immutable_for_testing().id(); + + let error = ProofBuilder::from_grpc_client(grpc_client(&cluster)) + .object(object_id) + .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_id)); + assert!(matches!(source.kind, SourceErrorKind::FetchObject { .. })); +} + +#[tokio::test] +async fn event_sequence_outside_the_transaction_is_rejected() { + let cluster = start_test_cluster().await; + let staking = staking_tx(&cluster).await; + let event_id = EventID { + tx_digest: staking.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)); +} + +#[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 = 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_object_id, second_object_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(second_object_id)); + 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 new file mode 100644 index 0000000..8473ad5 --- /dev/null +++ b/poi-rs/tests/proof_of_inclusion.rs @@ -0,0 +1,129 @@ +// 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, object_transfer_tx, 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.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) + .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 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()) + .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"); +} + +#[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.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) + .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.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) + .verify(&proof) + .expect("mixed target 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..1c3a81b --- /dev/null +++ b/poi-rs/tests/utils/mod.rs @@ -0,0 +1,157 @@ +// 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::{ChainIdentifier, TransactionDigest}, +}; +use test_cluster::{TestCluster, TestClusterBuilder}; + +pub mod proofs; + +pub struct CheckpointedTransfer { + pub digest: TransactionDigest, + 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) + .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 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() + .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; + let gas_object = cluster + .wallet + .get_object_ref(gas_object_id) + .await + .expect("mutated gas object must be available"); + + CheckpointedStaking { + digest: response.digest, + gas_object, + } +} + +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 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()]; + + 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 } + )); +}