From 6c3c0770edaff512c9a63af94918d730f44e53d3 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 28 Aug 2026 12:07:08 +0300 Subject: [PATCH 01/19] chore: add tests for event sequence validation and improve error handling in ProofVerifier --- .../poi_wasm/tests/proof-bindings.test.ts | 23 +++++++++++++++++++ poi-rs/src/proof.rs | 8 ++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index aa88a312..d85b059f 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -64,6 +64,29 @@ test("the public proof fixtures round trip and verify offline", async (context) } }); +test("rejects event sequences outside the wasm32 index range", async () => { + const committee = Committee.fromJSON(await readFixture("committee.json")); + + for (const eventSequence of [1n << 32n, (1n << 64n) - 1n]) { + const fixture = JSON.parse(await readFixture("event.json")) as EventProofFixture; + fixture.ProofV1.targets.events[0]!.eventSeq = eventSequence.toString(); + const proof = Proof.fromJSON(JSON.stringify(fixture)); + + assert.throws( + () => proof.verify(committee), + new RegExp(`event sequence number ${eventSequence} is out of bounds`), + ); + } +}); + +interface EventProofFixture { + ProofV1: { + targets: { + events: [{ eventSeq: string }]; + }; + }; +} + function readFixture(name: string): Promise { return readFile( new URL(`../../../../poi-rs/tests/fixtures/current/${name}`, import.meta.url), diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index ff54a6ff..dab77625 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -442,14 +442,16 @@ impl<'committee> ProofVerifier<'committee> { }); } - let event_index = event_id.event_seq as usize; - let Some(_) = events.get(event_index) else { + let event_exists = usize::try_from(event_id.event_seq) + .ok() + .is_some_and(|index| events.get(index).is_some()); + if !event_exists { return Err(VerifyError { kind: VerifyErrorKind::EventSequenceOutOfBounds { sequence: event_id.event_seq, }, }); - }; + } } Ok(()) From d7957a4765bc1318ef61df55e89c276ebf668e8c Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 28 Aug 2026 16:19:33 +0300 Subject: [PATCH 02/19] chore: enhance README verification checks and add detailed proof verification summary in poi.rs --- poi-rs/README.md | 6 +++--- poi-rs/src/bin/poi.rs | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/poi-rs/README.md b/poi-rs/README.md index a1bd8007..b6a32b87 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -151,10 +151,10 @@ Verification checks: - the checkpoint contents match the certified checkpoint summary; - the transaction digest matches the transaction effects; - the transaction effects are included in the checkpoint contents; -- an explicitly requested transaction matches the packaged transaction; -- requested object targets derive references present in the transaction effects; +- a transaction target declared by the proof matches the packaged transaction; +- object targets declared by the proof derive references present in the transaction effects; - event data matches the digest recorded in the effects when the proof includes event targets; and -- requested event targets belong to the transaction and select events in the authenticated event list. +- event targets declared by the proof belong to the transaction and select events in the authenticated event list. ## Proof Model diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index 42772285..692e2d49 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -12,6 +12,7 @@ use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use iota_config::{IOTA_GENESIS_FILENAME, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; +use iota_types::effects::TransactionEffectsExt; use iota_types::event::EventID; use poi_rs::{CommitteeResolution, PoiClient, Proof}; @@ -174,10 +175,41 @@ impl VerifyArgs { .verify(&proof) .await .context("proof verification failed")?; - writeln!(io::stdout().lock(), "valid").context("failed to write verification result to stdout") + write_verification_summary(io::stdout().lock(), &proof).context("failed to write verification result to stdout") } } +fn write_verification_summary(mut writer: impl Write, proof: &Proof) -> io::Result<()> { + let checkpoint = proof.checkpoint_summary(); + let transaction_proof = proof.transaction_proof(); + let transaction_digest = transaction_proof.effects.execution_digests().transaction; + writeln!(writer, "Proof verified successfully.")?; + writeln!(writer, " reported chain: {}", proof.chain().digest())?; + writeln!(writer, " checkpoint epoch: {}", checkpoint.epoch())?; + writeln!(writer, " checkpoint number: {}", checkpoint.sequence_number)?; + writeln!(writer, " timestamp (ms): {}", checkpoint.timestamp_ms)?; + writeln!(writer, " transaction: {transaction_digest}")?; + writeln!(writer, " targets:")?; + + let targets = proof.targets(); + if let Some(transaction) = targets.transaction { + writeln!(writer, " transaction: {transaction}")?; + } + for object in &targets.objects { + let object_ref = object.as_inner().object_ref(); + writeln!( + writer, + " object: {} @ {} ({})", + object_ref.object_id, object_ref.version, object_ref.digest + )?; + } + for event in &targets.events { + writeln!(writer, " event: {}:{}", event.tx_digest, event.event_seq)?; + } + + Ok(()) +} + #[derive(Debug, Args)] #[command(group( ArgGroup::new("endpoint") From 48d874af085c9887b1237ad7ebf65df5c4e9c407 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 10:03:26 +0300 Subject: [PATCH 03/19] chore: enhance proof verification to return VerifiedProof with authenticated claims --- 2026-08-27-poi-adversarial-review.md | 224 ++++++++++++++++++ bindings/wasm/poi_wasm/README.md | 12 +- .../examples/src/01_transaction_proof.ts | 5 +- .../examples/src/02_multi_target_proof.ts | 5 +- .../examples/src/03_reuse_verifier.ts | 7 +- .../poi_wasm/examples/src/04_object_proof.ts | 6 +- .../poi_wasm/examples/src/05_event_proof.ts | 6 +- bindings/wasm/poi_wasm/lib/index.ts | 1 + bindings/wasm/poi_wasm/src/committee.rs | 10 +- bindings/wasm/poi_wasm/src/proof.rs | 91 ++++++- .../poi_wasm/tests/proof-bindings.test.ts | 37 ++- examples/poi/01_transaction_proof.rs | 5 +- examples/poi/02_multi_target_proof.rs | 5 +- examples/poi/03_reuse_verifier.rs | 9 +- examples/poi/04_object_proof.rs | 7 +- examples/poi/05_event_proof.rs | 9 +- examples/poi/advanced/01_committee_cache.rs | 3 +- poi-rs/README.md | 12 +- poi-rs/src/bin/poi.rs | 29 +-- poi-rs/src/committee.rs | 8 +- poi-rs/src/lib.rs | 2 +- poi-rs/src/proof.rs | 133 +++++++++-- poi-rs/tests/proof_construction.rs | 2 +- poi-rs/tests/proof_serialization.rs | 2 +- poi-rs/tests/proof_verification.rs | 34 ++- poi-rs/tests/proof_workflows.rs | 10 +- 26 files changed, 583 insertions(+), 91 deletions(-) create mode 100644 2026-08-27-poi-adversarial-review.md diff --git a/2026-08-27-poi-adversarial-review.md b/2026-08-27-poi-adversarial-review.md new file mode 100644 index 00000000..d7a8ecc5 --- /dev/null +++ b/2026-08-27-poi-adversarial-review.md @@ -0,0 +1,224 @@ +# Proof of Inclusion: adversarial review of `feat/poi-implementation` + +**Repository:** [iotaledger/notarization](https://github.com/iotaledger/notarization), branch `feat/poi-implementation` at [`3a69eb0`](https://github.com/iotaledger/notarization/commit/3a69eb0454304902daa4e576626d8900656108c5) (merge of [#331](https://github.com/iotaledger/notarization/pull/331)), integration PR [#305](https://github.com/iotaledger/notarization/pull/305) into `main` (+14205 / -86, 97 files, 77 commits of which 20 merges). +**Reviewed:** 2026-08-27. +**Upstream pins:** [`iota` v1.29.0](https://github.com/iotaledger/iota/tree/v1.29.0) (`iota-types`, `iota-config`) and [`iota-rust-sdk` `2f021d0`](https://github.com/iotaledger/iota-rust-sdk/tree/2f021d0556e47564e9b04bcd0b3e8347c41a0a26) (`iota-sdk-types`, `iota-sdk-grpc-types`, `iota-sdk-grpc-client`). All line links below point at these exact revisions. +**Method:** source reading of the branch, the pinned upstream crates, the [PR #305 review thread](https://github.com/iotaledger/notarization/pull/305/files) and all 15 feeder PRs. Nine lens-specific reviewers (verifier cryptography, committee light client, builder and source, WASM Rust side, WASM TypeScript side, CLI and trust anchor, tests and CI, docs and API, process and scope) produced 79 raw findings, deduplicated to 65. The 35 at medium or above went to a two-reviewer adversarial pass (a refuter and a reproducer). The pass completed for 11 before a session limit stopped it. The remaining 24 were verified by hand against the code. No Rust toolchain was available, so nothing was compiled or executed. + +## Verdict + +The cryptographic core is sound. No path was found that makes `ProofVerifier` accept a false transaction, false effects, false object content, or false event content, and the committee handoff in the anchored walk is the standard light-client construction, bound by epoch and signature. The design is clean: a transport-independent `Source`, an offline verifier that takes the committee as input, and an explicit trust decision (`TrustedNode` versus `Anchored`) at the API surface. + +The problems sit around that core. One real soundness bug on the WASM target (a 64-bit event index cast to 32-bit `usize`). Two panics reachable from an endpoint the design declares untrusted. A CLI whose trust anchor is an unpinned HTTPS download cached forever, and whose only output on success is the word `valid`. An API that returns `()` from `verify` and leaves every claim to be read from the unverified proof, with the WASM binding exposing no verified content at all. Examples that teach `TrustedNode` against public endpoints. And a process gap: all 15 feeder PRs were self-merged with zero reviews, so the [single approval on #305](https://github.com/iotaledger/notarization/pull/305#pullrequestreview-4991410006) ("It looks very well built, only nitpicks") is the only human review of ~14k lines of verifier code. + +**Recommendation.** Do not merge as is. Fix H1 and H3 before merge (both are small). Fix H2 and H4 before any release that ships the `poi` binary. Treat M1, M2, M3 and M7 as release blockers for the WASM package, since they define what a JavaScript relying party can and cannot learn from a verified proof. Everything else can be tracked. + +## What holds + +Checked and found correct. Listed so the reader knows what the findings do not say. + +- [`verify_with_contents`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/messages_checkpoint.rs#L267-L290) verifies the aggregate BLS signature against the supplied committee with stake-weighted quorum, binds the summary epoch to the signature epoch and the committee epoch in both directions ([`verify_authority_signatures`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/messages_checkpoint.rs#L253-L260)), uses the `CheckpointSummary` intent scope, and recomputes the contents digest from the supplied `CheckpointContents`. +- Effects are authenticated through the `(transaction digest, effects digest)` pair in the certified contents ([`proof.rs:386-394`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L386-L394)), the transaction through `transaction.digest() == effects.transaction_digest` ([`:380`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L380)), events through `effects.events_digest()` ([`:396-401`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L396-L401)). Cached digests inside `Envelope` are serde-skipped, so a prover cannot inject them through JSON. +- Object targets are matched on the full `(id, version, digest)` recomputed from the packaged object ([`proof.rs:468-476`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L468-L476)). The object digest covers contents, owner and `previous_transaction`, so object bodies cannot be swapped. +- The anchored walk checks the summary epoch against the current committee, requires `end_of_epoch_data`, verifies the signature with the current committee ([`committee.rs:467`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L467)) before reading the next committee, derives the next epoch from verified data, and stores to the cache only after verification ([`:482-484`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L482-L484)). A hostile source in `Anchored` mode can deny service but cannot advance the committee. +- The private `GenesisBlob` mirror ([`committee.rs:206-215`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L215)) matches iota-config's [`RawGenesis`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-config/src/genesis.rs#L261-L269) BCS layout field for field. +- The hand-mirrored `Versioned*` enums in the WASM crate ([`versioned.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/versioned.rs)) match the SDK's own [`iota_grpc_types::v1::versioned`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/versioned.rs#L9-L14), which the SDK's own decoder uses ([`object.rs:20-22`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/object.rs#L20-L22)), so the native and WASM decode paths agree today (M14 covers the maintenance risk). +- `u64`/`bigint` marshalling, ESM/CJS packaging, TLS defaults of the public endpoints, and the `Committee.fromJSON` stake-sum and duplicate checks are as documented. + +## Findings + +Severity: **high** means a relying party can be misled or the verifier can be crashed by a party the design treats as untrusted, or a documented workflow leads to a false sense of security. **medium** means a correctness, robustness or API defect with a concrete misuse. **low** is minor. Verification status per finding: _confirmed_ (refuter and reproducer both upheld it), _disputed_ (they disagreed, resolution given), or _hand-verified_ (checked against the code after the automated pass stopped). + +### High + +#### H1. Event target index truncated on wasm32: `event_seq = 2^32 + k` verifies as event `k` (soundness, confirmed) + +**Proof.** [`poi-rs/src/proof.rs:445-446`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L445-L452): + +```rust +let event_index = event_id.event_seq as usize; +let Some(_) = events.get(event_index) else { /* EventSequenceOutOfBounds */ }; +``` + +`as` truncates. `poi-rs` is compiled for `wasm32-unknown-unknown` by the bindings ([`.cargo/config.toml`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/.cargo/config.toml#L1-L2), [`package.json:25`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/package.json#L25)), where `usize` is 32 bits. The builder gets this right ([`builder.rs:230-232`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L229-L236) uses `usize::try_from`). `TransactionEvents` is a `derive_more::Deref` newtype over `Vec` ([iota-sdk-types `events.rs:16-20`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-types/src/events.rs#L16-L20)), so nothing else bounds the index. `EventID.event_seq` is a `u64` serialized as a decimal string ([iota-types `event.rs:34-38`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/event.rs#L34-L38)), so any value round-trips through `Proof::from_json_slice`. The WASM `targets` getter re-emits the raw `u64` ([`poi_wasm/src/proof.rs:65-71`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L65-L71)). + +**Scenario.** A prover takes a genuine proof for a transaction with at least one event and sets `"eventSeq": "4294967296"` in `targets.events[0]`. `Proof.fromJSON(...).verify(committee)` in `poi_wasm` succeeds, `proof.targets.events[0].eventSequence` reads `4294967296n`, and `toJSON()` re-emits it. The same proof is rejected natively with `EventSequenceOutOfBounds`. An application that keys anything on the verified `EventID` (dedup, grant-once, audit records) can be fed `(T, k)`, `(T, 2^32+k)`, `(T, 2^33+k)` as distinct verified identifiers for one real event. Event content and transaction identity are not forgeable through this, which keeps it below critical. Existing tests cover only `event_seq: 1` on a one-event list ([`proof_verification.rs:115-133`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/proof_verification.rs#L115-L133)). + +**Fix.** Mirror the builder: `let Some(index) = usize::try_from(event_id.event_seq).ok() else { return Err(EventSequenceOutOfBounds) }`, or compare as `u64` against `events.len()`. Add verifier tests at `u32::MAX as u64 + 1` and `u64::MAX`, and make sure a test suite actually runs on wasm32 (M13). + +#### H2. The CLI trust anchor is an unpinned download, cached forever, never re-validated (security, confirmed) + +**Proof.** [`poi-rs/src/bin/poi.rs:243-268`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L243-L268): `load_genesis` fetches `https://dbfiles..iota.cafe/genesis.blob` with `reqwest::get(url).bytes()` ([`:258-264`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L258-L264)), writes the body to `/poi//genesis.blob`, and on every later run uses the file as is: [`if !path.is_file()`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L250) is the only check. No `error_for_status()`, no comparison of the blob's genesis checkpoint digest against a known chain identifier, no integrity check on reuse, and [`from_genesis`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L228) does not verify the genesis checkpoint against the committee it extracts. The help text says ["The genesis blob is the trust anchor"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L33-L34) and the README calls it ["a trusted genesis blob"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L186-L187). + +**Scenario.** Substitution: whoever serves a different body once (bucket or CDN compromise, `iota.cafe` DNS or CA compromise, a poisoned first run) or writes the cached file installs an attacker committee. Because `resolve_from_anchor` returns the anchor without any network call when the proof's epoch equals the anchor epoch ([`committee.rs:314-316`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L314-L316)), a fabricated epoch-0 proof then prints `valid` fully offline. For later epochs the attacker also needs to answer the gRPC epoch-close queries, and `--network` pins the endpoint to the official node (`--grpc-url` and `--network` are mutually exclusive, [`poi.rs:182-187`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L182-L187)), so a CDN-only compromise degrades to a permanent `InvalidEndOfEpochCheckpoint` failure rather than a false accept. Denial of service: a 404, 5xx or captive-portal HTML page is cached as `genesis.blob` and every later run fails in `bcs::from_reader` with "failed to load trusted genesis blob", with no mention of the cache path. + +Related (hand-verified): devnet regenesis makes the cached devnet blob stale with the same opaque failure and no recovery hint. A proof from another network, or `--genesis` for the wrong network, fails with a signature error that reads like a forged proof, because neither the blob's chain identifier nor `proof.chain()` is ever compared to anything ([`poi.rs:170-177`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177)). The examples know the chain identifiers ([`examples/poi/utils.rs:61-67`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L61-L67)) but only use them to pick a URL. + +**Fix.** `iota-types` already exports [`MAINNET_CHAIN_IDENTIFIER_BASE58` and `TESTNET_CHAIN_IDENTIFIER_BASE58`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/digests.rs#L20-L21). Compare `genesis.checkpoint.digest()` against the pinned identifier for `--network` on download and on every load from cache, call `error_for_status()`, refuse otherwise. Expose the genesis chain identifier from `CommitteeResolution::from_genesis*` and compare it to `proof.chain()` as a diagnostic gate (not a trust input) with a clear "proof declares chain X, anchor is chain Y" error. Key the devnet cache by chain identifier or do not offer a managed devnet blob. Document the cache path. + +#### H3. A malformed aggregate signature from an untrusted source panics the verifier (safety, confirmed with severity dispute) + +**Status: Unresolved — requires an upstream fix.** No local pre-validation workaround is being applied. + +**Proof.** [`poi-rs/src/source/grpc.rs:144-148`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L144-L148) and [`:201-205`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L201-L205) convert the SDK `SignedCheckpointSummary` with `.try_into()`, and the WASM [`decode_certified_summary`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L259-L275) does the same. That conversion ([iota-types `iota_sdk_types_conversions.rs:173-183`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/iota_sdk_types_conversions.rs#L173-L183)) always returns `Ok` but internally runs + +```rust +signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_bytes()).unwrap(), +``` + +([`:203-220`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/iota_sdk_types_conversions.rs#L203-L220)). The SDK signature type is a raw 48-byte array ([iota-sdk-types `bls12381.rs:139`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-types/src/crypto/bls12381.rs#L139)) decoded from the wire with no curve check, and blst rejects bad encodings (48 zero bytes suffice). The `.map_err(SourceError::invalid_response)` on the conversion is dead code. + +**Scenario.** In `Anchored` mode the endpoint is untrusted by design, and the panic fires inside `fetch_next_committee` before any of its checks. The CLI (`current_thread` tokio, default unwind) exits with a Rust panic instead of a `SourceError`. In a tokio service the task unwinds (the cache uses `tokio::sync::RwLock`, so no poisoning). On wasm32 it is an `unreachable` trap surfaced as a `RuntimeError`, and the in-instance committee cache is lost. The refuter downgraded this to medium on the grounds that an endpoint able to trigger it can already deny service by returning nothing. That is true, and the marginal harm is panic versus error, but the rubric's high tier covers a crash in the verifier path by a declared-untrusted party, and the same unwrap is reachable on the builder side through `Source::checkpoint`. High, with the understanding that the exploit value is availability only. + +**Required fix.** Make the upstream `SignedCheckpointSummary` conversion genuinely fallible by replacing its internal `unwrap()` with error propagation. This finding remains unresolved until the Notarization Toolkit adopts an upstream revision containing that fix. + +#### H4. `poi verify` prints only `valid` (ergonomics, hand-verified) + +**Status: Addressed.** The CLI now prints the verified checkpoint, transaction and declared targets. Expectation flags were intentionally not added. + +**Proof.** [`poi-rs/src/bin/poi.rs:177`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177): `writeln!(io::stdout().lock(), "valid")`. The verifier checks the proof's internal consistency; the targets are whatever the prover put into `ProofTargets`. Nothing shows the operator the chain, epoch, checkpoint sequence number and timestamp, transaction digest, object references with versions, or event IDs, and there is no way to state an expectation (`--transaction`, `--object`, `--event`) that the proof must satisfy. The README's check list says ["an explicitly requested transaction matches the packaged transaction"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L148-L157), where "requested" means requested by the prover, which reinforces the misreading. + +**Scenario.** A counterparty says "this proves object X was at version 7 in transaction T" and hands over a valid proof for a different object, an older version, or their own unrelated transaction. The operator runs the documented command, sees `valid`, and accepts the claim. This is the default CLI workflow the README documents. + +**Resolution.** On success, print a structured summary containing the authenticated epoch, checkpoint number and timestamp, transaction digest, each targeted object reference and each targeted event ID. The proof-reported chain is omitted because verification does not authenticate it. Reword the README checks to describe targets declared by the proof. No expectation-matching interface is included. + +#### H5. All 15 feeder PRs were self-merged with zero reviews (process, hand-verified) + +**Proof.** `gh pr view --json author,mergedBy,reviews` on [#306](https://github.com/iotaledger/notarization/pull/306), [#307](https://github.com/iotaledger/notarization/pull/307), [#308](https://github.com/iotaledger/notarization/pull/308), [#309](https://github.com/iotaledger/notarization/pull/309), [#310](https://github.com/iotaledger/notarization/pull/310), [#316](https://github.com/iotaledger/notarization/pull/316), [#317](https://github.com/iotaledger/notarization/pull/317), [#318](https://github.com/iotaledger/notarization/pull/318), [#320](https://github.com/iotaledger/notarization/pull/320), [#322](https://github.com/iotaledger/notarization/pull/322), [#323](https://github.com/iotaledger/notarization/pull/323), [#328](https://github.com/iotaledger/notarization/pull/328), [#329](https://github.com/iotaledger/notarization/pull/329), [#330](https://github.com/iotaledger/notarization/pull/330) and [#331](https://github.com/iotaledger/notarization/pull/331) shows author and merger `itsyaasir` and an empty reviews array for every one. #323 alone added 9014 lines. PR #305 carries one approving reviewer, UMR1352 ([approved 2026-08-21](https://github.com/iotaledger/notarization/pull/305#pullrequestreview-4991410006), [re-approved 2026-08-26](https://github.com/iotaledger/notarization/pull/305#pullrequestreview-5028437682)), whose inline comments cover a [TypeScript builder API](https://github.com/iotaledger/notarization/pull/305#discussion_r3828738292), [`JsError`](https://github.com/iotaledger/notarization/pull/305#discussion_r3829022671), [`Debug` derives](https://github.com/iotaledger/notarization/pull/305#discussion_r3829525916), a [constructor signature](https://github.com/iotaledger/notarization/pull/305#discussion_r3829746066) and [proof versioning](https://github.com/iotaledger/notarization/pull/305#discussion_r3829791078); none touches `verify_v1`, the anchored walk, or the gRPC decoding. The PR body of #305 and of every feeder PR is the untouched template. The error-model rework was [deferred to "a separate ticket"](https://github.com/iotaledger/notarization/pull/305#discussion_r3860375032) and no such issue exists in the repository. + +**Fix.** A second review of `poi-rs/src/proof.rs`, `committee.rs`, `source/grpc.rs` and `bindings/wasm/poi_wasm/src/source.rs` by someone with a consensus or cryptography background before merge. A filled PR body (design summary, threat model, test narrative). Branch protection on `feat/*` integration branches if the feeder-PR structure is kept. + +### Medium + +#### M1. A failed transaction verifies like a successful one (docs and API, confirmed) + +**Status: Not accepted as an issue.** Proof of Inclusion intentionally proves checkpoint inclusion regardless of whether the included transaction succeeded or failed. Transaction outcome is outside this feature's contract. + +**Proof.** Checkpoints include transactions whose execution failed; their effects carry `ExecutionStatus::Failure` and still have valid execution digests ([iota-sdk-types `execution_status.rs:27-32`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-types/src/execution_status.rs#L27-L32): "Failed transactions are still committed to the blockchain"). [`verify_transaction_proof` and `verify_targets`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L372-L419) never read `effects.status()`, the README's [eight verification checks](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L148-L157) do not mention it, and the WASM `Proof` exposes only `version`, `checkpointEpoch` and `targets` ([`poi_wasm/src/proof.rs:96-127`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L96-L127)). + +**Scenario.** A prover submits a notarization transaction that aborts on chain and is checkpointed; the packaged transaction body still shows the intended Move call; `verify()` returns `Ok`. Inclusion is true, so this is not a soundness bug, but the toolkit is pitched as evidence for notarization activity and a JavaScript relying party has no accessor for the status and no hint to look. Object targets are limited to the smashed gas coin for a failed transaction, and event targets cannot verify because a failed transaction emits no events, so the plain transaction target is the exposed case. + +**Disposition.** No code or API change. A checkpointed failed transaction remains a valid inclusion proof. + +#### M2. `verify()` returns `()`; callers read claims from the unverified proof; the WASM `Proof` exposes no verified content (API design, hand-verified) + +**Status: Addressed.** Both verification entry points now return authenticated claims through `VerifiedProof`; the raw `Proof` remains explicitly unverified. + +**Proof.** [`ProofVerifier::verify`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L340) and [`CommitteeResolver::verify`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L274) return unit. Every claim (targets, object contents, event content, checkpoint timestamp and number) is read afterwards from the same `Proof` value that was untrusted before the call, and no type distinguishes the two states. There is no accessor that returns the event content for an `EventID` target: the caller has to know that it lives at `transaction_proof().events.as_ref().unwrap().0[event_seq as usize]`, the same indexing the verifier gets wrong in H1. On the WASM side the `Proof` class ([`poi_wasm/src/proof.rs:96-127`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L96-L127)) exposes target IDs, versions and digests as strings and nothing else: no timestamp, no checkpoint number, no object body, no event body. + +**Scenario.** A Node.js relying party that needs the proven `LockedNotarizationCreated` payload has to fetch it again from the untrusted node. A Rust integrator holding two proofs verifies `proof_a` and reads `proof_b.targets()` after a refactor; the type system does not object. + +**Fix.** Return a `VerifiedProof<'a>` newtype from both `verify` functions with typed accessors (`transaction()`, `execution_status()`, `checkpoint_sequence_number()`, `timestamp_ms()`, `objects()`, `events() -> impl Iterator`). Keep `Proof::targets()` for inspection but document it as unverified. Expose at minimum timestamp, checkpoint number, object BCS or JSON and event content on the WASM side. + +**Resolution.** Rust verification returns a `must_use` `VerifiedProof<'a>` borrowing the authenticated checkpoint metadata, transaction data, transaction target, objects and event ID/content pairs from the input proof, plus the authenticated transaction digest. It does not expose the proof's packaged user signatures as verified because checkpoint inclusion does not authenticate those signature bytes. WASM verification returns an owned, read-only `VerifiedProof` snapshot that reuses `ProofTargets` for the authenticated target identities and exposes checkpoint metadata, the transaction digest, `objectBcs(index)`, and `eventContents(index)`. Execution status is intentionally omitted because M1 was not accepted as an issue: Proof of Inclusion authenticates checkpoint inclusion independently of transaction success. + +#### M3. Committee construction from node or JSON data panics on malformed input (safety, confirmed with severity dispute) + +**Proof.** Three paths build an `iota_types::Committee` from data that has not been signature-verified: the native `TrustedNode` fetch ([`grpc.rs:158-167`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L158-L167), `committee.into()`), the WASM [`decode_committee`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L277-L289) (`Committee::new` with only a key-length check), and [`Committee.fromJSON`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L37-L60) (checks duplicates and the stake sum but not key validity). [`Committee::new`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/committee.rs#L69-L78) asserts non-empty, some nonzero weight and `total == TOTAL_VOTING_POWER`, and [`load_inner`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/committee.rs#L150-L154) does `.expect("Validator pubkey is always verified on-chain")` on every key, so a 96-byte value that is not a valid G2 point panics. The WASM README says [`Committee.fromJSON()` "validates public keys"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/README.md#L96-L98); it validates their length. + +**Scenario.** On wasm32 the panic is a trap inside the wasm-bindgen-futures microtask: the caller's `await verifier.verify(proof)` never settles and later calls on the same resolver throw "recursive use of an object detected". An honest v1.29.0 node never returns an empty or unbalanced committee, so the trigger is a buggy or hostile node or proxy, or an operator's own `LedgerSource`. The anchored walk is safe: [`from_committee_members`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L482) runs only on quorum-signed data. + +**Fix.** One fallible constructor in `poi-rs` (non-empty, no duplicates, checked sum equals `TOTAL_VOTING_POWER`, `AuthorityPublicKey::try_from` per key) used by all three paths, with tests for an empty list, a wrong total and an off-curve key. Correct the README sentence. + +#### M4. The committee cache has no network or anchor binding (API design, disputed, resolved as medium) + +**Proof.** `resolve_from_anchor` returns whatever the cache holds for `target_epoch` after checking only that its `epoch` field matches ([`committee.rs:318-338`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L318-L338)), and the prefix walk adopts cached committees for `anchor + 1..` without checking they descend from the anchor ([`:342-368`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L342-L368)). `Committee` carries only epoch and voting rights, [`MemoryCommitteeCache`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache/in_memory.rs#L13-L16) is `Clone` over shared `Arc` state, and the [trait](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache.rs#L33-L45) offers no network handle. + +**Scenario.** An integrator who hands one backend to a mainnet-anchored and a testnet-anchored resolver gets, testnet first, a genuine testnet proof accepted as mainnet; mainnet first, testnet verification fails with a signature error that looks like a forged proof. The refuter is right that this requires violating a precondition documented in five places and that [README:145](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L145) and example 03 recommend reusing one verifier per network, not one cache across networks. The reproducer is right that the anchor is effectively bypassed whenever the cache is populated and that nothing at construction detects the mismatch. Rust library surface only; the WASM binding and the CLI always create a fresh in-memory cache. + +**Fix.** Namespace cache keys by anchor identity (genesis checkpoint digest, or a digest of the anchor's voting rights) inside `anchored_with_cache`, so two resolvers with different anchors cannot read each other's entries even when handed the same backend. + +#### M5. `from_genesis` accepts any six-field BCS blob and exposes no chain identity (robustness, disputed, resolved as medium) + +**Proof.** [`committee.rs:206-228`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L228) decodes the blob, reads only `objects`, and discards `checkpoint` and the rest (`#[allow(dead_code)]` says so). There is no `epoch == 0` requirement, no verification of the genesis checkpoint against the extracted committee (iota-config's [`Genesis::checkpoint()`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-config/src/genesis.rs#L125-L130) does this lazily), and the genesis checkpoint digest is not surfaced. + +**Scenario.** A wrong-network or stale anchor fails closed at the epoch-0 handoff with "failed to verify epoch 0 end-of-epoch checkpoint N", which names neither the cause nor the anchor's network. A self-consistency check would not stop a deliberate attacker (who re-signs with their own keys), which is why the refuter called this low. It is medium because the missing chain identifier is what H2's pin and diagnostics need, and because a corrupted blob that still satisfies the asserts loads while one that does not panics inside `from_genesis` (a wasm trap in `CommitteeResolution.fromGenesis`). + +**Fix.** Require `committee.epoch == 0` and `checkpoint.epoch() == 0`, run `verify_with_contents` on the genesis checkpoint as a corruption check, return a dedicated error kind for each, and expose the `ChainIdentifier` on the resolution. + +#### M6. The cold anchored walk is one sequential round trip per epoch with no retry, and the CLI never persists committees (performance, confirmed) + +**Proof.** [`committee.rs:382-385`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L382-L385) awaits `fetch_next_committee` once per epoch; each does one unary `get_epoch` (the SDK exposes no batch or list RPC for epochs) and one aggregate BLS verification, plus one `get_service_info` pre-flight at [`:374`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L374-L375) that only produces an earlier error. The CLI uses `from_genesis` with a fresh `MemoryCommitteeCache` ([`poi.rs:170`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177)) and persists only the genesis blob. [`examples/poi/advanced/01_committee_cache.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/advanced/01_committee_cache.rs) ships a file-backed cache that the CLI does not use. The reviewer's ["Epoch 469??"](https://github.com/iotaledger/notarization/pull/305#discussion_r3828832734) on 2026-08-21 gives the magnitude on a public network. + +**Scenario.** A cold `poi verify --network mainnet` is roughly 480 dependent round trips, tens of seconds to about two minutes depending on RTT. The SDK client has no retry, so one transient failure aborts with `FetchEpochHistory` and all progress is discarded. The realistic degradation is library integrators switching to `TrustedNode`. + +**Fix.** Prefetch `epoch_close_summary` with bounded concurrency (fetching depends only on the epoch number; verification stays sequential and the verify-then-store invariant holds), wire a file-backed cache into the CLI under the network's chain identifier, and drop or make optional the `current_epoch` pre-flight. + +#### M7. Examples 04 and 05 teach `TrustedNode` against public endpoints (docs, hand-verified) + +**Proof.** [`examples/poi/04_object_proof.rs:78`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/04_object_proof.rs#L78), [`05_event_proof.rs:77`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/05_event_proof.rs#L77), and the WASM examples [`04_object_proof.ts:48`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts#L48) and [`05_event_proof.ts:49`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts#L49) use `TrustedNode` on a client whose endpoint resolves to `https://grpc..iota.cafe:443` for known networks ([`utils.rs:273-275`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L273-L275)). A public Foundation endpoint is exactly the node the crate docs place [outside the caller's trust boundary](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L157-L161). + +**Scenario.** An integrator copies the example, keeps `TrustedNode`, points at a provider URL, and ships; the provider can then have any self-signed proof accepted. The success line asserts a verification result that in this mode depends entirely on the endpoint. + +**Fix.** Make 04 and 05 genesis-anchored like 01 to 03 (they already have `load_genesis`), move the `TrustedNode` demonstration to an `advanced/` example that refuses to run outside localnet, and print the resolution mode in the success line. + +#### M8. What an object or event target proves is not stated; "object state" reads as current state (docs, hand-verified) + +**Proof.** [`README.md:8`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L8-L10) promises "cryptographic evidence for a transaction, event, or object state" and [`:167`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L167) says "Object targets contain their exact object values", but no text says what a successful verification lets the caller believe per target kind. The code proves, for an object, that `(id, version, digest)` is among the objects written by that transaction ([`all_changed_objects()`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/effects/mod.rs#L527-L542) = mutated + created + unwrapped), not that it is the current version. `ProofBuilder::object()` without a transaction scope fetches the latest version at build time ([`builder.rs:199-207`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L196-L210)) and the CLI help says ["The source resolves its latest version"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L84). Deleted and wrapped objects can never be targets, and the builder error for them says ["was not changed by transaction"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L51-L58), which is wrong for a deletion. + +**Fix.** A "What a verified proof proves" section with one paragraph per target kind, an explicit statement that object targets are historical writes, that deletion is unsupported, and that the builder's "latest" is a build-time choice. Reword the builder error. + +#### M9. Not-yet-checkpointed transactions and missing protobuf fields surface as generic or misleading errors (ergonomics, hand-verified) + +**Proof.** Native: a transaction executed but not yet in a checkpoint comes back with `checkpoint` unset, and [`grpc.rs:99-101`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L99-L101) maps the SDK's [`TryFromProtoError::missing("checkpoint")`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/transaction.rs#L216-L221) to `SourceError::MissingData`, indistinguishable from a malformed response and with no typed variant to retry on. TypeScript: [`ledger-source.ts:93-104`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L93-L104) uses non-null assertions on every optional protobuf field (`transaction.transaction!.bcs!.data!`, `transaction.checkpoint!`, and so on throughout the file). An unset `checkpoint` becomes `undefined`, which serde rejects as "invalid response"; an absent `signatures` or `contents` object throws a raw `TypeError` that [`PoiError::from_js`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/error.rs#L50-L58) flattens into a source error carrying JavaScript engine text. + +**Fix.** Make `checkpoint_sequence_number` an `Option` on `SourceTransaction` and add `ProofBuilderError::TransactionNotCheckpointed { digest }`. Replace the assertions with a `required(value, path)` helper that throws a typed "response is missing " error. Poll for checkpoint inclusion in the examples. + +#### M10. The WASM `LedgerSource` throws on per-item `NOT_FOUND` where the native source returns `None` (ergonomics, confirmed with severity dispute) + +**Proof.** [`ledger-source.ts:69-71`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L69-L71) and [`:129-131`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L129-L131) throw on every per-item error status, including gRPC code 5. The native source maps `NOT_FOUND` to `Ok(None)` ([`grpc.rs:61-65`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L61-L65)) so the builder reports `TransactionNotFound`; the WASM path reports `ProofBuilderError::Source`. The documented `undefined` return ([`source-types.ts:42-48`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/source-types.ts#L42-L48)) is unreachable against a real node, which answers a batch with one result per request, and the only test for absence ([`ledger-source.test.ts:205`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/tests/ledger-source.test.ts#L205-L220)) feeds an empty list the node never sends. The contract is internal and JavaScript receives a flat `Error` either way (M11), which is why the refuter called it low. Medium because the two shipped bindings classify the most common user mistake differently and the test pins a fictional wire shape. + +**Fix.** Return `undefined` for per-item code 5 in `transaction()` and `object()`, keep throwing for other codes, and add a test with a per-item `NOT_FOUND` result. + +#### M11. Every WASM failure collapses into one flat `Error` string (ergonomics, disputed, resolved as medium) + +**Proof.** [`error.rs:15-37`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/error.rs#L15-L37) concatenates the Rust source chain into one message and emits `JsError::new(&message)`. [`PoiError::from_js`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/error.rs#L50-L58) keeps only `.message` of the JavaScript error, so the ConnectRPC `code` is lost on the way in as well. The two outcomes a verifier must treat oppositely, "proof rejected" and "node unreachable", differ only in unversioned English prefixes. The reviewer [raised this](https://github.com/iotaledger/notarization/pull/305#discussion_r3829095936) and the author [deferred it to a ticket](https://github.com/iotaledger/notarization/pull/305#discussion_r3860375032) that does not exist. The refuter notes that `resolve(epoch)` and `Proof.verify(committee)` are exported separately, so an integrator can split the phases structurally. + +**Fix.** Until the error model lands, attach a stable `code` (`PROOF_INVALID`, `COMMITTEE_RESOLUTION`, `SOURCE_REQUEST`, `NOT_FOUND`) via a small error class in `poi-client.ts`, preserve the original JavaScript error as `cause`, and document in the README that only `PROOF_INVALID` means the proof was rejected. Open the ticket. + +#### M12. No committee persistence or cache hook in the WASM API; `Committee` has no `toJSON` (ergonomics, hand-verified) + +**Proof.** [`CommitteeCache: Send + Sync`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache.rs#L39) with `Send` futures, so a JavaScript-backed cache cannot implement it, and the bindings always create a fresh `MemoryCommitteeCache` ([`poi_wasm/src/committee.rs:85-97`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L85-L97)). `WasmCommittee` exposes only [`epoch`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L62-L66), so a committee returned by `resolver.resolve(epoch)` cannot be stored and fed back through `Committee.fromJSON` and `anchored` as a newer anchor. Every cold start pays the full walk of M6, and the visible alternative in the examples is `trustedNode()`. + +**Fix.** Add `Committee.toJSON()` so a verified committee can be persisted and re-anchored, or make `CommitteeCache` `?Send` on wasm32 like `Source` and expose `anchoredWithCache(committee, jsCache)`. + +#### M13. Test gaps around exactly the checks that bind targets to evidence (tests, hand-verified) + +**Proof.** Untested in [`proof_verification.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/proof_verification.rs): [`TransactionTargetMismatch`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L411), [`MissingTarget`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L348), [`MissingEvents`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L431-L435), a signature failure at the `ProofVerifier` level (only a contents-digest tamper is tested), forged effects with the same transaction digest, and any event sequence at or above `2^32`. In `committee.rs`, the two cache epoch-consistency guards ([`:327`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L327-L335), [`:357-365`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L357-L365)) are never exercised, and the multi-epoch walk is covered only by a test-cluster integration test. Mutation reasoning: dropping the effects-digest half of the checkpoint membership comparison, the transaction-target check, or the cache guards all leave the suite green. The WASM crate's only Rust unit test ([`src/source.rs:307`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L307-L308)) cannot run as configured (the crate is excluded from the workspace, `.cargo/config.toml` forces wasm32, no `wasm-bindgen-test`), and it round-trips through the same mirrored enums it is meant to check. The TypeScript tests are happy-path only: no tampered proof, no wrong committee, no per-item error status. + +**Fix.** The three verifier tests are a few lines each. Add a mislabelled-cache test, a two-epoch mock walk, tests for `MissingEpochCloseProof`, `FetchCurrentEpoch` and a failing `store`, wasm32 execution of at least the verifier tests, and negative TypeScript tests. + +#### M14. Fixtures: undocumented single-validator localnet, epoch 0, rewritten five times along with the code (tests, hand-verified) + +**Proof.** [`poi-rs/tests/fixtures/current/`](https://github.com/iotaledger/notarization/tree/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/fixtures/current) comes from a localnet (chain `J3x3hNka...`, not any public network), epoch 0, one validator holding all 10 000 stake (`committee.json`), so quorum, signer bitmaps and aggregation over a subset are never exercised by a real artifact. The golden test ([`proof_serialization.rs:12-28`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/proof_serialization.rs#L12-L28)) verifies with `ProofVerifier::new(&committee)` directly, so it proves round-trip against the producing node, not anchored verification. No README, script or comment says how the fixtures were produced, and `git log` shows five commits touching them since [#318](https://github.com/iotaledger/notarization/pull/318), including [`1fdae39`](https://github.com/iotaledger/notarization/commit/1fdae39e9f69a1ddc8300f20f33e49bde0d5305c) (titled as an examples refactor) renaming `content_digest` to `contents_digest`. [Issue #297](https://github.com/iotaledger/notarization/issues/297) (golden fixtures and compatibility tests) is still open. The mirrored `Versioned*` enums ([`versioned.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/versioned.rs)) are correct today but will silently disagree with the SDK's [`#[non_exhaustive]` originals](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/versioned.rs#L9-L14) when upstream adds a `V2`. + +**Fix.** A `fixtures/README.md` with provenance and a regeneration recipe, a frozen `fixtures/v1/` set that CI forbids modifying, a second set captured from testnet at an epoch above 0 with a multi-validator committee, verified both offline and through a recorded `Source` walk. Depend on `iota_grpc_types::v1::versioned` instead of mirroring it. + +#### M15. Scope and release hygiene (process, hand-verified) + +**Proof.** [`notarization-move/scripts/publish_package.sh`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/notarization-move/scripts/publish_package.sh) is rewritten in [`1fdae39`](https://github.com/iotaledger/notarization/commit/1fdae39e9f69a1ddc8300f20f33e49bde0d5305c) ("refactor Proof of Inclusion examples"): `set -eu`, a hard dependency on `iota client chain-identifier`, and for chain IDs `6364aad5` / `2304aa97` / `daf90477` (mainnet, testnet, devnet per [`examples/poi/utils.rs:61-64`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L61-L67)) it drops `--with-unpublished-dependencies`. Nothing in PoI uses the script; it is a drive-by with no rationale in any PR body. [`poi-rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/Cargo.toml#L3) is `0.1.0-alpha` with git-only dependencies and no `publish = false`, now inside the workspace members; whether `cargo release publish` handles that is open. `poi_wasm` is [`0.0.1` in `package.json`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/package.json#L3-L4) and `0.1.0-alpha` in `Cargo.toml`. The [gRPC schema lock](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/grpc/iota-schema.lock.json#L1-L8) pins iota-rust-sdk `b77fcd5` while Cargo pins `2f021d0` (proto files identical between the two, so no drift today). No CHANGELOG entry, PR template unfilled, license headers alternate between "2020-2026" and "2026" on brand-new files. + +**Fix.** Split the script change into its own PR with a rationale, decide the distribution channel for `poi-rs`, align the two versions, add the CHANGELOG entry. + +#### M16. WASM bundle built without `--weak-refs`; handles are never freed (performance, hand-verified) + +**Proof.** [`poi_wasm/package.json:27`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/package.json#L27) runs `wasm-bindgen ... --target nodejs` without `--weak-refs`, unlike [`notarization_wasm`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/notarization_wasm/package.json#L19) and [`audit_trail_wasm`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/audit_trail_wasm/package.json#L19). Without it there is no `FinalizationRegistry` hook, so `Proof`, `Committee`, `CommitteeResolver` and `ProofBuilder` instances are released only by an explicit `.free()`, which no README, example or `poi-client.ts` mentions. `Proof.fromJSON` allocates the full checkpoint contents. + +**Fix.** Add `--weak-refs` (Node 24 has `FinalizationRegistry`) and document `.free()` for hot paths. + +### Low + +- **User signatures in the packaged transaction are not authenticated.** `Transaction::digest()` covers the transaction data, not the signatures, and IOTA `CheckpointContents` carry the user signatures per transaction but the verifier never compares them ([`proof.rs:380`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L378-L394)). A relying party reading `tx_signatures()` from a verified proof can be shown swapped signatures. Sender identity is inside the signed data and is covered. +- **Genesis anchoring inherits the long-range (weak subjectivity) assumption** and the docs do not say so ([`README.md:79-82`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L79-L82)). A verifier that trusts genesis trusts every historical committee not to have leaked keys. +- **[`from_genesis`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L194) returns `CommitteeResolutionErrorKind`, not `CommitteeResolutionError`**, so it does not compose with the rest of the error surface. +- **[`Source::checkpoint`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source.rs#L117) has no `Option`**, so an absent checkpoint is an opaque transport error. A historical object version pruned by the node is reported as `ObjectNotFound`. The builder does not re-check the latest-object path against the effects or the checkpoint sequence number against the request ([`builder.rs:196-221`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L196-L221)); the verifier catches every case, so this is a diagnostics gap for custom `Source` authors, and [`README.md:49`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L49) overclaims what the builder checks. +- **The TypeScript checkpoint sequence-number guard is inert**: the read mask omits `checkpoint.sequence_number`, so `value.sequenceNumber` is undefined ([`ledger-source.ts:174-181`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L174-L181)). The native source has no such guard at all. +- **[`Uint8Array::new(&value)`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L145) on an arbitrary JavaScript value coerces instead of rejecting.** BCS payloads cross the boundary as `Vec` through `serde_wasm_bindgen`, which likely iterates byte by byte (unverified; `serde_bytes::ByteBuf` would take the fast path). +- **Debug is still missing** on `PoiClient`, `ProofBuilder`, `CommitteeResolution` and `CommitteeResolver` after the reviewer [asked for it](https://github.com/iotaledger/notarization/pull/305#discussion_r3829525916). +- **Three names for one concept** (targets, requests, claims) across `builder.rs`, `proof.rs` and the READMEs. The README's verification list does not match the verifier exactly. `Proof` JSON stability is unspecified: the `ProofV1` tag does not pin the upstream serde encodings it embeds. The API-docs link and the `tree/main` links resolve only after merge and a docs deploy. +- **CI:** in [`build-and-test.yml`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/.github/workflows/build-and-test.yml), `test-wasm-notarization` now `needs: build-wasm-poi` and moved to Node 24, so a PoI build failure blocks the unrelated notarization tests. Nothing in CI executes the verifier on wasm32. `cargo test --workspace` now pulls the full IOTA node through `test-cluster` for every workspace test build. +- **Examples select the trusted genesis blob from the node-reported chain identifier** ([`utils.rs:61-67`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L61-L67)), which is fine for choosing a URL but is the untrusted input the [README](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L181-L182) tells integrators not to use for anchor selection. + +## Limits of this review + +No code was compiled or run. The wasm32 truncation in H1 rests on Rust cast semantics and the target's pointer width, not on an observed run. The byte-by-byte marshalling claim and the rate-limit consequence in M6 are stated as likely, not measured. CI logs could not be downloaded, so "what CI runs" is inferred from the workflow files. Whether `iota-sdk-grpc-client` and `iota-sdk-grpc-types` are on crates.io was not checked (M15). The automated adversarial pass stopped after 11 of 35 findings; the remaining 24 were checked against the code by hand and are marked as such. diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 306e4fe0..b7cdad8c 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -76,7 +76,8 @@ inside the caller's trust boundary. import { CommitteeResolution } from "@iota/poi-wasm"; const verifier = client.verifier(CommitteeResolution.trustedNode()); -await verifier.verify(proof); +const verified = await verifier.verify(proof); +console.log(verified.transaction); ``` Use genesis-anchored resolution to authenticate committee lineage independently from the node: @@ -88,9 +89,13 @@ const trustedGenesisBlob = await readFile("genesis.blob"); const resolution = CommitteeResolution.fromGenesis(trustedGenesisBlob); const verifier = client.verifier(resolution); -await verifier.verify(proof); +const verified = await verifier.verify(proof); ``` +Successful verification returns a read-only `VerifiedProof`. It exposes the authenticated transaction digest, +checkpoint metadata, and `ProofTargets`. Use `objectBcs(index)` and `eventContents(index)` to read the authenticated +target payloads. Continue using `Proof` only as the untrusted transport and serialization envelope. + `CommitteeResolution.fromGenesis()` decodes the BCS-encoded IOTA genesis blob and extracts its committee in Rust. Callers that already possess an extracted trusted committee can use `CommitteeResolution.anchored(committee)` instead. `Committee.fromJSON()` accepts the Rust `Committee` fields `epoch` and `voting_rights`, validates public keys, rejects @@ -101,7 +106,8 @@ The verifier fetches the certified checkpoint in each epoch-close proof, verifie only then accepts and caches the next committee. Each anchored verifier owns a fresh in-memory cache; the WASM Package does not accept a caller-provided committee cache. Retain the verifier when checking multiple proofs so it can reuse the committees authenticated during its lifetime. `CommitteeResolver.resolve(epoch)` and `Proof.verify(committee)` remain -available for lower-level committee resolution and offline verification. +available for lower-level committee resolution and offline verification; both verification methods return a +`VerifiedProof` on success. ## Trust Boundaries diff --git a/bindings/wasm/poi_wasm/examples/src/01_transaction_proof.ts b/bindings/wasm/poi_wasm/examples/src/01_transaction_proof.ts index 40eae143..6a216fe4 100644 --- a/bindings/wasm/poi_wasm/examples/src/01_transaction_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/01_transaction_proof.ts @@ -63,8 +63,9 @@ export async function createAndVerifyTransactionProof(): Promise { console.log("Stage 5 - Verify the received proof"); const verifier = context.poiClient.verifier(trust.resolution); - await verifier.verify(receivedProof); + const verified = await verifier.verify(receivedProof); console.log(" transaction proof verified successfully."); - console.log(`The transaction is included in a checkpoint authenticated through ${trust.description}.`); + console.log(` transaction: ${verified.transaction}`); + console.log(` checkpoint: ${verified.checkpointSequenceNumber}`); } diff --git a/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts b/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts index 64b44cad..3e778e6a 100644 --- a/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts @@ -54,8 +54,9 @@ export async function createAndVerifyMultiTargetProof(): Promise { console.log("Stage 4 - Verify every target in the proof"); const verifier = context.poiClient.verifier(trust.resolution); - await verifier.verify(proof); + const verified = await verifier.verify(proof); console.log(" multi-target proof verified successfully."); - console.log("The transaction, changed object, and emitted event are authenticated by one proof."); + console.log(` object targets: ${verified.targets.objects.length}`); + console.log(` event targets: ${verified.targets.events.length}`); } diff --git a/bindings/wasm/poi_wasm/examples/src/03_reuse_verifier.ts b/bindings/wasm/poi_wasm/examples/src/03_reuse_verifier.ts index f1cb2893..18a6a94e 100644 --- a/bindings/wasm/poi_wasm/examples/src/03_reuse_verifier.ts +++ b/bindings/wasm/poi_wasm/examples/src/03_reuse_verifier.ts @@ -40,11 +40,14 @@ export async function reuseVerifierForMultipleProofs(): Promise { console.log("Stage 4 - Verify both proofs with one verifier"); console.log(" verifying the first proof; this resolves its checkpoint committee..."); - await verifier.verify(firstProof); + const firstVerified = await verifier.verify(firstProof); console.log(" verifying the second proof with the same verifier..."); - await verifier.verify(secondProof); + const secondVerified = await verifier.verify(secondProof); console.log("\n both transaction proofs verified successfully."); + console.log( + ` authenticated checkpoints: ${firstVerified.checkpointSequenceNumber}, ${secondVerified.checkpointSequenceNumber}`, + ); console.log(`Both proofs used committee resolution through ${trust.description}.`); } diff --git a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts index 104431c7..ee0b55eb 100644 --- a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts @@ -45,8 +45,12 @@ export async function createAndVerifyObjectProof(): Promise { console.log(` object targets: ${proofTargets.objects.length}\n`); console.log("Stage 3 - Verify the object proof"); - await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); + const verified = await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); console.log(" object proof verified successfully."); + console.log( + ` authenticated object: ${verified.targets.objects[0]?.objectId} at version ${verified.targets.objects[0]?.version}`, + ); + console.log(` object BCS: ${verified.objectBcs(0).length} bytes`); console.log("The resolved object version was changed by a transaction trusted through the selected node."); } diff --git a/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts b/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts index d58f5301..bf18320f 100644 --- a/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts @@ -46,8 +46,12 @@ export async function createAndVerifyEventProof(): Promise { console.log(` event targets: ${proofTargets.events.length}\n`); console.log("Stage 3 - Verify the event proof"); - await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); + const verified = await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); console.log(" event proof verified successfully."); + console.log( + ` authenticated event: ${verified.targets.events[0]?.transactionDigest}:${verified.targets.events[0]?.eventSequence}`, + ); + console.log(` event contents: ${verified.eventContents(0).length} BCS bytes`); console.log("The selected event was emitted by a transaction trusted through the selected node."); } diff --git a/bindings/wasm/poi_wasm/lib/index.ts b/bindings/wasm/poi_wasm/lib/index.ts index fcfd2acb..f48dabff 100644 --- a/bindings/wasm/poi_wasm/lib/index.ts +++ b/bindings/wasm/poi_wasm/lib/index.ts @@ -9,5 +9,6 @@ export { ProofEventTarget, ProofObjectTarget, ProofTargets, + VerifiedProof, } from "../node/poi_wasm.js"; export { PoiClient, type PoiClientOptions, type ProofEventRequest, type ProofRequest } from "./poi-client.js"; diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index 32f9c2e8..ea8e362e 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -11,7 +11,7 @@ use serde::Deserialize; use wasm_bindgen::prelude::wasm_bindgen; use crate::error::{PoiError, WasmResult}; -use crate::proof::WasmProof; +use crate::proof::{WasmProof, WasmVerifiedProof}; use crate::source::LedgerSource; #[derive(Deserialize)] @@ -120,10 +120,10 @@ impl WasmCommitteeResolver { Ok(WasmCommittee(committee)) } - /// Resolves the committee required by `proof` and verifies the proof with it. - pub async fn verify(&self, proof: &WasmProof) -> WasmResult<()> { - self.0.verify(&proof.0).await?; + /// Resolves the committee required by `proof` and returns its authenticated claims. + pub async fn verify(&self, proof: &WasmProof) -> WasmResult { + let verified = self.0.verify(&proof.0).await?; - Ok(()) + Ok(WasmVerifiedProof::new(&proof.0, verified)) } } diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 99422294..1adc262b 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -4,11 +4,11 @@ use iota_sdk_types::{ObjectId, TransactionDigest}; use iota_types::event::EventID; use js_sys::Uint8Array; -use poi_rs::{Proof, ProofBuilder, ProofTargets}; +use poi_rs::{Proof, ProofBuilder, ProofTargets, VerifiedProof}; use wasm_bindgen::prelude::wasm_bindgen; use crate::committee::WasmCommittee; -use crate::error::WasmResult; +use crate::error::{PoiError, WasmResult}; use crate::source::LedgerSource; /// An object selected as a Proof of Inclusion target. @@ -79,6 +79,85 @@ impl From<&ProofTargets> for WasmProofTargets { } } +/// Authenticated claims returned by successful proof verification. +#[wasm_bindgen(js_name = VerifiedProof, inspectable)] +#[derive(Clone)] +pub struct WasmVerifiedProof(Proof); + +impl WasmVerifiedProof { + pub(crate) fn new<'proof>(proof: &'proof Proof, _verified: VerifiedProof<'proof>) -> Self { + Self(proof.clone()) + } +} + +#[wasm_bindgen(js_class = VerifiedProof)] +impl WasmVerifiedProof { + /// Returns the epoch of the authenticated checkpoint. + #[wasm_bindgen(getter, js_name = checkpointEpoch)] + pub fn checkpoint_epoch(&self) -> u64 { + self.0.checkpoint_summary().epoch() + } + + /// Returns the authenticated checkpoint sequence number. + #[wasm_bindgen(getter, js_name = checkpointSequenceNumber)] + pub fn checkpoint_sequence_number(&self) -> u64 { + self.0.checkpoint_summary().sequence_number + } + + /// Returns the authenticated checkpoint timestamp in milliseconds since the Unix epoch. + #[wasm_bindgen(getter, js_name = checkpointTimestampMs)] + pub fn checkpoint_timestamp_ms(&self) -> u64 { + self.0.checkpoint_summary().timestamp_ms + } + + /// Returns the digest of the transaction included in the authenticated checkpoint. + #[wasm_bindgen(getter)] + pub fn transaction(&self) -> String { + self.0.transaction_proof().transaction.digest().to_string() + } + + /// Returns the authenticated transaction, object, and event targets. + #[wasm_bindgen(getter)] + pub fn targets(&self) -> WasmProofTargets { + self.0.targets().into() + } + + /// Returns a selected authenticated object encoded as BCS. + #[wasm_bindgen(js_name = objectBcs)] + pub fn object_bcs(&self, target_index: u32) -> WasmResult { + let object = + self.0.targets().objects.get(target_index as usize).ok_or_else(|| { + PoiError::invalid_input(format!("object target index {target_index} is out of bounds")) + })?; + let bytes = bcs::to_bytes(object)?; + + Ok(Uint8Array::from(bytes.as_slice())) + } + + /// Returns the contents of a selected authenticated event. + #[wasm_bindgen(js_name = eventContents)] + pub fn event_contents(&self, target_index: u32) -> WasmResult { + let event_id = + self.0.targets().events.get(target_index as usize).ok_or_else(|| { + PoiError::invalid_input(format!("event target index {target_index} is out of bounds")) + })?; + let events = self + .0 + .transaction_proof() + .events + .as_ref() + .ok_or_else(|| PoiError::invalid_response("verified proof is missing event data"))?; + let event_index = usize::try_from(event_id.event_seq) + .map_err(|_| PoiError::invalid_response("verified event sequence exceeds the platform index range"))?; + let event = events + .0 + .get(event_index) + .ok_or_else(|| PoiError::invalid_response("verified event sequence is out of bounds"))?; + + Ok(Uint8Array::from(event.contents.as_slice())) + } +} + /// Proof of Inclusion evidence constructed by `poi-rs`. #[wasm_bindgen(js_name = Proof)] pub struct WasmProof(pub(crate) Proof); @@ -111,11 +190,11 @@ impl WasmProof { self.0.targets().into() } - /// Verifies this proof locally with the supplied committee. - pub fn verify(&self, committee: &WasmCommittee) -> WasmResult<()> { - poi_rs::ProofVerifier::new(committee.inner()).verify(&self.0)?; + /// Verifies this proof locally and returns its authenticated claims. + pub fn verify(&self, committee: &WasmCommittee) -> WasmResult { + let verified = poi_rs::ProofVerifier::new(committee.inner()).verify(&self.0)?; - Ok(()) + Ok(WasmVerifiedProof::new(&self.0, verified)) } /// Serializes this proof as JSON. diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index d85b059f..32c3f6a8 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -5,11 +5,12 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { Committee, Proof, type ProofTargets } from "../lib/index.js"; +import { Committee, Proof, type ProofTargets, type VerifiedProof } from "../lib/index.js"; const fixtures: readonly { name: string; assertTargets: (targets: ProofTargets) => void; + assertVerified: (proof: VerifiedProof) => void; }[] = [ { name: "transaction", @@ -21,6 +22,13 @@ const fixtures: readonly { assert.deepEqual(targets.objects, []); assert.deepEqual(targets.events, []); }, + assertVerified(proof) { + assert.equal(proof.targets.transaction, proof.transaction); + assert.deepEqual(proof.targets.objects, []); + assert.deepEqual(proof.targets.events, []); + assert.throws(() => proof.objectBcs(0), /object target index 0 is out of bounds/); + assert.throws(() => proof.eventContents(0), /event target index 0 is out of bounds/); + }, }, { name: "object", @@ -32,6 +40,15 @@ const fixtures: readonly { assert.ok(targets.objects[0]?.digest); assert.deepEqual(targets.events, []); }, + assertVerified(proof) { + assert.equal(proof.targets.transaction, undefined); + assert.equal(proof.targets.objects.length, 1); + assert.match(proof.targets.objects[0]?.objectId ?? "", /^0x[0-9a-f]{64}$/); + assert.equal(proof.targets.objects[0]?.version, 2n); + assert.ok(proof.targets.objects[0]?.digest); + assert.ok(proof.objectBcs(0).length > 0); + assert.deepEqual(proof.targets.events, []); + }, }, { name: "event", @@ -45,6 +62,17 @@ const fixtures: readonly { ); assert.equal(targets.events[0]?.eventSequence, 0n); }, + assertVerified(proof) { + assert.equal(proof.targets.transaction, undefined); + assert.deepEqual(proof.targets.objects, []); + assert.equal(proof.targets.events.length, 1); + assert.equal( + proof.targets.events[0]?.transactionDigest, + "25zdMVEMRtg7pDGqgLgL1Hf3s8sL9YGuN9UVeo3dECG6", + ); + assert.equal(proof.targets.events[0]?.eventSequence, 0n); + assert.ok(proof.eventContents(0).length > 0); + }, }, ]; @@ -56,10 +84,15 @@ test("the public proof fixtures round trip and verify offline", async (context) const proof = Proof.fromJSON(await readFixture(`${fixture.name}.json`)); const receivedProof = Proof.fromJSON(proof.toJSON()); - receivedProof.verify(committee); + const verified = receivedProof.verify(committee); assert.equal(receivedProof.version, 1); assert.equal(receivedProof.checkpointEpoch, 0n); + assert.equal(verified.checkpointEpoch, 0n); + assert.ok(verified.checkpointSequenceNumber >= 0n); + assert.ok(verified.checkpointTimestampMs > 0n); + assert.ok(verified.transaction); fixture.assertTargets(receivedProof.targets); + fixture.assertVerified(verified); }); } }); diff --git a/examples/poi/01_transaction_proof.rs b/examples/poi/01_transaction_proof.rs index adf5788c..6f9032c5 100644 --- a/examples/poi/01_transaction_proof.rs +++ b/examples/poi/01_transaction_proof.rs @@ -109,13 +109,14 @@ async fn main() -> Result<()> { // The resolver authenticates every committee transition from genesis to the // proof's epoch. Reuse the verifier when checking multiple proofs so its // in-memory committee cache can avoid repeating the walk. - verifier + let verified = verifier .verify(&received_proof) .await .context("transaction proof verification failed")?; println!("\nTransaction proof verified successfully."); - println!("The transaction is included in a checkpoint authenticated from the trusted genesis blob."); + println!(" transaction: {}", verified.transaction_digest()); + println!(" checkpoint number: {}", verified.checkpoint_sequence_number()); Ok(()) } diff --git a/examples/poi/02_multi_target_proof.rs b/examples/poi/02_multi_target_proof.rs index 044a7eba..bcfeb268 100644 --- a/examples/poi/02_multi_target_proof.rs +++ b/examples/poi/02_multi_target_proof.rs @@ -85,13 +85,14 @@ async fn main() -> Result<()> { // Genesis-anchored resolution authenticates every preceding committee // transition. This may take a long time on an established network. - verifier + let verified = verifier .verify(&proof) .await .context("multi-target proof verification failed")?; println!("\nMulti-target proof verified successfully."); - println!("The transaction, changed object, and emitted event are authenticated by one proof."); + println!(" object targets: {}", verified.objects().len()); + println!(" event targets: {}", verified.events().len()); Ok(()) } diff --git a/examples/poi/03_reuse_verifier.rs b/examples/poi/03_reuse_verifier.rs index 683b2706..7234ffd1 100644 --- a/examples/poi/03_reuse_verifier.rs +++ b/examples/poi/03_reuse_verifier.rs @@ -73,18 +73,23 @@ async fn main() -> Result<()> { let verifier = client.verifier(resolution); println!("Verifying the first proof; this performs the epoch walk..."); - verifier + let first_verified = verifier .verify(&first_proof) .await .context("first transaction proof verification failed")?; println!("Verifying the second proof with the same verifier..."); - verifier + let second_verified = verifier .verify(&second_proof) .await .context("second transaction proof verification failed")?; println!("\nBoth transaction proofs verified successfully."); + println!( + " authenticated checkpoints: {}, {}", + first_verified.checkpoint_sequence_number(), + second_verified.checkpoint_sequence_number() + ); println!("The second verification reused committee history authenticated during the first verification."); Ok(()) diff --git a/examples/poi/04_object_proof.rs b/examples/poi/04_object_proof.rs index 0a5bdb25..3a9cb991 100644 --- a/examples/poi/04_object_proof.rs +++ b/examples/poi/04_object_proof.rs @@ -76,13 +76,16 @@ async fn main() -> Result<()> { // Trusted-node resolution accepts the committee reported by the connected // node. It avoids the genesis walk but makes that node part of the trust boundary. let verifier = client.verifier(CommitteeResolution::TrustedNode); - verifier + let verified = verifier .verify(&proof) .await .context("object proof verification failed")?; println!("Object proof verified successfully."); - println!("The resolved object version was changed by a transaction included in the verified checkpoint."); + println!( + " authenticated object: {:?}", + verified.objects()[0].as_inner().object_ref() + ); Ok(()) } diff --git a/examples/poi/05_event_proof.rs b/examples/poi/05_event_proof.rs index 807f80d7..cd24b29c 100644 --- a/examples/poi/05_event_proof.rs +++ b/examples/poi/05_event_proof.rs @@ -75,13 +75,18 @@ async fn main() -> Result<()> { // Trusted-node resolution accepts the committee reported by the connected // node. It avoids the genesis walk but makes that node part of the trust boundary. let verifier = client.verifier(CommitteeResolution::TrustedNode); - verifier + let verified = verifier .verify(&proof) .await .context("event proof verification failed")?; println!("Event proof verified successfully."); - println!("The selected event was emitted by a transaction included in the verified checkpoint."); + for (event_id, event) in verified.events() { + println!( + " authenticated event: {event_id:?} ({} BCS bytes)", + event.contents.len() + ); + } Ok(()) } diff --git a/examples/poi/advanced/01_committee_cache.rs b/examples/poi/advanced/01_committee_cache.rs index 7c27f63c..a2c8ed71 100644 --- a/examples/poi/advanced/01_committee_cache.rs +++ b/examples/poi/advanced/01_committee_cache.rs @@ -55,13 +55,14 @@ async fn main() -> Result<()> { println!("Verifying the proof..."); let started = Instant::now(); - verifier + let verified = verifier .verify(&proof) .await .context("transaction proof verification failed")?; let elapsed = started.elapsed(); println!("\nTransaction proof verified successfully in {elapsed:?}."); + println!(" authenticated checkpoint: {}", verified.checkpoint_sequence_number()); println!("Run the example again to reuse the authenticated committees stored on disk."); Ok(()) diff --git a/poi-rs/README.md b/poi-rs/README.md index b6a32b87..80040fe3 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -131,7 +131,8 @@ let client = PoiClient::testnet()?; let resolution = CommitteeResolution::from_genesis(File::open("genesis.blob")?)?; let verifier = client.verifier(resolution); -verifier.verify(proof).await?; +let verified = verifier.verify(proof).await?; +println!("verified transaction: {}", verified.transaction_digest()); # Ok(()) # } ``` @@ -145,6 +146,11 @@ contains committees authenticated for the same network. Retain the verifier when checking multiple proofs so it can reuse its authenticated committee cache. `ProofVerifier` remains the offline entry point for callers that already possess the authoritative committee. +Successful verification returns a `VerifiedProof` that borrows from the input proof and exposes only authenticated +checkpoint metadata, transaction data and digest, object targets, and event claims. It intentionally omits the packaged +user signatures because checkpoint inclusion does not authenticate those bytes. Read relying-party data through this +returned value. The original `Proof` remains the portable untrusted envelope used for transport and serialization. + Verification checks: - the checkpoint summary is certified by the supplied committee; @@ -175,8 +181,8 @@ transaction itself, although transaction evidence supports every proof. authoritative. `CommitteeResolver::verify()` composes committee resolution with offline verification for source-backed workflows, while `CommitteeResolver::resolve()` returns the authenticated committee when callers need it directly. -Treat every proof payload as untrusted until verification succeeds. After successful verification, callers can trust -the authenticated target claims relative to the supplied committee. +Treat every proof payload as untrusted. After successful verification, trust claims relative to the supplied committee +through the returned `VerifiedProof`; do not read relying-party claims from an unrelated `Proof` value. The proof's `chain` value is informational. The verifier does not authenticate it, so applications must not use it to select a network, committee, genesis blob, or other trust anchor. diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index 692e2d49..da00ede5 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -12,9 +12,8 @@ use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use iota_config::{IOTA_GENESIS_FILENAME, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; -use iota_types::effects::TransactionEffectsExt; use iota_types::event::EventID; -use poi_rs::{CommitteeResolution, PoiClient, Proof}; +use poi_rs::{CommitteeResolution, PoiClient, Proof, VerifiedProof}; const GENESIS_CACHE_DIR: &str = "poi"; const MAINNET_GENESIS_URL: &str = "https://dbfiles.mainnet.iota.cafe/genesis.blob"; @@ -170,32 +169,28 @@ impl VerifyArgs { }; let resolution = CommitteeResolution::from_genesis(genesis) .map_err(|error| anyhow::anyhow!("failed to load trusted genesis blob: {error}"))?; - PoiClient::from_grpc_client(self.endpoint.client()?) + let verified = PoiClient::from_grpc_client(self.endpoint.client()?) .verifier(resolution) .verify(&proof) .await .context("proof verification failed")?; - write_verification_summary(io::stdout().lock(), &proof).context("failed to write verification result to stdout") + write_verification_summary(io::stdout().lock(), &verified) + .context("failed to write verification result to stdout") } } -fn write_verification_summary(mut writer: impl Write, proof: &Proof) -> io::Result<()> { - let checkpoint = proof.checkpoint_summary(); - let transaction_proof = proof.transaction_proof(); - let transaction_digest = transaction_proof.effects.execution_digests().transaction; +fn write_verification_summary(mut writer: impl Write, proof: &VerifiedProof<'_>) -> io::Result<()> { writeln!(writer, "Proof verified successfully.")?; - writeln!(writer, " reported chain: {}", proof.chain().digest())?; - writeln!(writer, " checkpoint epoch: {}", checkpoint.epoch())?; - writeln!(writer, " checkpoint number: {}", checkpoint.sequence_number)?; - writeln!(writer, " timestamp (ms): {}", checkpoint.timestamp_ms)?; - writeln!(writer, " transaction: {transaction_digest}")?; + writeln!(writer, " checkpoint epoch: {}", proof.checkpoint_epoch())?; + writeln!(writer, " checkpoint number: {}", proof.checkpoint_sequence_number())?; + writeln!(writer, " timestamp (ms): {}", proof.checkpoint_timestamp_ms())?; + writeln!(writer, " transaction: {}", proof.transaction_digest())?; writeln!(writer, " targets:")?; - let targets = proof.targets(); - if let Some(transaction) = targets.transaction { + if let Some(transaction) = proof.transaction_target() { writeln!(writer, " transaction: {transaction}")?; } - for object in &targets.objects { + for object in proof.objects() { let object_ref = object.as_inner().object_ref(); writeln!( writer, @@ -203,7 +198,7 @@ fn write_verification_summary(mut writer: impl Write, proof: &Proof) -> io::Resu object_ref.object_id, object_ref.version, object_ref.digest )?; } - for event in &targets.events { + for (event, _) in proof.events() { writeln!(writer, " event: {}:{}", event.tx_digest, event.event_seq)?; } diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 7b5fec50..77686db5 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -17,7 +17,8 @@ use iota_types::transaction::Transaction; use serde::Deserialize; use crate::{ - BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Proof, ProofVerifier, Source, VerifyError, + BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Proof, ProofVerifier, Source, VerifiedProof, + VerifyError, }; /// Error returned when a committee cannot be resolved for an epoch. @@ -270,8 +271,9 @@ where /// /// Committee resolution may fetch committee or epoch-close evidence from /// the source. The final proof verification is performed locally by - /// [`ProofVerifier`]. - pub async fn verify(&self, proof: &Proof) -> Result<(), ProofVerificationError> { + /// [`ProofVerifier`]. On success, the returned [`VerifiedProof`] borrows the + /// authenticated claims from `proof`. + pub async fn verify<'proof>(&self, proof: &'proof Proof) -> Result, ProofVerificationError> { let committee = self .resolve(proof.checkpoint_summary().epoch()) .await diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index 6d559e11..57d3c753 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -29,6 +29,6 @@ pub use committee::{ }; pub use proof::{ Proof, ProofTargets, ProofV1, ProofVerifier, SerializationError, SerializationErrorKind, TransactionProof, - VerifyError, VerifyErrorKind, + VerifiedProof, VerifyError, VerifyErrorKind, }; pub use source::{Source, SourceCheckpoint, SourceError, SourceTransaction}; diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index dab77625..7656657a 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -11,7 +11,7 @@ //! [`CertifiedCheckpointSummary`]: iota_types::messages_checkpoint::CertifiedCheckpointSummary //! [`Committee`]: iota_types::committee::Committee -use iota_sdk_types::{CheckpointContents, TransactionDigest}; +use iota_sdk_types::{CheckpointContents, Event, Transaction as TransactionData, TransactionDigest}; use iota_types::committee::Committee; use iota_types::digests::ChainIdentifier; use iota_types::effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}; @@ -173,6 +173,68 @@ impl TransactionProof { } } +/// Authenticated claims borrowed from a successfully verified proof. +/// +/// Values are exposed through this type only after all checkpoint, transaction, +/// object, and event checks have succeeded. The original [`Proof`] remains +/// available for serialization and inspection, but its contents must not be +/// treated as authenticated without a corresponding `VerifiedProof`. +#[derive(Debug)] +#[must_use = "read authenticated claims from the returned VerifiedProof"] +pub struct VerifiedProof<'proof> { + checkpoint_summary: &'proof CertifiedCheckpointSummary, + transaction: &'proof TransactionData, + transaction_digest: TransactionDigest, + transaction_target: Option<&'proof TransactionDigest>, + objects: &'proof [Object], + events: Vec<(&'proof EventID, &'proof Event)>, +} + +impl<'proof> VerifiedProof<'proof> { + /// Returns the epoch of the authenticated checkpoint. + pub fn checkpoint_epoch(&self) -> u64 { + self.checkpoint_summary.epoch() + } + + /// Returns the authenticated checkpoint sequence number. + pub fn checkpoint_sequence_number(&self) -> u64 { + self.checkpoint_summary.sequence_number + } + + /// Returns the authenticated checkpoint timestamp in milliseconds since the Unix epoch. + pub fn checkpoint_timestamp_ms(&self) -> u64 { + self.checkpoint_summary.timestamp_ms + } + + /// Returns the transaction data included in the authenticated checkpoint. + /// + /// User signatures packaged with the original proof are not returned because + /// checkpoint inclusion does not authenticate those signature bytes. + pub const fn transaction(&self) -> &'proof TransactionData { + self.transaction + } + + /// Returns the digest of the transaction included in the authenticated checkpoint. + pub const fn transaction_digest(&self) -> TransactionDigest { + self.transaction_digest + } + + /// Returns the explicit transaction target, when the proof declared one. + pub const fn transaction_target(&self) -> Option<&'proof TransactionDigest> { + self.transaction_target + } + + /// Returns the authenticated object targets. + pub const fn objects(&self) -> &'proof [Object] { + self.objects + } + + /// Returns authenticated event targets paired with their event contents. + pub fn events(&self) -> impl ExactSizeIterator + '_ { + self.events.iter().copied() + } +} + /// Versioned evidence that a transaction is included in a certified checkpoint. /// /// The enum is non-exhaustive so future crate versions can add support for new @@ -242,35 +304,40 @@ impl Proof { } } - /// Returns the network reported by the proof source. + /// Returns the unverified network reported by the proof source. + /// + /// This value is informational and is not authenticated by proof verification. pub const fn chain(&self) -> &ChainIdentifier { match self { Self::ProofV1(proof) => &proof.chain, } } - /// Returns the values selected for this proof. + /// Returns the unverified values declared by this proof. + /// + /// After verification, read authenticated targets from the returned + /// [`VerifiedProof`] instead. pub const fn targets(&self) -> &ProofTargets { match self { Self::ProofV1(proof) => &proof.targets, } } - /// Returns the certified checkpoint summary. + /// Returns the checkpoint summary carried by this unverified proof. pub const fn checkpoint_summary(&self) -> &CertifiedCheckpointSummary { match self { Self::ProofV1(proof) => &proof.checkpoint_summary, } } - /// Returns the checkpoint contents. + /// Returns the checkpoint contents carried by this unverified proof. pub const fn checkpoint_contents(&self) -> &CheckpointContents { match self { Self::ProofV1(proof) => &proof.checkpoint_contents, } } - /// Returns the transaction-specific evidence. + /// Returns the transaction-specific evidence carried by this unverified proof. pub const fn transaction_proof(&self) -> &TransactionProof { match self { Self::ProofV1(proof) => &proof.transaction_proof, @@ -334,17 +401,20 @@ impl<'committee> ProofVerifier<'committee> { /// - the transaction effects occur in the authenticated checkpoint contents; /// - every selected target matches the authenticated proof data. /// + /// On success, returns a [`VerifiedProof`] borrowing the authenticated claims + /// from `proof`. + /// /// # Errors /// /// Returns an error if any check fails. - pub fn verify(&self, proof: &Proof) -> Result<(), VerifyError> { + pub fn verify<'proof>(&self, proof: &'proof Proof) -> Result, VerifyError> { match proof { Proof::ProofV1(proof) => self.verify_v1(proof), } } /// Verifies a version 1 proof and all of its claims. - fn verify_v1(&self, proof: &ProofV1) -> Result<(), VerifyError> { + fn verify_v1<'proof>(&self, proof: &'proof ProofV1) -> Result, VerifyError> { if proof.targets.is_empty() { return Err(VerifyError { kind: VerifyErrorKind::MissingTarget, @@ -363,9 +433,16 @@ impl<'committee> ProofVerifier<'committee> { })?; self.verify_transaction_proof(summary, &proof.checkpoint_contents, &proof.transaction_proof)?; - self.verify_targets(&proof.targets, &proof.transaction_proof)?; - - Ok(()) + let events = self.verify_targets(&proof.targets, &proof.transaction_proof)?; + + Ok(VerifiedProof { + checkpoint_summary: summary, + transaction: proof.transaction_proof.transaction.data().transaction(), + transaction_digest: *proof.transaction_proof.transaction.digest(), + transaction_target: proof.targets.transaction.as_ref(), + objects: &proof.targets.objects, + events, + }) } /// Checks the transaction-to-effects, effects-to-checkpoint, and effects-to-events links. @@ -405,7 +482,11 @@ impl<'committee> ProofVerifier<'committee> { } /// Checks every declared target against the transaction proof. - fn verify_targets(&self, targets: &ProofTargets, transaction_proof: &TransactionProof) -> Result<(), VerifyError> { + fn verify_targets<'proof>( + &self, + targets: &'proof ProofTargets, + transaction_proof: &'proof TransactionProof, + ) -> Result, VerifyError> { let transaction_digest = transaction_proof.effects.execution_digests().transaction; if targets.transaction.is_some_and(|target| target != transaction_digest) { @@ -414,18 +495,20 @@ impl<'committee> ProofVerifier<'committee> { }); } - self.verify_event_targets(targets, transaction_proof)?; - self.verify_object_targets(targets, transaction_proof) + let events = self.verify_event_targets(targets, transaction_proof)?; + self.verify_object_targets(targets, transaction_proof)?; + + Ok(events) } /// Checks each event target against the proven transaction and its packaged events. - fn verify_event_targets( + fn verify_event_targets<'proof>( &self, - targets: &ProofTargets, - transaction_proof: &TransactionProof, - ) -> Result<(), VerifyError> { + targets: &'proof ProofTargets, + transaction_proof: &'proof TransactionProof, + ) -> Result, VerifyError> { if targets.events.is_empty() { - return Ok(()); + return Ok(Vec::new()); } let Some(events) = &transaction_proof.events else { @@ -435,6 +518,7 @@ impl<'committee> ProofVerifier<'committee> { }; let execution_digests = transaction_proof.effects.execution_digests(); + let mut verified_events = Vec::with_capacity(targets.events.len()); for event_id in &targets.events { if event_id.tx_digest != execution_digests.transaction { return Err(VerifyError { @@ -442,19 +526,20 @@ impl<'committee> ProofVerifier<'committee> { }); } - let event_exists = usize::try_from(event_id.event_seq) + let event = usize::try_from(event_id.event_seq) .ok() - .is_some_and(|index| events.get(index).is_some()); - if !event_exists { + .and_then(|index| events.get(index)); + let Some(event) = event else { return Err(VerifyError { kind: VerifyErrorKind::EventSequenceOutOfBounds { sequence: event_id.event_seq, }, }); - } + }; + verified_events.push((event_id, event)); } - Ok(()) + Ok(verified_events) } /// Checks each object target against the transaction effects. diff --git a/poi-rs/tests/proof_construction.rs b/poi-rs/tests/proof_construction.rs index f912b300..e0f7f2f6 100644 --- a/poi-rs/tests/proof_construction.rs +++ b/poi-rs/tests/proof_construction.rs @@ -72,7 +72,7 @@ async fn stacked_requests_are_deduplicated_and_reuse_transaction_evidence() { assert_eq!(proof.targets().transaction, Some(staking.digest)); assert_eq!(proof.targets().objects.len(), 1); assert_eq!(proof.targets().events.len(), 1); - ProofVerifier::new(&cluster.committee()) + let _verified = ProofVerifier::new(&cluster.committee()) .verify(&proof) .expect("the stacked-target proof must verify offline"); } diff --git a/poi-rs/tests/proof_serialization.rs b/poi-rs/tests/proof_serialization.rs index 5644e7ad..3d6361c9 100644 --- a/poi-rs/tests/proof_serialization.rs +++ b/poi-rs/tests/proof_serialization.rs @@ -13,7 +13,7 @@ fn assert_fixture_round_trips_and_verifies(fixture: &str) -> Proof { let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); let proof = Proof::from_json_slice(fixture.as_bytes()).expect("proof fixture must deserialize"); - ProofVerifier::new(&committee) + let _verified = ProofVerifier::new(&committee) .verify(&proof) .expect("proof fixture must verify offline"); assert_eq!( diff --git a/poi-rs/tests/proof_verification.rs b/poi-rs/tests/proof_verification.rs index ceaebb09..8a7eb031 100644 --- a/poi-rs/tests/proof_verification.rs +++ b/poi-rs/tests/proof_verification.rs @@ -22,9 +22,41 @@ fn proof_v1_mut(proof: &mut Proof) -> &mut ProofV1 { fn valid_transaction_proof_is_accepted() { let (committee, proof) = valid_transaction_proof(); - ProofVerifier::new(&committee) + let verified = ProofVerifier::new(&committee) .verify(&proof) .expect("a valid transaction proof must verify"); + + assert_eq!( + verified.transaction_target().copied(), + Some(verified.transaction_digest()) + ); + assert_eq!( + verified.transaction(), + proof.transaction_proof().transaction.data().transaction() + ); + assert!(verified.objects().is_empty()); + assert_eq!(verified.events().len(), 0); + assert_eq!(verified.checkpoint_epoch(), 0); + assert_eq!(verified.checkpoint_sequence_number(), 0); + assert_eq!(verified.checkpoint_timestamp_ms(), 0); +} + +#[test] +fn verified_event_content_is_exposed() { + 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: 0, + }; + proof_v1_mut(&mut proof).targets = ProofTargets::new().add_event(event_id); + + let verified = ProofVerifier::new(&committee) + .verify(&proof) + .expect("a valid event proof must verify"); + let events = verified.events().collect::>(); + + assert_eq!(events, vec![(&event_id, &target)]); } #[test] diff --git a/poi-rs/tests/proof_workflows.rs b/poi-rs/tests/proof_workflows.rs index f59c4de2..d0024167 100644 --- a/poi-rs/tests/proof_workflows.rs +++ b/poi-rs/tests/proof_workflows.rs @@ -31,7 +31,7 @@ async fn client_builds_and_verifies_a_transaction_proof_from_genesis() { assert!(proof.transaction_proof().events.is_none()); let resolution = CommitteeResolution::from_genesis(genesis).expect("test cluster genesis blob must load"); - client + let _verified = client .verifier(resolution) .verify(&proof) .await @@ -55,7 +55,7 @@ async fn client_builds_and_verifies_an_object_proof_with_a_trusted_node() { assert_eq!(proof.targets().objects[0].as_inner().object_ref(), transfer.gas_object); assert!(proof.targets().events.is_empty()); assert!(proof.transaction_proof().events.is_none()); - client + let _verified = client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await @@ -84,7 +84,7 @@ async fn client_builds_and_verifies_an_event_proof_with_a_trusted_node() { assert_eq!(proof.targets().events, vec![event_id]); assert!(proof.transaction_proof().events.is_some()); - client + let _verified = client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await @@ -106,7 +106,7 @@ async fn client_builds_one_verified_proof_for_multiple_objects() { assert_eq!(proof.transaction_proof().transaction.digest(), &transfer.digest); assert_eq!(proof.targets().objects.len(), 2); - client + let _verified = client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await @@ -135,7 +135,7 @@ async fn client_builds_one_verified_proof_for_object_and_event_targets() { assert_eq!(proof.targets().objects[0].as_inner().object_ref(), staking.gas_object); assert_eq!(proof.targets().objects.len(), 1); assert_eq!(proof.targets().events.len(), 1); - client + let _verified = client .verifier(CommitteeResolution::TrustedNode) .verify(&proof) .await From 65e908d059c43c7f202c04fb88c8af559f618c74 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 11:25:57 +0300 Subject: [PATCH 04/19] chore: enhance committee cache to use complete key for network and epoch isolation --- 2026-08-27-poi-adversarial-review.md | 4 + .../poi_wasm/examples/src/04_object_proof.ts | 4 +- .../poi_wasm/examples/src/05_event_proof.ts | 4 +- .../tests/committee-resolution.test.ts | 2 +- examples/poi/README.md | 2 +- examples/poi/advanced/01_committee_cache.rs | 70 ++++--- poi-rs/README.md | 4 +- poi-rs/src/cache.rs | 50 ++++- poi-rs/src/cache/in_memory.rs | 93 ++++++++-- poi-rs/src/committee.rs | 172 ++++++++++++++---- poi-rs/src/lib.rs | 2 +- poi-rs/tests/committee_resolution.rs | 9 +- 12 files changed, 323 insertions(+), 93 deletions(-) diff --git a/2026-08-27-poi-adversarial-review.md b/2026-08-27-poi-adversarial-review.md index d7a8ecc5..6f33cef8 100644 --- a/2026-08-27-poi-adversarial-review.md +++ b/2026-08-27-poi-adversarial-review.md @@ -114,6 +114,8 @@ signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_b #### M3. Committee construction from node or JSON data panics on malformed input (safety, confirmed with severity dispute) +**Status: Unresolved — requires an upstream fix.** The Notarization Toolkit will not duplicate `Committee` validation locally; this should be addressed by a fallible upstream committee constructor. + **Proof.** Three paths build an `iota_types::Committee` from data that has not been signature-verified: the native `TrustedNode` fetch ([`grpc.rs:158-167`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L158-L167), `committee.into()`), the WASM [`decode_committee`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L277-L289) (`Committee::new` with only a key-length check), and [`Committee.fromJSON`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L37-L60) (checks duplicates and the stake sum but not key validity). [`Committee::new`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/committee.rs#L69-L78) asserts non-empty, some nonzero weight and `total == TOTAL_VOTING_POWER`, and [`load_inner`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/committee.rs#L150-L154) does `.expect("Validator pubkey is always verified on-chain")` on every key, so a 96-byte value that is not a valid G2 point panics. The WASM README says [`Committee.fromJSON()` "validates public keys"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/README.md#L96-L98); it validates their length. **Scenario.** On wasm32 the panic is a trap inside the wasm-bindgen-futures microtask: the caller's `await verifier.verify(proof)` never settles and later calls on the same resolver throw "recursive use of an object detected". An honest v1.29.0 node never returns an empty or unbalanced committee, so the trigger is a buggy or hostile node or proxy, or an operator's own `LedgerSource`. The anchored walk is safe: [`from_committee_members`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L482) runs only on quorum-signed data. @@ -122,6 +124,8 @@ signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_b #### M4. The committee cache has no network or anchor binding (API design, disputed, resolved as medium) +**Status: Resolved.** Shared cache keys now include the trusted `ChainIdentifier` (the genesis checkpoint digest) and epoch. Genesis-based resolution derives it from the trusted blob; committee-based resolution requires callers to supply it explicitly when sharing a cache. + **Proof.** `resolve_from_anchor` returns whatever the cache holds for `target_epoch` after checking only that its `epoch` field matches ([`committee.rs:318-338`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L318-L338)), and the prefix walk adopts cached committees for `anchor + 1..` without checking they descend from the anchor ([`:342-368`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L342-L368)). `Committee` carries only epoch and voting rights, [`MemoryCommitteeCache`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache/in_memory.rs#L13-L16) is `Clone` over shared `Arc` state, and the [trait](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache.rs#L33-L45) offers no network handle. **Scenario.** An integrator who hands one backend to a mainnet-anchored and a testnet-anchored resolver gets, testnet first, a genuine testnet proof accepted as mainnet; mainnet first, testnet verification fails with a signature error that looks like a forged proof. The refuter is right that this requires violating a precondition documented in five places and that [README:145](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L145) and example 03 recommend reusing one verifier per network, not one cache across networks. The reproducer is right that the anchor is effectively bypassed whenever the cache is populated and that nothing at construction detects the mismatch. Rust library surface only; the WASM binding and the CLI always create a fresh in-memory cache. diff --git a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts index ee0b55eb..28d945cf 100644 --- a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts @@ -49,7 +49,9 @@ export async function createAndVerifyObjectProof(): Promise { console.log(" object proof verified successfully."); console.log( - ` authenticated object: ${verified.targets.objects[0]?.objectId} at version ${verified.targets.objects[0]?.version}`, + ` authenticated object: ${verified.targets.objects[0]?.objectId} at version ${ + verified.targets.objects[0]?.version + }`, ); console.log(` object BCS: ${verified.objectBcs(0).length} bytes`); console.log("The resolved object version was changed by a transaction trusted through the selected node."); diff --git a/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts b/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts index bf18320f..d003dc30 100644 --- a/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts @@ -50,7 +50,9 @@ export async function createAndVerifyEventProof(): Promise { console.log(" event proof verified successfully."); console.log( - ` authenticated event: ${verified.targets.events[0]?.transactionDigest}:${verified.targets.events[0]?.eventSequence}`, + ` authenticated event: ${verified.targets.events[0]?.transactionDigest}:${ + verified.targets.events[0]?.eventSequence + }`, ); console.log(` event contents: ${verified.eventContents(0).length} BCS bytes`); console.log("The selected event was emitted by a transaction trusted through the selected node."); diff --git a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts index 6319c755..51685179 100644 --- a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts @@ -5,8 +5,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import type { LedgerSource } from "../lib/source-types.js"; import { Committee, CommitteeResolution, CommitteeResolver } from "../lib/index.js"; +import type { LedgerSource } from "../lib/source-types.js"; test("the WASM resolver constructs a committee reported by a trusted node", async () => { const source = await committeeSource(); diff --git a/examples/poi/README.md b/examples/poi/README.md index 7afa5bc2..bf6f4be3 100644 --- a/examples/poi/README.md +++ b/examples/poi/README.md @@ -115,7 +115,7 @@ use trusted-node committee resolution so they can focus on target-driven discove - Treat the complete proof payload as untrusted until verification succeeds. - For local and custom networks, obtain `IOTA_GENESIS_PATH` independently from the party supplying the proof. - Ensure the genesis blob belongs to the same network as the proof. -- Scope persistent committee caches to one network and genesis anchor. +- Preserve the complete network-scoped key when implementing a persistent committee cache. - Use `CommitteeResolution::TrustedNode` only when the connected node is inside the verifier's trust boundary. ## Documentation And Resources diff --git a/examples/poi/advanced/01_committee_cache.rs b/examples/poi/advanced/01_committee_cache.rs index a2c8ed71..bdc64431 100644 --- a/examples/poi/advanced/01_committee_cache.rs +++ b/examples/poi/advanced/01_committee_cache.rs @@ -8,9 +8,9 @@ //! when the process exits. This advanced example supplies a file-based cache that //! lets later runs resume from committees authenticated during earlier runs. //! -//! The cache is part of the verifier's trust boundary. Its directory is scoped -//! to the active network, cached values are checked against their requested -//! epoch, and an authenticated committee can never overwrite a conflicting value. +//! Cache entries are scoped automatically to the verifier's network, +//! checked against their requested epoch, and never overwritten by a +//! conflicting value. use std::time::Instant; @@ -70,19 +70,18 @@ async fn main() -> Result<()> { mod file_committee_cache { + use std::fmt::Write as _; use std::fs; use std::io::Write; use std::path::PathBuf; - use iota_types::committee::{Committee, EpochId}; - use poi_rs::{CommitteeCache, CommitteeCacheError}; + use iota_types::committee::Committee; + use poi_rs::{CommitteeCache, CommitteeCacheError, CommitteeCacheKey}; use tempfile::NamedTempFile; /// File-backed storage for committees authenticated by a resolver. /// - /// Each epoch is stored in a separate BCS file. The directory must be scoped to - /// one trusted network and genesis anchor; mixing networks would violate the - /// cache's trust contract. + /// Each network and epoch is stored in a separate BCS file. #[derive(Clone, Debug)] pub struct FileCommitteeCache { directory: PathBuf, @@ -97,13 +96,26 @@ mod file_committee_cache { &self.directory } - fn committee_path(&self, epoch: EpochId) -> PathBuf { - self.directory.join(format!("epoch-{epoch}.bcs")) + fn network_directory(&self, key: CommitteeCacheKey) -> PathBuf { + let digest = key.chain_identifier().as_bytes(); + let mut chain = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut chain, "{byte:02x}").expect("writing to a string cannot fail"); + } + + self.directory.join(chain) + } + + fn committee_path(&self, key: CommitteeCacheKey) -> PathBuf { + self.network_directory(key).join(format!("epoch-{}.bcs", key.epoch())) } - fn backend(epoch: EpochId, source: impl std::error::Error + Send + Sync + 'static) -> CommitteeCacheError { + fn backend( + key: CommitteeCacheKey, + source: impl std::error::Error + Send + Sync + 'static, + ) -> CommitteeCacheError { CommitteeCacheError::Backend { - epoch, + epoch: key.epoch(), source: Box::new(source), } } @@ -111,26 +123,29 @@ mod file_committee_cache { #[async_trait::async_trait] impl CommitteeCache for FileCommitteeCache { - async fn committee(&self, epoch: EpochId) -> Result, CommitteeCacheError> { - let path = self.committee_path(epoch); + async fn committee(&self, key: CommitteeCacheKey) -> Result, CommitteeCacheError> { + let path = self.committee_path(key); let bytes = match fs::read(&path) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(Self::backend(epoch, error)), + Err(error) => return Err(Self::backend(key, error)), }; - let committee: Committee = bcs::from_bytes(&bytes).map_err(|error| Self::backend(epoch, error))?; + let committee: Committee = bcs::from_bytes(&bytes).map_err(|error| Self::backend(key, error))?; - if committee.epoch != epoch { - return Err(CommitteeCacheError::Conflict { epoch }); + if committee.epoch != key.epoch() { + return Err(CommitteeCacheError::Conflict { epoch: key.epoch() }); } Ok(Some(committee)) } - async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> { + async fn store(&self, key: CommitteeCacheKey, committee: &Committee) -> Result<(), CommitteeCacheError> { let epoch = committee.epoch; + if key.epoch() != epoch { + return Err(CommitteeCacheError::Conflict { epoch }); + } - if let Some(cached) = self.committee(epoch).await? { + if let Some(cached) = self.committee(key).await? { return if cached == *committee { Ok(()) } else { @@ -138,23 +153,24 @@ mod file_committee_cache { }; } - fs::create_dir_all(&self.directory).map_err(|error| Self::backend(epoch, error))?; - let bytes = bcs::to_bytes(committee).map_err(|error| Self::backend(epoch, error))?; - let mut temporary = NamedTempFile::new_in(&self.directory).map_err(|error| Self::backend(epoch, error))?; + let directory = self.network_directory(key); + fs::create_dir_all(&directory).map_err(|error| Self::backend(key, error))?; + let bytes = bcs::to_bytes(committee).map_err(|error| Self::backend(key, error))?; + let mut temporary = NamedTempFile::new_in(&directory).map_err(|error| Self::backend(key, error))?; temporary .write_all(&bytes) .and_then(|()| temporary.as_file().sync_all()) - .map_err(|error| Self::backend(epoch, error))?; + .map_err(|error| Self::backend(key, error))?; - match temporary.persist_noclobber(self.committee_path(epoch)) { + match temporary.persist_noclobber(self.committee_path(key)) { Ok(_) => Ok(()), Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { - match self.committee(epoch).await? { + match self.committee(key).await? { Some(cached) if cached == *committee => Ok(()), _ => Err(CommitteeCacheError::Conflict { epoch }), } } - Err(error) => Err(Self::backend(epoch, error.error)), + Err(error) => Err(Self::backend(key, error.error)), } } } diff --git a/poi-rs/README.md b/poi-rs/README.md index 80040fe3..9bf27a9d 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -141,7 +141,9 @@ println!("verified transaction: {}", verified.transaction_digest()); boundary. `CommitteeResolution::from_genesis()` loads an anchor committee from a trusted BCS-encoded genesis blob, while `CommitteeResolution::anchored()` accepts an already extracted trusted committee. Use `CommitteeResolution::anchored_with_cache()` or `CommitteeResolution::from_genesis_with_cache()` to supply a cache that -contains committees authenticated for the same network. +persists authenticated committees. Shared cache entries are keyed by both the trusted genesis checkpoint digest and +epoch, so one backend can be shared safely by resolvers for different networks. The genesis-based constructor derives +the chain identifier automatically; `anchored_with_cache()` requires it explicitly. Retain the verifier when checking multiple proofs so it can reuse its authenticated committee cache. `ProofVerifier` remains the offline entry point for callers that already possess the authoritative committee. diff --git a/poi-rs/src/cache.rs b/poi-rs/src/cache.rs index 61629e2c..56f3e219 100644 --- a/poi-rs/src/cache.rs +++ b/poi-rs/src/cache.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use iota_types::committee::{Committee, EpochId}; +use iota_types::digests::ChainIdentifier; use crate::BoxError; @@ -9,6 +10,42 @@ mod in_memory; pub use in_memory::MemoryCommitteeCache; +/// Identifies one committee-cache entry by its network and epoch. +/// +/// The chain identifier is the network's genesis checkpoint digest. Cache +/// adapters must use the complete key so entries authenticated for different +/// networks cannot collide. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CommitteeCacheKey { + chain_identifier: ChainIdentifier, + epoch: EpochId, +} + +impl CommitteeCacheKey { + /// Creates a cache key for `epoch` on the identified network. + pub const fn new(chain_identifier: ChainIdentifier, epoch: EpochId) -> Self { + Self { + chain_identifier, + epoch, + } + } + + /// Creates a key for a cache private to one resolver. + pub(crate) fn isolated(epoch: EpochId) -> Self { + Self::new(ChainIdentifier::default(), epoch) + } + + /// Returns the network's genesis checkpoint digest. + pub const fn chain_identifier(&self) -> &ChainIdentifier { + &self.chain_identifier + } + + /// Returns the cached committee epoch. + pub const fn epoch(&self) -> EpochId { + self.epoch + } +} + /// Error returned by a committee cache. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -32,14 +69,13 @@ pub enum CommitteeCacheError { /// 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. +/// Implementations must preserve committee integrity after storage and use the +/// complete [`CommitteeCacheKey`] for every lookup. #[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>; + /// Returns the authenticated committee for `key`, when available. + async fn committee(&self, key: CommitteeCacheKey) -> Result, CommitteeCacheError>; - /// Stores a committee after the resolver has authenticated it. - async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError>; + /// Stores a committee under `key` after the resolver has authenticated it. + async fn store(&self, key: CommitteeCacheKey, committee: &Committee) -> Result<(), CommitteeCacheError>; } diff --git a/poi-rs/src/cache/in_memory.rs b/poi-rs/src/cache/in_memory.rs index 27b7d4f9..aee712d3 100644 --- a/poi-rs/src/cache/in_memory.rs +++ b/poi-rs/src/cache/in_memory.rs @@ -4,15 +4,17 @@ use std::collections::BTreeMap; use std::sync::Arc; -use iota_types::committee::{Committee, EpochId}; +use iota_types::committee::Committee; +#[cfg(test)] +use iota_types::committee::EpochId; use tokio::sync::RwLock; -use super::{CommitteeCache, CommitteeCacheError}; +use super::{CommitteeCache, CommitteeCacheError, CommitteeCacheKey}; /// In-memory committee cache for application use and tests. #[derive(Clone, Debug, Default)] pub struct MemoryCommitteeCache { - committees: Arc>>, + committees: Arc>>, } impl MemoryCommitteeCache { @@ -34,19 +36,23 @@ impl MemoryCommitteeCache { #[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 committee(&self, key: CommitteeCacheKey) -> Result, CommitteeCacheError> { + Ok(self.committees.read().await.get(&key).cloned()) } - async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> { + async fn store(&self, key: CommitteeCacheKey, committee: &Committee) -> Result<(), CommitteeCacheError> { let epoch = committee.epoch; + if key.epoch() != epoch { + return Err(CommitteeCacheError::Conflict { epoch }); + } + let mut committees = self.committees.write().await; - if committees.get(&epoch).is_some_and(|cached| cached != committee) { + if committees.get(&key).is_some_and(|cached| cached != committee) { return Err(CommitteeCacheError::Conflict { epoch }); } - committees.entry(epoch).or_insert_with(|| committee.clone()); + committees.entry(key).or_insert_with(|| committee.clone()); Ok(()) } @@ -54,6 +60,8 @@ impl CommitteeCache for MemoryCommitteeCache { #[cfg(test)] mod tests { + use iota_types::digests::ChainIdentifier; + use super::*; fn committee_at(epoch: EpochId) -> Committee { @@ -62,23 +70,36 @@ mod tests { Committee::new(epoch, committee.voting_rights.iter().cloned().collect()) } + fn chain_identifier(byte: u8) -> ChainIdentifier { + ChainIdentifier::from(iota_sdk_types::CheckpointDigest::new([byte; 32])) + } + + fn key(chain_identifier: ChainIdentifier, epoch: EpochId) -> CommitteeCacheKey { + CommitteeCacheKey::new(chain_identifier, epoch) + } + #[tokio::test] async fn new_cache_is_empty() { let cache = MemoryCommitteeCache::new(); + let chain_identifier = chain_identifier(1); assert!(cache.is_empty().await); assert_eq!(cache.len().await, 0); - assert!(cache.committee(7).await.unwrap().is_none()); + assert!(cache.committee(key(chain_identifier, 7)).await.unwrap().is_none()); } #[tokio::test] async fn store_makes_a_committee_available_by_epoch() { let cache = MemoryCommitteeCache::new(); + let chain_identifier = chain_identifier(1); let committee = committee_at(7); - cache.store(&committee).await.unwrap(); + cache.store(key(chain_identifier, 7), &committee).await.unwrap(); - assert_eq!(cache.committee(7).await.unwrap(), Some(committee)); + assert_eq!( + cache.committee(key(chain_identifier, 7)).await.unwrap(), + Some(committee) + ); assert_eq!(cache.len().await, 1); assert!(!cache.is_empty().await); } @@ -86,27 +107,32 @@ mod tests { #[tokio::test] async fn storing_the_same_committee_is_idempotent() { let cache = MemoryCommitteeCache::new(); + let chain_identifier = chain_identifier(1); let committee = committee_at(7); - cache.store(&committee).await.unwrap(); - cache.store(&committee).await.unwrap(); + cache.store(key(chain_identifier, 7), &committee).await.unwrap(); + cache.store(key(chain_identifier, 7), &committee).await.unwrap(); - assert_eq!(cache.committee(7).await.unwrap(), Some(committee)); + assert_eq!( + cache.committee(key(chain_identifier, 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 chain_identifier = chain_identifier(1); 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(); + cache.store(key(chain_identifier, 7), &original).await.unwrap(); - let error = cache.store(&conflicting).await.unwrap_err(); + let error = cache.store(key(chain_identifier, 7), &conflicting).await.unwrap_err(); assert!(matches!(error, CommitteeCacheError::Conflict { epoch: 7 })); - assert_eq!(cache.committee(7).await.unwrap(), Some(original)); + assert_eq!(cache.committee(key(chain_identifier, 7)).await.unwrap(), Some(original)); assert_eq!(cache.len().await, 1); } @@ -114,10 +140,39 @@ mod tests { async fn clones_share_cached_committees() { let cache = MemoryCommitteeCache::new(); let clone = cache.clone(); + let chain_identifier = chain_identifier(1); let committee = committee_at(7); - cache.store(&committee).await.unwrap(); + cache.store(key(chain_identifier, 7), &committee).await.unwrap(); + + assert_eq!( + clone.committee(key(chain_identifier, 7)).await.unwrap(), + Some(committee) + ); + } + + #[tokio::test] + async fn the_same_epoch_is_isolated_between_networks() { + let cache = MemoryCommitteeCache::new(); + let first_chain = chain_identifier(1); + let second_chain = chain_identifier(2); + let committee = committee_at(7); - assert_eq!(clone.committee(7).await.unwrap(), Some(committee)); + cache + .store(key(first_chain, committee.epoch), &committee) + .await + .unwrap(); + + assert_eq!( + cache.committee(key(first_chain, committee.epoch)).await.unwrap(), + Some(committee.clone()) + ); + assert!( + cache + .committee(key(second_chain, committee.epoch)) + .await + .unwrap() + .is_none() + ); } } diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 77686db5..eb2395d2 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::CheckpointContents; use iota_types::committee::{Committee, EpochId}; +use iota_types::digests::ChainIdentifier; use iota_types::effects::{TransactionEffects, TransactionEvents}; use iota_types::error::IotaError; use iota_types::iota_system_state::{IotaSystemStateTrait, get_iota_system_state}; @@ -17,8 +18,8 @@ use iota_types::transaction::Transaction; use serde::Deserialize; use crate::{ - BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Proof, ProofVerifier, Source, VerifiedProof, - VerifyError, + BoxError, CommitteeCache, CommitteeCacheError, CommitteeCacheKey, MemoryCommitteeCache, Proof, ProofVerifier, + Source, VerifiedProof, VerifyError, }; /// Error returned when a committee cannot be resolved for an epoch. @@ -164,7 +165,10 @@ pub enum CommitteeResolution { Anchored { /// First committee trusted by the caller. committee: Committee, - /// Cache containing only committees authenticated for the same network. + /// Trusted network identity used to namespace a shared cache, or `None` + /// when the cache is private to this resolution. + chain_identifier: Option, + /// Cache containing authenticated successor committees. cache: Arc, }, } @@ -174,16 +178,25 @@ impl CommitteeResolution { /// /// Authenticated committees are retained in a fresh in-memory cache. pub fn anchored(committee: Committee) -> Self { - Self::anchored_with_cache(committee, MemoryCommitteeCache::new()) + Self::Anchored { + committee, + chain_identifier: None, + cache: Arc::new(MemoryCommitteeCache::new()), + } } /// Anchors committee resolution using a caller-provided committee cache. /// - /// The cache is part of the caller's trust boundary and must return only - /// committees authenticated for the same network. - pub fn anchored_with_cache(committee: Committee, cache: impl CommitteeCache + 'static) -> Self { + /// `chain_identifier` must be the trusted genesis checkpoint digest for the + /// network containing `committee`. It namespaces entries in the shared cache. + pub fn anchored_with_cache( + chain_identifier: ChainIdentifier, + committee: Committee, + cache: impl CommitteeCache + 'static, + ) -> Self { Self::Anchored { committee, + chain_identifier: Some(chain_identifier), cache: Arc::new(cache), } } @@ -199,7 +212,7 @@ impl CommitteeResolution { /// Anchors committee resolution from a trusted genesis blob using a caller-provided cache. /// /// The reader must contain the BCS-encoded `genesis.blob` for the proof's - /// network. The cache is part of the caller's trust boundary. + /// network. Cache entries are scoped automatically to the genesis checkpoint digest. pub fn from_genesis_with_cache( reader: impl Read, cache: impl CommitteeCache + 'static, @@ -219,6 +232,7 @@ impl CommitteeResolution { bcs::from_reader(reader).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { source: Box::new(source), })?; + let chain_identifier = ChainIdentifier::from(*genesis.checkpoint.digest()); let objects = genesis.objects.as_slice(); let system_state = get_iota_system_state(&objects).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { @@ -226,7 +240,7 @@ impl CommitteeResolution { })?; let committee = system_state.get_current_epoch_committee().committee().clone(); - Ok(Self::anchored_with_cache(committee, cache)) + Ok(Self::anchored_with_cache(chain_identifier, committee, cache)) } } @@ -261,8 +275,13 @@ where pub async fn resolve(&self, target_epoch: EpochId) -> Result { match &self.mode { CommitteeResolution::TrustedNode => self.resolve_from_node(target_epoch).await, - CommitteeResolution::Anchored { committee, cache } => { - self.resolve_from_anchor(committee, cache.as_ref(), target_epoch).await + CommitteeResolution::Anchored { + committee, + chain_identifier, + cache, + } => { + self.resolve_from_anchor(committee, *chain_identifier, cache.as_ref(), target_epoch) + .await } } } @@ -301,6 +320,7 @@ where async fn resolve_from_anchor( &self, trusted_committee: &Committee, + chain_identifier: Option, cache: &dyn CommitteeCache, target_epoch: EpochId, ) -> Result { @@ -317,7 +337,8 @@ where return Ok(trusted_committee.clone()); } - if let Some(committee) = cache.committee(target_epoch).await.map_err(|source| { + let target_key = Self::cache_key(chain_identifier, target_epoch); + if let Some(committee) = cache.committee(target_key).await.map_err(|source| { CommitteeResolutionError::new( target_epoch, CommitteeResolutionErrorKind::Cache { @@ -343,7 +364,8 @@ where while committee.epoch < target_epoch { let next_epoch = committee.epoch + 1; - let Some(cached) = cache.committee(next_epoch).await.map_err(|source| { + let next_key = Self::cache_key(chain_identifier, next_epoch); + let Some(cached) = cache.committee(next_key).await.map_err(|source| { CommitteeResolutionError::new( target_epoch, CommitteeResolutionErrorKind::Cache { @@ -382,7 +404,9 @@ where } while committee.epoch < target_epoch { - let next_committee = self.fetch_next_committee(target_epoch, &committee, cache).await?; + let next_committee = self + .fetch_next_committee(target_epoch, chain_identifier, &committee, cache) + .await?; committee = next_committee; } @@ -411,6 +435,7 @@ where async fn fetch_next_committee( &self, target_epoch: EpochId, + chain_identifier: Option, current_committee: &Committee, cache: &dyn CommitteeCache, ) -> Result { @@ -483,7 +508,8 @@ where .next_epoch_committee; let next_committee = Committee::from_committee_members(next_epoch, next_epoch_committee); - cache.store(&next_committee).await.map_err(|source| { + let cache_key = Self::cache_key(chain_identifier, next_committee.epoch); + cache.store(cache_key, &next_committee).await.map_err(|source| { CommitteeResolutionError::new( target_epoch, CommitteeResolutionErrorKind::Cache { @@ -495,6 +521,13 @@ where Ok(next_committee) } + + fn cache_key(chain_identifier: Option, epoch: EpochId) -> CommitteeCacheKey { + chain_identifier.map_or_else( + || CommitteeCacheKey::isolated(epoch), + |chain| CommitteeCacheKey::new(chain, epoch), + ) + } } #[cfg(feature = "native-grpc")] @@ -510,7 +543,7 @@ mod tests { use std::sync::Mutex; use iota_sdk_types::gas::GasCostSummary; - use iota_sdk_types::{CheckpointSummary, EndOfEpochData, ObjectId, TransactionDigest, Version}; + use iota_sdk_types::{CheckpointDigest, CheckpointSummary, EndOfEpochData, ObjectId, TransactionDigest, Version}; use iota_types::digests::ChainIdentifier; use iota_types::messages_checkpoint::CertifiedCheckpointSummary; use iota_types::object::Object; @@ -519,6 +552,7 @@ mod tests { use crate::{SourceCheckpoint, SourceError, SourceTransaction}; struct StaticCache { + key: CommitteeCacheKey, committee: Committee, } @@ -553,7 +587,7 @@ mod tests { } async fn current_epoch(&self) -> Result, SourceError> { - unreachable!("direct committee transition test does not resolve the current epoch") + Ok(Some(self.summary.epoch().saturating_add(1))) } async fn epoch_close_summary( @@ -577,11 +611,11 @@ mod tests { #[async_trait::async_trait] impl CommitteeCache for RecordingCache { - async fn committee(&self, _epoch: EpochId) -> Result, CommitteeCacheError> { + async fn committee(&self, _key: CommitteeCacheKey) -> Result, CommitteeCacheError> { Ok(None) } - async fn store(&self, committee: &Committee) -> Result<(), CommitteeCacheError> { + async fn store(&self, _key: CommitteeCacheKey, committee: &Committee) -> Result<(), CommitteeCacheError> { self.stored.lock().unwrap().push(committee.clone()); Ok(()) } @@ -589,22 +623,58 @@ mod tests { #[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 committee(&self, key: CommitteeCacheKey) -> Result, CommitteeCacheError> { + Ok((self.key == key).then(|| self.committee.clone())) } - async fn store(&self, _committee: &Committee) -> Result<(), CommitteeCacheError> { + async fn store(&self, _key: CommitteeCacheKey, _committee: &Committee) -> Result<(), CommitteeCacheError> { Ok(()) } } + fn chain_identifier(byte: u8) -> ChainIdentifier { + ChainIdentifier::from(CheckpointDigest::new([byte; 32])) + } + 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(); + signed_end_of_epoch_summary_from_test_committee( + current_epoch, + include_next_committee, + base_committee, + keypairs, + 5, + ) + } + + fn signed_end_of_epoch_summary_with_sizes( + current_epoch: EpochId, + include_next_committee: bool, + current_committee_size: usize, + next_committee_size: usize, + ) -> (Committee, Committee, CertifiedCheckpointSummary) { + let (base_committee, keypairs) = Committee::new_simple_test_committee_of_size(current_committee_size); + signed_end_of_epoch_summary_from_test_committee( + current_epoch, + include_next_committee, + base_committee, + keypairs, + next_committee_size, + ) + } + + fn signed_end_of_epoch_summary_from_test_committee( + current_epoch: EpochId, + include_next_committee: bool, + base_committee: Committee, + keypairs: Vec, + next_committee_size: usize, + ) -> (Committee, Committee, CertifiedCheckpointSummary) { 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_base_committee, _) = Committee::new_simple_test_committee_of_size(next_committee_size); let next_committee = Committee::new( current_epoch.saturating_add(1), next_base_committee.voting_rights.iter().cloned().collect(), @@ -643,7 +713,7 @@ mod tests { ); let committee = resolver - .fetch_next_committee(4, ¤t_committee, &cache) + .fetch_next_committee(4, None, ¤t_committee, &cache) .await .unwrap(); @@ -663,7 +733,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, &wrong_committee, &cache) + .fetch_next_committee(4, None, &wrong_committee, &cache) .await .unwrap_err(); @@ -688,7 +758,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, ¤t_committee, &cache) + .fetch_next_committee(4, None, ¤t_committee, &cache) .await .unwrap_err(); @@ -711,7 +781,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, &wrong_committee, &cache) + .fetch_next_committee(4, None, &wrong_committee, &cache) .await .unwrap_err(); @@ -733,7 +803,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, &expected_committee, &cache) + .fetch_next_committee(4, None, &expected_committee, &cache) .await .unwrap_err(); @@ -758,7 +828,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(EpochId::MAX, ¤t_committee, &cache) + .fetch_next_committee(EpochId::MAX, None, ¤t_committee, &cache) .await .unwrap_err(); @@ -777,7 +847,10 @@ mod tests { let CommitteeResolution::Anchored { cache, .. } = &resolver.mode else { panic!("anchor resolver must have a committee cache"); }; - cache.store(&next_committee).await.unwrap(); + cache + .store(CommitteeCacheKey::isolated(next_committee.epoch), &next_committee) + .await + .unwrap(); let resolved = resolver.resolve(4).await.unwrap(); @@ -787,17 +860,54 @@ mod tests { #[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 chain_identifier = chain_identifier(1); let cache = StaticCache { + key: CommitteeCacheKey::new(chain_identifier, next_committee.epoch), committee: next_committee.clone(), }; let client = GrpcClient::new("http://127.0.0.1:1").unwrap(); let resolver = CommitteeResolver::new( client, - CommitteeResolution::anchored_with_cache(current_committee, cache), + CommitteeResolution::anchored_with_cache(chain_identifier, current_committee, cache), ); let resolved = resolver.resolve(4).await.unwrap(); assert_eq!(resolved, next_committee); } + + #[tokio::test] + async fn shared_cache_isolated_between_distinct_networks() { + let (_, first_successor, _) = signed_end_of_epoch_summary(3, true); + let (second_anchor, second_successor, second_summary) = signed_end_of_epoch_summary_with_sizes(3, true, 6, 6); + let first_chain = chain_identifier(1); + let second_chain = chain_identifier(2); + let cache = MemoryCommitteeCache::new(); + cache + .store( + CommitteeCacheKey::new(first_chain, first_successor.epoch), + &first_successor, + ) + .await + .unwrap(); + let resolver = CommitteeResolver::new( + EpochCloseSource { + summary: second_summary, + }, + CommitteeResolution::anchored_with_cache(second_chain, second_anchor.clone(), cache.clone()), + ); + + let resolved = resolver.resolve(4).await.unwrap(); + + assert_eq!(resolved, second_successor); + assert_ne!(resolved, first_successor); + assert_eq!(cache.len().await, 2); + assert_eq!( + cache + .committee(CommitteeCacheKey::new(second_chain, resolved.epoch)) + .await + .unwrap(), + Some(resolved) + ); + } } diff --git a/poi-rs/src/lib.rs b/poi-rs/src/lib.rs index 57d3c753..5042e8d3 100644 --- a/poi-rs/src/lib.rs +++ b/poi-rs/src/lib.rs @@ -21,7 +21,7 @@ pub mod proof; pub mod source; pub use builder::{ProofBuilder, ProofBuilderError}; -pub use cache::{CommitteeCache, CommitteeCacheError, MemoryCommitteeCache}; +pub use cache::{CommitteeCache, CommitteeCacheError, CommitteeCacheKey, MemoryCommitteeCache}; pub use client::PoiClient; pub use committee::{ CommitteeResolution, CommitteeResolutionError, CommitteeResolutionErrorKind, CommitteeResolver, diff --git a/poi-rs/tests/committee_resolution.rs b/poi-rs/tests/committee_resolution.rs index 3919d740..407e5ac9 100644 --- a/poi-rs/tests/committee_resolution.rs +++ b/poi-rs/tests/committee_resolution.rs @@ -7,8 +7,11 @@ use std::fs::File; use iota_config::IOTA_GENESIS_FILENAME; use iota_grpc_client::Client as GrpcClient; -use poi_rs::{CommitteeCache, CommitteeResolution, CommitteeResolutionErrorKind, MemoryCommitteeCache, PoiClient}; -use utils::{advance_to_epoch, grpc_client, start_test_cluster}; +use poi_rs::{ + CommitteeCache, CommitteeCacheKey, CommitteeResolution, CommitteeResolutionErrorKind, MemoryCommitteeCache, + PoiClient, +}; +use utils::{advance_to_epoch, genesis_chain_identifier, grpc_client, start_test_cluster}; use crate::utils::committee_at; @@ -36,7 +39,7 @@ async fn genesis_anchored_client_authenticates_committee_across_epochs() { assert_eq!(resolved, expected[10]); assert_eq!( cache - .committee(10) + .committee(CommitteeCacheKey::new(genesis_chain_identifier(&cluster), 10)) .await .expect("caller-provided cache must remain readable"), Some(expected[10].clone()) From 770570153a07dd4545e30a71626849d68263aac1 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 11:57:33 +0300 Subject: [PATCH 05/19] chore: enhance committee resolution to validate genesis checkpoint and committee epoch --- 2026-08-27-poi-adversarial-review.md | 8 ++++-- poi-rs/src/committee.rs | 38 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/2026-08-27-poi-adversarial-review.md b/2026-08-27-poi-adversarial-review.md index 6f33cef8..d4fed909 100644 --- a/2026-08-27-poi-adversarial-review.md +++ b/2026-08-27-poi-adversarial-review.md @@ -33,6 +33,8 @@ Severity: **high** means a relying party can be misled or the verifier can be cr #### H1. Event target index truncated on wasm32: `event_seq = 2^32 + k` verifies as event `k` (soundness, confirmed) +**Status: Resolved.** Event sequence conversion is now checked with `usize::try_from`, so values outside the target architecture's index range are rejected instead of truncated. + **Proof.** [`poi-rs/src/proof.rs:445-446`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L445-L452): ```rust @@ -74,7 +76,7 @@ signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_b #### H4. `poi verify` prints only `valid` (ergonomics, hand-verified) -**Status: Addressed.** The CLI now prints the verified checkpoint, transaction and declared targets. Expectation flags were intentionally not added. +**Status: Resolved.** The CLI now prints the verified checkpoint, transaction and declared targets. Expectation flags were intentionally not added. **Proof.** [`poi-rs/src/bin/poi.rs:177`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177): `writeln!(io::stdout().lock(), "valid")`. The verifier checks the proof's internal consistency; the targets are whatever the prover put into `ProofTargets`. Nothing shows the operator the chain, epoch, checkpoint sequence number and timestamp, transaction digest, object references with versions, or event IDs, and there is no way to state an expectation (`--transaction`, `--object`, `--event`) that the proof must satisfy. The README's check list says ["an explicitly requested transaction matches the packaged transaction"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L148-L157), where "requested" means requested by the prover, which reinforces the misreading. @@ -102,7 +104,7 @@ signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_b #### M2. `verify()` returns `()`; callers read claims from the unverified proof; the WASM `Proof` exposes no verified content (API design, hand-verified) -**Status: Addressed.** Both verification entry points now return authenticated claims through `VerifiedProof`; the raw `Proof` remains explicitly unverified. +**Status: Resolved.** Both verification entry points now return authenticated claims through `VerifiedProof`; the raw `Proof` remains explicitly unverified. **Proof.** [`ProofVerifier::verify`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L340) and [`CommitteeResolver::verify`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L274) return unit. Every claim (targets, object contents, event content, checkpoint timestamp and number) is read afterwards from the same `Proof` value that was untrusted before the call, and no type distinguishes the two states. There is no accessor that returns the event content for an `EventID` target: the caller has to know that it lives at `transaction_proof().events.as_ref().unwrap().0[event_seq as usize]`, the same indexing the verifier gets wrong in H1. On the WASM side the `Proof` class ([`poi_wasm/src/proof.rs:96-127`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L96-L127)) exposes target IDs, versions and digests as strings and nothing else: no timestamp, no checkpoint number, no object body, no event body. @@ -134,6 +136,8 @@ signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_b #### M5. `from_genesis` accepts any six-field BCS blob and exposes no chain identity (robustness, disputed, resolved as medium) +**Status: Resolved.** Genesis-based resolution now requires epoch-zero checkpoint and committee data, verifies the checkpoint signature and contents against the extracted committee, and retains the checkpoint digest as the trusted `ChainIdentifier`. + **Proof.** [`committee.rs:206-228`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L228) decodes the blob, reads only `objects`, and discards `checkpoint` and the rest (`#[allow(dead_code)]` says so). There is no `epoch == 0` requirement, no verification of the genesis checkpoint against the extracted committee (iota-config's [`Genesis::checkpoint()`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-config/src/genesis.rs#L125-L130) does this lazily), and the genesis checkpoint digest is not surfaced. **Scenario.** A wrong-network or stale anchor fails closed at the epoch-0 handoff with "failed to verify epoch 0 end-of-epoch checkpoint N", which names neither the cause nor the anchor's network. A self-consistency check would not stop a deliberate attacker (who re-signs with their own keys), which is why the refuter called this low. It is medium because the missing chain identifier is what H2's pin and diagnostics need, and because a corrupted blob that still satisfies the asserts loads while one that does not panics inside `from_genesis` (a wasm trap in `CommitteeResolution.fromGenesis`). diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index eb2395d2..4dc0d309 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -52,6 +52,25 @@ pub enum CommitteeResolutionErrorKind { #[source] source: BoxError, }, + /// The trusted genesis checkpoint is not from epoch zero. + #[error("trusted genesis checkpoint has epoch {epoch}, expected epoch 0")] + UnexpectedGenesisCheckpointEpoch { + /// Epoch encoded in the genesis checkpoint. + epoch: EpochId, + }, + /// The committee extracted from the trusted genesis blob is not from epoch zero. + #[error("trusted genesis committee has epoch {epoch}, expected epoch 0")] + UnexpectedGenesisCommitteeEpoch { + /// Epoch encoded in the genesis committee. + epoch: EpochId, + }, + /// The trusted genesis checkpoint or its contents failed verification. + #[error("trusted genesis checkpoint failed verification")] + InvalidGenesisCheckpoint { + /// Checkpoint signature or contents verification failure. + #[source] + source: BoxError, + }, /// Fetching a committee directly from the trusted node failed. #[error("failed to fetch committee for epoch {epoch} from the trusted node")] FetchCommittee { @@ -232,13 +251,30 @@ impl CommitteeResolution { bcs::from_reader(reader).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { source: Box::new(source), })?; - let chain_identifier = ChainIdentifier::from(*genesis.checkpoint.digest()); + let checkpoint_epoch = genesis.checkpoint.epoch(); + if checkpoint_epoch != 0 { + return Err(CommitteeResolutionErrorKind::UnexpectedGenesisCheckpointEpoch { + epoch: checkpoint_epoch, + }); + } + let objects = genesis.objects.as_slice(); let system_state = get_iota_system_state(&objects).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { source: Box::new(source), })?; let committee = system_state.get_current_epoch_committee().committee().clone(); + if committee.epoch != 0 { + return Err(CommitteeResolutionErrorKind::UnexpectedGenesisCommitteeEpoch { epoch: committee.epoch }); + } + + genesis + .checkpoint + .verify_with_contents(&committee, Some(&genesis.checkpoint_contents)) + .map_err(|source| CommitteeResolutionErrorKind::InvalidGenesisCheckpoint { + source: Box::new(source), + })?; + let chain_identifier = ChainIdentifier::from(*genesis.checkpoint.digest()); Ok(Self::anchored_with_cache(chain_identifier, committee, cache)) } From fff7c0c3c48132195e872b0b04cd453bebd1c931 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 12:20:59 +0300 Subject: [PATCH 06/19] chore: stop tracking local audit --- 2026-08-27-poi-adversarial-review.md | 232 --------------------------- 1 file changed, 232 deletions(-) delete mode 100644 2026-08-27-poi-adversarial-review.md diff --git a/2026-08-27-poi-adversarial-review.md b/2026-08-27-poi-adversarial-review.md deleted file mode 100644 index d4fed909..00000000 --- a/2026-08-27-poi-adversarial-review.md +++ /dev/null @@ -1,232 +0,0 @@ -# Proof of Inclusion: adversarial review of `feat/poi-implementation` - -**Repository:** [iotaledger/notarization](https://github.com/iotaledger/notarization), branch `feat/poi-implementation` at [`3a69eb0`](https://github.com/iotaledger/notarization/commit/3a69eb0454304902daa4e576626d8900656108c5) (merge of [#331](https://github.com/iotaledger/notarization/pull/331)), integration PR [#305](https://github.com/iotaledger/notarization/pull/305) into `main` (+14205 / -86, 97 files, 77 commits of which 20 merges). -**Reviewed:** 2026-08-27. -**Upstream pins:** [`iota` v1.29.0](https://github.com/iotaledger/iota/tree/v1.29.0) (`iota-types`, `iota-config`) and [`iota-rust-sdk` `2f021d0`](https://github.com/iotaledger/iota-rust-sdk/tree/2f021d0556e47564e9b04bcd0b3e8347c41a0a26) (`iota-sdk-types`, `iota-sdk-grpc-types`, `iota-sdk-grpc-client`). All line links below point at these exact revisions. -**Method:** source reading of the branch, the pinned upstream crates, the [PR #305 review thread](https://github.com/iotaledger/notarization/pull/305/files) and all 15 feeder PRs. Nine lens-specific reviewers (verifier cryptography, committee light client, builder and source, WASM Rust side, WASM TypeScript side, CLI and trust anchor, tests and CI, docs and API, process and scope) produced 79 raw findings, deduplicated to 65. The 35 at medium or above went to a two-reviewer adversarial pass (a refuter and a reproducer). The pass completed for 11 before a session limit stopped it. The remaining 24 were verified by hand against the code. No Rust toolchain was available, so nothing was compiled or executed. - -## Verdict - -The cryptographic core is sound. No path was found that makes `ProofVerifier` accept a false transaction, false effects, false object content, or false event content, and the committee handoff in the anchored walk is the standard light-client construction, bound by epoch and signature. The design is clean: a transport-independent `Source`, an offline verifier that takes the committee as input, and an explicit trust decision (`TrustedNode` versus `Anchored`) at the API surface. - -The problems sit around that core. One real soundness bug on the WASM target (a 64-bit event index cast to 32-bit `usize`). Two panics reachable from an endpoint the design declares untrusted. A CLI whose trust anchor is an unpinned HTTPS download cached forever, and whose only output on success is the word `valid`. An API that returns `()` from `verify` and leaves every claim to be read from the unverified proof, with the WASM binding exposing no verified content at all. Examples that teach `TrustedNode` against public endpoints. And a process gap: all 15 feeder PRs were self-merged with zero reviews, so the [single approval on #305](https://github.com/iotaledger/notarization/pull/305#pullrequestreview-4991410006) ("It looks very well built, only nitpicks") is the only human review of ~14k lines of verifier code. - -**Recommendation.** Do not merge as is. Fix H1 and H3 before merge (both are small). Fix H2 and H4 before any release that ships the `poi` binary. Treat M1, M2, M3 and M7 as release blockers for the WASM package, since they define what a JavaScript relying party can and cannot learn from a verified proof. Everything else can be tracked. - -## What holds - -Checked and found correct. Listed so the reader knows what the findings do not say. - -- [`verify_with_contents`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/messages_checkpoint.rs#L267-L290) verifies the aggregate BLS signature against the supplied committee with stake-weighted quorum, binds the summary epoch to the signature epoch and the committee epoch in both directions ([`verify_authority_signatures`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/messages_checkpoint.rs#L253-L260)), uses the `CheckpointSummary` intent scope, and recomputes the contents digest from the supplied `CheckpointContents`. -- Effects are authenticated through the `(transaction digest, effects digest)` pair in the certified contents ([`proof.rs:386-394`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L386-L394)), the transaction through `transaction.digest() == effects.transaction_digest` ([`:380`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L380)), events through `effects.events_digest()` ([`:396-401`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L396-L401)). Cached digests inside `Envelope` are serde-skipped, so a prover cannot inject them through JSON. -- Object targets are matched on the full `(id, version, digest)` recomputed from the packaged object ([`proof.rs:468-476`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L468-L476)). The object digest covers contents, owner and `previous_transaction`, so object bodies cannot be swapped. -- The anchored walk checks the summary epoch against the current committee, requires `end_of_epoch_data`, verifies the signature with the current committee ([`committee.rs:467`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L467)) before reading the next committee, derives the next epoch from verified data, and stores to the cache only after verification ([`:482-484`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L482-L484)). A hostile source in `Anchored` mode can deny service but cannot advance the committee. -- The private `GenesisBlob` mirror ([`committee.rs:206-215`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L215)) matches iota-config's [`RawGenesis`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-config/src/genesis.rs#L261-L269) BCS layout field for field. -- The hand-mirrored `Versioned*` enums in the WASM crate ([`versioned.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/versioned.rs)) match the SDK's own [`iota_grpc_types::v1::versioned`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/versioned.rs#L9-L14), which the SDK's own decoder uses ([`object.rs:20-22`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/object.rs#L20-L22)), so the native and WASM decode paths agree today (M14 covers the maintenance risk). -- `u64`/`bigint` marshalling, ESM/CJS packaging, TLS defaults of the public endpoints, and the `Committee.fromJSON` stake-sum and duplicate checks are as documented. - -## Findings - -Severity: **high** means a relying party can be misled or the verifier can be crashed by a party the design treats as untrusted, or a documented workflow leads to a false sense of security. **medium** means a correctness, robustness or API defect with a concrete misuse. **low** is minor. Verification status per finding: _confirmed_ (refuter and reproducer both upheld it), _disputed_ (they disagreed, resolution given), or _hand-verified_ (checked against the code after the automated pass stopped). - -### High - -#### H1. Event target index truncated on wasm32: `event_seq = 2^32 + k` verifies as event `k` (soundness, confirmed) - -**Status: Resolved.** Event sequence conversion is now checked with `usize::try_from`, so values outside the target architecture's index range are rejected instead of truncated. - -**Proof.** [`poi-rs/src/proof.rs:445-446`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L445-L452): - -```rust -let event_index = event_id.event_seq as usize; -let Some(_) = events.get(event_index) else { /* EventSequenceOutOfBounds */ }; -``` - -`as` truncates. `poi-rs` is compiled for `wasm32-unknown-unknown` by the bindings ([`.cargo/config.toml`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/.cargo/config.toml#L1-L2), [`package.json:25`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/package.json#L25)), where `usize` is 32 bits. The builder gets this right ([`builder.rs:230-232`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L229-L236) uses `usize::try_from`). `TransactionEvents` is a `derive_more::Deref` newtype over `Vec` ([iota-sdk-types `events.rs:16-20`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-types/src/events.rs#L16-L20)), so nothing else bounds the index. `EventID.event_seq` is a `u64` serialized as a decimal string ([iota-types `event.rs:34-38`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/event.rs#L34-L38)), so any value round-trips through `Proof::from_json_slice`. The WASM `targets` getter re-emits the raw `u64` ([`poi_wasm/src/proof.rs:65-71`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L65-L71)). - -**Scenario.** A prover takes a genuine proof for a transaction with at least one event and sets `"eventSeq": "4294967296"` in `targets.events[0]`. `Proof.fromJSON(...).verify(committee)` in `poi_wasm` succeeds, `proof.targets.events[0].eventSequence` reads `4294967296n`, and `toJSON()` re-emits it. The same proof is rejected natively with `EventSequenceOutOfBounds`. An application that keys anything on the verified `EventID` (dedup, grant-once, audit records) can be fed `(T, k)`, `(T, 2^32+k)`, `(T, 2^33+k)` as distinct verified identifiers for one real event. Event content and transaction identity are not forgeable through this, which keeps it below critical. Existing tests cover only `event_seq: 1` on a one-event list ([`proof_verification.rs:115-133`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/proof_verification.rs#L115-L133)). - -**Fix.** Mirror the builder: `let Some(index) = usize::try_from(event_id.event_seq).ok() else { return Err(EventSequenceOutOfBounds) }`, or compare as `u64` against `events.len()`. Add verifier tests at `u32::MAX as u64 + 1` and `u64::MAX`, and make sure a test suite actually runs on wasm32 (M13). - -#### H2. The CLI trust anchor is an unpinned download, cached forever, never re-validated (security, confirmed) - -**Proof.** [`poi-rs/src/bin/poi.rs:243-268`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L243-L268): `load_genesis` fetches `https://dbfiles..iota.cafe/genesis.blob` with `reqwest::get(url).bytes()` ([`:258-264`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L258-L264)), writes the body to `/poi//genesis.blob`, and on every later run uses the file as is: [`if !path.is_file()`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L250) is the only check. No `error_for_status()`, no comparison of the blob's genesis checkpoint digest against a known chain identifier, no integrity check on reuse, and [`from_genesis`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L228) does not verify the genesis checkpoint against the committee it extracts. The help text says ["The genesis blob is the trust anchor"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L33-L34) and the README calls it ["a trusted genesis blob"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L186-L187). - -**Scenario.** Substitution: whoever serves a different body once (bucket or CDN compromise, `iota.cafe` DNS or CA compromise, a poisoned first run) or writes the cached file installs an attacker committee. Because `resolve_from_anchor` returns the anchor without any network call when the proof's epoch equals the anchor epoch ([`committee.rs:314-316`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L314-L316)), a fabricated epoch-0 proof then prints `valid` fully offline. For later epochs the attacker also needs to answer the gRPC epoch-close queries, and `--network` pins the endpoint to the official node (`--grpc-url` and `--network` are mutually exclusive, [`poi.rs:182-187`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L182-L187)), so a CDN-only compromise degrades to a permanent `InvalidEndOfEpochCheckpoint` failure rather than a false accept. Denial of service: a 404, 5xx or captive-portal HTML page is cached as `genesis.blob` and every later run fails in `bcs::from_reader` with "failed to load trusted genesis blob", with no mention of the cache path. - -Related (hand-verified): devnet regenesis makes the cached devnet blob stale with the same opaque failure and no recovery hint. A proof from another network, or `--genesis` for the wrong network, fails with a signature error that reads like a forged proof, because neither the blob's chain identifier nor `proof.chain()` is ever compared to anything ([`poi.rs:170-177`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177)). The examples know the chain identifiers ([`examples/poi/utils.rs:61-67`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L61-L67)) but only use them to pick a URL. - -**Fix.** `iota-types` already exports [`MAINNET_CHAIN_IDENTIFIER_BASE58` and `TESTNET_CHAIN_IDENTIFIER_BASE58`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/digests.rs#L20-L21). Compare `genesis.checkpoint.digest()` against the pinned identifier for `--network` on download and on every load from cache, call `error_for_status()`, refuse otherwise. Expose the genesis chain identifier from `CommitteeResolution::from_genesis*` and compare it to `proof.chain()` as a diagnostic gate (not a trust input) with a clear "proof declares chain X, anchor is chain Y" error. Key the devnet cache by chain identifier or do not offer a managed devnet blob. Document the cache path. - -#### H3. A malformed aggregate signature from an untrusted source panics the verifier (safety, confirmed with severity dispute) - -**Status: Unresolved — requires an upstream fix.** No local pre-validation workaround is being applied. - -**Proof.** [`poi-rs/src/source/grpc.rs:144-148`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L144-L148) and [`:201-205`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L201-L205) convert the SDK `SignedCheckpointSummary` with `.try_into()`, and the WASM [`decode_certified_summary`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L259-L275) does the same. That conversion ([iota-types `iota_sdk_types_conversions.rs:173-183`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/iota_sdk_types_conversions.rs#L173-L183)) always returns `Ok` but internally runs - -```rust -signature: crate::crypto::AggregateAuthoritySignature::from_bytes(signature.as_bytes()).unwrap(), -``` - -([`:203-220`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/iota_sdk_types_conversions.rs#L203-L220)). The SDK signature type is a raw 48-byte array ([iota-sdk-types `bls12381.rs:139`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-types/src/crypto/bls12381.rs#L139)) decoded from the wire with no curve check, and blst rejects bad encodings (48 zero bytes suffice). The `.map_err(SourceError::invalid_response)` on the conversion is dead code. - -**Scenario.** In `Anchored` mode the endpoint is untrusted by design, and the panic fires inside `fetch_next_committee` before any of its checks. The CLI (`current_thread` tokio, default unwind) exits with a Rust panic instead of a `SourceError`. In a tokio service the task unwinds (the cache uses `tokio::sync::RwLock`, so no poisoning). On wasm32 it is an `unreachable` trap surfaced as a `RuntimeError`, and the in-instance committee cache is lost. The refuter downgraded this to medium on the grounds that an endpoint able to trigger it can already deny service by returning nothing. That is true, and the marginal harm is panic versus error, but the rubric's high tier covers a crash in the verifier path by a declared-untrusted party, and the same unwrap is reachable on the builder side through `Source::checkpoint`. High, with the understanding that the exploit value is availability only. - -**Required fix.** Make the upstream `SignedCheckpointSummary` conversion genuinely fallible by replacing its internal `unwrap()` with error propagation. This finding remains unresolved until the Notarization Toolkit adopts an upstream revision containing that fix. - -#### H4. `poi verify` prints only `valid` (ergonomics, hand-verified) - -**Status: Resolved.** The CLI now prints the verified checkpoint, transaction and declared targets. Expectation flags were intentionally not added. - -**Proof.** [`poi-rs/src/bin/poi.rs:177`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177): `writeln!(io::stdout().lock(), "valid")`. The verifier checks the proof's internal consistency; the targets are whatever the prover put into `ProofTargets`. Nothing shows the operator the chain, epoch, checkpoint sequence number and timestamp, transaction digest, object references with versions, or event IDs, and there is no way to state an expectation (`--transaction`, `--object`, `--event`) that the proof must satisfy. The README's check list says ["an explicitly requested transaction matches the packaged transaction"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L148-L157), where "requested" means requested by the prover, which reinforces the misreading. - -**Scenario.** A counterparty says "this proves object X was at version 7 in transaction T" and hands over a valid proof for a different object, an older version, or their own unrelated transaction. The operator runs the documented command, sees `valid`, and accepts the claim. This is the default CLI workflow the README documents. - -**Resolution.** On success, print a structured summary containing the authenticated epoch, checkpoint number and timestamp, transaction digest, each targeted object reference and each targeted event ID. The proof-reported chain is omitted because verification does not authenticate it. Reword the README checks to describe targets declared by the proof. No expectation-matching interface is included. - -#### H5. All 15 feeder PRs were self-merged with zero reviews (process, hand-verified) - -**Proof.** `gh pr view --json author,mergedBy,reviews` on [#306](https://github.com/iotaledger/notarization/pull/306), [#307](https://github.com/iotaledger/notarization/pull/307), [#308](https://github.com/iotaledger/notarization/pull/308), [#309](https://github.com/iotaledger/notarization/pull/309), [#310](https://github.com/iotaledger/notarization/pull/310), [#316](https://github.com/iotaledger/notarization/pull/316), [#317](https://github.com/iotaledger/notarization/pull/317), [#318](https://github.com/iotaledger/notarization/pull/318), [#320](https://github.com/iotaledger/notarization/pull/320), [#322](https://github.com/iotaledger/notarization/pull/322), [#323](https://github.com/iotaledger/notarization/pull/323), [#328](https://github.com/iotaledger/notarization/pull/328), [#329](https://github.com/iotaledger/notarization/pull/329), [#330](https://github.com/iotaledger/notarization/pull/330) and [#331](https://github.com/iotaledger/notarization/pull/331) shows author and merger `itsyaasir` and an empty reviews array for every one. #323 alone added 9014 lines. PR #305 carries one approving reviewer, UMR1352 ([approved 2026-08-21](https://github.com/iotaledger/notarization/pull/305#pullrequestreview-4991410006), [re-approved 2026-08-26](https://github.com/iotaledger/notarization/pull/305#pullrequestreview-5028437682)), whose inline comments cover a [TypeScript builder API](https://github.com/iotaledger/notarization/pull/305#discussion_r3828738292), [`JsError`](https://github.com/iotaledger/notarization/pull/305#discussion_r3829022671), [`Debug` derives](https://github.com/iotaledger/notarization/pull/305#discussion_r3829525916), a [constructor signature](https://github.com/iotaledger/notarization/pull/305#discussion_r3829746066) and [proof versioning](https://github.com/iotaledger/notarization/pull/305#discussion_r3829791078); none touches `verify_v1`, the anchored walk, or the gRPC decoding. The PR body of #305 and of every feeder PR is the untouched template. The error-model rework was [deferred to "a separate ticket"](https://github.com/iotaledger/notarization/pull/305#discussion_r3860375032) and no such issue exists in the repository. - -**Fix.** A second review of `poi-rs/src/proof.rs`, `committee.rs`, `source/grpc.rs` and `bindings/wasm/poi_wasm/src/source.rs` by someone with a consensus or cryptography background before merge. A filled PR body (design summary, threat model, test narrative). Branch protection on `feat/*` integration branches if the feeder-PR structure is kept. - -### Medium - -#### M1. A failed transaction verifies like a successful one (docs and API, confirmed) - -**Status: Not accepted as an issue.** Proof of Inclusion intentionally proves checkpoint inclusion regardless of whether the included transaction succeeded or failed. Transaction outcome is outside this feature's contract. - -**Proof.** Checkpoints include transactions whose execution failed; their effects carry `ExecutionStatus::Failure` and still have valid execution digests ([iota-sdk-types `execution_status.rs:27-32`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-types/src/execution_status.rs#L27-L32): "Failed transactions are still committed to the blockchain"). [`verify_transaction_proof` and `verify_targets`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L372-L419) never read `effects.status()`, the README's [eight verification checks](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L148-L157) do not mention it, and the WASM `Proof` exposes only `version`, `checkpointEpoch` and `targets` ([`poi_wasm/src/proof.rs:96-127`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L96-L127)). - -**Scenario.** A prover submits a notarization transaction that aborts on chain and is checkpointed; the packaged transaction body still shows the intended Move call; `verify()` returns `Ok`. Inclusion is true, so this is not a soundness bug, but the toolkit is pitched as evidence for notarization activity and a JavaScript relying party has no accessor for the status and no hint to look. Object targets are limited to the smashed gas coin for a failed transaction, and event targets cannot verify because a failed transaction emits no events, so the plain transaction target is the exposed case. - -**Disposition.** No code or API change. A checkpointed failed transaction remains a valid inclusion proof. - -#### M2. `verify()` returns `()`; callers read claims from the unverified proof; the WASM `Proof` exposes no verified content (API design, hand-verified) - -**Status: Resolved.** Both verification entry points now return authenticated claims through `VerifiedProof`; the raw `Proof` remains explicitly unverified. - -**Proof.** [`ProofVerifier::verify`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L340) and [`CommitteeResolver::verify`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L274) return unit. Every claim (targets, object contents, event content, checkpoint timestamp and number) is read afterwards from the same `Proof` value that was untrusted before the call, and no type distinguishes the two states. There is no accessor that returns the event content for an `EventID` target: the caller has to know that it lives at `transaction_proof().events.as_ref().unwrap().0[event_seq as usize]`, the same indexing the verifier gets wrong in H1. On the WASM side the `Proof` class ([`poi_wasm/src/proof.rs:96-127`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/proof.rs#L96-L127)) exposes target IDs, versions and digests as strings and nothing else: no timestamp, no checkpoint number, no object body, no event body. - -**Scenario.** A Node.js relying party that needs the proven `LockedNotarizationCreated` payload has to fetch it again from the untrusted node. A Rust integrator holding two proofs verifies `proof_a` and reads `proof_b.targets()` after a refactor; the type system does not object. - -**Fix.** Return a `VerifiedProof<'a>` newtype from both `verify` functions with typed accessors (`transaction()`, `execution_status()`, `checkpoint_sequence_number()`, `timestamp_ms()`, `objects()`, `events() -> impl Iterator`). Keep `Proof::targets()` for inspection but document it as unverified. Expose at minimum timestamp, checkpoint number, object BCS or JSON and event content on the WASM side. - -**Resolution.** Rust verification returns a `must_use` `VerifiedProof<'a>` borrowing the authenticated checkpoint metadata, transaction data, transaction target, objects and event ID/content pairs from the input proof, plus the authenticated transaction digest. It does not expose the proof's packaged user signatures as verified because checkpoint inclusion does not authenticate those signature bytes. WASM verification returns an owned, read-only `VerifiedProof` snapshot that reuses `ProofTargets` for the authenticated target identities and exposes checkpoint metadata, the transaction digest, `objectBcs(index)`, and `eventContents(index)`. Execution status is intentionally omitted because M1 was not accepted as an issue: Proof of Inclusion authenticates checkpoint inclusion independently of transaction success. - -#### M3. Committee construction from node or JSON data panics on malformed input (safety, confirmed with severity dispute) - -**Status: Unresolved — requires an upstream fix.** The Notarization Toolkit will not duplicate `Committee` validation locally; this should be addressed by a fallible upstream committee constructor. - -**Proof.** Three paths build an `iota_types::Committee` from data that has not been signature-verified: the native `TrustedNode` fetch ([`grpc.rs:158-167`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L158-L167), `committee.into()`), the WASM [`decode_committee`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L277-L289) (`Committee::new` with only a key-length check), and [`Committee.fromJSON`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L37-L60) (checks duplicates and the stake sum but not key validity). [`Committee::new`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/committee.rs#L69-L78) asserts non-empty, some nonzero weight and `total == TOTAL_VOTING_POWER`, and [`load_inner`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/committee.rs#L150-L154) does `.expect("Validator pubkey is always verified on-chain")` on every key, so a 96-byte value that is not a valid G2 point panics. The WASM README says [`Committee.fromJSON()` "validates public keys"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/README.md#L96-L98); it validates their length. - -**Scenario.** On wasm32 the panic is a trap inside the wasm-bindgen-futures microtask: the caller's `await verifier.verify(proof)` never settles and later calls on the same resolver throw "recursive use of an object detected". An honest v1.29.0 node never returns an empty or unbalanced committee, so the trigger is a buggy or hostile node or proxy, or an operator's own `LedgerSource`. The anchored walk is safe: [`from_committee_members`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L482) runs only on quorum-signed data. - -**Fix.** One fallible constructor in `poi-rs` (non-empty, no duplicates, checked sum equals `TOTAL_VOTING_POWER`, `AuthorityPublicKey::try_from` per key) used by all three paths, with tests for an empty list, a wrong total and an off-curve key. Correct the README sentence. - -#### M4. The committee cache has no network or anchor binding (API design, disputed, resolved as medium) - -**Status: Resolved.** Shared cache keys now include the trusted `ChainIdentifier` (the genesis checkpoint digest) and epoch. Genesis-based resolution derives it from the trusted blob; committee-based resolution requires callers to supply it explicitly when sharing a cache. - -**Proof.** `resolve_from_anchor` returns whatever the cache holds for `target_epoch` after checking only that its `epoch` field matches ([`committee.rs:318-338`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L318-L338)), and the prefix walk adopts cached committees for `anchor + 1..` without checking they descend from the anchor ([`:342-368`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L342-L368)). `Committee` carries only epoch and voting rights, [`MemoryCommitteeCache`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache/in_memory.rs#L13-L16) is `Clone` over shared `Arc` state, and the [trait](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache.rs#L33-L45) offers no network handle. - -**Scenario.** An integrator who hands one backend to a mainnet-anchored and a testnet-anchored resolver gets, testnet first, a genuine testnet proof accepted as mainnet; mainnet first, testnet verification fails with a signature error that looks like a forged proof. The refuter is right that this requires violating a precondition documented in five places and that [README:145](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L145) and example 03 recommend reusing one verifier per network, not one cache across networks. The reproducer is right that the anchor is effectively bypassed whenever the cache is populated and that nothing at construction detects the mismatch. Rust library surface only; the WASM binding and the CLI always create a fresh in-memory cache. - -**Fix.** Namespace cache keys by anchor identity (genesis checkpoint digest, or a digest of the anchor's voting rights) inside `anchored_with_cache`, so two resolvers with different anchors cannot read each other's entries even when handed the same backend. - -#### M5. `from_genesis` accepts any six-field BCS blob and exposes no chain identity (robustness, disputed, resolved as medium) - -**Status: Resolved.** Genesis-based resolution now requires epoch-zero checkpoint and committee data, verifies the checkpoint signature and contents against the extracted committee, and retains the checkpoint digest as the trusted `ChainIdentifier`. - -**Proof.** [`committee.rs:206-228`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L206-L228) decodes the blob, reads only `objects`, and discards `checkpoint` and the rest (`#[allow(dead_code)]` says so). There is no `epoch == 0` requirement, no verification of the genesis checkpoint against the extracted committee (iota-config's [`Genesis::checkpoint()`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-config/src/genesis.rs#L125-L130) does this lazily), and the genesis checkpoint digest is not surfaced. - -**Scenario.** A wrong-network or stale anchor fails closed at the epoch-0 handoff with "failed to verify epoch 0 end-of-epoch checkpoint N", which names neither the cause nor the anchor's network. A self-consistency check would not stop a deliberate attacker (who re-signs with their own keys), which is why the refuter called this low. It is medium because the missing chain identifier is what H2's pin and diagnostics need, and because a corrupted blob that still satisfies the asserts loads while one that does not panics inside `from_genesis` (a wasm trap in `CommitteeResolution.fromGenesis`). - -**Fix.** Require `committee.epoch == 0` and `checkpoint.epoch() == 0`, run `verify_with_contents` on the genesis checkpoint as a corruption check, return a dedicated error kind for each, and expose the `ChainIdentifier` on the resolution. - -#### M6. The cold anchored walk is one sequential round trip per epoch with no retry, and the CLI never persists committees (performance, confirmed) - -**Proof.** [`committee.rs:382-385`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L382-L385) awaits `fetch_next_committee` once per epoch; each does one unary `get_epoch` (the SDK exposes no batch or list RPC for epochs) and one aggregate BLS verification, plus one `get_service_info` pre-flight at [`:374`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L374-L375) that only produces an earlier error. The CLI uses `from_genesis` with a fresh `MemoryCommitteeCache` ([`poi.rs:170`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L170-L177)) and persists only the genesis blob. [`examples/poi/advanced/01_committee_cache.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/advanced/01_committee_cache.rs) ships a file-backed cache that the CLI does not use. The reviewer's ["Epoch 469??"](https://github.com/iotaledger/notarization/pull/305#discussion_r3828832734) on 2026-08-21 gives the magnitude on a public network. - -**Scenario.** A cold `poi verify --network mainnet` is roughly 480 dependent round trips, tens of seconds to about two minutes depending on RTT. The SDK client has no retry, so one transient failure aborts with `FetchEpochHistory` and all progress is discarded. The realistic degradation is library integrators switching to `TrustedNode`. - -**Fix.** Prefetch `epoch_close_summary` with bounded concurrency (fetching depends only on the epoch number; verification stays sequential and the verify-then-store invariant holds), wire a file-backed cache into the CLI under the network's chain identifier, and drop or make optional the `current_epoch` pre-flight. - -#### M7. Examples 04 and 05 teach `TrustedNode` against public endpoints (docs, hand-verified) - -**Proof.** [`examples/poi/04_object_proof.rs:78`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/04_object_proof.rs#L78), [`05_event_proof.rs:77`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/05_event_proof.rs#L77), and the WASM examples [`04_object_proof.ts:48`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts#L48) and [`05_event_proof.ts:49`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts#L49) use `TrustedNode` on a client whose endpoint resolves to `https://grpc..iota.cafe:443` for known networks ([`utils.rs:273-275`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L273-L275)). A public Foundation endpoint is exactly the node the crate docs place [outside the caller's trust boundary](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L157-L161). - -**Scenario.** An integrator copies the example, keeps `TrustedNode`, points at a provider URL, and ships; the provider can then have any self-signed proof accepted. The success line asserts a verification result that in this mode depends entirely on the endpoint. - -**Fix.** Make 04 and 05 genesis-anchored like 01 to 03 (they already have `load_genesis`), move the `TrustedNode` demonstration to an `advanced/` example that refuses to run outside localnet, and print the resolution mode in the success line. - -#### M8. What an object or event target proves is not stated; "object state" reads as current state (docs, hand-verified) - -**Proof.** [`README.md:8`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L8-L10) promises "cryptographic evidence for a transaction, event, or object state" and [`:167`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L167) says "Object targets contain their exact object values", but no text says what a successful verification lets the caller believe per target kind. The code proves, for an object, that `(id, version, digest)` is among the objects written by that transaction ([`all_changed_objects()`](https://github.com/iotaledger/iota/blob/v1.29.0/crates/iota-types/src/effects/mod.rs#L527-L542) = mutated + created + unwrapped), not that it is the current version. `ProofBuilder::object()` without a transaction scope fetches the latest version at build time ([`builder.rs:199-207`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L196-L210)) and the CLI help says ["The source resolves its latest version"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/bin/poi.rs#L84). Deleted and wrapped objects can never be targets, and the builder error for them says ["was not changed by transaction"](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L51-L58), which is wrong for a deletion. - -**Fix.** A "What a verified proof proves" section with one paragraph per target kind, an explicit statement that object targets are historical writes, that deletion is unsupported, and that the builder's "latest" is a build-time choice. Reword the builder error. - -#### M9. Not-yet-checkpointed transactions and missing protobuf fields surface as generic or misleading errors (ergonomics, hand-verified) - -**Proof.** Native: a transaction executed but not yet in a checkpoint comes back with `checkpoint` unset, and [`grpc.rs:99-101`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L99-L101) maps the SDK's [`TryFromProtoError::missing("checkpoint")`](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/transaction.rs#L216-L221) to `SourceError::MissingData`, indistinguishable from a malformed response and with no typed variant to retry on. TypeScript: [`ledger-source.ts:93-104`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L93-L104) uses non-null assertions on every optional protobuf field (`transaction.transaction!.bcs!.data!`, `transaction.checkpoint!`, and so on throughout the file). An unset `checkpoint` becomes `undefined`, which serde rejects as "invalid response"; an absent `signatures` or `contents` object throws a raw `TypeError` that [`PoiError::from_js`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/error.rs#L50-L58) flattens into a source error carrying JavaScript engine text. - -**Fix.** Make `checkpoint_sequence_number` an `Option` on `SourceTransaction` and add `ProofBuilderError::TransactionNotCheckpointed { digest }`. Replace the assertions with a `required(value, path)` helper that throws a typed "response is missing " error. Poll for checkpoint inclusion in the examples. - -#### M10. The WASM `LedgerSource` throws on per-item `NOT_FOUND` where the native source returns `None` (ergonomics, confirmed with severity dispute) - -**Proof.** [`ledger-source.ts:69-71`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L69-L71) and [`:129-131`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L129-L131) throw on every per-item error status, including gRPC code 5. The native source maps `NOT_FOUND` to `Ok(None)` ([`grpc.rs:61-65`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source/grpc.rs#L61-L65)) so the builder reports `TransactionNotFound`; the WASM path reports `ProofBuilderError::Source`. The documented `undefined` return ([`source-types.ts:42-48`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/source-types.ts#L42-L48)) is unreachable against a real node, which answers a batch with one result per request, and the only test for absence ([`ledger-source.test.ts:205`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/tests/ledger-source.test.ts#L205-L220)) feeds an empty list the node never sends. The contract is internal and JavaScript receives a flat `Error` either way (M11), which is why the refuter called it low. Medium because the two shipped bindings classify the most common user mistake differently and the test pins a fictional wire shape. - -**Fix.** Return `undefined` for per-item code 5 in `transaction()` and `object()`, keep throwing for other codes, and add a test with a per-item `NOT_FOUND` result. - -#### M11. Every WASM failure collapses into one flat `Error` string (ergonomics, disputed, resolved as medium) - -**Proof.** [`error.rs:15-37`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/error.rs#L15-L37) concatenates the Rust source chain into one message and emits `JsError::new(&message)`. [`PoiError::from_js`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/error.rs#L50-L58) keeps only `.message` of the JavaScript error, so the ConnectRPC `code` is lost on the way in as well. The two outcomes a verifier must treat oppositely, "proof rejected" and "node unreachable", differ only in unversioned English prefixes. The reviewer [raised this](https://github.com/iotaledger/notarization/pull/305#discussion_r3829095936) and the author [deferred it to a ticket](https://github.com/iotaledger/notarization/pull/305#discussion_r3860375032) that does not exist. The refuter notes that `resolve(epoch)` and `Proof.verify(committee)` are exported separately, so an integrator can split the phases structurally. - -**Fix.** Until the error model lands, attach a stable `code` (`PROOF_INVALID`, `COMMITTEE_RESOLUTION`, `SOURCE_REQUEST`, `NOT_FOUND`) via a small error class in `poi-client.ts`, preserve the original JavaScript error as `cause`, and document in the README that only `PROOF_INVALID` means the proof was rejected. Open the ticket. - -#### M12. No committee persistence or cache hook in the WASM API; `Committee` has no `toJSON` (ergonomics, hand-verified) - -**Proof.** [`CommitteeCache: Send + Sync`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/cache.rs#L39) with `Send` futures, so a JavaScript-backed cache cannot implement it, and the bindings always create a fresh `MemoryCommitteeCache` ([`poi_wasm/src/committee.rs:85-97`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L85-L97)). `WasmCommittee` exposes only [`epoch`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/committee.rs#L62-L66), so a committee returned by `resolver.resolve(epoch)` cannot be stored and fed back through `Committee.fromJSON` and `anchored` as a newer anchor. Every cold start pays the full walk of M6, and the visible alternative in the examples is `trustedNode()`. - -**Fix.** Add `Committee.toJSON()` so a verified committee can be persisted and re-anchored, or make `CommitteeCache` `?Send` on wasm32 like `Source` and expose `anchoredWithCache(committee, jsCache)`. - -#### M13. Test gaps around exactly the checks that bind targets to evidence (tests, hand-verified) - -**Proof.** Untested in [`proof_verification.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/proof_verification.rs): [`TransactionTargetMismatch`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L411), [`MissingTarget`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L348), [`MissingEvents`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L431-L435), a signature failure at the `ProofVerifier` level (only a contents-digest tamper is tested), forged effects with the same transaction digest, and any event sequence at or above `2^32`. In `committee.rs`, the two cache epoch-consistency guards ([`:327`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L327-L335), [`:357-365`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L357-L365)) are never exercised, and the multi-epoch walk is covered only by a test-cluster integration test. Mutation reasoning: dropping the effects-digest half of the checkpoint membership comparison, the transaction-target check, or the cache guards all leave the suite green. The WASM crate's only Rust unit test ([`src/source.rs:307`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L307-L308)) cannot run as configured (the crate is excluded from the workspace, `.cargo/config.toml` forces wasm32, no `wasm-bindgen-test`), and it round-trips through the same mirrored enums it is meant to check. The TypeScript tests are happy-path only: no tampered proof, no wrong committee, no per-item error status. - -**Fix.** The three verifier tests are a few lines each. Add a mislabelled-cache test, a two-epoch mock walk, tests for `MissingEpochCloseProof`, `FetchCurrentEpoch` and a failing `store`, wasm32 execution of at least the verifier tests, and negative TypeScript tests. - -#### M14. Fixtures: undocumented single-validator localnet, epoch 0, rewritten five times along with the code (tests, hand-verified) - -**Proof.** [`poi-rs/tests/fixtures/current/`](https://github.com/iotaledger/notarization/tree/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/fixtures/current) comes from a localnet (chain `J3x3hNka...`, not any public network), epoch 0, one validator holding all 10 000 stake (`committee.json`), so quorum, signer bitmaps and aggregation over a subset are never exercised by a real artifact. The golden test ([`proof_serialization.rs:12-28`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/tests/proof_serialization.rs#L12-L28)) verifies with `ProofVerifier::new(&committee)` directly, so it proves round-trip against the producing node, not anchored verification. No README, script or comment says how the fixtures were produced, and `git log` shows five commits touching them since [#318](https://github.com/iotaledger/notarization/pull/318), including [`1fdae39`](https://github.com/iotaledger/notarization/commit/1fdae39e9f69a1ddc8300f20f33e49bde0d5305c) (titled as an examples refactor) renaming `content_digest` to `contents_digest`. [Issue #297](https://github.com/iotaledger/notarization/issues/297) (golden fixtures and compatibility tests) is still open. The mirrored `Versioned*` enums ([`versioned.rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/versioned.rs)) are correct today but will silently disagree with the SDK's [`#[non_exhaustive]` originals](https://github.com/iotaledger/iota-rust-sdk/blob/2f021d0556e47564e9b04bcd0b3e8347c41a0a26/crates/iota-sdk-grpc-types/src/proto/iota/grpc/v1/versioned.rs#L9-L14) when upstream adds a `V2`. - -**Fix.** A `fixtures/README.md` with provenance and a regeneration recipe, a frozen `fixtures/v1/` set that CI forbids modifying, a second set captured from testnet at an epoch above 0 with a multi-validator committee, verified both offline and through a recorded `Source` walk. Depend on `iota_grpc_types::v1::versioned` instead of mirroring it. - -#### M15. Scope and release hygiene (process, hand-verified) - -**Proof.** [`notarization-move/scripts/publish_package.sh`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/notarization-move/scripts/publish_package.sh) is rewritten in [`1fdae39`](https://github.com/iotaledger/notarization/commit/1fdae39e9f69a1ddc8300f20f33e49bde0d5305c) ("refactor Proof of Inclusion examples"): `set -eu`, a hard dependency on `iota client chain-identifier`, and for chain IDs `6364aad5` / `2304aa97` / `daf90477` (mainnet, testnet, devnet per [`examples/poi/utils.rs:61-64`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L61-L67)) it drops `--with-unpublished-dependencies`. Nothing in PoI uses the script; it is a drive-by with no rationale in any PR body. [`poi-rs`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/Cargo.toml#L3) is `0.1.0-alpha` with git-only dependencies and no `publish = false`, now inside the workspace members; whether `cargo release publish` handles that is open. `poi_wasm` is [`0.0.1` in `package.json`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/package.json#L3-L4) and `0.1.0-alpha` in `Cargo.toml`. The [gRPC schema lock](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/grpc/iota-schema.lock.json#L1-L8) pins iota-rust-sdk `b77fcd5` while Cargo pins `2f021d0` (proto files identical between the two, so no drift today). No CHANGELOG entry, PR template unfilled, license headers alternate between "2020-2026" and "2026" on brand-new files. - -**Fix.** Split the script change into its own PR with a rationale, decide the distribution channel for `poi-rs`, align the two versions, add the CHANGELOG entry. - -#### M16. WASM bundle built without `--weak-refs`; handles are never freed (performance, hand-verified) - -**Proof.** [`poi_wasm/package.json:27`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/package.json#L27) runs `wasm-bindgen ... --target nodejs` without `--weak-refs`, unlike [`notarization_wasm`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/notarization_wasm/package.json#L19) and [`audit_trail_wasm`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/audit_trail_wasm/package.json#L19). Without it there is no `FinalizationRegistry` hook, so `Proof`, `Committee`, `CommitteeResolver` and `ProofBuilder` instances are released only by an explicit `.free()`, which no README, example or `poi-client.ts` mentions. `Proof.fromJSON` allocates the full checkpoint contents. - -**Fix.** Add `--weak-refs` (Node 24 has `FinalizationRegistry`) and document `.free()` for hot paths. - -### Low - -- **User signatures in the packaged transaction are not authenticated.** `Transaction::digest()` covers the transaction data, not the signatures, and IOTA `CheckpointContents` carry the user signatures per transaction but the verifier never compares them ([`proof.rs:380`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/proof.rs#L378-L394)). A relying party reading `tx_signatures()` from a verified proof can be shown swapped signatures. Sender identity is inside the signed data and is covered. -- **Genesis anchoring inherits the long-range (weak subjectivity) assumption** and the docs do not say so ([`README.md:79-82`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L79-L82)). A verifier that trusts genesis trusts every historical committee not to have leaked keys. -- **[`from_genesis`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/committee.rs#L194) returns `CommitteeResolutionErrorKind`, not `CommitteeResolutionError`**, so it does not compose with the rest of the error surface. -- **[`Source::checkpoint`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/source.rs#L117) has no `Option`**, so an absent checkpoint is an opaque transport error. A historical object version pruned by the node is reported as `ObjectNotFound`. The builder does not re-check the latest-object path against the effects or the checkpoint sequence number against the request ([`builder.rs:196-221`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/src/builder.rs#L196-L221)); the verifier catches every case, so this is a diagnostics gap for custom `Source` authors, and [`README.md:49`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L49) overclaims what the builder checks. -- **The TypeScript checkpoint sequence-number guard is inert**: the read mask omits `checkpoint.sequence_number`, so `value.sequenceNumber` is undefined ([`ledger-source.ts:174-181`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/lib/ledger-source.ts#L174-L181)). The native source has no such guard at all. -- **[`Uint8Array::new(&value)`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/bindings/wasm/poi_wasm/src/source.rs#L145) on an arbitrary JavaScript value coerces instead of rejecting.** BCS payloads cross the boundary as `Vec` through `serde_wasm_bindgen`, which likely iterates byte by byte (unverified; `serde_bytes::ByteBuf` would take the fast path). -- **Debug is still missing** on `PoiClient`, `ProofBuilder`, `CommitteeResolution` and `CommitteeResolver` after the reviewer [asked for it](https://github.com/iotaledger/notarization/pull/305#discussion_r3829525916). -- **Three names for one concept** (targets, requests, claims) across `builder.rs`, `proof.rs` and the READMEs. The README's verification list does not match the verifier exactly. `Proof` JSON stability is unspecified: the `ProofV1` tag does not pin the upstream serde encodings it embeds. The API-docs link and the `tree/main` links resolve only after merge and a docs deploy. -- **CI:** in [`build-and-test.yml`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/.github/workflows/build-and-test.yml), `test-wasm-notarization` now `needs: build-wasm-poi` and moved to Node 24, so a PoI build failure blocks the unrelated notarization tests. Nothing in CI executes the verifier on wasm32. `cargo test --workspace` now pulls the full IOTA node through `test-cluster` for every workspace test build. -- **Examples select the trusted genesis blob from the node-reported chain identifier** ([`utils.rs:61-67`](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/examples/poi/utils.rs#L61-L67)), which is fine for choosing a URL but is the untrusted input the [README](https://github.com/iotaledger/notarization/blob/3a69eb0454304902daa4e576626d8900656108c5/poi-rs/README.md#L181-L182) tells integrators not to use for anchor selection. - -## Limits of this review - -No code was compiled or run. The wasm32 truncation in H1 rests on Rust cast semantics and the target's pointer width, not on an observed run. The byte-by-byte marshalling claim and the rate-limit consequence in M6 are stated as likely, not measured. CI logs could not be downloaded, so "what CI runs" is inferred from the workflow files. Whether `iota-sdk-grpc-client` and `iota-sdk-grpc-types` are on crates.io was not checked (M15). The automated adversarial pass stopped after 11 of 35 findings; the remaining 24 were checked against the code by hand and are marked as such. From d8157e78f7c48c2fc7045f780c427a2903fd56b0 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 13:08:59 +0300 Subject: [PATCH 07/19] chore: enhance examples and documentation for trusted-node resolution --- bindings/wasm/poi_wasm/examples/README.md | 15 ++++-- .../poi_wasm/examples/src/04_object_proof.ts | 21 ++++---- .../poi_wasm/examples/src/05_event_proof.ts | 22 ++++---- .../poi_wasm/examples/src/06_trusted_node.ts | 39 ++++++++++++++ .../wasm/poi_wasm/examples/src/examples.ts | 5 ++ examples/poi/04_object_proof.rs | 16 +++--- examples/poi/05_event_proof.rs | 16 +++--- examples/poi/Cargo.toml | 4 ++ examples/poi/README.md | 18 ++++--- examples/poi/advanced/02_trusted_node.rs | 52 +++++++++++++++++++ examples/poi/run.sh | 5 +- 11 files changed, 167 insertions(+), 46 deletions(-) create mode 100644 bindings/wasm/poi_wasm/examples/src/06_trusted_node.ts create mode 100644 examples/poi/advanced/02_trusted_node.rs diff --git a/bindings/wasm/poi_wasm/examples/README.md b/bindings/wasm/poi_wasm/examples/README.md index 7995fd22..78a29a6b 100644 --- a/bindings/wasm/poi_wasm/examples/README.md +++ b/bindings/wasm/poi_wasm/examples/README.md @@ -101,6 +101,12 @@ IOTA_GENESIS_PATH="$HOME/.iota/iota_config/genesis.blob" \ npm run example:node -- 02_multi_target_proof ``` +Run the trusted-node example against any active network without a genesis blob: + +```bash +npm run example:node -- 06_trusted_node +``` + Run against the active public faucet network. The utility funds the active CLI wallet and publishes Single Notarization when necessary: @@ -124,19 +130,20 @@ npm run example:node -- 05_event_proof | [03_reuse_verifier](./src/03_reuse_verifier.ts) | Two creation transactions while reusing one verifier and its committee cache. | | [04_object_proof](./src/04_object_proof.ts) | A freshly created `Notarization` object, starting from only its object ID. | | [05_event_proof](./src/05_event_proof.ts) | A fresh `LockedNotarizationCreated` event, starting from only its event ID. | +| [06_trusted_node](./src/06_trusted_node.ts) | A transaction proof verified through a trusted endpoint on any network. | ## Committee Trust The gRPC endpoint supplies transaction, checkpoint, and committee-transition evidence. That evidence remains untrusted until verification succeeds. -Examples 01, 02, and 03 use genesis-anchored committee resolution. The utility +Examples 01 through 05 use genesis-anchored committee resolution. The utility downloads and caches the official genesis blob for mainnet, testnet, and devnet. Set `IOTA_GENESIS_PATH` for localnet and custom networks. -Examples 04 and 05 use trusted-node resolution so they can focus on -target-driven object and event discovery. In this mode, the selected gRPC node -is inside the verifier's trust boundary. +Example 06 demonstrates trusted-node resolution on any active network without a +genesis blob. In this mode, the selected gRPC node is inside the verifier's +trust boundary and must be operated by a party the verifier trusts. Obtain custom genesis blobs independently, verify that each blob belongs to the selected network, and do not accept a trust anchor from the same untrusted party diff --git a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts index 28d945cf..449f39e3 100644 --- a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts @@ -9,27 +9,30 @@ * and transaction evidence into one proof. * * The discovered transaction supports the object claim but does not become an - * explicit transaction target. Trusted-node resolution keeps the example - * focused on object-driven discovery. + * explicit transaction target. Verification authenticates committee history + * from a trusted genesis blob. */ import { strict as assert } from "node:assert"; import { fromHex, normalizeIotaObjectId } from "@iota/iota-sdk/utils"; -import { CommitteeResolution } from "@iota/poi-wasm"; -import { createNotarization, preparePoiExample } from "./util.js"; +import { createNotarization, loadGenesisCommitteeResolution, preparePoiExample } from "./util.js"; /** Demonstrates target-driven object discovery and verification. */ export async function createAndVerifyObjectProof(): Promise { console.log("=== Proof of Inclusion: Create and Verify an Object Proof ===\n"); - console.log("Stage 1 - Create a Notarization object using Locked Notarization"); + console.log("Stage 1 - Configure the proof source and establish committee trust"); const context = await preparePoiExample(); + const trust = await loadGenesisCommitteeResolution(context); + console.log(` trust anchor: ${trust.description}`); + + console.log("\nStage 2 - Create a Notarization object using Locked Notarization"); const targets = await createNotarization(context); // No transaction digest is supplied. The builder discovers the transaction // that produced the latest object version and constructs its evidence. - console.log("\nStage 2 - Construct a proof from only the Notarization object ID"); + console.log("\nStage 3 - Construct a proof from only the Notarization object ID"); const object = fromHex(normalizeIotaObjectId(targets.objectId, false, true)); const proof = await context.poiClient.makeProof({ objects: [object] }); const proofTargets = proof.targets; @@ -44,8 +47,8 @@ export async function createAndVerifyObjectProof(): Promise { console.log(` object version: ${proofTargets.objects[0]?.version}`); console.log(` object targets: ${proofTargets.objects.length}\n`); - console.log("Stage 3 - Verify the object proof"); - const verified = await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); + console.log("Stage 4 - Verify the object proof"); + const verified = await context.poiClient.verifier(trust.resolution).verify(proof); console.log(" object proof verified successfully."); console.log( @@ -54,5 +57,5 @@ export async function createAndVerifyObjectProof(): Promise { }`, ); console.log(` object BCS: ${verified.objectBcs(0).length} bytes`); - console.log("The resolved object version was changed by a transaction trusted through the selected node."); + console.log("The resolved object version was authenticated from the trusted network genesis."); } diff --git a/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts b/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts index d003dc30..1484b350 100644 --- a/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/05_event_proof.ts @@ -7,29 +7,31 @@ * An event ID contains its emitting transaction digest and sequence number, so * it can select an execution without a separate transaction target. The proof * carries the transaction's complete event list because the effects commit to - * that complete list. - * - * Trusted-node resolution keeps the example focused on event-driven discovery. + * that complete list. Verification authenticates committee history from a + * trusted genesis blob. */ import { strict as assert } from "node:assert"; import { fromBase58 } from "@iota/iota-sdk/utils"; -import { CommitteeResolution } from "@iota/poi-wasm"; -import { createNotarization, preparePoiExample } from "./util.js"; +import { createNotarization, loadGenesisCommitteeResolution, preparePoiExample } from "./util.js"; /** Demonstrates event-driven transaction discovery and verification. */ export async function createAndVerifyEventProof(): Promise { console.log("=== Proof of Inclusion: Create and Verify an Event Proof ===\n"); - console.log("Stage 1 - Emit a LockedNotarizationCreated event as fresh proof evidence"); + console.log("Stage 1 - Configure the proof source and establish committee trust"); const context = await preparePoiExample(); + const trust = await loadGenesisCommitteeResolution(context); + console.log(` trust anchor: ${trust.description}`); + + console.log("\nStage 2 - Emit a LockedNotarizationCreated event as fresh proof evidence"); const targets = await createNotarization(context); const transaction = fromBase58(targets.transactionDigest); // The event ID already identifies the emitting transaction, allowing the // builder to fetch all required transaction, effects, and event evidence. - console.log("\nStage 2 - Construct a proof from only the event ID"); + console.log("\nStage 3 - Construct a proof from only the event ID"); const proof = await context.poiClient.makeProof({ events: [{ transaction, sequence: targets.eventSequence }], }); @@ -45,8 +47,8 @@ export async function createAndVerifyEventProof(): Promise { console.log(` checkpoint epoch: ${proof.checkpointEpoch}`); console.log(` event targets: ${proofTargets.events.length}\n`); - console.log("Stage 3 - Verify the event proof"); - const verified = await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); + console.log("Stage 4 - Verify the event proof"); + const verified = await context.poiClient.verifier(trust.resolution).verify(proof); console.log(" event proof verified successfully."); console.log( @@ -55,5 +57,5 @@ export async function createAndVerifyEventProof(): Promise { }`, ); console.log(` event contents: ${verified.eventContents(0).length} BCS bytes`); - console.log("The selected event was emitted by a transaction trusted through the selected node."); + console.log("The selected event was authenticated from the trusted network genesis."); } diff --git a/bindings/wasm/poi_wasm/examples/src/06_trusted_node.ts b/bindings/wasm/poi_wasm/examples/src/06_trusted_node.ts new file mode 100644 index 00000000..cdb2b603 --- /dev/null +++ b/bindings/wasm/poi_wasm/examples/src/06_trusted_node.ts @@ -0,0 +1,39 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * # Verify a Proof Using a Trusted Node + * + * Trusted-node committee resolution accepts the committee reported by the + * connected node without authenticating its lineage from genesis. It is + * appropriate only when that node is inside the verifier's trust boundary. + * + * This example can run against any network, but the selected gRPC endpoint + * must be operated by a party the verifier trusts. + */ + +import { fromBase58 } from "@iota/iota-sdk/utils"; +import { CommitteeResolution } from "@iota/poi-wasm"; +import { createNotarization, preparePoiExample } from "./util.js"; + +/** Demonstrates trusted-node committee resolution against a trusted endpoint. */ +export async function verifyWithTrustedNode(): Promise { + console.log("=== Proof of Inclusion Advanced: Trusted-Node Resolution ===\n"); + + const context = await preparePoiExample(); + + const targets = await createNotarization(context, "PoI trusted-node example"); + const transaction = fromBase58(targets.transactionDigest); + const proof = await context.poiClient.makeProof({ transaction }); + + console.log(`Network: ${context.networkAlias}`); + console.log("Committee resolution: trusted node"); + console.log(`Transaction target: ${targets.transactionDigest}\n`); + + const verified = await context.poiClient.verifier(CommitteeResolution.trustedNode()).verify(proof); + + console.log("Transaction proof verified successfully."); + console.log(` authenticated checkpoint: ${verified.checkpointSequenceNumber}`); + console.log(` authenticated transaction: ${verified.transaction}`); + console.log("The selected node supplied the committee and is part of the trust boundary."); +} diff --git a/bindings/wasm/poi_wasm/examples/src/examples.ts b/bindings/wasm/poi_wasm/examples/src/examples.ts index f1cd3519..b20f0dda 100644 --- a/bindings/wasm/poi_wasm/examples/src/examples.ts +++ b/bindings/wasm/poi_wasm/examples/src/examples.ts @@ -6,6 +6,7 @@ import { createAndVerifyMultiTargetProof } from "./02_multi_target_proof.js"; import { reuseVerifierForMultipleProofs } from "./03_reuse_verifier.js"; import { createAndVerifyObjectProof } from "./04_object_proof.js"; import { createAndVerifyEventProof } from "./05_event_proof.js"; +import { verifyWithTrustedNode } from "./06_trusted_node.js"; interface PoiExample { readonly testName: string; @@ -34,4 +35,8 @@ export const examples: Readonly> = { testName: "creates and verifies an event proof", run: createAndVerifyEventProof, }, + "06_trusted_node": { + testName: "verifies a transaction proof through a trusted node", + run: verifyWithTrustedNode, + }, }; diff --git a/examples/poi/04_object_proof.rs b/examples/poi/04_object_proof.rs index 3a9cb991..452eb7cb 100644 --- a/examples/poi/04_object_proof.rs +++ b/examples/poi/04_object_proof.rs @@ -11,9 +11,8 @@ //! The discovered transaction is evidence supporting the object claim. It is not //! an explicit transaction target unless the caller also invokes `transaction`. //! -//! This focused example uses trusted-node committee resolution to avoid an epoch -//! walk. Use this mode only when the connected node is inside the verifier's -//! trust boundary. +//! Verification authenticates the checkpoint committee from a trusted genesis +//! blob, independently of the node that supplied the proof evidence. use anyhow::{Context, Result, ensure}; use poi_examples::prepare_poi_example; @@ -23,12 +22,17 @@ use poi_rs::CommitteeResolution; /// 1. Request a proof using only an object ID. /// 2. Let the builder resolve the latest object version and its transaction. /// 3. Distinguish supporting transaction evidence from an explicit target. -/// 4. Resolve the committee from a trusted node and verify the object claim. +/// 4. Authenticate committee history from genesis and verify the object claim. #[tokio::main] async fn main() -> Result<()> { println!("=== Proof of Inclusion: Create and Verify an Object Proof ===\n"); let context = prepare_poi_example().await?; + let genesis = context.load_genesis().await?; + let resolution = CommitteeResolution::from_genesis(genesis) + .context("failed to load the committee from the trusted genesis blob")?; + println!("Committee resolution: genesis anchored"); + let object_id = context.create_notarization("PoI object-proof example").await?.object_id; let client = &context.poi_client; @@ -73,9 +77,7 @@ async fn main() -> Result<()> { proof.checkpoint_summary().sequence_number ); - // Trusted-node resolution accepts the committee reported by the connected - // node. It avoids the genesis walk but makes that node part of the trust boundary. - let verifier = client.verifier(CommitteeResolution::TrustedNode); + let verifier = client.verifier(resolution); let verified = verifier .verify(&proof) .await diff --git a/examples/poi/05_event_proof.rs b/examples/poi/05_event_proof.rs index cd24b29c..5eebe470 100644 --- a/examples/poi/05_event_proof.rs +++ b/examples/poi/05_event_proof.rs @@ -11,9 +11,8 @@ //! digest in the transaction effects commits to that complete list. Verification //! then checks that the selected sequence exists in the authenticated events. //! -//! This focused example uses trusted-node committee resolution to avoid an epoch -//! walk. Use this mode only when the connected node is inside the verifier's -//! trust boundary. +//! Verification authenticates the checkpoint committee from a trusted genesis +//! blob, independently of the node that supplied the proof evidence. use anyhow::{Context, Result, ensure}; use poi_examples::prepare_poi_example; @@ -23,12 +22,17 @@ use poi_rs::CommitteeResolution; /// 1. Identify an event by transaction digest and sequence number. /// 2. Construct a proof without adding an explicit transaction target. /// 3. Inspect the event target and its supporting event evidence. -/// 4. Resolve the committee from a trusted node and verify the event claim. +/// 4. Authenticate committee history from genesis and verify the event claim. #[tokio::main] async fn main() -> Result<()> { println!("=== Proof of Inclusion: Create and Verify an Event Proof ===\n"); let context = prepare_poi_example().await?; + let genesis = context.load_genesis().await?; + let resolution = CommitteeResolution::from_genesis(genesis) + .context("failed to load the committee from the trusted genesis blob")?; + println!("Committee resolution: genesis anchored"); + let event_id = context.create_notarization("PoI event-proof example").await?.event_id; let client = &context.poi_client; @@ -72,9 +76,7 @@ async fn main() -> Result<()> { proof.checkpoint_summary().sequence_number ); - // Trusted-node resolution accepts the committee reported by the connected - // node. It avoids the genesis walk but makes that node part of the trust boundary. - let verifier = client.verifier(CommitteeResolution::TrustedNode); + let verifier = client.verifier(resolution); let verified = verifier .verify(&proof) .await diff --git a/examples/poi/Cargo.toml b/examples/poi/Cargo.toml index 5dd713a5..ed891f7c 100644 --- a/examples/poi/Cargo.toml +++ b/examples/poi/Cargo.toml @@ -50,3 +50,7 @@ path = "05_event_proof.rs" [[example]] name = "advanced_01_committee_cache" path = "advanced/01_committee_cache.rs" + +[[example]] +name = "advanced_02_trusted_node" +path = "advanced/02_trusted_node.rs" diff --git a/examples/poi/README.md b/examples/poi/README.md index bf6f4be3..df0fb4d6 100644 --- a/examples/poi/README.md +++ b/examples/poi/README.md @@ -52,10 +52,12 @@ The shared setup performs the following work: > Mainnet examples submit paid transactions from the active CLI wallet. Set `IOTA_NOTARIZATION_PKG_ID` to an existing > Single Notarization Move Package and fund the active wallet before running them. -Examples 01, 02, 03, and the advanced file-cache example use genesis-anchored verification. For mainnet, testnet, and -devnet, the active network's chain identifier selects a built-in genesis URL. The downloaded genesis blob remains +Examples 01 through 05 and the advanced file-cache example use genesis-anchored verification. For mainnet, testnet, +and devnet, the active network's chain identifier selects a built-in genesis URL. The downloaded genesis blob remains cached in the IOTA configuration directory. Local and custom networks require an independently obtained -`IOTA_GENESIS_PATH` because the verifier cannot infer a trusted genesis source for them. +`IOTA_GENESIS_PATH` because the verifier cannot infer a trusted genesis source for them. The advanced trusted-node +example can run on any network because it does not require genesis, but it places the connected node inside the +verifier's trust boundary. ## Running an Example @@ -78,12 +80,12 @@ The focused runner executes every example: ./examples/poi/run.sh ``` -The runner starts each example in a separate process. On its first run, examples 01, 02, 03, and the advanced cache +The runner starts each example in a separate process. On its first run, examples 01 through 05 and the advanced cache example may each perform a genesis-to-current-epoch committee walk. Run individual examples when you do not need the complete set. -The complete runner creates seven locked `Notarization` objects because the verifier-reuse example creates two -transactions. A non-mainnet run may also publish the Single Notarization Move Package once. On mainnet, all seven +The complete runner creates eight locked `Notarization` objects because the verifier-reuse example creates two +transactions. A non-mainnet run may also publish the Single Notarization Move Package once. On mainnet, all eight transactions consume paid gas from the active wallet. ## Examples @@ -96,6 +98,7 @@ transactions consume paid gas from the active wallet. | [04_object_proof](./04_object_proof.rs) | Starts from a fresh object ID and lets the builder discover the transaction that created its latest version. | | [05_event_proof](./05_event_proof.rs) | Starts from a fresh event ID without declaring a separate transaction target. | | [advanced_01_committee_cache](./advanced/01_committee_cache.rs) | Persists authenticated committees in a cache scoped to the active network. | +| [advanced_02_trusted_node](./advanced/02_trusted_node.rs) | Demonstrates trusted-node committee resolution against a trusted endpoint on any network. | ## Example Workflow @@ -105,7 +108,8 @@ its `LockedNotarizationCreated` event in one proof. The verifier-reuse example creates two transactions and retains one verifier across both proofs. The second verification reuses any committee history authenticated during the first verification. The object and event examples -use trusted-node committee resolution so they can focus on target-driven discovery without performing a genesis walk. +authenticate committee history from genesis. The advanced trusted-node example isolates the alternative trust model +and can run against any active network without a genesis blob. ## Trust Boundaries diff --git a/examples/poi/advanced/02_trusted_node.rs b/examples/poi/advanced/02_trusted_node.rs new file mode 100644 index 00000000..70da6a6e --- /dev/null +++ b/examples/poi/advanced/02_trusted_node.rs @@ -0,0 +1,52 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Verify a Proof Using a Trusted Node +//! +//! Trusted-node committee resolution accepts the committee reported by the +//! connected node without authenticating its lineage from genesis. It is +//! appropriate only when that node is inside the verifier's trust boundary. +//! +//! This example can run against any network, but the selected gRPC endpoint +//! must be operated by a party the verifier trusts. + +use anyhow::{Context, Result}; +use poi_examples::prepare_poi_example; +use poi_rs::CommitteeResolution; + +/// Demonstrates trusted-node committee resolution against a trusted endpoint. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Proof of Inclusion Advanced: Trusted-Node Resolution ===\n"); + + let context = prepare_poi_example().await?; + + let transaction_digest = context + .create_notarization("PoI trusted-node example") + .await? + .transaction_digest; + let client = &context.poi_client; + let proof = client + .proof() + .transaction(transaction_digest) + .build() + .await + .context("failed to construct the transaction proof")?; + + println!("Network: {}", context.network_alias); + println!("Committee resolution: trusted node"); + println!("Transaction target: {transaction_digest}\n"); + + let verified = client + .verifier(CommitteeResolution::TrustedNode) + .verify(&proof) + .await + .context("trusted-node proof verification failed")?; + + println!("Transaction proof verified successfully."); + println!(" authenticated checkpoint: {}", verified.checkpoint_sequence_number()); + println!(" authenticated transaction: {}", verified.transaction_digest()); + println!("The selected node supplied the committee and is part of the trust boundary."); + + Ok(()) +} diff --git a/examples/poi/run.sh b/examples/poi/run.sh index f507076c..c2dd269d 100755 --- a/examples/poi/run.sh +++ b/examples/poi/run.sh @@ -8,9 +8,9 @@ set -e echo "Running all Proof of Inclusion examples..." echo "================================" echo "Using the active IOTA CLI environment and wallet." -echo "This run submits seven locked Notarization transactions." +echo "This run submits eight locked Notarization transactions." echo "Genesis-anchored examples run in separate processes and may repeat the committee walk." -echo "On mainnet, all seven transactions consume paid gas from the active wallet." +echo "On mainnet, all eight transactions consume paid gas from the active wallet." echo "" cargo run --release -p poi-examples --example 01_transaction_proof @@ -19,6 +19,7 @@ cargo run --release -p poi-examples --example 03_reuse_verifier cargo run --release -p poi-examples --example 04_object_proof cargo run --release -p poi-examples --example 05_event_proof cargo run --release -p poi-examples --example advanced_01_committee_cache +cargo run --release -p poi-examples --example advanced_02_trusted_node echo "" echo "All Proof of Inclusion examples completed successfully!" From 4c3ecc98db835c9c52421e0f93102f16e5957711 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 13:41:06 +0300 Subject: [PATCH 08/19] chore: update documentation and comments for object proof handling and version resolution --- bindings/wasm/poi_wasm/README.md | 20 ++++++++++---- .../poi_wasm/examples/src/04_object_proof.ts | 8 +++--- bindings/wasm/poi_wasm/lib/poi-client.ts | 5 +++- bindings/wasm/poi_wasm/src/proof.rs | 3 +++ examples/poi/04_object_proof.rs | 5 ++-- examples/poi/README.md | 18 ++++++------- poi-rs/README.md | 26 +++++++++++++------ poi-rs/src/bin/poi.rs | 4 +-- poi-rs/src/builder.rs | 11 +++++--- 9 files changed, 65 insertions(+), 35 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index b7cdad8c..ebd11913 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -5,9 +5,9 @@ The Proof of Inclusion Wasm Package provides the Node.js and TypeScript interface for Proof of Inclusion in the IOTA Notarization Toolkit. It connects a generated IOTA `LedgerService` client to `poi-rs` compiled as WebAssembly. -Use the Package to construct portable proofs for IOTA transactions, events, and object states and to verify those proofs -locally. `PoiClient` hides the generated protobuf client, ConnectRPC transport, and JavaScript-to-WASM source adapter, -while Rust owns proof construction, committee resolution, and verification. +Use the Package to construct portable proofs for IOTA transactions, events, and specific object versions and to verify +those proofs locally. `PoiClient` hides the generated protobuf client, ConnectRPC transport, and +JavaScript-to-WASM source adapter, while Rust owns proof construction, committee resolution, and verification. Proof of Inclusion operates on existing ledger activity and does not define a separate Move Package. @@ -61,8 +61,8 @@ targets. Event sequence numbers and all other 64-bit values use JavaScript `bigi The serialized proof records the targets explicitly selected by the caller. Its checkpoint summary and checkpoint contents are sibling fields, while the required transaction proof contains the transaction, effects, and optional event -evidence. Object targets contain the selected object values, and event targets select events from the authenticated -transaction event list. +evidence. Object targets contain the selected historical object values, and event targets select events from the +authenticated transaction event list. The JavaScript source adapter passes only opaque BCS bytes and checkpoint sequence numbers into WASM. Rust decodes those values into existing IOTA domain types and delegates target resolution and proof construction to `poi-rs`. @@ -109,6 +109,16 @@ committees authenticated during its lifetime. `CommitteeResolver.resolve(epoch)` available for lower-level committee resolution and offline verification; both verification methods return a `VerifiedProof` on success. +## What a Verified Proof Proves + +A verified proof establishes the following claims relative to the supplied committee: + +- A transaction target proves that the selected transaction and its effects are included in the certified checkpoint. +- An object target proves the exact object version returned by `objectBcs(index)`. For an object ID without a transaction + or event target, `makeProof()` resolves its latest version at proof construction time; the proof does not claim that it + remains latest. Deleted and wrapped objects are unsupported. +- An event target proves that `eventContents(index)` returns the selected event's authenticated contents. + ## Trust Boundaries Treat the node, source adapter, and complete proof payload as untrusted until verification succeeds. Trusted-node diff --git a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts index 449f39e3..dc13fdc4 100644 --- a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts @@ -4,9 +4,9 @@ /** * # Create and Verify an Object Proof * - * Starting from only an object ID, the builder fetches the object's latest - * version, discovers the transaction that produced it, and packages the object - * and transaction evidence into one proof. + * Starting from only an object ID, the builder fetches its latest version at + * proof construction time, discovers the transaction that produced it, and + * packages both as one proof. * * The discovered transaction supports the object claim but does not become an * explicit transaction target. Verification authenticates committee history @@ -31,7 +31,7 @@ export async function createAndVerifyObjectProof(): Promise { const targets = await createNotarization(context); // No transaction digest is supplied. The builder discovers the transaction - // that produced the latest object version and constructs its evidence. + // that produced the version returned at build time and constructs its evidence. console.log("\nStage 3 - Construct a proof from only the Notarization object ID"); const object = fromHex(normalizeIotaObjectId(targets.objectId, false, true)); const proof = await context.poiClient.makeProof({ objects: [object] }); diff --git a/bindings/wasm/poi_wasm/lib/poi-client.ts b/bindings/wasm/poi_wasm/lib/poi-client.ts index 93d2776c..06660ab1 100644 --- a/bindings/wasm/poi_wasm/lib/poi-client.ts +++ b/bindings/wasm/poi_wasm/lib/poi-client.ts @@ -34,7 +34,10 @@ export interface ProofEventRequest { export interface ProofRequest { /** Transaction selected as an explicit proof target. */ transaction?: Uint8Array; - /** Object IDs selected as proof targets. */ + /** + * Object IDs selected as proof targets. Without a transaction or event + * target, the source resolves the latest version at proof construction time. + */ objects?: readonly Uint8Array[]; /** Event IDs selected as proof targets. */ events?: readonly ProofEventRequest[]; diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 1adc262b..272518b1 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -225,6 +225,9 @@ impl WasmProofBuilder { } /// Adds an object proof request. + /// + /// Without a transaction or event request, the source resolves the object's + /// latest version at proof construction time. pub fn object(self, object_id: Uint8Array) -> WasmResult { let object_id = ObjectId::from_bytes(object_id.to_vec())?; Ok(Self(self.0.object(object_id))) diff --git a/examples/poi/04_object_proof.rs b/examples/poi/04_object_proof.rs index 452eb7cb..4377fb35 100644 --- a/examples/poi/04_object_proof.rs +++ b/examples/poi/04_object_proof.rs @@ -4,9 +4,8 @@ //! # Create and Verify an Object Proof //! //! An application can request a Proof of Inclusion using only an object ID. The -//! builder fetches the object's latest version, discovers the transaction that -//! produced that version, and packages the object and transaction evidence into -//! one proof. +//! builder fetches its latest version at proof construction time, discovers the +//! transaction that produced it, and packages both as one proof. //! //! The discovered transaction is evidence supporting the object claim. It is not //! an explicit transaction target unless the caller also invokes `transaction`. diff --git a/examples/poi/README.md b/examples/poi/README.md index df0fb4d6..09871d52 100644 --- a/examples/poi/README.md +++ b/examples/poi/README.md @@ -90,15 +90,15 @@ transactions consume paid gas from the active wallet. ## Examples -| Name | Information | -| :-------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | -| [01_transaction_proof](./01_transaction_proof.rs) | Creates a transaction proof, serializes it as JSON, and verifies it from a trusted network genesis blob. | -| [02_multi_target_proof](./02_multi_target_proof.rs) | Combines transaction, changed-object, and emitted-event targets in one proof. | -| [03_reuse_verifier](./03_reuse_verifier.rs) | Reuses one genesis-anchored verifier across proofs for two fresh transactions. | -| [04_object_proof](./04_object_proof.rs) | Starts from a fresh object ID and lets the builder discover the transaction that created its latest version. | -| [05_event_proof](./05_event_proof.rs) | Starts from a fresh event ID without declaring a separate transaction target. | -| [advanced_01_committee_cache](./advanced/01_committee_cache.rs) | Persists authenticated committees in a cache scoped to the active network. | -| [advanced_02_trusted_node](./advanced/02_trusted_node.rs) | Demonstrates trusted-node committee resolution against a trusted endpoint on any network. | +| Name | Information | +| :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------- | +| [01_transaction_proof](./01_transaction_proof.rs) | Creates a transaction proof, serializes it as JSON, and verifies it from a trusted network genesis blob. | +| [02_multi_target_proof](./02_multi_target_proof.rs) | Combines transaction, changed-object, and emitted-event targets in one proof. | +| [03_reuse_verifier](./03_reuse_verifier.rs) | Reuses one genesis-anchored verifier across proofs for two fresh transactions. | +| [04_object_proof](./04_object_proof.rs) | Resolves the latest object version at proof construction time and proves its exact value. | +| [05_event_proof](./05_event_proof.rs) | Starts from a fresh event ID without declaring a separate transaction target. | +| [advanced_01_committee_cache](./advanced/01_committee_cache.rs) | Persists authenticated committees in a cache scoped to the active network. | +| [advanced_02_trusted_node](./advanced/02_trusted_node.rs) | Demonstrates trusted-node committee resolution against a trusted endpoint on any network. | ## Example Workflow diff --git a/poi-rs/README.md b/poi-rs/README.md index 9bf27a9d..6cc13f42 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -5,13 +5,13 @@ The Proof of Inclusion Rust Package constructs and verifies portable evidence that IOTA ledger data is included in a certified checkpoint. It is the Rust Package for Proof of Inclusion in the IOTA Notarization Toolkit. -Use Proof of Inclusion when a verifier needs cryptographic evidence for a transaction, event, or object state without -trusting the source that transports the proof. `PoiClient` provides the main entry point, `ProofBuilder` constructs the -evidence, and `ProofVerifier` verifies it locally against a committee the caller trusts. +Use Proof of Inclusion when a verifier needs cryptographic evidence for a transaction, event, or specific object version +without trusting the source that transports the proof. `PoiClient` provides the main entry point, +`ProofBuilder` constructs the evidence, and `ProofVerifier` verifies it locally against a committee the caller trusts. Proof of Inclusion operates on existing IOTA ledger activity. It does not define a separate on-chain object or Move Package. Single Notarization and Audit Trails can create ledger activity that applications later prove, but Proof of -Inclusion also supports transactions, events, and object states created by other IOTA applications. +Inclusion also supports transactions, events, and object versions created by other IOTA applications. You can find the full IOTA Notarization Toolkit documentation [here](https://docs.iota.org/developer/iota-notarization). @@ -164,6 +164,16 @@ Verification checks: - event data matches the digest recorded in the effects when the proof includes event targets; and - event targets declared by the proof belong to the transaction and select events in the authenticated event list. +## What a Verified Proof Proves + +A verified proof establishes the following claims relative to the supplied committee: + +- A transaction target proves that the selected transaction and its effects are included in the certified checkpoint. +- An object target proves that the exact object version was written by the authenticated transaction. For an object ID + without a transaction or event target, the builder resolves its latest version at proof construction time; the proof + does not claim that it remains latest. Deleted and wrapped objects are unsupported. +- An event target proves that the selected event and its contents appear in the authenticated transaction's event list. + ## Proof Model A `Proof` contains three layers of evidence: @@ -172,10 +182,10 @@ A `Proof` contains three layers of evidence: - A `CertifiedCheckpointSummary` and its `CheckpointContents` link the transaction to a committee-certified checkpoint. - A required `TransactionProof` contains the transaction, its effects, and event data when event targets are present. -Object targets contain their exact object values. Verification derives each object reference and finds it in the -transaction effects. Event targets contain `EventID` values, while the transaction proof carries the complete event list -needed to verify the effects' event digest. A transaction target is present only when the caller explicitly requests the -transaction itself, although transaction evidence supports every proof. +Object targets contain their exact values. Event targets contain `EventID` values, while the +transaction proof carries the complete event list needed to verify the effects' event digest. A transaction target is +present only when the caller explicitly requests the transaction itself, although transaction evidence supports every +proof. ## Trust Boundaries diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index da00ede5..f3a2f369 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -81,8 +81,8 @@ struct CreateArgs { /// Transaction digest to prove. #[arg(long, value_name = "DIGEST")] transaction: Option, - /// Object ID to prove. The source resolves its latest version unless a transaction or event scopes the proof. May - /// be repeated. + /// Object ID to prove. Without a transaction or event target, the source resolves its latest version at proof + /// construction time. May be repeated. #[arg(long, value_name = "OBJECT_ID")] object: Vec, /// Event identifier formatted as TRANSACTION_DIGEST:EVENT_SEQUENCE. May be repeated. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 9dbbf584..1c1eaaab 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -48,8 +48,10 @@ pub enum ProofBuilderError { /// Requested object ID. object_id: ObjectId, }, - /// The requested object was not changed by the selected transaction. - #[error("object {object_id} was not changed by transaction {transaction_digest}")] + /// The selected transaction did not write a provable value for the requested object. + #[error( + "transaction {transaction_digest} did not write a provable value for object {object_id}; deleted and wrapped objects are unsupported" + )] ObjectNotChangedByTransaction { /// Requested object ID. object_id: ObjectId, @@ -129,13 +131,16 @@ impl ProofBuilder { /// Adds an object proof request by object ID. /// - /// The source resolves the ID to the exact object reference packaged in the proof. + /// Without a transaction or event request, the source resolves the object's + /// latest version at proof construction time. pub fn object(mut self, object_id: ObjectId) -> Self { Self::push_unique(&mut self.object_ids, object_id); self } /// Adds multiple object proof requests by object ID. + /// + /// Object resolution follows the build-time semantics of [`Self::object`]. pub fn objects(mut self, object_ids: impl IntoIterator) -> Self { for object_id in object_ids { Self::push_unique(&mut self.object_ids, object_id); From 272a7a42e72b3d7b374bd3d5d7fa94ba574a3f9e Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 15:48:11 +0300 Subject: [PATCH 09/19] chore: add stable error handling for Proof of Inclusion with detailed error codes and tests --- bindings/wasm/poi_wasm/README.md | 3 + bindings/wasm/poi_wasm/lib/error.ts | 34 +++++ bindings/wasm/poi_wasm/lib/index.ts | 1 + bindings/wasm/poi_wasm/src/error.rs | 116 +++++++++++++++--- .../tests/committee-resolution.test.ts | 31 ++++- .../poi_wasm/tests/proof-bindings.test.ts | 9 +- 6 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 bindings/wasm/poi_wasm/lib/error.ts diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index ebd11913..17b77f2e 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -109,6 +109,9 @@ committees authenticated during its lifetime. `CommitteeResolver.resolve(epoch)` available for lower-level committee resolution and offline verification; both verification methods return a `VerifiedProof` on success. +Verification failures are normal JavaScript errors with a stable `code`. Use `isPoiError(error)` before reading the +code. Only `PROOF_INVALID` means the proof was rejected. + ## What a Verified Proof Proves A verified proof establishes the following claims relative to the supplied committee: diff --git a/bindings/wasm/poi_wasm/lib/error.ts b/bindings/wasm/poi_wasm/lib/error.ts new file mode 100644 index 00000000..0f7efa26 --- /dev/null +++ b/bindings/wasm/poi_wasm/lib/error.ts @@ -0,0 +1,34 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** Stable error codes returned by the Proof of Inclusion Wasm Package. */ +export type PoiErrorCode = + | "PROOF_INVALID" + | "COMMITTEE_RESOLUTION" + | "SOURCE_REQUEST" + | "NOT_FOUND" + | "INVALID_INPUT" + | "INTERNAL"; + +/** Error returned by the Proof of Inclusion Wasm Package. */ +export interface PoiError extends Error { + readonly code: Code; +} + +/** Returns whether `error` is a Proof of Inclusion error with a stable code. */ +export function isPoiError(error: unknown): error is PoiError { + if (!(error instanceof Error)) { + return false; + } + + return error.name === "PoiError" && isPoiErrorCode((error as { code?: unknown }).code); +} + +function isPoiErrorCode(code: unknown): code is PoiErrorCode { + return code === "PROOF_INVALID" + || code === "COMMITTEE_RESOLUTION" + || code === "SOURCE_REQUEST" + || code === "NOT_FOUND" + || code === "INVALID_INPUT" + || code === "INTERNAL"; +} diff --git a/bindings/wasm/poi_wasm/lib/index.ts b/bindings/wasm/poi_wasm/lib/index.ts index f48dabff..00ce9326 100644 --- a/bindings/wasm/poi_wasm/lib/index.ts +++ b/bindings/wasm/poi_wasm/lib/index.ts @@ -11,4 +11,5 @@ export { ProofTargets, VerifiedProof, } from "../node/poi_wasm.js"; +export { isPoiError, type PoiError, type PoiErrorCode } from "./error.js"; export { PoiClient, type PoiClientOptions, type ProofEventRequest, type ProofRequest } from "./poi-client.js"; diff --git a/bindings/wasm/poi_wasm/src/error.rs b/bindings/wasm/poi_wasm/src/error.rs index 9dbd9fd7..e05f8e7e 100644 --- a/bindings/wasm/poi_wasm/src/error.rs +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -2,21 +2,102 @@ // SPDX-License-Identifier: Apache-2.0 use std::error::Error; +use std::string::FromUtf8Error; -use wasm_bindgen::{JsCast, JsError, JsValue}; +use iota_sdk_types::{AddressParseError, DigestParseError}; +use poi_rs::{ + CommitteeResolutionError, CommitteeResolutionErrorKind, ProofBuilderError, ProofVerificationError, + SerializationError, SourceError, VerifyError, +}; +use wasm_bindgen::{JsCast, JsValue}; pub type WasmResult = Result; -#[derive(Debug)] -pub struct WasmError { - message: String, +#[derive(Debug, thiserror::Error)] +pub enum WasmError { + #[error(transparent)] + Verify(#[from] VerifyError), + #[error(transparent)] + ProofVerification(#[from] ProofVerificationError), + #[error(transparent)] + CommitteeResolution(#[from] CommitteeResolutionError), + #[error(transparent)] + ProofBuilder(#[from] ProofBuilderError), + #[error(transparent)] + Source(#[from] SourceError), + #[error(transparent)] + Serialization(#[from] SerializationError), + #[error(transparent)] + Poi(#[from] PoiError), + #[error(transparent)] + Address(#[from] AddressParseError), + #[error(transparent)] + Digest(#[from] DigestParseError), + #[error(transparent)] + Bcs(#[from] bcs::Error), + #[error(transparent)] + Utf8(#[from] FromUtf8Error), +} + +#[derive(Clone, Copy, Debug)] +enum WasmErrorCode { + ProofInvalid, + CommitteeResolution, + SourceRequest, + NotFound, + InvalidInput, + Internal, +} + +impl WasmErrorCode { + const fn as_str(self) -> &'static str { + match self { + Self::ProofInvalid => "PROOF_INVALID", + Self::CommitteeResolution => "COMMITTEE_RESOLUTION", + Self::SourceRequest => "SOURCE_REQUEST", + Self::NotFound => "NOT_FOUND", + Self::InvalidInput => "INVALID_INPUT", + Self::Internal => "INTERNAL", + } + } +} + +impl WasmError { + fn code(&self) -> WasmErrorCode { + match self { + Self::Verify(_) => WasmErrorCode::ProofInvalid, + Self::ProofVerification(error) => match error { + ProofVerificationError::CommitteeResolution { source } => committee_resolution_code(source), + ProofVerificationError::Proof { .. } => WasmErrorCode::ProofInvalid, + _ => WasmErrorCode::Internal, + }, + Self::CommitteeResolution(error) => committee_resolution_code(error), + Self::ProofBuilder(error) => match error { + ProofBuilderError::Source { .. } => WasmErrorCode::SourceRequest, + ProofBuilderError::TransactionNotFound { .. } + | ProofBuilderError::ObjectNotFound { .. } + | ProofBuilderError::EventNotFound { .. } => WasmErrorCode::NotFound, + ProofBuilderError::MissingRequest + | ProofBuilderError::ObjectReferenceMismatch { .. } + | ProofBuilderError::ObjectNotChangedByTransaction { .. } + | ProofBuilderError::TransactionMismatch { .. } => WasmErrorCode::InvalidInput, + _ => WasmErrorCode::Internal, + }, + Self::Source(_) => WasmErrorCode::SourceRequest, + Self::Serialization(_) | Self::Address(_) | Self::Digest(_) => WasmErrorCode::InvalidInput, + Self::Poi(error) => match error { + PoiError::JavaScript(_) => WasmErrorCode::SourceRequest, + PoiError::InvalidInput(_) => WasmErrorCode::InvalidInput, + PoiError::InvalidResponse(_) => WasmErrorCode::Internal, + }, + Self::Bcs(_) | Self::Utf8(_) => WasmErrorCode::Internal, + } + } } -impl From for WasmError -where - E: Error, -{ - fn from(error: E) -> Self { +impl From for JsValue { + fn from(error: WasmError) -> Self { + let code = error.code().as_str(); let mut message = error.to_string(); let mut source = error.source(); @@ -26,18 +107,25 @@ where source = cause.source(); } - Self { message } + let js_error = js_sys::Error::new(&message); + js_error.set_name("PoiError"); + let _ = js_sys::Reflect::set(js_error.as_ref(), &JsValue::from_str("code"), &JsValue::from_str(code)); + + js_error.into() } } -impl From for JsValue { - fn from(error: WasmError) -> Self { - JsError::new(&error.message).into() +fn committee_resolution_code(error: &CommitteeResolutionError) -> WasmErrorCode { + match &error.kind { + CommitteeResolutionErrorKind::FetchCommittee { .. } + | CommitteeResolutionErrorKind::FetchCurrentEpoch { .. } + | CommitteeResolutionErrorKind::FetchEpochHistory { .. } => WasmErrorCode::SourceRequest, + _ => WasmErrorCode::CommitteeResolution, } } #[derive(Debug, thiserror::Error)] -pub(crate) enum PoiError { +pub enum PoiError { #[error("{0}")] JavaScript(String), #[error("{0}")] diff --git a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts index 51685179..fdd6de95 100644 --- a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { Committee, CommitteeResolution, CommitteeResolver } from "../lib/index.js"; +import { Committee, CommitteeResolution, CommitteeResolver, isPoiError } from "../lib/index.js"; import type { LedgerSource } from "../lib/source-types.js"; test("the WASM resolver constructs a committee reported by a trusted node", async () => { @@ -42,7 +42,34 @@ test("the anchored resolver reports a missing current epoch", async () => { source, CommitteeResolution.anchored(committee), ).resolve(1n), - /service information is missing the current epoch/, + (error) => { + assert.ok(isPoiError(error)); + assert.equal(error.code, "COMMITTEE_RESOLUTION"); + assert.match(error.message, /service information is missing the current epoch/); + return true; + }, + ); +}); + +test("the resolver reports source request failures separately", async () => { + const source = { + async currentEpoch() { + throw new Error("node unavailable"); + }, + } as unknown as LedgerSource; + const committee = await loadCommittee(); + + await assert.rejects( + new CommitteeResolver( + source, + CommitteeResolution.anchored(committee), + ).resolve(1n), + (error) => { + assert.ok(isPoiError(error)); + assert.equal(error.code, "SOURCE_REQUEST"); + assert.match(error.message, /node unavailable/); + return true; + }, ); }); diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index 32c3f6a8..91cb83c6 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { Committee, Proof, type ProofTargets, type VerifiedProof } from "../lib/index.js"; +import { Committee, isPoiError, Proof, type ProofTargets, type VerifiedProof } from "../lib/index.js"; const fixtures: readonly { name: string; @@ -107,7 +107,12 @@ test("rejects event sequences outside the wasm32 index range", async () => { assert.throws( () => proof.verify(committee), - new RegExp(`event sequence number ${eventSequence} is out of bounds`), + (error) => { + assert.ok(isPoiError(error)); + assert.equal(error.code, "PROOF_INVALID"); + assert.match(error.message, new RegExp(`event sequence number ${eventSequence} is out of bounds`)); + return true; + }, ); } }); From f733afeb8ec346800ea60049bceefba80538b56c Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 15:55:44 +0300 Subject: [PATCH 10/19] chore: enhance committee serialization with toJSON method and update error handling --- bindings/wasm/poi_wasm/README.md | 2 +- bindings/wasm/poi_wasm/src/committee.rs | 15 +++++++++++++-- bindings/wasm/poi_wasm/src/error.rs | 4 +++- .../poi_wasm/tests/committee-bindings.test.ts | 3 +++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 17b77f2e..b0ab5d22 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -100,7 +100,7 @@ target payloads. Continue using `Proof` only as the untrusted transport and seri Callers that already possess an extracted trusted committee can use `CommitteeResolution.anchored(committee)` instead. `Committee.fromJSON()` accepts the Rust `Committee` fields `epoch` and `voting_rights`, validates public keys, rejects duplicate authorities, requires total voting power to equal 10,000, and reconstructs the committee's derived lookup -state. +state. Use `Committee.toJSON()` to persist a resolved committee and restore it later with `Committee.fromJSON()`. The verifier fetches the certified checkpoint in each epoch-close proof, verifies it with the current committee, and only then accepts and caches the next committee. Each anchored verifier owns a fresh in-memory cache; the WASM Package diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index ea8e362e..cbcbea4f 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -7,14 +7,14 @@ use iota_types::base_types::AuthorityName; use iota_types::committee::{Committee, EpochId, StakeUnit, TOTAL_VOTING_POWER}; use js_sys::Uint8Array; use poi_rs::{CommitteeResolution, CommitteeResolver}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::wasm_bindgen; use crate::error::{PoiError, WasmResult}; use crate::proof::{WasmProof, WasmVerifiedProof}; use crate::source::LedgerSource; -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] struct CommitteeJson { epoch: EpochId, voting_rights: Vec<(AuthorityName, StakeUnit)>, @@ -59,6 +59,17 @@ impl WasmCommittee { Ok(WasmCommittee(Committee::new(committee.epoch, voting_rights))) } + /// Serializes this committee for persistence and later restoration with `fromJSON`. + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> WasmResult { + let committee = CommitteeJson { + epoch: self.0.epoch, + voting_rights: self.0.voting_rights.clone(), + }; + + Ok(serde_json::to_string(&committee)?) + } + /// Returns the epoch governed by this committee. #[wasm_bindgen(getter)] pub fn epoch(&self) -> u64 { diff --git a/bindings/wasm/poi_wasm/src/error.rs b/bindings/wasm/poi_wasm/src/error.rs index e05f8e7e..f557e347 100644 --- a/bindings/wasm/poi_wasm/src/error.rs +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -28,6 +28,8 @@ pub enum WasmError { #[error(transparent)] Serialization(#[from] SerializationError), #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] Poi(#[from] PoiError), #[error(transparent)] Address(#[from] AddressParseError), @@ -90,7 +92,7 @@ impl WasmError { PoiError::InvalidInput(_) => WasmErrorCode::InvalidInput, PoiError::InvalidResponse(_) => WasmErrorCode::Internal, }, - Self::Bcs(_) | Self::Utf8(_) => WasmErrorCode::Internal, + Self::Json(_) | Self::Bcs(_) | Self::Utf8(_) => WasmErrorCode::Internal, } } } diff --git a/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts index 91c08ac4..56128ffd 100644 --- a/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts @@ -17,8 +17,11 @@ test("the WASM committee can be deserialized from Rust JSON", async () => { ); const committee = Committee.fromJSON(json); + const restored = Committee.fromJSON(committee.toJSON()); assert.equal(committee.epoch, 0n); + assert.equal(restored.epoch, committee.epoch); + assert.equal(restored.toJSON(), committee.toJSON()); }); test("the WASM committee rejects invalid total voting power", async () => { From fbff3dd15ecf055bb7e463685ab7c9860d63dfbf Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 16:11:34 +0300 Subject: [PATCH 11/19] chore: add tests for proof verification including target requirements and committee validation --- .../poi_wasm/tests/proof-bindings.test.ts | 16 ++ poi-rs/src/committee.rs | 241 ++++++++++++++++++ poi-rs/tests/proof_verification.rs | 76 +++++- 3 files changed, 332 insertions(+), 1 deletion(-) diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index 91cb83c6..a1c563ee 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -117,6 +117,22 @@ test("rejects event sequences outside the wasm32 index range", async () => { } }); +test("rejects a proof with a committee from another epoch", async () => { + const committeeFixture = JSON.parse(await readFixture("committee.json")) as { epoch: number }; + committeeFixture.epoch = 1; + const committee = Committee.fromJSON(JSON.stringify(committeeFixture)); + const proof = Proof.fromJSON(await readFixture("transaction.json")); + + assert.throws( + () => proof.verify(committee), + (error) => { + assert.ok(isPoiError(error)); + assert.equal(error.code, "PROOF_INVALID"); + return true; + }, + ); +}); + interface EventProofFixture { ProofV1: { targets: { diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 4dc0d309..d040fbf0 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -576,6 +576,7 @@ impl CommitteeResolver { #[cfg(test)] mod tests { + use std::collections::BTreeMap; use std::sync::Mutex; use iota_sdk_types::gas::GasCostSummary; @@ -592,11 +593,20 @@ mod tests { committee: Committee, } + struct FailingStoreCache; + #[derive(Clone)] struct EpochCloseSource { summary: CertifiedCheckpointSummary, } + #[derive(Clone)] + struct CommitteeHistorySource { + current_epoch: Option, + summaries: BTreeMap, + fail_current_epoch: bool, + } + #[async_trait::async_trait] impl Source for EpochCloseSource { async fn chain_identifier(&self) -> Result { @@ -634,6 +644,44 @@ mod tests { } } + #[async_trait::async_trait] + impl Source for CommitteeHistorySource { + async fn chain_identifier(&self) -> Result { + unreachable!("committee history does not resolve a chain identifier") + } + + async fn transaction( + &self, + _transaction_digest: TransactionDigest, + ) -> Result, SourceError> { + unreachable!("committee history does not resolve transactions") + } + + async fn object(&self, _object_id: ObjectId, _version: Option) -> Result, SourceError> { + unreachable!("committee history does not resolve objects") + } + + async fn checkpoint(&self, _sequence_number: u64) -> Result { + unreachable!("committee history does not resolve checkpoints") + } + + async fn committee(&self, _epoch: EpochId) -> Result { + unreachable!("anchored committee history does not trust node committees") + } + + async fn current_epoch(&self) -> Result, SourceError> { + if self.fail_current_epoch { + return Err(SourceError::request(std::io::Error::other("current epoch unavailable"))); + } + + Ok(self.current_epoch) + } + + async fn epoch_close_summary(&self, epoch: EpochId) -> Result, SourceError> { + Ok(self.summaries.get(&epoch).cloned()) + } + } + #[derive(Clone, Default)] struct RecordingCache { stored: Arc>>, @@ -668,10 +716,57 @@ mod tests { } } + #[async_trait::async_trait] + impl CommitteeCache for FailingStoreCache { + async fn committee(&self, _key: CommitteeCacheKey) -> Result, CommitteeCacheError> { + Ok(None) + } + + async fn store(&self, key: CommitteeCacheKey, _committee: &Committee) -> Result<(), CommitteeCacheError> { + Err(CommitteeCacheError::Backend { + epoch: key.epoch(), + source: Box::new(std::io::Error::other("cache unavailable")), + }) + } + } + fn chain_identifier(byte: u8) -> ChainIdentifier { ChainIdentifier::from(CheckpointDigest::new([byte; 32])) } + fn committee_with_keypairs(epoch: EpochId, size: usize) -> (Committee, Vec) { + let (base_committee, keypairs) = Committee::new_simple_test_committee_of_size(size); + let committee = Committee::new(epoch, base_committee.voting_rights.iter().cloned().collect()); + + (committee, keypairs) + } + + fn signed_committee_transition( + current: &Committee, + keypairs: &[iota_types::crypto::AuthorityKeyPair], + next: &Committee, + ) -> CertifiedCheckpointSummary { + let summary = CheckpointSummary { + epoch: current.epoch, + sequence_number: current.epoch, + network_total_transactions: 0, + contents_digest: Default::default(), + previous_digest: None, + epoch_rolling_gas_cost_summary: GasCostSummary::default(), + timestamp_ms: 0, + checkpoint_commitments: Vec::new(), + end_of_epoch_data: Some(EndOfEpochData { + next_epoch_committee: next.committee_members(), + next_epoch_protocol_version: 1, + epoch_commitments: Vec::new(), + epoch_supply_change: 0, + }), + version_specific_data: Vec::new(), + }; + + CertifiedCheckpointSummary::new_from_keypairs_for_testing(summary, keypairs, current) + } + fn signed_end_of_epoch_summary( current_epoch: EpochId, include_next_committee: bool, @@ -912,6 +1007,152 @@ mod tests { assert_eq!(resolved, next_committee); } + #[tokio::test] + async fn target_cache_entry_must_contain_the_requested_epoch() { + let (anchor, _) = committee_with_keypairs(3, 4); + let (mislabeled, _) = committee_with_keypairs(5, 5); + let chain_identifier = chain_identifier(1); + let cache = StaticCache { + key: CommitteeCacheKey::new(chain_identifier, 4), + committee: mislabeled, + }; + let resolver = CommitteeResolver::new( + CommitteeHistorySource { + current_epoch: Some(5), + summaries: BTreeMap::new(), + fail_current_epoch: false, + }, + CommitteeResolution::anchored_with_cache(chain_identifier, anchor, cache), + ); + + let error = resolver.resolve(4).await.unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::Cache { + epoch: 4, + source: CommitteeCacheError::Conflict { epoch: 4 } + } + )); + } + + #[tokio::test] + async fn intermediate_cache_entry_must_contain_its_key_epoch() { + let (anchor, _) = committee_with_keypairs(3, 4); + let (mislabeled, _) = committee_with_keypairs(5, 5); + let chain_identifier = chain_identifier(1); + let cache = StaticCache { + key: CommitteeCacheKey::new(chain_identifier, 4), + committee: mislabeled, + }; + let resolver = CommitteeResolver::new( + CommitteeHistorySource { + current_epoch: Some(5), + summaries: BTreeMap::new(), + fail_current_epoch: false, + }, + CommitteeResolution::anchored_with_cache(chain_identifier, anchor, cache), + ); + + let error = resolver.resolve(5).await.unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::Cache { + epoch: 4, + source: CommitteeCacheError::Conflict { epoch: 4 } + } + )); + } + + #[tokio::test] + async fn anchored_resolution_reports_a_current_epoch_source_failure() { + let (anchor, _) = committee_with_keypairs(0, 4); + let resolver = CommitteeResolver::new( + CommitteeHistorySource { + current_epoch: None, + summaries: BTreeMap::new(), + fail_current_epoch: true, + }, + CommitteeResolution::anchored(anchor), + ); + + let error = resolver.resolve(1).await.unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::FetchCurrentEpoch { .. } + )); + } + + #[tokio::test] + async fn anchored_resolution_reports_missing_epoch_close_evidence() { + let (anchor, _) = committee_with_keypairs(0, 4); + let resolver = CommitteeResolver::new( + CommitteeHistorySource { + current_epoch: Some(1), + summaries: BTreeMap::new(), + fail_current_epoch: false, + }, + CommitteeResolution::anchored(anchor), + ); + + let error = resolver.resolve(1).await.unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::MissingEpochCloseProof { epoch: 0 } + )); + } + + #[tokio::test] + async fn anchored_resolution_reports_a_cache_store_failure() { + let (anchor, keypairs) = committee_with_keypairs(0, 4); + let (next, _) = committee_with_keypairs(1, 5); + let summary = signed_committee_transition(&anchor, &keypairs, &next); + let resolver = CommitteeResolver::new( + CommitteeHistorySource { + current_epoch: Some(1), + summaries: BTreeMap::from([(0, summary)]), + fail_current_epoch: false, + }, + CommitteeResolution::anchored_with_cache(chain_identifier(1), anchor, FailingStoreCache), + ); + + let error = resolver.resolve(1).await.unwrap_err(); + + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::Cache { + epoch: 1, + source: CommitteeCacheError::Backend { epoch: 1, .. } + } + )); + } + + #[tokio::test] + async fn anchored_resolution_authenticates_multiple_epoch_transitions() { + let (first, first_keypairs) = committee_with_keypairs(0, 4); + let (second, second_keypairs) = committee_with_keypairs(1, 5); + let (expected, _) = committee_with_keypairs(2, 6); + let summaries = BTreeMap::from([ + (0, signed_committee_transition(&first, &first_keypairs, &second)), + (1, signed_committee_transition(&second, &second_keypairs, &expected)), + ]); + let resolver = CommitteeResolver::new( + CommitteeHistorySource { + current_epoch: Some(2), + summaries, + fail_current_epoch: false, + }, + CommitteeResolution::anchored(first), + ); + + let resolved = resolver.resolve(2).await.unwrap(); + + assert_eq!(resolved, expected); + } + #[tokio::test] async fn shared_cache_isolated_between_distinct_networks() { let (_, first_successor, _) = signed_end_of_epoch_summary(3, true); diff --git a/poi-rs/tests/proof_verification.rs b/poi-rs/tests/proof_verification.rs index 8a7eb031..fdb2f79a 100644 --- a/poi-rs/tests/proof_verification.rs +++ b/poi-rs/tests/proof_verification.rs @@ -4,7 +4,7 @@ mod utils; use iota_sdk_types::CheckpointContents; -use iota_types::effects::TransactionEvents; +use iota_types::effects::{TestEffectsBuilder, TransactionEvents}; use iota_types::event::EventID; use iota_types::messages_checkpoint::CheckpointContentsExt; use iota_types::object::Object; @@ -41,6 +41,43 @@ fn valid_transaction_proof_is_accepted() { assert_eq!(verified.checkpoint_timestamp_ms(), 0); } +#[test] +fn proof_requires_a_target() { + let (committee, mut proof) = valid_transaction_proof(); + proof_v1_mut(&mut proof).targets = ProofTargets::new(); + + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("a proof without a target must be rejected"); + + assert!(matches!(error.kind, VerifyErrorKind::MissingTarget)); +} + +#[test] +fn transaction_target_must_match_the_packaged_transaction() { + let (committee, mut proof) = valid_transaction_proof(); + proof_v1_mut(&mut proof).targets = + ProofTargets::new().set_transaction(iota_sdk_types::TransactionDigest::new([0xff; 32])); + + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("a mismatched transaction target must be rejected"); + + assert!(matches!(error.kind, VerifyErrorKind::TransactionTargetMismatch)); +} + +#[test] +fn checkpoint_signature_must_match_the_supplied_committee() { + let (_, proof) = valid_transaction_proof(); + let (wrong_committee, _) = iota_types::committee::Committee::new_simple_test_committee_of_size(6); + + let error = ProofVerifier::new(&wrong_committee) + .verify(&proof) + .expect_err("a checkpoint signed by another committee must be rejected"); + + assert!(matches!(error.kind, VerifyErrorKind::CheckpointSummary { .. })); +} + #[test] fn verified_event_content_is_exposed() { let target = event(vec![1, 2, 3]); @@ -114,6 +151,22 @@ fn transaction_must_be_present_in_the_checkpoint() { assert!(matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint)); } +#[test] +fn forged_effects_with_the_same_transaction_are_rejected() { + let (committee, mut proof) = valid_transaction_proof(); + let forged_events = TransactionEvents(vec![event(vec![0xff])]); + let forged_effects = TestEffectsBuilder::new(proof.transaction_proof().transaction.data()) + .with_events_digest(forged_events.digest()) + .build(); + proof_v1_mut(&mut proof).transaction_proof.effects = forged_effects; + + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("effects absent from the checkpoint must be rejected even when the transaction matches"); + + assert!(matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint)); +} + #[test] fn object_target_must_appear_in_the_transaction_effects() { let object = Object::immutable_for_testing(); @@ -144,6 +197,27 @@ fn event_target_must_belong_to_the_proven_transaction() { assert!(matches!(error.kind, VerifyErrorKind::EventTransactionMismatch)); } +#[test] +fn event_target_requires_packaged_event_data() { + let target = event(vec![1, 2, 3]); + let (committee, transaction_digest, mut proof) = proof_with_events(TransactionEvents(vec![target])); + let event_id = EventID { + tx_digest: transaction_digest, + event_seq: 0, + }; + { + let proof = proof_v1_mut(&mut proof); + proof.targets = ProofTargets::new().add_event(event_id); + proof.transaction_proof.events = None; + } + + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("an event target without packaged events must be rejected"); + + assert!(matches!(error.kind, VerifyErrorKind::MissingEvents)); +} + #[test] fn event_sequence_must_exist_in_the_transaction() { let target = event(vec![1, 2, 3]); From 632d16c52a09fe2363a58f257d85e5582dc3a212 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 16:29:08 +0300 Subject: [PATCH 12/19] Update proof serialization fixtures to version 1 format - Moved committee, transaction, object, and event JSON fixtures to a new versioned directory (v1). - Updated test files to reference the new fixture paths. - Added README documentation for the v1 fixtures, outlining regeneration instructions and guidelines for maintaining fixture integrity. - Introduced new fixture files for committee, transaction, object, and event in the v1 directory with synthetic test data. --- bindings/wasm/poi_wasm/src/source.rs | 2 +- .../poi_wasm/tests/committee-bindings.test.ts | 4 ++-- .../tests/committee-resolution.test.ts | 2 +- .../poi_wasm/tests/proof-bindings.test.ts | 2 +- poi-rs/tests/fixtures/README.md | 19 +++++++++++++++++++ .../fixtures/{current => v1}/committee.json | 0 .../tests/fixtures/{current => v1}/event.json | 0 .../fixtures/{current => v1}/object.json | 0 .../fixtures/{current => v1}/transaction.json | 0 poi-rs/tests/proof_serialization.rs | 8 ++++---- 10 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 poi-rs/tests/fixtures/README.md rename poi-rs/tests/fixtures/{current => v1}/committee.json (100%) rename poi-rs/tests/fixtures/{current => v1}/event.json (100%) rename poi-rs/tests/fixtures/{current => v1}/object.json (100%) rename poi-rs/tests/fixtures/{current => v1}/transaction.json (100%) diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index b71f83e1..b0f77ed3 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -306,7 +306,7 @@ mod tests { #[test] fn decodes_the_grpc_bcs_evidence_into_existing_iota_types() { - let proof = Proof::from_json_slice(include_bytes!("../../../../poi-rs/tests/fixtures/current/event.json")) + let proof = Proof::from_json_slice(include_bytes!("../../../../poi-rs/tests/fixtures/v1/event.json")) .expect("fixture must deserialize"); let transaction_proof = proof.transaction_proof(); let checkpoint_summary = proof.checkpoint_summary(); diff --git a/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts index 56128ffd..3bae5f53 100644 --- a/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts @@ -10,7 +10,7 @@ import { Committee } from "../lib/index.js"; test("the WASM committee can be deserialized from Rust JSON", async () => { const json = await readFile( new URL( - "../../../../poi-rs/tests/fixtures/current/committee.json", + "../../../../poi-rs/tests/fixtures/v1/committee.json", import.meta.url, ), "utf8", @@ -28,7 +28,7 @@ test("the WASM committee rejects invalid total voting power", async () => { const fixture = JSON.parse( await readFile( new URL( - "../../../../poi-rs/tests/fixtures/current/committee.json", + "../../../../poi-rs/tests/fixtures/v1/committee.json", import.meta.url, ), "utf8", diff --git a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts index fdd6de95..b61e0d62 100644 --- a/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-resolution.test.ts @@ -109,7 +109,7 @@ interface CommitteeFixture { async function readCommitteeJson(): Promise { return readFile( new URL( - "../../../../poi-rs/tests/fixtures/current/committee.json", + "../../../../poi-rs/tests/fixtures/v1/committee.json", import.meta.url, ), "utf8", diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index a1c563ee..9f24616f 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -143,7 +143,7 @@ interface EventProofFixture { function readFixture(name: string): Promise { return readFile( - new URL(`../../../../poi-rs/tests/fixtures/current/${name}`, import.meta.url), + new URL(`../../../../poi-rs/tests/fixtures/v1/${name}`, import.meta.url), "utf8", ); } diff --git a/poi-rs/tests/fixtures/README.md b/poi-rs/tests/fixtures/README.md new file mode 100644 index 00000000..a7913cdd --- /dev/null +++ b/poi-rs/tests/fixtures/README.md @@ -0,0 +1,19 @@ +# Proof Fixtures + +The `v1` directory contains frozen fixtures for the version 1 proof format. The fixtures are synthetic test data, not captures from localnet or a public IOTA network. They use an epoch 0 single-validator test committee and share one generated checkpoint body. + +Do not rewrite the `v1` files for routine refactors. When the serialized proof format changes intentionally, generate a new versioned directory and update the compatibility tests to cover both versions as appropriate. + +## Regeneration + +Create a temporary Rust test beside `proof_serialization.rs` and construct the fixture data with the same test APIs used by `tests/utils/proofs.rs`: + +- `Committee::new_simple_test_committee()` creates the committee and signing keys. +- `FullCheckpointContents::random_for_testing()` creates synthetic transaction data. +- `TestEffectsBuilder` adds the object or event effects required by each target. +- `CertifiedCheckpointSummary::new_from_keypairs_for_testing()` signs the checkpoint summary. +- `serde_json::to_string_pretty()` serializes the committee and proofs. + +Write the committee once as `committee.json`, then serialize transaction-only, object and event target proofs as `transaction.json`, `object.json` and `event.json`. + +Run the temporary generator with `cargo test -p poi-rs --test -- --test-threads=1`. The test helpers generate new random values, so compare the serialized structure rather than expecting identical bytes. Remove the generator after writing the files, inspect the fixture diff, and run `cargo test -p poi-rs --test proof_serialization -- --test-threads=1` before accepting a new fixture version. diff --git a/poi-rs/tests/fixtures/current/committee.json b/poi-rs/tests/fixtures/v1/committee.json similarity index 100% rename from poi-rs/tests/fixtures/current/committee.json rename to poi-rs/tests/fixtures/v1/committee.json diff --git a/poi-rs/tests/fixtures/current/event.json b/poi-rs/tests/fixtures/v1/event.json similarity index 100% rename from poi-rs/tests/fixtures/current/event.json rename to poi-rs/tests/fixtures/v1/event.json diff --git a/poi-rs/tests/fixtures/current/object.json b/poi-rs/tests/fixtures/v1/object.json similarity index 100% rename from poi-rs/tests/fixtures/current/object.json rename to poi-rs/tests/fixtures/v1/object.json diff --git a/poi-rs/tests/fixtures/current/transaction.json b/poi-rs/tests/fixtures/v1/transaction.json similarity index 100% rename from poi-rs/tests/fixtures/current/transaction.json rename to poi-rs/tests/fixtures/v1/transaction.json diff --git a/poi-rs/tests/proof_serialization.rs b/poi-rs/tests/proof_serialization.rs index 3d6361c9..e3a635bc 100644 --- a/poi-rs/tests/proof_serialization.rs +++ b/poi-rs/tests/proof_serialization.rs @@ -4,10 +4,10 @@ use iota_types::committee::Committee; use poi_rs::{Proof, ProofVerifier}; -const COMMITTEE: &str = include_str!("fixtures/current/committee.json"); -const TRANSACTION: &str = include_str!("fixtures/current/transaction.json"); -const OBJECT: &str = include_str!("fixtures/current/object.json"); -const EVENT: &str = include_str!("fixtures/current/event.json"); +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_fixture_round_trips_and_verifies(fixture: &str) -> Proof { let committee: Committee = serde_json::from_str(COMMITTEE).expect("committee fixture must deserialize"); From 25a8c6f6c61ad93273f924b6e2fcf68e0b17552d Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 31 Aug 2026 17:39:49 +0300 Subject: [PATCH 13/19] chore: update checkpoint handling to return undefined for non-existent checkpoints and enhance error reporting --- bindings/wasm/poi_wasm/lib/ledger-source.ts | 8 +---- bindings/wasm/poi_wasm/lib/source-types.ts | 2 +- bindings/wasm/poi_wasm/package.json | 2 +- bindings/wasm/poi_wasm/src/error.rs | 1 + bindings/wasm/poi_wasm/src/source.rs | 9 ++++-- .../wasm/poi_wasm/tests/ledger-source.test.ts | 7 ++--- poi-rs/src/builder.rs | 14 +++++++-- poi-rs/src/committee.rs | 13 +++++++-- poi-rs/src/source.rs | 4 ++- poi-rs/src/source/grpc.rs | 13 +++++---- poi-rs/tests/committee_resolution.rs | 13 +++++++++ poi-rs/tests/proof_construction.rs | 29 ++++++++++++++++++- poi-rs/tests/utils/sources.rs | 17 +++++++++-- 13 files changed, 101 insertions(+), 31 deletions(-) diff --git a/bindings/wasm/poi_wasm/lib/ledger-source.ts b/bindings/wasm/poi_wasm/lib/ledger-source.ts index d3b4f4a8..6519082c 100644 --- a/bindings/wasm/poi_wasm/lib/ledger-source.ts +++ b/bindings/wasm/poi_wasm/lib/ledger-source.ts @@ -149,7 +149,7 @@ export class LedgerSource implements LedgerSourceContract { public async checkpoint( sequenceNumber: bigint, - ): Promise { + ): Promise { let checkpoint: CheckpointEvidence | undefined; let reachedEnd = false; @@ -211,12 +211,6 @@ export class LedgerSource implements LedgerSourceContract { } } - if (!checkpoint) { - throw new Error( - `getCheckpoint returned no checkpoint for sequence number ${sequenceNumber}`, - ); - } - if (!reachedEnd) { throw new Error( `getCheckpoint did not finish sequence number ${sequenceNumber}`, diff --git a/bindings/wasm/poi_wasm/lib/source-types.ts b/bindings/wasm/poi_wasm/lib/source-types.ts index 4e04083e..d4c9e27f 100644 --- a/bindings/wasm/poi_wasm/lib/source-types.ts +++ b/bindings/wasm/poi_wasm/lib/source-types.ts @@ -46,7 +46,7 @@ export interface LedgerSource { digest: Uint8Array, ): Promise; object(objectId: Uint8Array, version?: bigint): Promise; - checkpoint(sequenceNumber: bigint): Promise; + checkpoint(sequenceNumber: bigint): Promise; committee(epoch: bigint): Promise; currentEpoch(): Promise; epochCloseSummary( diff --git a/bindings/wasm/poi_wasm/package.json b/bindings/wasm/poi_wasm/package.json index ac77323d..d400e569 100644 --- a/bindings/wasm/poi_wasm/package.json +++ b/bindings/wasm/poi_wasm/package.json @@ -24,7 +24,7 @@ "build": "npm run grpc:generate && npm run build:nodejs && npm run typecheck", "build:src:nodejs": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", "prebundle:nodejs": "rimraf node", - "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/poi_wasm.wasm --typescript --target nodejs --out-dir node && node ../build/node poi_wasm --skip-fetch-polyfill && tsc --project ./lib/tsconfig.json", + "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/poi_wasm.wasm --typescript --weak-refs --target nodejs --out-dir node && node ../build/node poi_wasm --skip-fetch-polyfill && tsc --project ./lib/tsconfig.json", "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/poi_wasm_bg.wasm -o node/poi_wasm_bg.wasm", "grpc:schema:update": "node scripts/update-iota-schema.mjs", "grpc:generate": "node scripts/generate-grpc.mjs", diff --git a/bindings/wasm/poi_wasm/src/error.rs b/bindings/wasm/poi_wasm/src/error.rs index f557e347..3305cc5f 100644 --- a/bindings/wasm/poi_wasm/src/error.rs +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -77,6 +77,7 @@ impl WasmError { Self::ProofBuilder(error) => match error { ProofBuilderError::Source { .. } => WasmErrorCode::SourceRequest, ProofBuilderError::TransactionNotFound { .. } + | ProofBuilderError::CheckpointNotFound { .. } | ProofBuilderError::ObjectNotFound { .. } | ProofBuilderError::EventNotFound { .. } => WasmErrorCode::NotFound, ProofBuilderError::MissingRequest diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index b0f77ed3..af721b71 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -149,15 +149,20 @@ impl Source for LedgerSource { Ok(Some(object.into())) } - async fn checkpoint(&self, sequence_number: u64) -> Result { + async fn checkpoint(&self, sequence_number: u64) -> Result, SourceError> { let value = self .checkpoint(sequence_number) .await .map_err(|source| SourceError::request(PoiError::from_js(source)))?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + let evidence: JsCheckpointEvidence = serde_wasm_bindgen::from_value(value) .map_err(|source| SourceError::invalid_response(PoiError::invalid_response(source.to_string())))?; - decode_checkpoint(evidence) + decode_checkpoint(evidence).map(Some) } async fn committee(&self, epoch: EpochId) -> Result { diff --git a/bindings/wasm/poi_wasm/tests/ledger-source.test.ts b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts index 4fef6d89..a6075aaa 100644 --- a/bindings/wasm/poi_wasm/tests/ledger-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts @@ -220,13 +220,10 @@ test("returns undefined when a transaction or object is not returned", async () assert.equal(await source.object(bytes(0x02)), undefined); }); -test("rejects incomplete checkpoint evidence", async () => { +test("returns undefined when a checkpoint is not returned", async () => { const source = checkpointSource(endMarker()); - await assert.rejects( - source.checkpoint(42n), - /returned no checkpoint for sequence number 42/, - ); + assert.equal(await source.checkpoint(42n), undefined); }); test("rejects a checkpoint stream without an end marker", async () => { diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 1c1eaaab..1f4ce38d 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -30,6 +30,12 @@ pub enum ProofBuilderError { /// Transaction digest that was not returned. transaction_digest: TransactionDigest, }, + /// The source did not return the checkpoint containing the transaction. + #[error("checkpoint {sequence_number} was not found")] + CheckpointNotFound { + /// Checkpoint sequence number that was not returned. + sequence_number: u64, + }, /// The source did not return a requested object. #[error("object {object_id} was not found")] ObjectNotFound { @@ -219,11 +225,15 @@ impl ProofBuilder { .chain_identifier() .await .map_err(|source| ProofBuilderError::Source { source })?; + let checkpoint_sequence_number = transaction.checkpoint_sequence_number; let checkpoint = self .source - .checkpoint(transaction.checkpoint_sequence_number) + .checkpoint(checkpoint_sequence_number) .await - .map_err(|source| ProofBuilderError::Source { source })?; + .map_err(|source| ProofBuilderError::Source { source })? + .ok_or(ProofBuilderError::CheckpointNotFound { + sequence_number: checkpoint_sequence_number, + })?; let transaction_events = if self.event_ids.is_empty() { None } else { diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index d040fbf0..0cd51b6f 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -224,7 +224,7 @@ impl CommitteeResolution { /// /// The reader must contain the BCS-encoded `genesis.blob` for the proof's /// network. Authenticated committees are retained in a fresh in-memory cache. - pub fn from_genesis(reader: impl Read) -> Result { + pub fn from_genesis(reader: impl Read) -> Result { Self::from_genesis_with_cache(reader, MemoryCommitteeCache::new()) } @@ -235,6 +235,13 @@ impl CommitteeResolution { pub fn from_genesis_with_cache( reader: impl Read, cache: impl CommitteeCache + 'static, + ) -> Result { + Self::load_genesis(reader, cache).map_err(|kind| CommitteeResolutionError::new(0, kind)) + } + + fn load_genesis( + reader: impl Read, + cache: impl CommitteeCache + 'static, ) -> Result { #[allow(dead_code)] #[derive(Deserialize)] @@ -624,7 +631,7 @@ mod tests { unreachable!("committee transition does not resolve objects") } - async fn checkpoint(&self, _sequence_number: u64) -> Result { + async fn checkpoint(&self, _sequence_number: u64) -> Result, SourceError> { unreachable!("committee transition does not resolve checkpoints") } @@ -661,7 +668,7 @@ mod tests { unreachable!("committee history does not resolve objects") } - async fn checkpoint(&self, _sequence_number: u64) -> Result { + async fn checkpoint(&self, _sequence_number: u64) -> Result, SourceError> { unreachable!("committee history does not resolve checkpoints") } diff --git a/poi-rs/src/source.rs b/poi-rs/src/source.rs index ba53b9b5..2c8ff0b6 100644 --- a/poi-rs/src/source.rs +++ b/poi-rs/src/source.rs @@ -114,7 +114,9 @@ pub trait Source { async fn object(&self, object_id: ObjectId, version: Option) -> Result, SourceError>; /// Fetches and decodes one certified checkpoint and its contents. - async fn checkpoint(&self, sequence_number: u64) -> Result; + /// + /// Returns `None` when the checkpoint does not exist. + async fn checkpoint(&self, sequence_number: u64) -> Result, SourceError>; /// Fetches the committee reported for `epoch`. async fn committee(&self, epoch: EpochId) -> Result; diff --git a/poi-rs/src/source/grpc.rs b/poi-rs/src/source/grpc.rs index 558c0773..fddc7417 100644 --- a/poi-rs/src/source/grpc.rs +++ b/poi-rs/src/source/grpc.rs @@ -126,8 +126,8 @@ impl Source for GrpcClient { Ok(Some(object)) } - async fn checkpoint(&self, sequence_number: u64) -> Result { - let checkpoint = self + async fn checkpoint(&self, sequence_number: u64) -> Result, SourceError> { + let checkpoint = match self .get_checkpoint_by_sequence_number( sequence_number, Some(ReadMask::from(&[ @@ -139,8 +139,11 @@ impl Source for GrpcClient { None, ) .await - .map(|response| response.into_inner()) - .map_err(SourceError::request)?; + { + Ok(response) => response.into_inner(), + Err(error) if error.is_not_found() => return Ok(None), + Err(error) => return Err(SourceError::request(error)), + }; let summary: CertifiedCheckpointSummary = checkpoint .signed_summary() .map_err(SourceError::invalid_response)? @@ -152,7 +155,7 @@ impl Source for GrpcClient { .contents() .map_err(SourceError::invalid_response)?; - Ok(SourceCheckpoint { summary, contents }) + Ok(Some(SourceCheckpoint { summary, contents })) } async fn committee(&self, epoch: EpochId) -> Result { diff --git a/poi-rs/tests/committee_resolution.rs b/poi-rs/tests/committee_resolution.rs index 407e5ac9..6c3f509a 100644 --- a/poi-rs/tests/committee_resolution.rs +++ b/poi-rs/tests/committee_resolution.rs @@ -19,6 +19,19 @@ fn disconnected_client() -> GrpcClient { GrpcClient::new("http://127.0.0.1:1").expect("disconnected gRPC client must be constructed") } +#[test] +fn genesis_loading_errors_are_scoped_to_epoch_zero() { + let Err(error) = CommitteeResolution::from_genesis(std::io::empty()) else { + panic!("empty genesis data must be rejected"); + }; + + assert_eq!(error.target_epoch, 0); + assert!(matches!( + error.kind, + CommitteeResolutionErrorKind::LoadGenesisCommittee { .. } + )); +} + #[tokio::test] async fn genesis_anchored_client_authenticates_committee_across_epochs() { let cluster = start_test_cluster().await; diff --git a/poi-rs/tests/proof_construction.rs b/poi-rs/tests/proof_construction.rs index e0f7f2f6..5f8a8359 100644 --- a/poi-rs/tests/proof_construction.rs +++ b/poi-rs/tests/proof_construction.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use iota_sdk_types::TransactionDigest; use iota_types::event::EventID; use iota_types::object::Object; -use poi_rs::{PoiClient, ProofBuilderError, ProofVerifier, SourceError}; +use poi_rs::{PoiClient, ProofBuilderError, ProofVerifier, Source, SourceError}; use utils::sources::{MissingSource, RecordingSource, RejectingSource}; use utils::{genesis_chain_identifier, grpc_client, object_transfer_tx, staking_tx, start_test_cluster, transfer_tx}; @@ -97,6 +97,33 @@ async fn transaction_not_returned_by_the_source_is_reported_as_missing() { assert_eq!(missing_transaction, transaction_digest); } +#[tokio::test] +async fn checkpoint_not_returned_by_the_source_is_reported_as_missing() { + let cluster = start_test_cluster().await; + let transfer = transfer_tx(&cluster).await; + let client = grpc_client(&cluster); + let checkpoint_sequence_number = client + .transaction(transfer.digest) + .await + .expect("transaction request must succeed") + .expect("executed transaction must exist") + .checkpoint_sequence_number; + let source = RecordingSource::new(client, Arc::new(Mutex::new(Vec::new()))).without_checkpoints(); + + let error = PoiClient::new(source) + .proof() + .transaction(transfer.digest) + .build() + .await + .expect_err("a checkpoint omitted by the source must be rejected"); + + assert!(matches!( + error, + ProofBuilderError::CheckpointNotFound { sequence_number } + if sequence_number == checkpoint_sequence_number + )); +} + #[tokio::test] async fn proof_uses_the_genesis_checkpoint_as_its_chain_identifier() { let cluster = start_test_cluster().await; diff --git a/poi-rs/tests/utils/sources.rs b/poi-rs/tests/utils/sources.rs index 3acb4ebb..bb3b97d3 100644 --- a/poi-rs/tests/utils/sources.rs +++ b/poi-rs/tests/utils/sources.rs @@ -32,7 +32,7 @@ impl Source for RejectingSource { Ok(None) } - async fn checkpoint(&self, _sequence_number: u64) -> Result { + async fn checkpoint(&self, _sequence_number: u64) -> Result, SourceError> { unreachable!("rejected transactions do not resolve a checkpoint") } @@ -54,6 +54,7 @@ pub struct RecordingSource { source: GrpcClient, transactions: Arc>>, object_override: Option, + omit_checkpoints: bool, } impl RecordingSource { @@ -62,6 +63,7 @@ impl RecordingSource { source, transactions, object_override: None, + omit_checkpoints: false, } } @@ -69,6 +71,11 @@ impl RecordingSource { self.object_override = Some(object); self } + + pub fn without_checkpoints(mut self) -> Self { + self.omit_checkpoints = true; + self + } } #[async_trait] @@ -97,7 +104,11 @@ impl Source for RecordingSource { self.source.object(object_id, version).await } - async fn checkpoint(&self, sequence_number: u64) -> Result { + async fn checkpoint(&self, sequence_number: u64) -> Result, SourceError> { + if self.omit_checkpoints { + return Ok(None); + } + self.source.checkpoint(sequence_number).await } @@ -134,7 +145,7 @@ impl Source for MissingSource { Ok(None) } - async fn checkpoint(&self, _sequence_number: u64) -> Result { + async fn checkpoint(&self, _sequence_number: u64) -> Result, SourceError> { unreachable!("missing targets do not resolve a checkpoint") } From 029d233899be93c14a2344dd2fec97a6ae4d5456 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 1 Sep 2026 10:23:24 +0300 Subject: [PATCH 14/19] chore: update terminology from "claims" to "targets" in documentation and code for consistency --- bindings/wasm/poi_wasm/README.md | 2 +- .../examples/src/02_multi_target_proof.ts | 2 +- .../poi_wasm/examples/src/04_object_proof.ts | 2 +- bindings/wasm/poi_wasm/lib/ledger-source.ts | 1 + bindings/wasm/poi_wasm/src/committee.rs | 2 +- bindings/wasm/poi_wasm/src/error.rs | 2 +- bindings/wasm/poi_wasm/src/proof.rs | 12 +++---- bindings/wasm/poi_wasm/src/source.rs | 11 +++++-- .../wasm/poi_wasm/tests/ledger-source.test.ts | 1 + .../poi_wasm/tests/proof-bindings.test.ts | 20 +++++++++++ examples/poi/02_multi_target_proof.rs | 4 +-- examples/poi/04_object_proof.rs | 4 +-- examples/poi/05_event_proof.rs | 2 +- poi-rs/README.md | 26 +++++++-------- poi-rs/src/builder.rs | 33 ++++++++++--------- poi-rs/src/client.rs | 2 +- poi-rs/src/committee.rs | 22 +++++++++++-- poi-rs/src/proof.rs | 18 +++++----- poi-rs/tests/proof_construction.rs | 14 ++++---- 19 files changed, 114 insertions(+), 66 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index b0ab5d22..b0150b37 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -114,7 +114,7 @@ code. Only `PROOF_INVALID` means the proof was rejected. ## What a Verified Proof Proves -A verified proof establishes the following claims relative to the supplied committee: +Successful verification authenticates the following targets relative to the supplied committee: - A transaction target proves that the selected transaction and its effects are included in the certified checkpoint. - An object target proves the exact object version returned by `objectBcs(index)`. For an object ID without a transaction diff --git a/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts b/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts index 3e778e6a..7eae94da 100644 --- a/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/02_multi_target_proof.ts @@ -4,7 +4,7 @@ /** * # Create and Verify a Multi-Target Proof * - * A single Proof of Inclusion can authenticate several claims about the same + * A single Proof of Inclusion can authenticate several targets from the same * transaction. This example proves the transaction itself, one object changed * by it, and one event emitted by it. * diff --git a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts index dc13fdc4..265b9b27 100644 --- a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts @@ -8,7 +8,7 @@ * proof construction time, discovers the transaction that produced it, and * packages both as one proof. * - * The discovered transaction supports the object claim but does not become an + * The discovered transaction supports the object target but does not become an * explicit transaction target. Verification authenticates committee history * from a trusted genesis blob. */ diff --git a/bindings/wasm/poi_wasm/lib/ledger-source.ts b/bindings/wasm/poi_wasm/lib/ledger-source.ts index 6519082c..e403cced 100644 --- a/bindings/wasm/poi_wasm/lib/ledger-source.ts +++ b/bindings/wasm/poi_wasm/lib/ledger-source.ts @@ -26,6 +26,7 @@ const TRANSACTION_PROOF_FIELDS = [ "checkpoint", ]; const CHECKPOINT_PROOF_FIELDS = [ + "checkpoint.sequence_number", "checkpoint.summary.bcs", "checkpoint.signature", "checkpoint.contents.bcs", diff --git a/bindings/wasm/poi_wasm/src/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index cbcbea4f..32f8972b 100644 --- a/bindings/wasm/poi_wasm/src/committee.rs +++ b/bindings/wasm/poi_wasm/src/committee.rs @@ -131,7 +131,7 @@ impl WasmCommitteeResolver { Ok(WasmCommittee(committee)) } - /// Resolves the committee required by `proof` and returns its authenticated claims. + /// Resolves the committee required by `proof` and returns its authenticated targets. pub async fn verify(&self, proof: &WasmProof) -> WasmResult { let verified = self.0.verify(&proof.0).await?; diff --git a/bindings/wasm/poi_wasm/src/error.rs b/bindings/wasm/poi_wasm/src/error.rs index 3305cc5f..a18abdc6 100644 --- a/bindings/wasm/poi_wasm/src/error.rs +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -80,7 +80,7 @@ impl WasmError { | ProofBuilderError::CheckpointNotFound { .. } | ProofBuilderError::ObjectNotFound { .. } | ProofBuilderError::EventNotFound { .. } => WasmErrorCode::NotFound, - ProofBuilderError::MissingRequest + ProofBuilderError::MissingTarget | ProofBuilderError::ObjectReferenceMismatch { .. } | ProofBuilderError::ObjectNotChangedByTransaction { .. } | ProofBuilderError::TransactionMismatch { .. } => WasmErrorCode::InvalidInput, diff --git a/bindings/wasm/poi_wasm/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 272518b1..0e7ffb3c 100644 --- a/bindings/wasm/poi_wasm/src/proof.rs +++ b/bindings/wasm/poi_wasm/src/proof.rs @@ -79,7 +79,7 @@ impl From<&ProofTargets> for WasmProofTargets { } } -/// Authenticated claims returned by successful proof verification. +/// Authenticated targets returned by successful proof verification. #[wasm_bindgen(js_name = VerifiedProof, inspectable)] #[derive(Clone)] pub struct WasmVerifiedProof(Proof); @@ -190,7 +190,7 @@ impl WasmProof { self.0.targets().into() } - /// Verifies this proof locally and returns its authenticated claims. + /// Verifies this proof locally and returns its authenticated targets. pub fn verify(&self, committee: &WasmCommittee) -> WasmResult { let verified = poi_rs::ProofVerifier::new(committee.inner()).verify(&self.0)?; @@ -218,22 +218,22 @@ impl WasmProofBuilder { Self(ProofBuilder::new(source)) } - /// Adds a transaction proof request. + /// Adds a transaction proof target. pub fn transaction(self, transaction_digest: Uint8Array) -> WasmResult { let digest = TransactionDigest::from_bytes(transaction_digest.to_vec())?; Ok(Self(self.0.transaction(digest))) } - /// Adds an object proof request. + /// Adds an object proof target. /// - /// Without a transaction or event request, the source resolves the object's + /// Without a transaction or event target, the source resolves the object's /// latest version at proof construction time. pub fn object(self, object_id: Uint8Array) -> WasmResult { let object_id = ObjectId::from_bytes(object_id.to_vec())?; Ok(Self(self.0.object(object_id))) } - /// Adds an event proof request. + /// Adds an event proof target. pub fn event(self, transaction_digest: Uint8Array, event_sequence: u64) -> WasmResult { let tx_digest = TransactionDigest::from_bytes(transaction_digest.to_vec())?; Ok(Self(self.0.event(EventID { diff --git a/bindings/wasm/poi_wasm/src/source.rs b/bindings/wasm/poi_wasm/src/source.rs index af721b71..413c98ae 100644 --- a/bindings/wasm/poi_wasm/src/source.rs +++ b/bindings/wasm/poi_wasm/src/source.rs @@ -18,8 +18,8 @@ use iota_types::object::Object; use js_sys::Uint8Array; use poi_rs::{Source, SourceCheckpoint, SourceError, SourceTransaction}; use serde::Deserialize; -use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; use crate::error::PoiError; use crate::versioned::{ @@ -142,7 +142,14 @@ impl Source for LedgerSource { return Ok(None); } - let bytes = Uint8Array::new(&value).to_vec(); + let bytes = value + .dyn_into::() + .map_err(|_| { + SourceError::invalid_response(PoiError::invalid_response( + "object source response must be a Uint8Array", + )) + })? + .to_vec(); let versioned: VersionedObject = decode_bcs(&bytes).map_err(SourceError::invalid_response)?; let VersionedObject::V1(object) = versioned; diff --git a/bindings/wasm/poi_wasm/tests/ledger-source.test.ts b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts index a6075aaa..73de7bc2 100644 --- a/bindings/wasm/poi_wasm/tests/ledger-source.test.ts +++ b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts @@ -118,6 +118,7 @@ test("returns the BCS evidence needed by poi-rs", async () => { value: 42n, }); assert.deepEqual(request.readMask?.paths, [ + "checkpoint.sequence_number", "checkpoint.summary.bcs", "checkpoint.signature", "checkpoint.contents.bcs", diff --git a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index 9f24616f..14fcec96 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -6,6 +6,8 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import { Committee, isPoiError, Proof, type ProofTargets, type VerifiedProof } from "../lib/index.js"; +import type { LedgerSource } from "../lib/source-types.js"; +import { ProofBuilder } from "../node/poi_wasm.js"; const fixtures: readonly { name: string; @@ -133,6 +135,24 @@ test("rejects a proof with a committee from another epoch", async () => { ); }); +test("rejects a non-Uint8Array object response", async () => { + const source = { + async object() { + return { 0: 255, length: 1 }; + }, + } as unknown as LedgerSource; + + await assert.rejects( + new ProofBuilder(source).object(new Uint8Array(32)).build(), + (error) => { + assert.ok(isPoiError(error)); + assert.equal(error.code, "SOURCE_REQUEST"); + assert.match(error.message, /object source response must be a Uint8Array/); + return true; + }, + ); +}); + interface EventProofFixture { ProofV1: { targets: { diff --git a/examples/poi/02_multi_target_proof.rs b/examples/poi/02_multi_target_proof.rs index bcfeb268..ff78aca0 100644 --- a/examples/poi/02_multi_target_proof.rs +++ b/examples/poi/02_multi_target_proof.rs @@ -3,7 +3,7 @@ //! # Create and Verify a Multi-Target Proof //! -//! A single Proof of Inclusion can authenticate several claims about the same +//! A single Proof of Inclusion can authenticate several targets from the same //! transaction. This example proves the transaction itself, one object changed //! by the transaction, and one event emitted by the transaction. //! @@ -18,7 +18,7 @@ use poi_rs::CommitteeResolution; /// Demonstrates how to: /// 1. Establish committee trust from the active network's genesis blob. /// 2. Create transaction, object, and event targets in one execution. -/// 3. Construct one proof containing all three claims. +/// 3. Construct one proof containing all three targets. /// 4. Inspect the targets resolved by the builder. /// 5. Verify every target in one operation. #[tokio::main] diff --git a/examples/poi/04_object_proof.rs b/examples/poi/04_object_proof.rs index 4377fb35..ed256a5f 100644 --- a/examples/poi/04_object_proof.rs +++ b/examples/poi/04_object_proof.rs @@ -7,7 +7,7 @@ //! builder fetches its latest version at proof construction time, discovers the //! transaction that produced it, and packages both as one proof. //! -//! The discovered transaction is evidence supporting the object claim. It is not +//! The discovered transaction is evidence supporting the object target. It is not //! an explicit transaction target unless the caller also invokes `transaction`. //! //! Verification authenticates the checkpoint committee from a trusted genesis @@ -21,7 +21,7 @@ use poi_rs::CommitteeResolution; /// 1. Request a proof using only an object ID. /// 2. Let the builder resolve the latest object version and its transaction. /// 3. Distinguish supporting transaction evidence from an explicit target. -/// 4. Authenticate committee history from genesis and verify the object claim. +/// 4. Authenticate committee history from genesis and verify the object target. #[tokio::main] async fn main() -> Result<()> { println!("=== Proof of Inclusion: Create and Verify an Object Proof ===\n"); diff --git a/examples/poi/05_event_proof.rs b/examples/poi/05_event_proof.rs index 5eebe470..8eacd773 100644 --- a/examples/poi/05_event_proof.rs +++ b/examples/poi/05_event_proof.rs @@ -22,7 +22,7 @@ use poi_rs::CommitteeResolution; /// 1. Identify an event by transaction digest and sequence number. /// 2. Construct a proof without adding an explicit transaction target. /// 3. Inspect the event target and its supporting event evidence. -/// 4. Authenticate committee history from genesis and verify the event claim. +/// 4. Authenticate committee history from genesis and verify the event target. #[tokio::main] async fn main() -> Result<()> { println!("=== Proof of Inclusion: Create and Verify an Event Proof ===\n"); diff --git a/poi-rs/README.md b/poi-rs/README.md index 6cc13f42..e15da0ff 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -149,24 +149,24 @@ Retain the verifier when checking multiple proofs so it can reuse its authentica remains the offline entry point for callers that already possess the authoritative committee. Successful verification returns a `VerifiedProof` that borrows from the input proof and exposes only authenticated -checkpoint metadata, transaction data and digest, object targets, and event claims. It intentionally omits the packaged +checkpoint metadata, transaction data and digest, object targets, and event targets. It intentionally omits the packaged user signatures because checkpoint inclusion does not authenticate those bytes. Read relying-party data through this returned value. The original `Proof` remains the portable untrusted envelope used for transport and serialization. Verification checks: -- 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; -- a transaction target declared by the proof matches the packaged transaction; -- object targets declared by the proof derive references present in the transaction effects; -- event data matches the digest recorded in the effects when the proof includes event targets; and -- event targets declared by the proof belong to the transaction and select events in the authenticated event list. +- the proof contains at least one transaction, object, or event target; +- the supplied committee certifies the checkpoint summary and its checkpoint-contents digest; +- the packaged transaction digest matches the transaction effects; +- the transaction effects are included in the authenticated checkpoint contents; +- packaged event data matches the digest in the transaction effects; +- an explicit transaction target matches the packaged transaction; +- every object target's exact reference appears in the transaction effects; and +- every event target has packaged event data, belongs to the packaged transaction, and selects an existing event. ## What a Verified Proof Proves -A verified proof establishes the following claims relative to the supplied committee: +Successful verification authenticates the following targets relative to the supplied committee: - A transaction target proves that the selected transaction and its effects are included in the certified checkpoint. - An object target proves that the exact object version was written by the authenticated transaction. For an object ID @@ -193,8 +193,8 @@ proof. authoritative. `CommitteeResolver::verify()` composes committee resolution with offline verification for source-backed workflows, while `CommitteeResolver::resolve()` returns the authenticated committee when callers need it directly. -Treat every proof payload as untrusted. After successful verification, trust claims relative to the supplied committee -through the returned `VerifiedProof`; do not read relying-party claims from an unrelated `Proof` value. +Treat every proof payload as untrusted. After successful verification, trust target data relative to the supplied +committee through the returned `VerifiedProof`; do not read relying-party data from an unrelated `Proof` value. The proof's `chain` value is informational. The verifier does not authenticate it, so applications must not use it to select a network, committee, genesis blob, or other trust anchor. @@ -249,7 +249,7 @@ formats. - `Proof`: Versioned Proof of Inclusion envelope. - `ProofV1`: Version 1 checkpoint and transaction evidence carried by `Proof::ProofV1`. - `TransactionProof`: Transaction, effects, and optional event evidence used to prove inclusion. -- `ProofTargets`: Transaction, object, and event claims explicitly selected by the caller. +- `ProofTargets`: Transaction, object, and event targets explicitly selected by the caller. - `PoiClient`: Source-backed entry point for proof construction and committee-aware verification. - `CommitteeResolution`: Trusted-node or anchored committee-resolution configuration, including the committee cache. - `ProofBuilder`: Proof-construction workflow for public networks or custom sources. diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 1f4ce38d..92b21bef 100644 --- a/poi-rs/src/builder.rs +++ b/poi-rs/src/builder.rs @@ -14,9 +14,9 @@ use crate::{Proof, ProofTargets, ProofV1, Source, SourceError, TransactionProof} #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ProofBuilderError { - /// No proof request was selected before building. - #[error("proof builder requires a request")] - MissingRequest, + /// No proof target was selected before building. + #[error("proof builder requires a target")] + MissingTarget, /// The configured source failed while reading proof evidence. #[error("source failed while reading proof evidence")] Source { @@ -61,15 +61,15 @@ pub enum ProofBuilderError { ObjectNotChangedByTransaction { /// Requested object ID. object_id: ObjectId, - /// Transaction selected by the other proof requests. + /// Transaction selected by the other proof targets. transaction_digest: TransactionDigest, }, - /// The requests belong to different transactions. - #[error("proof requests belong to different transactions: {actual}, expected {expected}")] + /// The targets belong to different transactions. + #[error("proof targets belong to different transactions: {actual}, expected {expected}")] TransactionMismatch { - /// Transaction selected by the first request. + /// Transaction selected by the first target. expected: TransactionDigest, - /// Transaction selected by a conflicting request. + /// Transaction selected by a conflicting target. actual: TransactionDigest, }, } @@ -79,6 +79,7 @@ pub enum ProofBuilderError { /// The builder keeps proof construction independent of a specific transport. /// With the `native-grpc` feature enabled, SDK gRPC clients can be adapted /// through `ProofBuilder::from_grpc_client`. +#[derive(Debug)] pub struct ProofBuilder { source: S, transaction_digests: Vec, @@ -129,22 +130,22 @@ impl ProofBuilder { } } - /// Adds a transaction proof request. + /// Adds a transaction proof target. pub fn transaction(mut self, transaction_digest: TransactionDigest) -> Self { Self::push_unique(&mut self.transaction_digests, transaction_digest); self } - /// Adds an object proof request by object ID. + /// Adds an object proof target by object ID. /// - /// Without a transaction or event request, the source resolves the object's + /// Without a transaction or event target, the source resolves the object's /// latest version at proof construction time. pub fn object(mut self, object_id: ObjectId) -> Self { Self::push_unique(&mut self.object_ids, object_id); self } - /// Adds multiple object proof requests by object ID. + /// Adds multiple object proof targets by object ID. /// /// Object resolution follows the build-time semantics of [`Self::object`]. pub fn objects(mut self, object_ids: impl IntoIterator) -> Self { @@ -154,13 +155,13 @@ impl ProofBuilder { self } - /// Adds an event proof request. + /// Adds an event proof target. pub fn event(mut self, event_id: EventID) -> Self { Self::push_unique(&mut self.event_ids, event_id); self } - /// Adds multiple event proof requests. + /// Adds multiple event proof targets. pub fn events(mut self, event_ids: impl IntoIterator) -> Self { for event_id in event_ids { Self::push_unique(&mut self.event_ids, event_id); @@ -171,7 +172,7 @@ impl ProofBuilder { /// Builds the requested proof from the configured source. pub async fn build(self) -> Result { if self.transaction_digests.is_empty() && self.object_ids.is_empty() && self.event_ids.is_empty() { - return Err(ProofBuilderError::MissingRequest); + return Err(ProofBuilderError::MissingTarget); } self.build_proof().await @@ -214,7 +215,7 @@ impl ProofBuilder { } let transaction_digest = - selected_transaction.expect("ProofBuilder only builds a proof for non-empty requests"); + selected_transaction.expect("ProofBuilder only builds a proof for non-empty targets"); let transaction = self.fetch_transaction(transaction_digest).await?; (transaction, objects) diff --git a/poi-rs/src/client.rs b/poi-rs/src/client.rs index 358588b8..67007b6b 100644 --- a/poi-rs/src/client.rs +++ b/poi-rs/src/client.rs @@ -7,7 +7,7 @@ use iota_grpc_client::Client as GrpcClient; use crate::{CommitteeResolution, CommitteeResolver, ProofBuilder, Source}; /// Convenient entry point for proof construction and verification backed by one ledger source. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct PoiClient { source: S, } diff --git a/poi-rs/src/committee.rs b/poi-rs/src/committee.rs index 0cd51b6f..47919e22 100644 --- a/poi-rs/src/committee.rs +++ b/poi-rs/src/committee.rs @@ -1,6 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::fmt; use std::io::Read; use std::sync::Arc; @@ -192,6 +193,23 @@ pub enum CommitteeResolution { }, } +impl fmt::Debug for CommitteeResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TrustedNode => formatter.write_str("TrustedNode"), + Self::Anchored { + committee, + chain_identifier, + .. + } => formatter + .debug_struct("Anchored") + .field("committee", committee) + .field("chain_identifier", chain_identifier) + .finish_non_exhaustive(), + } + } +} + impl CommitteeResolution { /// Anchors committee resolution at an already trusted committee. /// @@ -292,7 +310,7 @@ impl CommitteeResolution { /// 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)] +#[derive(Clone, Debug)] pub struct CommitteeResolver { source: S, mode: CommitteeResolution, @@ -334,7 +352,7 @@ where /// Committee resolution may fetch committee or epoch-close evidence from /// the source. The final proof verification is performed locally by /// [`ProofVerifier`]. On success, the returned [`VerifiedProof`] borrows the - /// authenticated claims from `proof`. + /// authenticated targets from `proof`. pub async fn verify<'proof>(&self, proof: &'proof Proof) -> Result, ProofVerificationError> { let committee = self .resolve(proof.checkpoint_summary().epoch()) diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 7656657a..a79ee7cf 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -85,13 +85,13 @@ pub enum VerifyErrorKind { /// Event targets are present but the proof does not contain transaction events. #[error("event targets require transaction event data")] MissingEvents, - /// An event claim identifies a transaction other than the one proven by the envelope. + /// An event target identifies a transaction other than the one proven by the envelope. #[error("event target does not belong to the transaction")] EventTransactionMismatch, - /// An event claim refers to an index outside the packaged transaction events. + /// An event target refers to an index outside the packaged transaction events. #[error("event sequence number {sequence} is out of bounds")] EventSequenceOutOfBounds { - /// Transaction-local event index requested by the claim. + /// Transaction-local event index selected by the target. sequence: u64, }, /// A claimed object reference is absent from the packaged transaction effects. @@ -115,7 +115,7 @@ pub struct ProofTargets { } impl ProofTargets { - /// Creates an empty set of claims. + /// Creates an empty set of targets. pub fn new() -> Self { Self::default() } @@ -173,14 +173,14 @@ impl TransactionProof { } } -/// Authenticated claims borrowed from a successfully verified proof. +/// Authenticated targets borrowed from a successfully verified proof. /// /// Values are exposed through this type only after all checkpoint, transaction, /// object, and event checks have succeeded. The original [`Proof`] remains /// available for serialization and inspection, but its contents must not be /// treated as authenticated without a corresponding `VerifiedProof`. #[derive(Debug)] -#[must_use = "read authenticated claims from the returned VerifiedProof"] +#[must_use = "read authenticated targets from the returned VerifiedProof"] pub struct VerifiedProof<'proof> { checkpoint_summary: &'proof CertifiedCheckpointSummary, transaction: &'proof TransactionData, @@ -391,7 +391,7 @@ impl<'committee> ProofVerifier<'committee> { self.committee } - /// Verifies a proof and all of its claims. + /// Verifies a proof and all of its targets. /// /// Verification checks that: /// @@ -401,7 +401,7 @@ impl<'committee> ProofVerifier<'committee> { /// - the transaction effects occur in the authenticated checkpoint contents; /// - every selected target matches the authenticated proof data. /// - /// On success, returns a [`VerifiedProof`] borrowing the authenticated claims + /// On success, returns a [`VerifiedProof`] borrowing the authenticated targets /// from `proof`. /// /// # Errors @@ -413,7 +413,7 @@ impl<'committee> ProofVerifier<'committee> { } } - /// Verifies a version 1 proof and all of its claims. + /// Verifies a version 1 proof and all of its targets. fn verify_v1<'proof>(&self, proof: &'proof ProofV1) -> Result, VerifyError> { if proof.targets.is_empty() { return Err(VerifyError { diff --git a/poi-rs/tests/proof_construction.rs b/poi-rs/tests/proof_construction.rs index 5f8a8359..8daa7054 100644 --- a/poi-rs/tests/proof_construction.rs +++ b/poi-rs/tests/proof_construction.rs @@ -30,18 +30,18 @@ async fn client_uses_a_custom_source_for_proof_building() { } #[tokio::test] -async fn proof_requires_at_least_one_request() { +async fn proof_requires_at_least_one_target() { let error = PoiClient::new(RejectingSource) .proof() .build() .await - .expect_err("a proof without a request must be rejected"); + .expect_err("a proof without a target must be rejected"); - assert!(matches!(error, ProofBuilderError::MissingRequest)); + assert!(matches!(error, ProofBuilderError::MissingTarget)); } #[tokio::test] -async fn stacked_requests_are_deduplicated_and_reuse_transaction_evidence() { +async fn stacked_targets_are_deduplicated_and_reuse_transaction_evidence() { let cluster = start_test_cluster().await; let staking = staking_tx(&cluster).await; let object_id = staking.gas_object.object_id; @@ -61,7 +61,7 @@ async fn stacked_requests_are_deduplicated_and_reuse_transaction_evidence() { .event(event_id) .build() .await - .expect("stacked requests from one transaction must produce a proof"); + .expect("stacked targets from one transaction must produce a proof"); assert_eq!( *transactions @@ -198,7 +198,7 @@ async fn explicit_transaction_and_event_from_different_transactions_are_rejected .event(event_id) .build() .await - .expect_err("requests from different transactions must be rejected"); + .expect_err("targets from different transactions must be rejected"); assert!(matches!( error, @@ -263,7 +263,7 @@ async fn object_outside_the_event_transaction_is_rejected() { } #[tokio::test] -async fn object_requests_from_different_transactions_are_rejected() { +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; From ef5d093da1fb396ec95bcb839b46db832a18abe9 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 1 Sep 2026 11:05:02 +0300 Subject: [PATCH 15/19] chore: add JSON compatibility section to README and proof.rs for versioning guidelines --- bindings/wasm/poi_wasm/README.md | 9 +++++++++ poi-rs/README.md | 9 +++++++++ poi-rs/src/proof.rs | 5 +++++ 3 files changed, 23 insertions(+) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index b0150b37..7e347b40 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -122,6 +122,15 @@ Successful verification authenticates the following targets relative to the supp remains latest. Deleted and wrapped objects are unsupported. - An event target proves that `eventContents(index)` returns the selected event's authenticated contents. +## JSON Compatibility + +Proof JSON is a versioned persistence and exchange format. Releases that support `ProofV1` continue to deserialize its +existing JSON shape and serialize the same field structure. Frozen V1 fixtures enforce this contract for transaction, +object, and event proofs. + +Dependency upgrades must not silently change the `ProofV1` representation. Preserve the existing shape with custom +serialization when necessary, or introduce a new `Proof` variant for an incompatible format change. + ## Trust Boundaries Treat the node, source adapter, and complete proof payload as untrusted until verification succeeds. Trusted-node diff --git a/poi-rs/README.md b/poi-rs/README.md index e15da0ff..fcaad260 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -187,6 +187,15 @@ transaction proof carries the complete event list needed to verify the effects' present only when the caller explicitly requests the transaction itself, although transaction evidence supports every proof. +## JSON Compatibility + +Proof JSON is a versioned persistence and exchange format. Releases that support `ProofV1` continue to deserialize its +existing JSON shape and serialize the same field structure. Frozen V1 fixtures enforce this contract for transaction, +object, and event proofs. + +Dependency upgrades must not silently change the `ProofV1` representation. Preserve the existing shape with custom +serialization when necessary, or introduce a new `Proof` variant for an incompatible format change. + ## Trust Boundaries `ProofVerifier` is intentionally offline. It does not make RPC calls and does not decide which committee is diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index a79ee7cf..543c979d 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -241,6 +241,11 @@ impl<'proof> VerifiedProof<'proof> { /// proof formats without making matches in downstream crates source-breaking. /// Its serialized representation is externally tagged, with the variant name /// identifying the proof format, for example `{ "ProofV1": { ... } }`. +/// +/// The JSON representation of each variant is a compatibility contract. A +/// release that supports `ProofV1` must continue to deserialize its existing +/// shape and serialize the same field structure. Incompatible changes require +/// a new [`Proof`] variant. #[derive(Clone, Debug, Serialize, Deserialize)] #[non_exhaustive] pub enum Proof { From 3215d50fbc8fafd16ccaaa85e3516bd12e7b0c90 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 1 Sep 2026 12:01:11 +0300 Subject: [PATCH 16/19] chore: update test-wasm-notarization job dependencies and add Wasm example execution --- .github/workflows/build-and-test.yml | 43 +++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index f8efb6e2..f78022ad 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -257,7 +257,7 @@ jobs: additional-artifact-paths: bindings/wasm/poi_wasm/package.json test-wasm-notarization: - needs: [build-wasm-notarization, build-wasm-poi, check-for-run-condition] + needs: [build-wasm-notarization, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: @@ -273,22 +273,49 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 24.x + node-version: 20.x - name: Install JS dependencies run: npm ci working-directory: bindings/wasm/notarization_wasm - - name: Install Proof of Inclusion JS dependencies - run: npm ci - working-directory: bindings/wasm/poi_wasm - - name: Download bindings/wasm/notarization_wasm artifacts uses: actions/download-artifact@v4 with: name: notarization-wasm-bindings-build path: bindings/wasm/notarization_wasm + - name: Start iota sandbox + uses: "./.github/actions/iota/setup" + with: + iota-version: ${{ env.IOTA_VERSION }} + with-grpc: true + + - name: publish Notarization Move package + run: echo "IOTA_NOTARIZATION_PKG_ID=$(./publish_package.sh)" >> "$GITHUB_ENV" + working-directory: notarization-move/scripts/ + + - name: Run Wasm examples + run: npm run test:node + working-directory: bindings/wasm/notarization_wasm + + test-wasm-poi: + needs: [build-wasm-poi, check-for-run-condition] + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 24.x + + - name: Install JS dependencies + run: npm ci + working-directory: bindings/wasm/poi_wasm + - name: Download bindings/wasm/poi_wasm artifacts uses: actions/download-artifact@v4 with: @@ -305,10 +332,6 @@ jobs: run: echo "IOTA_NOTARIZATION_PKG_ID=$(./publish_package.sh)" >> "$GITHUB_ENV" working-directory: notarization-move/scripts/ - - name: Run Wasm examples - run: npm run test:node - working-directory: bindings/wasm/notarization_wasm - - name: Run Proof of Inclusion Wasm examples run: | export IOTA_GENESIS_PATH="$HOME/.iota/iota_config/genesis.blob" From 173969ae6e287f059b5b658e5638a808d890afbe Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 1 Sep 2026 14:35:56 +0300 Subject: [PATCH 17/19] chore: enhance genesis blob handling and validation for mainnet and testnet --- poi-rs/README.md | 5 ++ poi-rs/src/bin/poi.rs | 105 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/poi-rs/README.md b/poi-rs/README.md index fcaad260..dc2cb20d 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -250,6 +250,11 @@ cargo run --release -p poi-rs --features cli --bin poi -- verify \ proof.json ``` +For `--network mainnet` and `--network testnet`, the CLI downloads the genesis blob to the IOTA configuration +directory under `poi//genesis.blob`. It validates the blob against the network's canonical genesis digest on +download and every cache load. Devnet has no stable genesis digest, so verification on devnet requires an explicit +trusted blob through `--genesis`. + Run `cargo run --release -p poi-rs --features cli --bin poi -- --help` for all targets, network options, and file input formats. diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index f3a2f369..f8eb5771 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -7,11 +7,12 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, bail, ensure}; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use iota_config::{IOTA_GENESIS_FILENAME, iota_config_dir}; +use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; +use iota_types::digests::{ChainIdentifier, get_mainnet_chain_identifier, get_testnet_chain_identifier}; use iota_types::event::EventID; use poi_rs::{CommitteeResolution, PoiClient, Proof, VerifiedProof}; @@ -30,7 +31,7 @@ const VERIFY_EXAMPLES: &str = r#"Examples: 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. +Mainnet and testnet download, validate, and cache their genesis blob automatically. Devnet requires --genesis because its genesis digest is not stable. 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)] @@ -156,7 +157,7 @@ impl VerifyArgs { }; let genesis = match self.genesis.as_deref() { Some(path) => { - fs::File::open(path).with_context(|| format!("failed to open genesis blob '{}'", path.display()))? + fs::read(path).with_context(|| format!("failed to read genesis blob '{}'", path.display()))? } None => { load_genesis( @@ -167,7 +168,7 @@ impl VerifyArgs { .await? } }; - let resolution = CommitteeResolution::from_genesis(genesis) + let resolution = CommitteeResolution::from_genesis(genesis.as_slice()) .map_err(|error| anyhow::anyhow!("failed to load trusted genesis blob: {error}"))?; let verified = PoiClient::from_grpc_client(self.endpoint.client()?) .verifier(resolution) @@ -258,6 +259,16 @@ impl Network { } } + fn expected_chain_identifier(self) -> Result { + match self { + Self::Mainnet => Ok(get_mainnet_chain_identifier()), + Self::Testnet => Ok(get_testnet_chain_identifier()), + Self::Devnet => bail!( + "managed genesis is unavailable for devnet because its genesis digest is not stable; pass --genesis PATH" + ), + } + } + fn client(self) -> Result { match self { Self::Mainnet => GrpcClient::new_mainnet().context("failed to configure mainnet gRPC endpoint"), @@ -267,14 +278,25 @@ impl Network { } } -async fn load_genesis(network: Network) -> Result { +async fn load_genesis(network: Network) -> Result> { + network.expected_chain_identifier()?; 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 bytes = if path.is_file() { + let bytes = + fs::read(&path).with_context(|| format!("failed to read cached genesis blob '{}'", path.display()))?; + validate_genesis(network, &bytes).with_context(|| { + format!( + "cached genesis blob '{}' is not trusted; remove it to download a fresh copy", + path.display() + ) + })?; + bytes + } else { let parent = path .parent() .context("managed genesis path does not have a parent directory")?; @@ -285,13 +307,36 @@ async fn load_genesis(network: Network) -> Result { let bytes = reqwest::get(url) .await .with_context(|| format!("failed to download {} genesis blob from '{url}'", network.name()))? + .error_for_status() + .with_context(|| format!("{} genesis download returned an error", 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()))?; - } + .with_context(|| format!("failed to read genesis blob from '{url}'"))? + .to_vec(); + validate_genesis(network, &bytes) + .with_context(|| format!("downloaded {} genesis blob is not trusted", network.name()))?; + fs::write(&path, &bytes).with_context(|| format!("failed to cache genesis blob at '{}'", path.display()))?; + bytes + }; - fs::File::open(&path).with_context(|| format!("failed to open genesis blob '{}'", path.display())) + Ok(bytes) +} + +/// Validates that the genesis blob is valid BCS and matches the expected chain +/// identifier for the network. +fn validate_genesis(network: Network, bytes: &[u8]) -> Result<()> { + let genesis: Genesis = + bcs::from_bytes(bytes).with_context(|| format!("{} genesis blob is not valid BCS", network.name()))?; + let actual = ChainIdentifier::from(*genesis.checkpoint().digest()); + let expected = network.expected_chain_identifier()?; + ensure!( + actual == expected, + "{} genesis digest mismatch: expected {}, found {}", + network.name(), + expected.digest(), + actual.digest() + ); + Ok(()) } #[tokio::main(flavor = "current_thread")] @@ -303,3 +348,41 @@ fn parse_event_id(value: &str) -> Result { EventID::try_from(value.to_owned()) .map_err(|error| format!("invalid event ID '{value}'; expected TRANSACTION_DIGEST:EVENT_SEQUENCE: {error}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_genesis_uses_the_pinned_network_identifiers() { + assert_eq!( + Network::Mainnet + .expected_chain_identifier() + .expect("mainnet must have a pinned identifier"), + get_mainnet_chain_identifier() + ); + assert_eq!( + Network::Testnet + .expected_chain_identifier() + .expect("testnet must have a pinned identifier"), + get_testnet_chain_identifier() + ); + } + + #[test] + fn managed_genesis_is_unavailable_for_devnet() { + let error = Network::Devnet + .expected_chain_identifier() + .expect_err("devnet must require an explicit genesis blob"); + + assert!(error.to_string().contains("pass --genesis PATH")); + } + + #[test] + fn managed_genesis_rejects_invalid_bcs() { + let error = + validate_genesis(Network::Mainnet, b"not a genesis blob").expect_err("invalid BCS must not be trusted"); + + assert!(error.to_string().contains("mainnet genesis blob is not valid BCS")); + } +} From 24a2f02070d1a9fccd40891834cd5ec078e7eaeb Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 1 Sep 2026 14:58:36 +0300 Subject: [PATCH 18/19] chore: enhance transaction verification to include user signatures and update related tests --- bindings/wasm/poi_wasm/README.md | 3 ++- poi-rs/README.md | 10 ++++++---- poi-rs/src/proof.rs | 30 ++++++++++++++++++++---------- poi-rs/tests/proof_verification.rs | 18 ++++++++++++++++++ poi-rs/tests/utils/proofs.rs | 11 +++++++++-- 5 files changed, 55 insertions(+), 17 deletions(-) diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 7e347b40..91e53f32 100644 --- a/bindings/wasm/poi_wasm/README.md +++ b/bindings/wasm/poi_wasm/README.md @@ -116,7 +116,8 @@ code. Only `PROOF_INVALID` means the proof was rejected. Successful verification authenticates the following targets relative to the supplied committee: -- A transaction target proves that the selected transaction and its effects are included in the certified checkpoint. +- A transaction target proves that the selected transaction, its user signatures, and its effects are included in the + certified checkpoint. - An object target proves the exact object version returned by `objectBcs(index)`. For an object ID without a transaction or event target, `makeProof()` resolves its latest version at proof construction time; the proof does not claim that it remains latest. Deleted and wrapped objects are unsupported. diff --git a/poi-rs/README.md b/poi-rs/README.md index dc2cb20d..a8486c34 100644 --- a/poi-rs/README.md +++ b/poi-rs/README.md @@ -148,10 +148,11 @@ the chain identifier automatically; `anchored_with_cache()` requires it explicit Retain the verifier when checking multiple proofs so it can reuse its authenticated committee cache. `ProofVerifier` remains the offline entry point for callers that already possess the authoritative committee. -Successful verification returns a `VerifiedProof` that borrows from the input proof and exposes only authenticated -checkpoint metadata, transaction data and digest, object targets, and event targets. It intentionally omits the packaged -user signatures because checkpoint inclusion does not authenticate those bytes. Read relying-party data through this -returned value. The original `Proof` remains the portable untrusted envelope used for transport and serialization. +Successful verification returns a `VerifiedProof` that borrows from the input proof and exposes authenticated checkpoint +metadata, transaction data and digest, object targets, and event targets. Verification also authenticates the packaged +user signatures against the checkpoint contents, although `VerifiedProof` does not expose them. Read relying-party data +through this returned value. The original `Proof` remains the portable untrusted envelope used for transport and +serialization. Verification checks: @@ -159,6 +160,7 @@ Verification checks: - the supplied committee certifies the checkpoint summary and its checkpoint-contents digest; - the packaged transaction digest matches the transaction effects; - the transaction effects are included in the authenticated checkpoint contents; +- the packaged user signatures match those committed by the checkpoint; - packaged event data matches the digest in the transaction effects; - an explicit transaction target matches the packaged transaction; - every object target's exact reference appears in the transaction effects; and diff --git a/poi-rs/src/proof.rs b/poi-rs/src/proof.rs index 543c979d..ea11dd8b 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -16,7 +16,7 @@ use iota_types::committee::Committee; use iota_types::digests::ChainIdentifier; use iota_types::effects::{TransactionEffects, TransactionEffectsAPI, TransactionEffectsExt, TransactionEvents}; use iota_types::event::EventID; -use iota_types::messages_checkpoint::{CertifiedCheckpointSummary, CheckpointContentsExt}; +use iota_types::messages_checkpoint::CertifiedCheckpointSummary; use iota_types::object::Object; use iota_types::transaction::Transaction; use serde::{Deserialize, Serialize}; @@ -79,6 +79,9 @@ pub enum VerifyErrorKind { /// The packaged transaction effects are absent from the authenticated checkpoint contents. #[error("transaction digest not found in the checkpoint contents")] TransactionNotInCheckpoint, + /// The packaged user signatures differ from those committed by the checkpoint. + #[error("transaction signatures do not match the checkpoint contents")] + TransactionSignaturesMismatch, /// The packaged events do not match the events digest in the transaction effects. #[error("events digest does not match the execution digest")] EventsDigestMismatch, @@ -208,8 +211,8 @@ impl<'proof> VerifiedProof<'proof> { /// Returns the transaction data included in the authenticated checkpoint. /// - /// User signatures packaged with the original proof are not returned because - /// checkpoint inclusion does not authenticate those signature bytes. + /// Verification authenticates the packaged user signatures against the checkpoint + /// contents, but this accessor returns only the transaction data. pub const fn transaction(&self) -> &'proof TransactionData { self.transaction } @@ -404,6 +407,7 @@ impl<'committee> ProofVerifier<'committee> { /// - the checkpoint contents match the digest in that summary; /// - the transaction, effects, and optional events are internally consistent; /// - the transaction effects occur in the authenticated checkpoint contents; + /// - the packaged user signatures match those committed by the checkpoint; /// - every selected target matches the authenticated proof data. /// /// On success, returns a [`VerifiedProof`] borrowing the authenticated targets @@ -437,7 +441,7 @@ impl<'committee> ProofVerifier<'committee> { }, })?; - self.verify_transaction_proof(summary, &proof.checkpoint_contents, &proof.transaction_proof)?; + self.verify_transaction_proof(&proof.checkpoint_contents, &proof.transaction_proof)?; let events = self.verify_targets(&proof.targets, &proof.transaction_proof)?; Ok(VerifiedProof { @@ -453,7 +457,6 @@ impl<'committee> ProofVerifier<'committee> { /// Checks the transaction-to-effects, effects-to-checkpoint, and effects-to-events links. fn verify_transaction_proof( &self, - summary: &CertifiedCheckpointSummary, checkpoint_contents: &CheckpointContents, transaction_proof: &TransactionProof, ) -> Result<(), VerifyError> { @@ -465,13 +468,20 @@ impl<'committee> ProofVerifier<'committee> { }); } - let transaction_is_in_checkpoint = checkpoint_contents - .enumerate_transactions(summary) - .any(|(_, digests)| digests == execution_digests); + let checkpoint_transaction = checkpoint_contents + .transactions() + .iter() + .find(|transaction| { + transaction.transaction == execution_digests.transaction + && transaction.effects == execution_digests.effects + }) + .ok_or(VerifyError { + kind: VerifyErrorKind::TransactionNotInCheckpoint, + })?; - if !transaction_is_in_checkpoint { + if checkpoint_transaction.signatures.as_slice() != transaction_proof.transaction.data().signatures() { return Err(VerifyError { - kind: VerifyErrorKind::TransactionNotInCheckpoint, + kind: VerifyErrorKind::TransactionSignaturesMismatch, }); } diff --git a/poi-rs/tests/proof_verification.rs b/poi-rs/tests/proof_verification.rs index fdb2f79a..3a5b2619 100644 --- a/poi-rs/tests/proof_verification.rs +++ b/poi-rs/tests/proof_verification.rs @@ -8,6 +8,7 @@ use iota_types::effects::{TestEffectsBuilder, TransactionEvents}; use iota_types::event::EventID; use iota_types::messages_checkpoint::CheckpointContentsExt; use iota_types::object::Object; +use iota_types::transaction::SenderSignedTransactionAPI; use poi_rs::{Proof, ProofTargets, ProofV1, ProofVerifier, VerifyErrorKind}; use utils::proofs::{event, execution_data, proof_with_events, proof_with_targets, valid_transaction_proof}; @@ -151,6 +152,23 @@ fn transaction_must_be_present_in_the_checkpoint() { assert!(matches!(error.kind, VerifyErrorKind::TransactionNotInCheckpoint)); } +#[test] +fn transaction_signatures_must_match_the_checkpoint() { + let (committee, mut proof) = valid_transaction_proof(); + proof_v1_mut(&mut proof) + .transaction_proof + .transaction + .data_mut_for_testing() + .tx_signatures_mut_for_testing() + .clear(); + + let error = ProofVerifier::new(&committee) + .verify(&proof) + .expect_err("altered transaction signatures must be rejected"); + + assert!(matches!(error.kind, VerifyErrorKind::TransactionSignaturesMismatch)); +} + #[test] fn forged_effects_with_the_same_transaction_are_rejected() { let (committee, mut proof) = valid_transaction_proof(); diff --git a/poi-rs/tests/utils/proofs.rs b/poi-rs/tests/utils/proofs.rs index ed249aa2..b4383827 100644 --- a/poi-rs/tests/utils/proofs.rs +++ b/poi-rs/tests/utils/proofs.rs @@ -53,7 +53,7 @@ fn proof_from_execution( execution: ExecutionData, events: Option, ) -> (Committee, Proof) { - let contents = CheckpointContents::new_with_digests_only_for_tests([execution.digests()]); + let contents = checkpoint_contents(&execution); let (committee, summary) = signed_checkpoint(&contents); let chain = ChainIdentifier::from(*summary.digest()); let proof = ProofV1::new( @@ -74,7 +74,7 @@ pub fn proof_with_events(events: TransactionEvents) -> (Committee, TransactionDi 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 contents = checkpoint_contents(&execution); let (committee, summary) = signed_checkpoint(&contents); let chain = ChainIdentifier::from(*summary.digest()); let proof = ProofV1::new( @@ -89,6 +89,13 @@ pub fn proof_with_events(events: TransactionEvents) -> (Committee, TransactionDi (committee, transaction_digest, proof) } +fn checkpoint_contents(execution: &ExecutionData) -> CheckpointContents { + CheckpointContents::new_with_digests_and_signatures( + [execution.digests()], + vec![execution.transaction.data().signatures().to_vec()], + ) +} + pub fn event(contents: Vec) -> Event { Event { package_id: ObjectId::SYSTEM, From b8c24be2ae0a7af534fb73acf040be2c4a70f2e3 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 1 Sep 2026 15:01:11 +0300 Subject: [PATCH 19/19] chore: reorganize imports for clarity in poi.rs --- poi-rs/src/bin/poi.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/poi-rs/src/bin/poi.rs b/poi-rs/src/bin/poi.rs index f8eb5771..e4187817 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -9,7 +9,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail, ensure}; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use iota_config::{IOTA_GENESIS_FILENAME, genesis::Genesis, iota_config_dir}; +use iota_config::genesis::Genesis; +use iota_config::{IOTA_GENESIS_FILENAME, iota_config_dir}; use iota_grpc_client::Client as GrpcClient; use iota_sdk_types::{ObjectId, TransactionDigest}; use iota_types::digests::{ChainIdentifier, get_mainnet_chain_identifier, get_testnet_chain_identifier};