diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 648036f..b4c050d 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0-alpha" authors.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["iota", "tangle", "utxo", "audit-trail", "audit-trail"] +keywords = ["iota", "tangle", "utxo", "audit-trail"] license.workspace = true readme = "./README.md" repository.workspace = true diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index 65aec6c..6f96799 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -65,7 +65,8 @@ Available examples: ### Real-World -| Name | Description | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `01_customs_clearance` | Models customs clearance with role-tag restrictions, delegated capabilities, denied inspection writes, and a final write lock | -| `02_clinical_trial` | Models a clinical trial with time-constrained capabilities, mid-study tag addition, deletion windows, time-locks, and regulator verification | +| Name | Description | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01_customs_clearance` | Models customs clearance with role-tag restrictions, delegated capabilities, denied inspection writes, and a final write lock | +| `02_clinical_trial` | Models a clinical trial with time-constrained capabilities, mid-study tag addition, deletion windows, time-locks, and regulator verification | +| `03_digital_product_passport` | Models a Digital Product Passport for an e-bike battery with lifecycle-scoped actors, technician access approval, an annual maintenance event, and documented Lifecycle Credit reward evidence | diff --git a/bindings/wasm/audit_trail_wasm/examples/src/main.ts b/bindings/wasm/audit_trail_wasm/examples/src/main.ts index db36f30..3ec08c8 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/main.ts @@ -14,6 +14,7 @@ import { capabilityConstraints } from "./advanced/10_capability_constraints"; import { manageRecordTags } from "./advanced/11_manage_record_tags"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; +import { digitalProductPassport } from "./real-world/03_digital_product_passport"; export async function main(example?: string) { const argument = example ?? process.argv?.[2]?.toLowerCase(); @@ -48,6 +49,8 @@ export async function main(example?: string) { return customsClearance(); case "02_clinical_trial": return clinicalTrial(); + case "03_digital_product_passport": + return digitalProductPassport(); default: throw new Error(`Unknown example name: '${argument}'`); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts index 85e6ccb..c628673 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts @@ -31,17 +31,15 @@ */ import { - AuditTrailClient, CapabilityIssueOptions, Data, LockingConfig, LockingWindow, PermissionSet, - RoleTags, TimeLock, } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; -import { getFundedClient, TEST_GAS_BUDGET } from "../util"; +import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util"; export async function customsClearance(): Promise { console.log("=== Customs Clearance ===\n"); @@ -277,26 +275,3 @@ export async function customsClearance(): Promise { console.log("\nCustoms clearance completed successfully."); } - -async function issueTaggedRecordRole( - admin: AuditTrailClient, - trailId: string, - roleName: string, - tag: string, - issuedTo: string, -): Promise { - await admin - .trail(trailId) - .access() - .forRole(roleName) - .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) - .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin - .trail(trailId) - .access() - .forRole(roleName) - .issueCapability(new CapabilityIssueOptions(issuedTo)) - .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); -} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts index 597602a..a4deea4 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts @@ -34,17 +34,15 @@ */ import { - AuditTrailClient, CapabilityIssueOptions, Data, LockingConfig, LockingWindow, PermissionSet, - RoleTags, TimeLock, } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; -import { getFundedClient, TEST_GAS_BUDGET } from "../util"; +import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util"; export async function clinicalTrial(): Promise { console.log("=== Clinical Trial Data Integrity ===\n"); @@ -290,26 +288,3 @@ export async function clinicalTrial(): Promise { console.log("\nClinical trial data-integrity verification completed successfully."); } - -async function issueTaggedRecordRole( - admin: AuditTrailClient, - trailId: string, - roleName: string, - tag: string, - issuedTo: string, -): Promise { - await admin - .trail(trailId) - .access() - .forRole(roleName) - .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) - .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin - .trail(trailId) - .access() - .forRole(roleName) - .issueCapability(new CapabilityIssueOptions(issuedTo)) - .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); -} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts new file mode 100644 index 0000000..983e057 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts @@ -0,0 +1,342 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * # Digital Product Passport Example + * + * Models a Digital Product Passport (DPP) for an e-bike battery, inspired by the + * public IOTA DPP demo. + * + * Scope note: this example stays within the Audit Trail SDK. The demo's wider + * IOTA stack (Identity, Hierarchies, Tokenization, and Gas Station) is mapped + * here onto audit-trail-native concepts: + * + * - product identity, bill of materials, reward policy, and service history are + * captured as immutable audit records + * - service-network authorization is represented through role-scoped capabilities + * - Lifecycle Credit (LCC) payouts are documented as reward records rather than + * executed as token transfers + * + * ## Actors + * + * - **Manufacturer**: Creates the DPP, publishes manufacturing data, and + * administers roles and capabilities. + * - **LifecycleManager**: Updates the mutable lifecycle-stage metadata. + * - **Distributor**: Writes logistics and handover records. + * - **Consumer**: Writes the commissioning / in-use activation record. + * - **ServiceTechnician**: Reviews the passport, requests write access, and + * records the maintenance event once authorized. + * - **Recycler**: Prepared for future end-of-life events through a + * recycling-scoped capability. + * - **EPRO**: Records reward policy and the reward-payout evidence for verified + * maintenance. + * + * ## How the trail is used as a DPP + * + * - immutable_metadata: product identity for the battery passport + * - updatable_metadata: current lifecycle stage + * - record tags: manufacturing, logistics, ownership, maintenance, recycling, rewards + * - roles and capabilities: each actor can write only its assigned slice of the lifecycle + * - access-request flow: the technician is denied maintenance writes until the + * manufacturer issues the scoped capability + * - service evidence: the maintenance event mirrors the demo's "Annual + * Maintenance" / "Health Snapshot" pattern with a 76% health score and a + * 1-LCC reward record + */ + +import { AuditTrailClient, CapabilityIssueOptions, Data, PermissionSet, RoleTags } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util"; + +export async function digitalProductPassport(): Promise { + console.log("=== Digital Product Passport ===\n"); + + const manufacturer = await getFundedClient(); + const lifecycleManager = await getFundedClient(); + const distributor = await getFundedClient(); + const consumer = await getFundedClient(); + const serviceTechnician = await getFundedClient(); + const recycler = await getFundedClient(); + const epro = await getFundedClient(); + + console.log("Manufacturer wallet: ", manufacturer.senderAddress()); + console.log("Lifecycle manager wallet: ", lifecycleManager.senderAddress()); + console.log("Distributor wallet: ", distributor.senderAddress()); + console.log("Consumer wallet: ", consumer.senderAddress()); + console.log("Service technician wallet:", serviceTechnician.senderAddress()); + console.log("Recycler wallet: ", recycler.senderAddress()); + console.log("EPRO wallet: ", epro.senderAddress(), "\n"); + + // === Create the DPP trail === + + console.log("Creating the DPP trail for EcoBike's battery..."); + + const { output: created } = await manufacturer + .createTrail() + .withRecordTags(["manufacturing", "logistics", "ownership", "maintenance", "recycling", "rewards"]) + .withTrailMetadata("DPP: Pro 48V Battery", "Manufacturer: EcoBike | Serial: EB-48V-2024-001337") + .withUpdatableMetadata("Lifecycle Stage: Manufactured") + .withInitialRecordString( + "event=dpp_created\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike", + "event:dpp_created", + "manufacturing", + ) + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + const trailId = created.id; + console.log("Trail created with ID", trailId, "\n"); + + // === Define DPP roles and issue capabilities === + + console.log("Configuring DPP actor roles..."); + + await issueTaggedRecordRole(manufacturer, trailId, "Manufacturer", "manufacturing", manufacturer.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "Distributor", "logistics", distributor.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "Consumer", "ownership", consumer.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "Recycler", "recycling", recycler.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "EPRO", "rewards", epro.senderAddress()); + + await manufacturer + .trail(trailId) + .access() + .forRole("ServiceTechnician") + .create(PermissionSet.recordAdminPermissions(), new RoleTags(["maintenance"])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + await issueMetadataRole(manufacturer, trailId, "LifecycleManager", lifecycleManager.senderAddress()); + + // === Prepare the passport with lifecycle context from the DPP demo === + + console.log("Publishing product details, service-network context, and reward policy..."); + + await manufacturer + .trail(trailId) + .records() + .add( + Data.fromString( + "event=product_details_published\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike\nmanufacturer_did=did:iota:testnet:0xdc704ab63984d5763576c12ce5f62fe735766bc1fc9892a5e2a7be777a9af897\nbattery_details=48V removable e-bike battery with smart BMS\nbill_of_materials=cathode:NMC811;anode:graphite;housing:recycled_aluminum;bms:BMS-v3\ncompliance=CE,RoHS,UN38.3\nsustainability=recycled_aluminum_housing:35%\nservice_network=EcoBike certified service network", + ), + "event:product_details_published", + "manufacturing", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + await epro + .trail(trailId) + .records() + .add( + Data.fromString( + "event=reward_policy_published\nreward_type=LCC\nannual_maintenance_reward=1 LCC\nrecycling_reward=10 LCC\nfinal_owner_reward=10 LCC\nmanufacturer_return_reward=10 LCC\nend_of_life_bundle=30 LCC\nsettlement_operator=EcoCycle EPRO", + ), + "event:reward_policy_published", + "rewards", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(epro); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: In Distribution") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + await distributor + .trail(trailId) + .records() + .add( + Data.fromString( + "event=distributed\nshipment_id=SHIP-EB-2026-0042\ntracking_status=Delivered to Nairobi certified service region\ntransport_certification=ADR-compliant battery transport", + ), + "event:distributed", + "logistics", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(distributor); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: In Use") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + await consumer + .trail(trailId) + .records() + .add( + Data.fromString( + "event=commissioned\nowner_profile=Urban commuter fleet\nusage_status=Battery commissioned for daily e-bike service\nrepair_options=EcoBike certified annual maintenance available", + ), + "event:commissioned", + "ownership", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(consumer); + + // === Technician reviews history and requests maintenance access === + + console.log("Technician reviews the current DPP history..."); + + const historyBeforeService = await serviceTechnician.trail(trailId).records().listPage(undefined, 20); + console.log("Technician can already read", historyBeforeService.records.length, "public DPP records.\n"); + + let unauthorizedWriteSucceeded = false; + try { + await serviceTechnician + .trail(trailId) + .records() + .add( + Data.fromString("event=unauthorized_maintenance_attempt"), + "event:unauthorized_maintenance_attempt", + "maintenance", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(serviceTechnician); + unauthorizedWriteSucceeded = true; + } catch { + // Expected + } + assert.equal( + unauthorizedWriteSucceeded, + false, + "maintenance writes must fail until the technician is explicitly authorized", + ); + console.log("Maintenance write denied before access grant, as expected.\n"); + + const nowMs = BigInt(Date.now()); + const technicianValidUntilMs = nowMs + BigInt(30 * 24 * 60 * 60 * 1000); + + const issuedTechnicianCap = await manufacturer + .trail(trailId) + .access() + .forRole("ServiceTechnician") + .issueCapability( + new CapabilityIssueOptions(serviceTechnician.senderAddress(), nowMs, technicianValidUntilMs), + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + console.log( + "Issued ServiceTechnician capability", + issuedTechnicianCap.output.capabilityId, + "(valid until", + technicianValidUntilMs + ").\n", + ); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: Maintenance In Progress") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + // === Perform the maintenance event described in the DPP demo === + + console.log("Recording the annual maintenance event..."); + + const maintenanceEvent = await serviceTechnician + .trail(trailId) + .records() + .add( + Data.fromString( + "entry_type=Annual Maintenance\nservice_action=Health Snapshot\nhealth_score=76%\nfindings=Routine maintenance completed successfully\nwork_performed=Battery contacts cleaned; cell balance check passed; firmware diagnostics passed\nnext_service_due=2027-04-20", + ), + "event:annual_maintenance", + "maintenance", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(serviceTechnician); + + console.log("Service technician added maintenance record", maintenanceEvent.output.sequenceNumber + ".\n"); + + const rewardEvent = await epro + .trail(trailId) + .records() + .add( + Data.fromString( + `event=lcc_reward_distributed\ntrigger_record=${maintenanceEvent.output.sequenceNumber}\nreward_type=LCC\namount=1\nreason=Annual maintenance completed\nbeneficiary=${serviceTechnician.senderAddress()}`, + ), + "event:lcc_reward_distributed", + "rewards", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(epro); + + console.log( + "EPRO added reward record", + rewardEvent.output.sequenceNumber + " for the verified maintenance event.\n", + ); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: Maintained and Ready for Continued Use") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + // === Verify the resulting DPP === + + console.log("Verifying the resulting DPP..."); + + const onChain = await manufacturer.trail(trailId).get(); + const firstPage = await manufacturer.trail(trailId).records().listPage(undefined, 20); + + console.log("Recorded DPP events:"); + for (const record of firstPage.records) { + console.log(` #${record.sequenceNumber} | tag=${record.tag} | metadata=${record.metadata}`); + } + + assert.equal( + firstPage.records.length, + 7, + "expected 7 DPP records (initial + product details + reward policy + distribution + commissioning + maintenance + reward payout)", + ); + assert.ok( + onChain.tags.some((t) => t.tag === "maintenance") + && onChain.tags.some((t) => t.tag === "recycling") + && onChain.tags.some((t) => t.tag === "rewards"), + "expected the DPP tag registry to contain maintenance, recycling, and rewards", + ); + assert.ok( + onChain.roles.roles.some((r) => r.name === "Manufacturer") + && onChain.roles.roles.some((r) => r.name === "Distributor") + && onChain.roles.roles.some((r) => r.name === "Consumer") + && onChain.roles.roles.some((r) => r.name === "ServiceTechnician") + && onChain.roles.roles.some((r) => r.name === "Recycler") + && onChain.roles.roles.some((r) => r.name === "EPRO") + && onChain.roles.roles.some((r) => r.name === "LifecycleManager"), + "expected all DPP roles to be registered", + ); + assert.equal(onChain.updatableMetadata, "Lifecycle Stage: Maintained and Ready for Continued Use"); + + const maintenanceRecord = firstPage.records.find((record) => record.metadata === "event:annual_maintenance"); + assert.ok(maintenanceRecord, "expected the maintenance record to be present in the DPP history"); + + const rewardRecord = firstPage.records.find((record) => record.metadata === "event:lcc_reward_distributed"); + assert.ok(rewardRecord, "expected the reward payout record to be present in the DPP history"); + + console.log("\nDigital Product Passport scenario completed successfully."); +} + +async function issueMetadataRole( + admin: AuditTrailClient, + trailId: string, + roleName: string, + issuedTo: string, +): Promise { + await admin + .trail(trailId) + .access() + .forRole(roleName) + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + await admin + .trail(trailId) + .access() + .forRole(roleName) + .issueCapability(new CapabilityIssueOptions(issuedTo)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts index 7848a8e..b7ba809 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts @@ -16,6 +16,7 @@ import { capabilityConstraints } from "./advanced/10_capability_constraints"; import { manageRecordTags } from "./advanced/11_manage_record_tags"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; +import { digitalProductPassport } from "./real-world/03_digital_product_passport"; describe("Audit trail wasm node examples", function() { afterEach(() => { @@ -61,4 +62,7 @@ describe("Audit trail wasm node examples", function() { it("runs clinical trial example", async () => { await clinicalTrial(); }); + it("runs digital product passport example", async () => { + await digitalProductPassport(); + }); }); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/util.ts b/bindings/wasm/audit_trail_wasm/examples/src/util.ts index 96fc941..524bcfc 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/util.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/util.ts @@ -10,6 +10,7 @@ import { PackageOverrides, Permission, PermissionSet, + RoleTags, TimeLock, } from "@iota/audit-trail/node"; import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; @@ -96,3 +97,26 @@ export async function grantSelfRecordPermissions(client: AuditTrailClient, trail .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); } + +export async function issueTaggedRecordRole( + admin: AuditTrailClient, + trailId: string, + roleName: string, + tag: string, + issuedTo: string, +): Promise { + await admin + .trail(trailId) + .access() + .forRole(roleName) + .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + await admin + .trail(trailId) + .access() + .forRole(roleName) + .issueCapability(new CapabilityIssueOptions(issuedTo)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts index 1555454..8f82711 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts @@ -14,6 +14,7 @@ import { capabilityConstraints } from "./advanced/10_capability_constraints"; import { manageRecordTags } from "./advanced/11_manage_record_tags"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; +import { digitalProductPassport } from "./real-world/03_digital_product_passport"; export async function main(example?: string) { const argument = example ?? new URLSearchParams(window.location.search).get("example")?.toLowerCase(); @@ -48,6 +49,8 @@ export async function main(example?: string) { return customsClearance(); case "02_clinical_trial": return clinicalTrial(); + case "03_digital_product_passport": + return digitalProductPassport(); default: throw new Error(`Unknown example name: '${argument}'`); } diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 5c0b68c..a0354e0 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -100,6 +100,10 @@ path = "audit-trail/real-world/01_customs_clearance.rs" name = "02_clinical_trial" path = "audit-trail/real-world/02_clinical_trial.rs" +[[example]] +name = "03_digital_product_passport" +path = "audit-trail/real-world/03_digital_product_passport.rs" + [dependencies] anyhow.workspace = true audit_trail = { path = "../audit-trail-rs" } diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index 23a0be5..840db4e 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -77,10 +77,11 @@ IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --releas ## Real-World Examples -| Name | Information | -| :----------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [01_customs_clearance](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/01_customs_clearance.rs) | Models customs clearance with role-tag restrictions, delegated capabilities, denied inspection writes, and a final write lock. | -| [02_clinical_trial](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/02_clinical_trial.rs) | Models a Phase III clinical trial with time-constrained capabilities, mid-study tag additions, deletion-window enforcement, time-locked datasets, and read-only regulator verification. | +| Name | Information | +| :------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [01_customs_clearance](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/01_customs_clearance.rs) | Models customs clearance with role-tag restrictions, delegated capabilities, denied inspection writes, and a final write lock. | +| [02_clinical_trial](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/02_clinical_trial.rs) | Models a Phase III clinical trial with time-constrained capabilities, mid-study tag additions, deletion-window enforcement, time-locked datasets, and read-only regulator verification. | +| [03_digital_product_passport](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/03_digital_product_passport.rs) | Models a Digital Product Passport for an e-bike battery with lifecycle-scoped actors, technician access approval, an annual maintenance event, and documented Lifecycle Credit reward evidence. | ## Key Concepts diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 6c651fa..4e720a5 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -27,15 +27,12 @@ //! - locking: writes are frozen once the shipment is fully cleared use anyhow::{Result, ensure}; -use audit_trail::AuditTrailClient; use audit_trail::core::types::{ CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, - RoleTags, TimeLock, + TimeLock, }; -use examples::get_funded_audit_trail_client; -use iota_sdk::types::base_types::{IotaAddress, ObjectID}; +use examples::{get_funded_audit_trail_client, issue_tagged_record_role}; use product_common::core_client::CoreClient; -use product_common::test_utils::InMemSigner; use sha2::{Digest, Sha256}; #[tokio::main] @@ -336,33 +333,3 @@ async fn main() -> Result<()> { Ok(()) } - -async fn issue_tagged_record_role( - client: &AuditTrailClient, - trail_id: ObjectID, - role_name: &str, - tag: &str, - issued_to: IotaAddress, -) -> Result<()> { - client - .trail(trail_id) - .access() - .for_role(role_name) - .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new([tag]))) - .build_and_execute(client) - .await?; - - client - .trail(trail_id) - .access() - .for_role(role_name) - .issue_capability(CapabilityIssueOptions { - issued_to: Some(issued_to), - valid_from_ms: None, - valid_until_ms: None, - }) - .build_and_execute(client) - .await?; - - Ok(()) -} diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index ec5ca14..0a427f5 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -32,15 +32,12 @@ //! - read-only verification: a regulator inspects the trail without write access use anyhow::{Result, ensure}; -use audit_trail::AuditTrailClient; use audit_trail::core::types::{ CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, - RoleTags, TimeLock, + TimeLock, }; -use examples::get_funded_audit_trail_client; -use iota_sdk::types::base_types::{IotaAddress, ObjectID}; +use examples::{get_funded_audit_trail_client, issue_tagged_record_role}; use product_common::core_client::CoreClient; -use product_common::test_utils::InMemSigner; #[tokio::main] async fn main() -> Result<()> { @@ -358,33 +355,3 @@ async fn main() -> Result<()> { Ok(()) } - -async fn issue_tagged_record_role( - client: &AuditTrailClient, - trail_id: ObjectID, - role_name: &str, - tag: &str, - issued_to: IotaAddress, -) -> Result<()> { - client - .trail(trail_id) - .access() - .for_role(role_name) - .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new([tag]))) - .build_and_execute(client) - .await?; - - client - .trail(trail_id) - .access() - .for_role(role_name) - .issue_capability(CapabilityIssueOptions { - issued_to: Some(issued_to), - valid_from_ms: None, - valid_until_ms: None, - }) - .build_and_execute(client) - .await?; - - Ok(()) -} diff --git a/examples/audit-trail/real-world/03_digital_product_passport.rs b/examples/audit-trail/real-world/03_digital_product_passport.rs new file mode 100644 index 0000000..dadef6d --- /dev/null +++ b/examples/audit-trail/real-world/03_digital_product_passport.rs @@ -0,0 +1,431 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Digital Product Passport Example +//! +//! This example models a Digital Product Passport (DPP) for an e-bike battery, +//! inspired by the public IOTA DPP demo. +//! +//! Scope note: this example stays within the Audit Trail SDK. The demo's wider +//! IOTA stack (Identity, Hierarchies, Tokenization, and Gas Station) is mapped +//! here onto audit-trail-native concepts: +//! +//! - product identity, bill of materials, reward policy, and service history are captured as immutable audit records +//! - service-network authorization is represented through role-scoped capabilities +//! - Lifecycle Credit (LCC) payouts are documented as reward records rather than executed as token transfers +//! +//! ## Actors +//! +//! - **Manufacturer**: Creates the DPP, publishes manufacturing data, and administers roles and capabilities. +//! - **LifecycleManager**: Updates the mutable lifecycle-stage metadata. +//! - **Distributor**: Writes logistics and handover records. +//! - **Consumer**: Writes the commissioning / in-use activation record. +//! - **ServiceTechnician**: Reviews the passport, requests write access, and records the maintenance event once +//! authorized. +//! - **Recycler**: Prepared for future end-of-life events through a recycling-scoped capability. +//! - **EPRO**: Records reward policy and the reward-payout evidence for verified maintenance. +//! +//! ## How the trail is used as a DPP +//! +//! - `immutable_metadata`: product identity for the battery passport +//! - `updatable_metadata`: current lifecycle stage +//! - record tags: `manufacturing`, `logistics`, `ownership`, `maintenance`, `recycling`, `rewards` +//! - roles and capabilities: each actor can write only its assigned slice of the lifecycle +//! - access-request flow: the technician is denied maintenance writes until the manufacturer issues the scoped +//! capability +//! - service evidence: the maintenance event mirrors the demo's "Annual Maintenance" / "Health Snapshot" pattern with a +//! 76% health score and a 1-LCC reward record + +use anyhow::{Result, ensure}; +use audit_trail::AuditTrailClient; +use audit_trail::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet, RoleTags, +}; +use examples::{get_funded_audit_trail_client, issue_tagged_record_role}; +use iota_sdk::types::base_types::{IotaAddress, ObjectID}; +use product_common::core_client::CoreClient; +use product_common::test_utils::InMemSigner; + +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Digital Product Passport ===\n"); + + let manufacturer = get_funded_audit_trail_client().await?; + let lifecycle_manager = get_funded_audit_trail_client().await?; + let distributor = get_funded_audit_trail_client().await?; + let consumer = get_funded_audit_trail_client().await?; + let service_technician = get_funded_audit_trail_client().await?; + let recycler = get_funded_audit_trail_client().await?; + let epro = get_funded_audit_trail_client().await?; + + println!("Manufacturer wallet: {}", manufacturer.sender_address()); + println!("Lifecycle manager wallet: {}", lifecycle_manager.sender_address()); + println!("Distributor wallet: {}", distributor.sender_address()); + println!("Consumer wallet: {}", consumer.sender_address()); + println!("Service technician wallet: {}", service_technician.sender_address()); + println!("Recycler wallet: {}", recycler.sender_address()); + println!("EPRO wallet: {}\n", epro.sender_address()); + + // --------------------------------------------------------------------- + // 1. Create the DPP audit trail + // --------------------------------------------------------------------- + println!("Creating the DPP trail for EcoBike's battery..."); + + let created = manufacturer + .create_trail() + .with_record_tags([ + "manufacturing", + "logistics", + "ownership", + "maintenance", + "recycling", + "rewards", + ]) + .with_trail_metadata(ImmutableMetadata::new( + "DPP: Pro 48V Battery".to_string(), + Some("Manufacturer: EcoBike | Serial: EB-48V-2024-001337".to_string()), + )) + .with_updatable_metadata("Lifecycle Stage: Manufactured") + .with_initial_record(InitialRecord::new( + Data::text( + "event=dpp_created\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike", + ), + Some("event:dpp_created".to_string()), + Some("manufacturing".to_string()), + )) + .finish() + .build_and_execute(&manufacturer) + .await? + .output; + + let trail_id = created.trail_id; + println!("Trail created with ID {trail_id}\n"); + + // --------------------------------------------------------------------- + // 2. Define DPP roles and issue capabilities + // --------------------------------------------------------------------- + println!("Configuring DPP actor roles..."); + + issue_tagged_record_role( + &manufacturer, + trail_id, + "Manufacturer", + "manufacturing", + manufacturer.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer, + trail_id, + "Distributor", + "logistics", + distributor.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer, + trail_id, + "Consumer", + "ownership", + consumer.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer, + trail_id, + "Recycler", + "recycling", + recycler.sender_address(), + ) + .await?; + issue_tagged_record_role(&manufacturer, trail_id, "EPRO", "rewards", epro.sender_address()).await?; + + manufacturer + .trail(trail_id) + .access() + .for_role("ServiceTechnician") + .create( + PermissionSet::record_admin_permissions(), + Some(RoleTags::new(["maintenance"])), + ) + .build_and_execute(&manufacturer) + .await?; + + issue_metadata_role( + &manufacturer, + trail_id, + "LifecycleManager", + lifecycle_manager.sender_address(), + ) + .await?; + + // --------------------------------------------------------------------- + // 3. Prepare the passport with lifecycle context from the DPP demo + // --------------------------------------------------------------------- + println!("Publishing product details, service-network context, and reward policy..."); + + manufacturer + .trail(trail_id) + .records() + .add( + Data::text( + "event=product_details_published\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike\nmanufacturer_did=did:iota:testnet:0xdc704ab63984d5763576c12ce5f62fe735766bc1fc9892a5e2a7be777a9af897\nbattery_details=48V removable e-bike battery with smart BMS\nbill_of_materials=cathode:NMC811;anode:graphite;housing:recycled_aluminum;bms:BMS-v3\ncompliance=CE,RoHS,UN38.3\nsustainability=recycled_aluminum_housing:35%\nservice_network=EcoBike certified service network", + ), + Some("event:product_details_published".to_string()), + Some("manufacturing".to_string()), + ) + .build_and_execute(&manufacturer) + .await?; + + epro.trail(trail_id) + .records() + .add( + Data::text( + "event=reward_policy_published\nreward_type=LCC\nannual_maintenance_reward=1 LCC\nrecycling_reward=10 LCC\nfinal_owner_reward=10 LCC\nmanufacturer_return_reward=10 LCC\nend_of_life_bundle=30 LCC\nsettlement_operator=EcoCycle EPRO", + ), + Some("event:reward_policy_published".to_string()), + Some("rewards".to_string()), + ) + .build_and_execute(&epro) + .await?; + + lifecycle_manager + .trail(trail_id) + .update_metadata(Some("Lifecycle Stage: In Distribution".to_string())) + .build_and_execute(&lifecycle_manager) + .await?; + + distributor + .trail(trail_id) + .records() + .add( + Data::text( + "event=distributed\nshipment_id=SHIP-EB-2026-0042\ntracking_status=Delivered to Nairobi certified service region\ntransport_certification=ADR-compliant battery transport", + ), + Some("event:distributed".to_string()), + Some("logistics".to_string()), + ) + .build_and_execute(&distributor) + .await?; + + lifecycle_manager + .trail(trail_id) + .update_metadata(Some("Lifecycle Stage: In Use".to_string())) + .build_and_execute(&lifecycle_manager) + .await?; + + consumer + .trail(trail_id) + .records() + .add( + Data::text( + "event=commissioned\nowner_profile=Urban commuter fleet\nusage_status=Battery commissioned for daily e-bike service\nrepair_options=EcoBike certified annual maintenance available", + ), + Some("event:commissioned".to_string()), + Some("ownership".to_string()), + ) + .build_and_execute(&consumer) + .await?; + + // --------------------------------------------------------------------- + // 4. Technician reviews history and requests maintenance access + // --------------------------------------------------------------------- + println!("Technician reviews the current DPP history..."); + + let history_before_service = service_technician.trail(trail_id).records().list_page(None, 20).await?; + println!( + "Technician can already read {} public DPP records.\n", + history_before_service.records.len() + ); + + let denied_before_grant = service_technician + .trail(trail_id) + .records() + .add( + Data::text("event=unauthorized_maintenance_attempt"), + Some("event:unauthorized_maintenance_attempt".to_string()), + Some("maintenance".to_string()), + ) + .build_and_execute(&service_technician) + .await; + + ensure!( + denied_before_grant.is_err(), + "maintenance writes must fail until the technician is explicitly authorized" + ); + println!("Maintenance write denied before access grant, as expected.\n"); + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let technician_valid_until_ms = now_ms + 30 * 24 * 60 * 60 * 1000; + + let issued_technician_cap = manufacturer + .trail(trail_id) + .access() + .for_role("ServiceTechnician") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(service_technician.sender_address()), + valid_from_ms: Some(now_ms), + valid_until_ms: Some(technician_valid_until_ms), + }) + .build_and_execute(&manufacturer) + .await? + .output; + + println!( + "Issued ServiceTechnician capability {} (valid until {}).\n", + issued_technician_cap.capability_id, technician_valid_until_ms + ); + + lifecycle_manager + .trail(trail_id) + .update_metadata(Some("Lifecycle Stage: Maintenance In Progress".to_string())) + .build_and_execute(&lifecycle_manager) + .await?; + + // --------------------------------------------------------------------- + // 5. Perform the maintenance event described in the DPP demo + // --------------------------------------------------------------------- + println!("Recording the annual maintenance event..."); + + let maintenance_event = service_technician + .trail(trail_id) + .records() + .add( + Data::text( + "entry_type=Annual Maintenance\nservice_action=Health Snapshot\nhealth_score=76%\nfindings=Routine maintenance completed successfully\nwork_performed=Battery contacts cleaned; cell balance check passed; firmware diagnostics passed\nnext_service_due=2027-04-20", + ), + Some("event:annual_maintenance".to_string()), + Some("maintenance".to_string()), + ) + .build_and_execute(&service_technician) + .await? + .output; + + println!( + "Service technician added maintenance record #{}.\n", + maintenance_event.sequence_number + ); + + let reward_event = epro + .trail(trail_id) + .records() + .add( + Data::text(format!( + "event=lcc_reward_distributed\ntrigger_record={}\nreward_type=LCC\namount=1\nreason=Annual maintenance completed\nbeneficiary={}", + maintenance_event.sequence_number, + service_technician.sender_address() + )), + Some("event:lcc_reward_distributed".to_string()), + Some("rewards".to_string()), + ) + .build_and_execute(&epro) + .await? + .output; + + println!( + "EPRO added reward record #{} for the verified maintenance event.\n", + reward_event.sequence_number + ); + + lifecycle_manager + .trail(trail_id) + .update_metadata(Some( + "Lifecycle Stage: Maintained and Ready for Continued Use".to_string(), + )) + .build_and_execute(&lifecycle_manager) + .await?; + + // --------------------------------------------------------------------- + // 6. Verify the prepared DPP state + // --------------------------------------------------------------------- + println!("Verifying the resulting DPP..."); + + let on_chain = manufacturer.trail(trail_id).get().await?; + let first_page = manufacturer.trail(trail_id).records().list_page(None, 20).await?; + + println!("Recorded DPP events:"); + for (sequence_number, record) in &first_page.records { + println!( + " #{} | tag={:?} | metadata={:?}", + sequence_number, record.tag, record.metadata + ); + } + + ensure!( + first_page.records.len() == 7, + "expected 7 DPP records (initial + product details + reward policy + distribution + commissioning + maintenance + reward payout)" + ); + ensure!( + on_chain.tags.tag_map.contains_key("maintenance") + && on_chain.tags.tag_map.contains_key("recycling") + && on_chain.tags.tag_map.contains_key("rewards"), + "expected the DPP tag registry to contain maintenance, recycling, and rewards" + ); + ensure!( + on_chain.roles.roles.contains_key("Manufacturer") + && on_chain.roles.roles.contains_key("Distributor") + && on_chain.roles.roles.contains_key("Consumer") + && on_chain.roles.roles.contains_key("ServiceTechnician") + && on_chain.roles.roles.contains_key("Recycler") + && on_chain.roles.roles.contains_key("EPRO") + && on_chain.roles.roles.contains_key("LifecycleManager"), + "expected all DPP roles to be registered" + ); + ensure!( + on_chain.updatable_metadata.as_deref() == Some("Lifecycle Stage: Maintained and Ready for Continued Use"), + "expected the DPP lifecycle stage to reflect the completed maintenance event" + ); + + let maintenance_record = first_page + .records + .iter() + .find(|(_, record)| record.metadata.as_deref() == Some("event:annual_maintenance")); + ensure!( + maintenance_record.is_some(), + "expected the maintenance record to be present in the DPP history" + ); + + let reward_record = first_page + .records + .iter() + .find(|(_, record)| record.metadata.as_deref() == Some("event:lcc_reward_distributed")); + ensure!( + reward_record.is_some(), + "expected the reward payout record to be present in the DPP history" + ); + + println!("\nDigital Product Passport scenario completed successfully."); + + Ok(()) +} + +async fn issue_metadata_role( + client: &AuditTrailClient, + trail_id: ObjectID, + role_name: &str, + issued_to: IotaAddress, +) -> Result<()> { + client + .trail(trail_id) + .access() + .for_role(role_name) + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(client) + .await?; + + client + .trail(trail_id) + .access() + .for_role(role_name) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(issued_to), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(client) + .await?; + + Ok(()) +} diff --git a/examples/audit-trail/run.sh b/examples/audit-trail/run.sh index d6c68a0..8cf9c6c 100755 --- a/examples/audit-trail/run.sh +++ b/examples/audit-trail/run.sh @@ -32,6 +32,7 @@ examples=( "11_manage_record_tags" "01_customs_clearance" "02_clinical_trial" + "03_digital_product_passport" ) for example in "${examples[@]}"; do diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index a3af6ae..3f0376d 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; +use audit_trail::core::types::{CapabilityIssueOptions, PermissionSet, RoleTags}; use audit_trail::{AuditTrailClient, PackageOverrides}; -use iota_sdk::types::base_types::ObjectID; +use iota_sdk::types::base_types::{IotaAddress, ObjectID}; use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; @@ -74,3 +75,35 @@ pub async fn get_funded_audit_trail_client() -> Result, + trail_id: ObjectID, + role_name: &str, + tag: &str, + issued_to: IotaAddress, +) -> Result<(), anyhow::Error> { + client + .trail(trail_id) + .access() + .for_role(role_name) + .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new([tag]))) + .build_and_execute(client) + .await + .map_err(|e| anyhow::anyhow!("failed to create role '{role_name}': {e}"))?; + + client + .trail(trail_id) + .access() + .for_role(role_name) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(issued_to), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(client) + .await + .map_err(|e| anyhow::anyhow!("failed to issue capability for role '{role_name}': {e}"))?; + + Ok(()) +}