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" diff --git a/bindings/wasm/poi_wasm/README.md b/bindings/wasm/poi_wasm/README.md index 306e4fe0..91e53f32 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`. @@ -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,20 +89,48 @@ 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 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 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. + +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 + +Successful verification authenticates the following targets relative to the supplied committee: + +- 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. +- 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 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/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..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. * @@ -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..265b9b27 100644 --- a/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts +++ b/bindings/wasm/poi_wasm/examples/src/04_object_proof.ts @@ -4,32 +4,35 @@ /** * # 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. Trusted-node resolution keeps the example - * focused on object-driven discovery. + * The discovered transaction supports the object target but does not become an + * 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"); + // 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] }); const proofTargets = proof.targets; @@ -44,9 +47,15 @@ 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"); - 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("The resolved object version was changed by a transaction trusted through the selected node."); + 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 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 d58f5301..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,9 +47,15 @@ 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"); - 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("The selected event was emitted by a transaction trusted through the selected node."); + 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 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/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 fcfd2acb..00ce9326 100644 --- a/bindings/wasm/poi_wasm/lib/index.ts +++ b/bindings/wasm/poi_wasm/lib/index.ts @@ -9,5 +9,7 @@ export { ProofEventTarget, ProofObjectTarget, 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/lib/ledger-source.ts b/bindings/wasm/poi_wasm/lib/ledger-source.ts index d3b4f4a8..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", @@ -149,7 +150,7 @@ export class LedgerSource implements LedgerSourceContract { public async checkpoint( sequenceNumber: bigint, - ): Promise { + ): Promise { let checkpoint: CheckpointEvidence | undefined; let reachedEnd = false; @@ -211,12 +212,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/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/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/committee.rs b/bindings/wasm/poi_wasm/src/committee.rs index 32f9c2e8..32f8972b 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; +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 { @@ -120,10 +131,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 targets. + 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/error.rs b/bindings/wasm/poi_wasm/src/error.rs index 9dbd9fd7..a18abdc6 100644 --- a/bindings/wasm/poi_wasm/src/error.rs +++ b/bindings/wasm/poi_wasm/src/error.rs @@ -2,21 +2,105 @@ // 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)] + Json(#[from] serde_json::Error), + #[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::CheckpointNotFound { .. } + | ProofBuilderError::ObjectNotFound { .. } + | ProofBuilderError::EventNotFound { .. } => WasmErrorCode::NotFound, + ProofBuilderError::MissingTarget + | 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::Json(_) | 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 +110,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/src/proof.rs b/bindings/wasm/poi_wasm/src/proof.rs index 99422294..0e7ffb3c 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 targets 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 targets. + 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. @@ -139,19 +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 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 b71f83e1..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,22 +142,34 @@ 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; 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 { @@ -306,7 +318,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 91c08ac4..3bae5f53 100644 --- a/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/committee-bindings.test.ts @@ -10,22 +10,25 @@ 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", ); 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 () => { 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 6319c755..b61e0d62 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 { Committee, CommitteeResolution, CommitteeResolver, isPoiError } from "../lib/index.js"; import type { LedgerSource } from "../lib/source-types.js"; -import { Committee, CommitteeResolution, CommitteeResolver } from "../lib/index.js"; test("the WASM resolver constructs a committee reported by a trusted node", async () => { const source = await committeeSource(); @@ -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; + }, ); }); @@ -82,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/ledger-source.test.ts b/bindings/wasm/poi_wasm/tests/ledger-source.test.ts index 4fef6d89..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", @@ -220,13 +221,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/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts index aa88a312..14fcec96 100644 --- a/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts +++ b/bindings/wasm/poi_wasm/tests/proof-bindings.test.ts @@ -5,11 +5,14 @@ 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, 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; assertTargets: (targets: ProofTargets) => void; + assertVerified: (proof: VerifiedProof) => void; }[] = [ { name: "transaction", @@ -21,6 +24,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 +42,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 +64,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,17 +86,84 @@ 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); }); } }); +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), + (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; + }, + ); + } +}); + +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; + }, + ); +}); + +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: { + events: [{ eventSeq: string }]; + }; + }; +} + 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/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..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] @@ -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..ed256a5f 100644 --- a/examples/poi/04_object_proof.rs +++ b/examples/poi/04_object_proof.rs @@ -4,16 +4,14 @@ //! # 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 +//! The discovered transaction is evidence supporting the object target. 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 +21,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 target. #[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,16 +76,17 @@ 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); - verifier + let verifier = client.verifier(resolution); + 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..8eacd773 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 target. #[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,16 +76,19 @@ 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); - verifier + let verifier = client.verifier(resolution); + 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/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 7afa5bc2..09871d52 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,24 +80,25 @@ 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 -| 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. | +| 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 @@ -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 @@ -115,7 +119,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 7c27f63c..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; @@ -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(()) @@ -69,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, @@ -96,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), } } @@ -110,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 { @@ -137,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/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!" diff --git a/poi-rs/README.md b/poi-rs/README.md index a1bd8007..a8486c34 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). @@ -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(()) # } ``` @@ -140,21 +141,40 @@ verifier.verify(proof).await?; 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. +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: -- 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; -- an explicitly requested transaction matches the packaged transaction; -- requested object targets 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. +- 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; +- 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 +- every event target has packaged event data, belongs to the packaged transaction, and selects an existing event. + +## What a Verified Proof Proves + +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 + 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 @@ -164,10 +184,19 @@ 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. + +## 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 @@ -175,8 +204,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 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. @@ -223,6 +252,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. @@ -231,7 +265,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/bin/poi.rs b/poi-rs/src/bin/poi.rs index 42772285..e4187817 100644 --- a/poi-rs/src/bin/poi.rs +++ b/poi-rs/src/bin/poi.rs @@ -7,13 +7,15 @@ 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::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}; 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"; @@ -30,7 +32,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)] @@ -81,8 +83,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. @@ -156,7 +158,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,17 +169,44 @@ 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}"))?; - 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")?; - writeln!(io::stdout().lock(), "valid").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: &VerifiedProof<'_>) -> io::Result<()> { + writeln!(writer, "Proof verified successfully.")?; + 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:")?; + + if let Some(transaction) = proof.transaction_target() { + writeln!(writer, " transaction: {transaction}")?; + } + for object in proof.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 proof.events() { + writeln!(writer, " event: {}:{}", event.tx_digest, event.event_seq)?; + } + + Ok(()) +} + #[derive(Debug, Args)] #[command(group( ArgGroup::new("endpoint") @@ -231,6 +260,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"), @@ -240,14 +279,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")?; @@ -258,13 +308,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")] @@ -276,3 +349,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")); + } +} diff --git a/poi-rs/src/builder.rs b/poi-rs/src/builder.rs index 9dbbf584..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 { @@ -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 { @@ -48,20 +54,22 @@ 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, - /// 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, }, } @@ -71,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, @@ -121,21 +130,24 @@ 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. /// - /// The source resolves the ID to the exact object reference packaged in the proof. + /// 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 { for object_id in object_ids { Self::push_unique(&mut self.object_ids, object_id); @@ -143,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); @@ -160,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 @@ -203,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) @@ -214,11 +226,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/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/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 7b5fec50..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; @@ -8,6 +9,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,7 +19,8 @@ use iota_types::transaction::Transaction; use serde::Deserialize; use crate::{ - BoxError, CommitteeCache, CommitteeCacheError, MemoryCommitteeCache, Proof, ProofVerifier, Source, VerifyError, + BoxError, CommitteeCache, CommitteeCacheError, CommitteeCacheKey, MemoryCommitteeCache, Proof, ProofVerifier, + Source, VerifiedProof, VerifyError, }; /// Error returned when a committee cannot be resolved for an epoch. @@ -50,6 +53,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 { @@ -163,26 +185,55 @@ 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, }, } +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. /// /// 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), } } @@ -191,17 +242,24 @@ 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()) } /// 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, + ) -> 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)] @@ -218,14 +276,32 @@ impl CommitteeResolution { bcs::from_reader(reader).map_err(|source| CommitteeResolutionErrorKind::LoadGenesisCommittee { source: Box::new(source), })?; + 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(committee, cache)) + Ok(Self::anchored_with_cache(chain_identifier, committee, cache)) } } @@ -234,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, @@ -260,8 +336,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 } } } @@ -270,8 +351,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 targets from `proof`. + pub async fn verify<'proof>(&self, proof: &'proof Proof) -> Result, ProofVerificationError> { let committee = self .resolve(proof.checkpoint_summary().epoch()) .await @@ -299,6 +381,7 @@ where async fn resolve_from_anchor( &self, trusted_committee: &Committee, + chain_identifier: Option, cache: &dyn CommitteeCache, target_epoch: EpochId, ) -> Result { @@ -315,7 +398,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 { @@ -341,7 +425,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 { @@ -380,7 +465,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; } @@ -409,6 +496,7 @@ where async fn fetch_next_committee( &self, target_epoch: EpochId, + chain_identifier: Option, current_committee: &Committee, cache: &dyn CommitteeCache, ) -> Result { @@ -481,7 +569,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 { @@ -493,6 +582,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")] @@ -505,10 +601,11 @@ impl CommitteeResolver { #[cfg(test)] mod tests { + use std::collections::BTreeMap; 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; @@ -517,14 +614,24 @@ mod tests { use crate::{SourceCheckpoint, SourceError, SourceTransaction}; struct StaticCache { + key: CommitteeCacheKey, 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 { @@ -542,7 +649,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") } @@ -551,7 +658,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( @@ -562,6 +669,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, SourceError> { + 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>>, @@ -575,11 +720,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(()) } @@ -587,22 +732,105 @@ 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(()) } } + #[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, ) -> (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(), @@ -641,7 +869,7 @@ mod tests { ); let committee = resolver - .fetch_next_committee(4, ¤t_committee, &cache) + .fetch_next_committee(4, None, ¤t_committee, &cache) .await .unwrap(); @@ -661,7 +889,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, &wrong_committee, &cache) + .fetch_next_committee(4, None, &wrong_committee, &cache) .await .unwrap_err(); @@ -686,7 +914,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, ¤t_committee, &cache) + .fetch_next_committee(4, None, ¤t_committee, &cache) .await .unwrap_err(); @@ -709,7 +937,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, &wrong_committee, &cache) + .fetch_next_committee(4, None, &wrong_committee, &cache) .await .unwrap_err(); @@ -731,7 +959,7 @@ mod tests { ); let error = resolver - .fetch_next_committee(4, &expected_committee, &cache) + .fetch_next_committee(4, None, &expected_committee, &cache) .await .unwrap_err(); @@ -756,7 +984,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(); @@ -775,7 +1003,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(); @@ -785,17 +1016,200 @@ 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 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); + 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 6d559e11..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, @@ -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 ff54a6ff..ea11dd8b 100644 --- a/poi-rs/src/proof.rs +++ b/poi-rs/src/proof.rs @@ -11,12 +11,12 @@ //! [`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}; 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,19 +79,22 @@ 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, /// 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 +118,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,12 +176,79 @@ impl TransactionProof { } } +/// 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 targets 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. + /// + /// 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 + } + + /// 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 /// 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 { @@ -242,35 +312,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, @@ -324,7 +399,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: /// @@ -332,19 +407,23 @@ 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 + /// 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> { + /// 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 { kind: VerifyErrorKind::MissingTarget, @@ -362,16 +441,22 @@ impl<'committee> ProofVerifier<'committee> { }, })?; - self.verify_transaction_proof(summary, &proof.checkpoint_contents, &proof.transaction_proof)?; - self.verify_targets(&proof.targets, &proof.transaction_proof)?; + self.verify_transaction_proof(&proof.checkpoint_contents, &proof.transaction_proof)?; + let events = self.verify_targets(&proof.targets, &proof.transaction_proof)?; - Ok(()) + 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. fn verify_transaction_proof( &self, - summary: &CertifiedCheckpointSummary, checkpoint_contents: &CheckpointContents, transaction_proof: &TransactionProof, ) -> Result<(), VerifyError> { @@ -383,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, }); } @@ -405,7 +497,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 +510,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 +533,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,17 +541,20 @@ impl<'committee> ProofVerifier<'committee> { }); } - let event_index = event_id.event_seq as usize; - let Some(_) = events.get(event_index) else { + let event = usize::try_from(event_id.event_seq) + .ok() + .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/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 3919d740..6c3f509a 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; @@ -16,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; @@ -36,7 +52,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()) 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_construction.rs b/poi-rs/tests/proof_construction.rs index f912b300..8daa7054 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}; @@ -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 @@ -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"); } @@ -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; @@ -171,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, @@ -236,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; diff --git a/poi-rs/tests/proof_serialization.rs b/poi-rs/tests/proof_serialization.rs index 5644e7ad..e3a635bc 100644 --- a/poi-rs/tests/proof_serialization.rs +++ b/poi-rs/tests/proof_serialization.rs @@ -4,16 +4,16 @@ 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"); 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..3a5b2619 100644 --- a/poi-rs/tests/proof_verification.rs +++ b/poi-rs/tests/proof_verification.rs @@ -4,10 +4,11 @@ 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; +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}; @@ -22,9 +23,78 @@ 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 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]); + 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] @@ -82,6 +152,39 @@ 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(); + 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(); @@ -112,6 +215,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]); 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 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, 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") }